9.5 KiB
9.5 KiB
Auth architecture
Current implementation: SvelteKit directly + PocketBase. Hono is not part of
auth today — it is reserved for future services (email, etc.). The /api proxy target
still points at it, but no auth traffic flows through it.
┌────────────────────────────────────────────────────────┐
│ BROWSER (Svelte) │
│ /login /signup forms · $app/forms · use:enhance │
└──────────────────────────┬─────────────────────────────┘
│ 1. form action POST /login
│ /signup (returns result)
▼
┌────────────────────────────────────────────────────────┐
│ SVELTEKIT SERVER (Node) │
│ │
│ hooks.server.ts (runs once per request, first) │
│ · read httpOnly cookie pb_session │
│ · createPbClient(token) → pb.authRefresh() │
│ · → { id, username, role, famId } │
│ · event.locals.user / event.locals.pbToken │
│ · route guards: public vs authed vs /admin │
│ │
│ +page.server.ts (actions / loads) │
│ login.ts / signup.ts / session.ts │
│ · createPbClient / createSuperPbClient │
│ (ABSOLUTE PB URL - server-to-server, no proxy) │
└──────────────────────────┬─────────────────────────────┘
│ 2. PB API (REST) │
▼
┌────────────────────────────────────────────────────────┐
│ POCKETBASE (100.103.22.104:8090) │
│ collections: users (auth) · fams · members · ... │
│ │
│ PB is the SOURCE OF TRUTH: │
│ · password hashing (bcrypt-style) │
│ · JWT token issue + expiry (exp claim) │
│ · authRefresh / token rotation │
│ · per-collection API rules (authz boundary) │
└────────────────────────────────────────────────────────┘
Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services
Request lifecycle (authenticated)
- Browser sends request; sends cookie
pb_session(httpOnly, sameSite=lax, secure in prod). hooks.server.tsextracts the token.createPbClient(token)builds a PB client pre-authenticated as that user.pb.collection('users').authRefresh():- validates the token (PB JWTs can't be checked offline),
- returns the fresh record →
event.locals.user = { id, username, role, famId }, - returns a fresh token; if it changed, the cookie is rolled forward.
- Route guard runs (public / authed / admin-only).
+page.server.ts/+server.tsuselocals.userfor identity; use the token-backed PB client for any CRUD so PB's collection rules apply.
Key decisions to replicate in another project
- Server-only PB client lives in
src/lib/server/; never imported by browser code. Secrets (superuser creds) stay server-side. - Absolute PB base URL in the SDK (e.g.
http://host:8090), NOT a relative/pb. All calls run in Node where relative URLs fail. The Vite/pbproxy is a browser-only convenience and is unnecessary for server-side calls. - Env vars: read via
$app/env/privatefor private vars (declared insrc/env.ts), never$app/env/public. createSuperPbClient= anonymous client +_superusersauth, used for admin-style creates (signup). Superusers bypass PB collection rules.- Session cookie: PB JWT in
pb_session; expiry governed by the token'sexp, cookiemaxAgeis just a ceiling;authRefreshrolls it forward. - Authz boundary lives in PocketBase collection rules (famId scoping), not just app code.
Signup flow (/signup)
Multi-step form on a single route. All steps run as form actions on the server; the
use:enhance handler only advances the step on a non-failure result.
ADMIN USER
+-----------------------------------------+ 1. POST ?/signup {familyName,email,password}
| +-----------+ +-----------+ +--------+ | ─────────────────────────────►
| | step 1 | | step 2 | | step 3 | | (use:enhance advances step on success)
| | family+ | | username | | child | |
| | email+pass| | | | name | |
| +-----------+ +-----------+ +--------+ |
+-----------------------------------------+
▲ 3. set httpOnly cookie pb_session
│ return {success:true}
▼
┌────────────────────────────────────────────────────────────────────────┐
│ /signup/+page.server.ts actions: signup · username · child │
└────────────────────────────────────────────────────────────────────────┘
│
?/signup │ ?/username ?/child
─────────────────────┼───────────────────┬───────────────────────
signupAdmin() │ setUsername() │ createChild()
─────────────────────┼───────────────────┴───────────────────────
│ ▼ (famId from locals.user)
▼
createSuperPbClient() ── superuser-authenticated PB client
│
├─(1) fams.create({ name, slug }) → family.id
├─(2) users.create({ email,password,name, → user, role not set
│ famId: family.id })
├─(3) users.authWithPassword(email,password) → token
└─(4) setSessionCookie(cookies, token)
│
▼
hooks.server.ts picks up the cookie next request
→ authRefresh → event.locals.user populated → user is "logged in"
Failure path: any PB error → throw SignupError(msg)
→ action returns fail(status, { message })
→ FE renders form.message, does NOT advance step
Signup step detail
| Step | Action | Server fn | Writes | Returns |
|---|---|---|---|---|
| 1 | ?/signup |
signupAdmin |
fams + users | cookie set, {success:true} |
| 2 | ?/username |
setUsername |
users.name | {success:true, username} |
| 3 | ?/child |
createChild |
members (name, famId) | {success:true, code} |
Notes / caveats in the current code
userscollection has norole/usernamefields yet —setUsernamewrites toname, androleisn't persisted (hooks reads it asundefined). Addrole+usernametousersif you need role-based guards (/adminrelies onrole==='admin').createChildwrites amembersrecord withinviteCode, butmembershas noinviteCodefield (it's dropped). The member is created without ausersauth record, so it can't log in yet — see "Extending — Child / device accounts (Option C)" in the rootreadme.md.fams.createRuleisnull(admin-only),users.createRuleis""(public); signup uses a superuser client, so both work regardless.