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 /join forms · $app/forms · enhance │
└──────────────────────────┬─────────────────────────────┘
│ 1. form action POST
│ /login · /signup · /join
▼
┌────────────────────────────────────────────────────────┐
│ SVELTEKIT SERVER (Node) │
│ │
│ hooks.server.ts (runs once per request, first) │
│ · read httpOnly cookie pb_token │
│ · createPbClient(token) → pb.authRefresh() │
│ · → { id, name, username, role, famId, color } │
│ · event.locals.user / event.locals.pbToken │
│ · route guards: public vs authed vs admin-only │
│ │
│ +page.server.ts (actions / loads) │
│ signup.ts / login.ts / member-otp.ts / session.ts │
│ · createSuperClient (signup / OTP, superuser) │
│ · createPbClient(token) (server-to-server) │
└──────────────────────────┬─────────────────────────────┘
│ 2. PB API (REST) │
▼
┌────────────────────────────────────────────────────────┐
│ POCKETBASE (SERVER_IP:8090) │
│ collections: users (auth) · fams · ... │
│ │
│ 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
Roles (in the users collection)
| Role | Auth | Session cookie | Record in |
|---|---|---|---|
| Admin | PB email + password | pb_token (24hr JWT) |
users (role parent) |
| Member | Invite OTP + server-derived password | pb_token (httpOnly) |
users (role child) |
| Superuser | PB _superusers (server-side only, pb-admin) |
— | — |
- Admins (parents) authenticate via email/password → PB JWT in an httpOnly
pb_tokencookie. The session user is{ id, name, username, role: 'parent', famId, color }. - Members (children) are
usersrecords withrole='child'; their PBusernameis the composite{famSlug}:{handle}(globally unique auth identity) wherehandleis the whitespace-free lowercase form of their name, andnamekeeps the raw display name. Their PB password is derived server-side asMEMBER_SECRET + famSlug + username(they never know or type it). Access is gated by a 20-minute OTP inuser_configs. On join theyauthWithPasswordand get the same httpOnlypb_tokencookie. There is no separatememberscollection anymore. - Platform superuser (
_superusers) is used only server-side bypb-admin.tsfor cross-family / signup / OTP writes. Not an app role.
Request lifecycle (authenticated)
- Browser sends request; sends cookie
pb_token(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, name, username, role, famId, color }, - 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.
Because pb_token is httpOnly, the browser PB SDK cannot read it from document.cookie.
It is instead seeded from the SSR page.data.pbToken prop via initPb(token) in the
[fam]/+layout.svelte onMount.
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.
Step 1 (?/signup) familyName + yourName + email + password
· create fams → fam (slug = slugify(familyName))
· create users → parent (role 'parent', name = yourName, username = `{famSlug}:{handle(yourName)}`)
· create settings
· authWithPassword(email, password) → set httpOnly pb_token cookie → step 2
Step 2 (?/child) child's first name (optional)
· issueAccess() → upserts child user + OTP → returns { code, joinUrl } → step 3
· or "Skip for now" → dashboard
Step 3 show OTP join code + joinUrl (+ "Go to dashboard" link)
All create calls use the superuser client (createSuperClient / pbAdmin), which bypasses
PB collection rules. PB requires a username on users auth records — for both parents and
children it's the composite {famSlug}:{handle} (globally unique so PB's auth-identity
unique index holds, even though the URL segment is only per-family), where handle is the
whitespace-free lowercase form of the name ("Joe Edhook" → joeedhook). name keeps the
raw human-entered display name. The URL segment is handleOf(username) (part after the last
:) → /{famSlug}/{handle}; parents still authenticate with email+password and land on the
fam dashboard /{famSlug}.
Child join flow (/{famSlug}/join/{username}?code=…)
- Admin issues a child in Settings →
issueAccess(member-otp.ts):createChild({ name, famId, famSlug })upserts theusersrecord (username = {famSlug}:{handle(name)},name= display, password = derived), and upserts auser_configsrow with a 20-minotp. Returns{ otp, joinUrl }. - The join page auto-fills the OTP from the
?code=query param; the child submits the form. redeemOtpverifies the fam slug, the child role, the OTP + its TTL, thenauthWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle))and returns a fresh JWT, which is set as thepb_tokencookie.- Child is redirected to their own kanban
/{famSlug}/{username}.
Key decisions to replicate in another project
- Server-only PB client lives in
src/lib/server/; never imported by browser code. Secrets (superuser creds,MEMBER_SECRET) 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. createSuperClient= anonymous client +_superusersauth, used for signup and OTP writes. Superusers bypass PB collection rules.- Session cookie: PB JWT in
pb_token,httpOnly:true; 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.
- Child identity is a
usersrecord;username(slug) is used for URLs and the derived password,namefor display. Never store rawdeviceToken; the OTP gate is transient.