migrate auth v2 code

This commit is contained in:
JCEEE
2026-08-16 10:11:39 +01:00
parent 3dd94b8a7c
commit c73ced7894
45 changed files with 1965 additions and 1519 deletions
+78 -81
View File
@@ -7,31 +7,31 @@ still points at it, but no auth traffic flows through it.
```
┌────────────────────────────────────────────────────────┐
│ BROWSER (Svelte) │
│ /login /signup forms · $app/forms · use:enhance │
│ /login /signup /join forms · $app/forms · enhance │
└──────────────────────────┬─────────────────────────────┘
│ 1. form action POST /login
│ /signup (returns result)
│ 1. form action POST
│ /login · /signup · /join
▼
┌────────────────────────────────────────────────────────┐
│ SVELTEKIT SERVER (Node) │
│ │
│ hooks.server.ts (runs once per request, first) │
│ · read httpOnly cookie pb_session │
│ · read httpOnly cookie pb_token │
│ · createPbClient(token) → pb.authRefresh() │
│ · → { id, username, role, famId } │
│ · → { id, name, username, role, famId, color } │
│ · event.locals.user / event.locals.pbToken │
│ · route guards: public vs authed vs /admin │
│ · route guards: public vs authed vs admin-only │
│ │
│ +page.server.ts (actions / loads) │
│ login.ts / signup.ts / session.ts │
│ · createPbClient / createSuperPbClient │
│ (ABSOLUTE PB URL - server-to-server, no proxy) │
│ signup.ts / login.ts / member-otp.ts / session.ts │
│ · createSuperClient (signup / OTP, superuser) │
│ · createPbClient(token) (server-to-server) │
└──────────────────────────┬─────────────────────────────┘
│ 2. PB API (REST) │
▼
┌────────────────────────────────────────────────────────┐
│ POCKETBASE (100.103.22.104:8090) │
│ collections: users (auth) · fams · members · ... │
│ POCKETBASE (SERVER_IP:8090) │
│ collections: users (auth) · fams · ... │
│ │
│ PB is the SOURCE OF TRUTH: │
│ · password hashing (bcrypt-style) │
@@ -43,96 +43,93 @@ still points at it, but no auth traffic flows through it.
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_session` (httpOnly, sameSite=lax, secure in prod).
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, username, role, famId }`,
- 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.
## Key decisions to replicate in another project
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`.
- **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 `/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`.
- **`createSuperPbClient`** = anonymous client + `_superusers` auth, used for admin-style
creates (signup). Superusers bypass PB collection rules.
- **Session cookie**: PB JWT in `pb_session`; 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.
---
# Signup flow (`/signup`)
## 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"
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
Failure path: any PB error → throw SignupError(msg)
→ action returns fail(status, { message })
→ FE renders form.message, does NOT advance step
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)
```
## Signup step detail
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}`.
| 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}` |
## Child join flow (`/{famSlug}/join/{username}?code=…`)
## Notes / caveats in the current 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}`.
- `users` collection has **no `role`/`username`** fields yet — `setUsername` writes to
`name`, and `role` isn't persisted (hooks reads it as `undefined`). Add `role` +
`username` to `users` if you need role-based guards (`/admin` relies on `role==='admin'`).
- `createChild` writes a `members` record with `inviteCode`, but `members` has no
`inviteCode` field (it's dropped). The member is created without a `users` auth record,
so it can't log in yet — see "Extending — Child / device accounts (Option C)" in the
root `readme.md`.
- `fams.createRule` is `null` (admin-only), `users.createRule` is `""` (public); signup
uses a superuser client, so both work regardless.
## 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.