# 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_token` cookie. The session user is `{ id, name, username, role: 'parent', famId, color }`. - **Members (children)** are `users` records with `role='child'`; their PB `username` is the composite `{famSlug}:{handle}` (globally unique auth identity) where `handle` is the whitespace-free lowercase form of their name, and `name` keeps the raw display name. Their PB password is **derived** server-side as `MEMBER_SECRET + famSlug + username` (they never know or type it). Access is gated by a 20-minute OTP in `user_configs`. On join they `authWithPassword` and get the same httpOnly `pb_token` cookie. There is no separate `members` collection anymore. - **Platform superuser** (`_superusers`) is used only server-side by `pb-admin.ts` for cross-family / signup / OTP writes. Not an app role. ## Request lifecycle (authenticated) 1. Browser sends request; sends cookie `pb_token` (httpOnly, sameSite=lax, secure in prod). 2. `hooks.server.ts` extracts the token. 3. `createPbClient(token)` builds a PB client pre-authenticated as that user. 4. `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. 5. Route guard runs (public / authed / admin-only). 6. `+page.server.ts` / `+server.ts` use `locals.user` for 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=…`) 1. Admin issues a child in Settings → `issueAccess` (`member-otp.ts`): `createChild({ name, famId, famSlug })` upserts the `users` record (`username = {famSlug}:{handle(name)}`, `name` = display, password = derived), and upserts a `user_configs` row with a 20-min `otp`. Returns `{ otp, joinUrl }`. 2. The join page auto-fills the OTP from the `?code=` query param; the child submits the form. 3. `redeemOtp` verifies the fam slug, the child role, the OTP + its TTL, then `authWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle))` and returns a fresh JWT, which is set as the `pb_token` cookie. 4. 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 `/pb` proxy is a browser-only convenience and is unnecessary for server-side calls. - **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`), never `$app/env/public`. - **`createSuperClient`** = anonymous client + `_superusers` auth, 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's `exp`, cookie `maxAge` is just a ceiling; `authRefresh` rolls it forward. - **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code. - **Child identity** is a `users` record; `username` (slug) is used for URLs and the derived password, `name` for display. Never store raw `deviceToken`; the OTP gate is transient.