From 3ed9a42845c70c099712424c7a6ccf6c38a22aff Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:24:43 -0400 Subject: [PATCH 01/73] docs(12): capture phase context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restored onto phase-12 branch — discuss-phase originally committed the context (dc41073) on the phase-18 branch by mistake. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../12-initial-setup-wizard/12-CONTEXT.md | 226 ++++++++++++++++++ .../12-DISCUSSION-LOG.md | 122 ++++++++++ 2 files changed, 348 insertions(+) create mode 100644 .planning/phases/12-initial-setup-wizard/12-CONTEXT.md create mode 100644 .planning/phases/12-initial-setup-wizard/12-DISCUSSION-LOG.md diff --git a/.planning/phases/12-initial-setup-wizard/12-CONTEXT.md b/.planning/phases/12-initial-setup-wizard/12-CONTEXT.md new file mode 100644 index 0000000..7c97ef7 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-CONTEXT.md @@ -0,0 +1,226 @@ +# Phase 12: Initial Setup Wizard - Context + +**Gathered:** 2026-06-15 +**Status:** Ready for planning + + +## Phase Boundary + +Phase 12 delivers the **first-run, pre-auth setup wizard** that bootstraps a fresh FamilySync +instance through a validated, step-by-step flow instead of hand-editing config — and reworks the +admin bootstrap so the operator who completes setup becomes the admin. + +**Delivers:** +- A standalone `/setup` page (the only screen outside the OIDC guard), reached when the instance + is not yet configured, that **collects non-secret config**, **validates** live connectivity + (DB / OIDC / VAPID / Fastmail CalDAV), provisions the **first local user + credential**, and + **locks** (`setup_complete` + 423 guard) on completion. +- A **minimal-env-kernel + DB-backed-config** model: the running app reads non-secret config from + `app_config` (written by the wizard) instead of requiring it all in env. +- A **repo helper script** to generate the bootstrap secrets before first boot. +- The **WR-01 bootstrap rework**: a pre-OIDC local user, claimed by the first OIDC login. + +**NOT in this phase:** +- The visual redesign of the wizard from scratch — `12-UI-SPEC.md` already contracts the look/feel + (but see the ⚠ note: its *validate-only* assumption is partially superseded — Steps 2–4 need + rework; this phase revises the UI-SPEC, it does not re-derive the design system). +- Full local-auth / no-OIDC operating mode (deferred — see Deferred Ideas). +- Multi-provider credentials beyond Fastmail/CalDAV (Phase 10 D-04 generic shape only). +- Any new crypto or a duplicate credential-storage path (reuse Phase 10's helper). + + + +## Implementation Decisions + +### Wizard nature — minimal env kernel + DB-backed config +- **D-01: Minimal env kernel.** Only the irreducible bootstrap floor stays in env (it cannot live + in the DB it protects/reaches): **DB connection** (chicken-and-egg), **`SESSION_SECRET`**, + **`APP_PASSWORD_ENCRYPTION_KEY`** (storing it beside the ciphertext it decrypts defeats the + encryption — SC-3 / Pitfall 10), **`VAPID_PRIVATE_KEY`** (SC-3 bars it from the DB), and OIDC + **`client_secret`**. +- **D-02: Non-secret config moves to `app_config`.** The wizard **collects via form fields and + writes** the non-secret, runtime-read config to `app_config`: **app/external URL**, **OIDC issuer + + client_id**, **VAPID public key**. Runtime consumers (auth middleware boot config, push, + broker) read these from `app_config` rather than env. This is the operator's "config lives in the + DB" goal, bounded by the D-01 floor. +- **D-03: Env resolution precedence.** Kernel env values come from **Docker-provided `process.env` + first, falling back to a `.env` file** (standard dotenv precedence). `.env` is not eliminated — + it shrinks to the kernel. +- **⚠ Supersedes the validate-only `12-UI-SPEC.md`.** Steps 3/4 now need **input fields** (they + collect config, not just validate env). The UI-SPEC must be revised before/within planning — see + Canonical References. + +### Restart & resume — no mid-wizard restart +- **D-04: Full kernel defined before first boot.** The operator sets the entire env kernel + (DB connection + all secrets) **before the container's first boot**, via an **Unraid template / + clear bootstrap instructions**. The container comes up already holding its secrets, so the wizard + **never forces a paste-and-restart mid-flow**. The mid-wizard restart problem is designed out. +- **D-05: Secret generation → repo helper script.** Generation moves OUT of the wizard to a + **repo helper script** (e.g. `npm run generate-secrets`) that prints all four values + (`SESSION_SECRET`, `APP_PASSWORD_ENCRYPTION_KEY`, VAPID public + private) formatted for pasting, + generating the VAPID pair with the app's own `web-push` lib for an exact match. +- **D-06: Stateless resume.** No persisted step cursor. Because the kernel is present at boot, the + validation steps simply re-pass on any refresh/re-entry; the env + DB *are* the progress state. +- **⚠ SETUP-03 deviation.** SETUP-03 says "*the wizard* generates secrets." Under this model the + wizard does **not** generate; the helper script does, at provisioning time. UI-SPEC Step 2 + ("Generate Secrets" + copy + acknowledge) is **dropped/reworked**. Capture as a requirements + deviation for the planner/researcher. + +### Credential + admin — pre-OIDC local user, claimed at first login +- **D-07: Pre-OIDC local user.** The wizard provisions a **local user row** (no OIDC identity yet) + that holds the **first validated Fastmail credential** and the **pending-admin** status. Schema: + `users.oidc_iss` / `oidc_sub` become **nullable**, plus a **claimed/pending marker**, so a user + can exist before OIDC and be adopted later. +- **D-08: First-login-claims.** The first OIDC login **after `setup_complete`** **claims/merges** + the single unclaimed local user — populating its `oidc_iss`/`oidc_sub`, keeping the credential + + `is_admin`. This **repurposes Phase 10's first-login-wins** (D-01 there) from "creates a new + admin" to "claims the pending admin," and is the **WR-01 bootstrap rework** Phase 10 flagged for + Phase 12. No email coupling (respects D-10 identity model). Threat model: only household members + can reach Authelia OIDC at all, so the claim window is acceptable for a 2-person self-hosted app. +- **D-09: Credential stored via setup endpoint reusing the shared helper.** A pre-auth `/api/setup/*` + endpoint stores the local user's credential by calling the **shared + `validateEncryptAndStoreCredential` helper** internally (no new crypto, no duplicated logic). + CalDAV PROPFIND validation (SC-2) runs here against the entered app password. + **⚠ Deviation from the literal roadmap constraint** "do NOT create `/api/setup/credentials` — + reuse the Phase 10 admin routes": a pre-auth wizard physically cannot call the admin-gated + `/api/admin/credentials`. The deviation honors the constraint's **spirit** (reuse the helper / + no new crypto) while satisfying the pre-auth requirement. Flag for the researcher to confirm the + exact endpoint shape. + +### Completion signal & 423 guard +- **D-10: Defense-in-depth guard.** Each setup-route invocation locks (**423**) if + **`app_config.setup_complete` is true OR the system is already effectively configured** + (a `member_credentials` row exists AND VAPID env present) — **re-evaluated fresh every call, + never cached at startup**. Satisfies SC-4 (the flag) and SC-5 (the live check), and protects + manually/upgrade-configured instances that never set the flag. The wizard **only flips + `setup_complete` once preconditions are met**, so it cannot self-lock mid-flow. + +### Claude's Discretion +- Exact reworked step list (e.g. Welcome / Config-collect / Validate / Credential / Complete) and + per-step field grouping — planner's call, consistent with the revised UI-SPEC. +- Exact `/api/setup/*` route paths and the `app_config` key naming for the new non-secret config — + planner's call, following the existing `routes/*.ts` Hono + `app_config` key/value pattern + (already used for `household_timezone`). +- The migration packaging for the nullable-OIDC-identity + claimed-marker schema change + (Drizzle generate+migrate, never push). +- Whether runtime config reads from `app_config` are cached per-process or read per-request — + planner's call, balancing the SC-5 "every invocation" intent for the guard specifically. + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Requirements & roadmap +- `.planning/REQUIREMENTS.md` — SETUP-01..04 (full wording). **Note the captured deviations:** + SETUP-03 ("wizard generates secrets") is reworked → repo helper script (D-05); SETUP-01's + "wizard defines config" is realized as collect-to-`app_config` for non-secret values only (D-02), + with the secret/DB floor staying in env (D-01). +- `.planning/ROADMAP.md` §Phase 12 — goal, 5 success criteria, pitfalls, and the hard constraints. + **Two literal constraints are deliberately deviated** (with rationale above): "wizard generates + secrets" (D-05) and "no `/api/setup/credentials`, reuse admin routes" (D-09). The researcher must + reconcile these explicitly. +- `.planning/ROADMAP.md` lines ~160-170 — v1.1 DB-foundation note (`app_config.setup_complete` + created in Phase 10, consumed here) and the shared `/api/admin` surface constraint. + +### UI design contract (PARTIALLY SUPERSEDED — must be revised) +- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — the design system, tokens, surfaces, + copywriting, and a11y contract still hold. **BUT its core *validate-only* assumption is + superseded by D-02 (Steps 3/4 collect config → need input fields) and D-04/D-05 (Step 2 + "Generate Secrets" is dropped — generation is pre-boot).** Revise the UI-SPEC's Wizard-Steps and + Interaction-Contract sections before/within planning; do not implement Steps 2–4 as currently + written. + +### Prior-phase context this phase builds on +- `.planning/phases/10-admin-role-settings/10-CONTEXT.md` — D-01 first-login-wins (tightened here + to first-login-claims), D-03 `isAdmin` on `/api/me`, D-04 generic provider credential shape, + D-07 self-service `SetupBanner`/`CredentialSheet`, the shared + `validateEncryptAndStoreCredential` helper, and the `app_config` table/`setup_complete` column. +- `.planning/codebase/ARCHITECTURE.md`, `STRUCTURE.md`, `CONVENTIONS.md` — API/PWA layout, route + + schema + frontend conventions to match. + +### Key source files +- `apps/api/src/auth/user.ts` — the documented first-login-wins hook (l.108-122) that this phase + **tightens** to gate on `setup_complete` and **repurposes** to claim the pending local user (D-08). +- `apps/api/src/db/schema.ts` — `users` (make `oidc_iss`/`oidc_sub` nullable + add claimed marker, + D-07), `app_config` (new non-secret config keys, D-02), `member_credentials` (per-user, reused). +- `apps/api/src/routes/admin.ts` — existing `app_config` upsert pattern (`household_timezone`, + l.196-263) and the `validateEncryptAndStoreCredential` reuse target (D-09). +- `apps/api/src/broker/crypto.ts` — `encryptPassword`/`decryptPassword` (reuse, no changes). +- `apps/api/src/broker/client.ts` — `createDAVClient`/`fetchCalendars` for CalDAV PROPFIND + validation (SC-2). +- `apps/api/src/index.ts` — route mounting + middleware order; `/api/setup/*` mounts **before** the + OIDC guard (like `/health`). +- `apps/api/src/lib/householdTimezone.ts` — existing example of an `app_config`-backed runtime read + (pattern to follow for D-02 config reads). +- `apps/pwa/src/App.tsx` — the app-level gate that redirects to `/setup` when unconfigured + (per UI-SPEC §Routing); `apps/pwa/src/components/SetupBanner.tsx` + `CredentialSheet.tsx` — the + self-service credential flow reused post-login. + + + +## Existing Code Insights + +### Reusable Assets +- `validateEncryptAndStoreCredential` (Phase 10) — the single validate→encrypt→store path; D-09 + calls it from the pre-auth setup endpoint against the local user id. +- `broker/crypto.ts` + `broker/client.ts` — credential encryption + CalDAV PROPFIND validation; + reused unchanged for SC-2/SC-3. +- `app_config` key/value table + the `household_timezone` upsert/read pattern + (`routes/admin.ts`, `lib/householdTimezone.ts`) — the template for D-02's non-secret config + storage and runtime reads. +- `SetupBanner` + `CredentialSheet` (self-service mode, D-07 of Phase 10) — the post-login + credential UX; complements the wizard rather than duplicating it. +- First-login-wins block in `auth/user.ts` — written specifically to be tightened here (its inline + comment names this phase). + +### Established Patterns +- Routes are per-feature Hono routers under `apps/api/src/routes/`; `/api/setup/*` mounts before the + OIDC guard (only `/health`-style pre-auth surface today). +- Identity is `oidc_iss + oidc_sub`, never email (D-10) — D-08 claim must NOT introduce email-keyed + matching. +- Schema migrations via `drizzle-kit generate` + `migrate`, never `push` ([[drizzle-mariadb-push-unsafe]]). +- PWA routing is declarative `react-router` in `App.tsx`; server state via TanStack Query. + +### Integration Points +- `app_config.setup_complete` (created Phase 10) → flipped here on completion; read by the gate + + the 423 guard (D-10) + the tightened first-login claim (D-08). +- New `app_config` non-secret keys (D-02) → read by auth-config boot, push, and the PWA (e.g. VAPID + public key fetched rather than baked into the build). +- Nullable-OIDC-identity + claimed marker (D-07) → consumed by `upsertUser`/login (D-08) and is the + seed the deferred local-auth phase extends. +- `GET /api/setup/status` (pre-OIDC) → drives the `App.tsx` redirect-to-`/setup` gate. + + + +## Specific Ideas + +- The operator explicitly wants **config to live in the DB**, with `.env` reduced to a Docker-fed + (or `.env`-fallback) kernel — the wizard is the source of truth for non-secret config (D-01/D-02/D-03). +- The operator runs an **Unraid** deployment and envisions an **Unraid template** (or equivalent + clear instructions) that fully provisions the env kernel before first boot (D-04) — this is why + the in-wizard generate-and-restart dance is intentionally removed. +- The credential/local-user model is framed as **provider-agnostic**: "when we switch from Fastmail + to a generic provider, the first user who gets provisioned will need to enter this" — keep the + Phase 10 generic provider shape (D-04 there); Fastmail/CalDAV remains the only implementation. +- The local user is explicitly conceived as a "**local user who gets merged into an OIDC user once + that's set up**" (D-07/D-08). + + + +## Deferred Ideas + +- **Local-auth / no-OIDC operating mode** — the operator wants the option to run FamilySync + **entirely on local DB users with no OIDC**, and wire OIDC in later (they use it this way + themselves). **Its own future phase.** Phase 12's pre-OIDC local-user provisioning (D-07) is the + deliberate **foundation** that phase extends — capture now, build later. (Roadmap/backlog: add a + phase.) + +None of the above is in scope for Phase 12 beyond laying the local-user groundwork. + + +--- + +*Phase: 12-initial-setup-wizard* +*Context gathered: 2026-06-15* diff --git a/.planning/phases/12-initial-setup-wizard/12-DISCUSSION-LOG.md b/.planning/phases/12-initial-setup-wizard/12-DISCUSSION-LOG.md new file mode 100644 index 0000000..3852c83 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-DISCUSSION-LOG.md @@ -0,0 +1,122 @@ +# Phase 12: Initial Setup Wizard - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-06-15 +**Phase:** 12-initial-setup-wizard +**Areas discussed:** Wizard nature, Restart & resume, Credential + admin, Guard + +--- + +## Wizard nature + +### Q1 — Does the wizard write config, or validate-only? + +| Option | Description | Selected | +|--------|-------------|----------| +| Validate-only (UI-SPEC) | Wizard never writes config; checks env, generates secrets to paste, validates, flips setup_complete | | +| Collect-and-write some | Wizard accepts some config via fields and persists it; contradicts the no-input UI-SPEC | ✓ | + +**User's choice:** Collect-and-write some — "I'm okay to migrate away from a .env file entirely if that helps. The wizard could supersede it and config values live in the DB." + +### Q2 — Which config to collect-and-write? + +| Option | Description | Selected | +|--------|-------------|----------| +| App / external URL | Non-secret runtime URL → app_config | (folded into D-02) | +| OIDC client_id + issuer | Non-secret OIDC identifiers (caveat: boot-time read) | (folded into D-02) | +| VAPID public key | Non-secret half of keypair → app_config | (folded into D-02) | +| Nothing — reconsider | Keep validate-only after all | | + +**User's choice (free text):** "I want the env variables to come through from docker or fall back to that .env file" + (Q3) "minimal kernel + db config." + +### Q3 — How far to push config-into-DB? + +| Option | Description | Selected | +|--------|-------------|----------| +| Minimal kernel + DB config | Env keeps bootstrap kernel only; wizard writes non-secret config to app_config | ✓ | +| Wizard validates env, writes only credential | Smallest, matches UI-SPEC | | +| Full DB-config migration | Move everything + refactor all consumers; scope risk | | + +**User's choice:** Minimal kernel + DB config; env from Docker, `.env` fallback. +**Notes:** Hard floor surfaced and accepted: DB connection + ENCRYPTION_KEY + VAPID_PRIVATE_KEY + SESSION_SECRET + OIDC client_secret cannot leave env (chicken-and-egg / key-beside-ciphertext / SC-3). + +--- + +## Restart & resume + +### Q1 — How to handle re-entry after the post-secrets restart? + +| Option | Description | Selected | +|--------|-------------|----------| +| Live re-detection, world is the state | No cursor; each step re-checks live env/DB; skip satisfied steps | | +| Persisted step cursor in app_config | Store setup_step; can drift from reality | | +| Always restart from Step 1 | Simplest but breaks the un-re-showable secrets step | | + +**User's choice (free text / reframe):** "No, I'm envisioning an Unraid template or clear instructions to bootstrap the image and all of those variables should be defined before first boot." +**Notes:** This designs OUT the mid-wizard restart entirely — kernel is complete at first boot; the generate-secrets step leaves the wizard. + +### Q2 — Where do the secret values come from before first boot? + +| Option | Description | Selected | +|--------|-------------|----------| +| Documented commands in template/README | openssl + npx web-push generate-vapid-keys | | +| Helper script in the repo | npm run generate-secrets prints all four, VAPID via web-push lib | ✓ | +| Pre-boot generator endpoint/mode | App boots unconfigured to generate; reintroduces complexity | | + +**User's choice:** Helper script in the repo. + +--- + +## Credential + admin + +### Q1 — How is the first Fastmail credential handled with no user row? + +| Option | Description | Selected | +|--------|-------------|----------| +| Validate in wizard, store post-login via self-service | CalDAV-validate only; store later via SetupBanner; double-entry | | +| Store against a pending/first user row | Wizard reserves a user row + credential, reconcile at login | ✓ (refined) | +| No credential in wizard at all | Drops the CalDAV step; fails SC-2 | | + +**User's choice (free text):** "When we switch from Fastmail to generic provider, the first user who gets provisioned will need to enter this. … we need a local user who will get merged into an OIDC user once that's set up." +**Notes:** Refined into the local-user → claim-at-first-login model (D-07/D-08); provider-agnostic framing. + +### Q2 — How is the first OIDC login matched to the pending local user? + +| Option | Description | Selected | +|--------|-------------|----------| +| First-login-claims | First OIDC login after setup_complete adopts the local user | ✓ | +| Match by email claim | Couples identity to email (fights D-10) | | +| Explicit claim code | One-time code; strongest but extra friction | | + +**User's choice:** First-login-claims. +**Notes:** User added a deferred capability — a fully local (no-OIDC) operating mode, wiring OIDC in later — to be captured as its own future phase. + +--- + +## Guard + +### Q1 — What does the 423 guard trust per invocation? + +| Option | Description | Selected | +|--------|-------------|----------| +| Flag OR live-config (defense in depth) | Lock if setup_complete OR (creds row + VAPID env), re-evaluated every call | ✓ | +| Flag only, read fresh each call | Single setup_complete flag, re-read per call | | +| Live-computed only (no flag) | No persisted flag; risks premature mid-flow lock | | + +**User's choice:** Flag OR live-config (defense in depth). + +--- + +## Claude's Discretion + +- Exact reworked step list and per-step field grouping (consistent with the revised UI-SPEC). +- Exact `/api/setup/*` route paths and new `app_config` key names. +- Migration packaging for the nullable-OIDC-identity + claimed-marker schema change. +- Whether `app_config` runtime reads are per-process cached or per-request. + +## Deferred Ideas + +- **Local-auth / no-OIDC operating mode** — run entirely on local DB users, wire OIDC in later; + its own future phase, built atop Phase 12's local-user foundation (D-07). -- 2.54.0 From f5542dce10c1c251878053241dcd65104b35b67c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:32:55 -0400 Subject: [PATCH 02/73] docs(12): research phase 12 initial setup wizard Covers pre-auth /api/setup/* route surface, minimal-env-kernel + DB-backed config model, pre-OIDC local user + first-login-claims schema migration, defense-in-depth 423 guard, generate-secrets helper script, and explicit reconciliation of the SETUP-03 and D-09 deviations from the roadmap. Co-Authored-By: Claude Sonnet 4.6 --- .../12-initial-setup-wizard/12-RESEARCH.md | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 .planning/phases/12-initial-setup-wizard/12-RESEARCH.md diff --git a/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md b/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md new file mode 100644 index 0000000..af920d4 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md @@ -0,0 +1,773 @@ +# Phase 12: Initial Setup Wizard — Research + +**Researched:** 2026-06-15 +**Domain:** First-run bootstrap wizard — pre-auth API surface, DB-backed config, pre-OIDC local user, 423 guard, secret generation helper +**Confidence:** HIGH (all findings grounded in direct codebase inspection) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**D-01: Minimal env kernel.** Only the irreducible bootstrap floor stays in env: DB connection, SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID_PRIVATE_KEY, OIDC client_secret. + +**D-02: Non-secret config moves to app_config.** The wizard collects via form fields and writes to app_config: app/external URL, OIDC issuer + client_id, VAPID public key. Runtime consumers read these from app_config rather than env. + +**D-03: Env resolution precedence.** Kernel env values come from Docker-provided process.env first, falling back to a .env file. + +**D-04: Full kernel defined before first boot.** The operator sets the entire env kernel before the container's first boot. No mid-wizard paste-and-restart. + +**D-05: Secret generation → repo helper script.** Generation moves OUT of the wizard to a repo helper script (e.g. npm run generate-secrets) that prints all four values (SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID public + private) formatted for pasting. + +**D-06: Stateless resume.** No persisted step cursor. The env + DB are the progress state. + +**D-07: Pre-OIDC local user.** The wizard provisions a local user row (no OIDC identity yet) that holds the first validated Fastmail credential and the pending-admin status. users.oidc_iss / oidc_sub become nullable, plus a claimed/pending marker. + +**D-08: First-login-claims.** The first OIDC login after setup_complete claims/merges the single unclaimed local user — populating its oidc_iss/oidc_sub, keeping the credential + is_admin. No email coupling (respects D-10 identity model). + +**D-09: Credential stored via setup endpoint reusing the shared helper.** A pre-auth /api/setup/* endpoint stores the local user's credential by calling the shared validateEncryptAndStoreCredential helper internally (no new crypto, no duplicated logic). The deviation from the literal roadmap "reuse admin routes" constraint honors its spirit (shared helper / no new crypto) while satisfying the pre-auth requirement. + +**D-10: Defense-in-depth guard.** Each setup-route invocation locks (423) if app_config.setup_complete is true OR the system is already effectively configured (a member_credentials row exists AND VAPID env present) — re-evaluated fresh every call, never cached at startup. + +### Claude's Discretion + +- Exact reworked step list and per-step field grouping — planner's call. +- Exact /api/setup/* route paths and the app_config key naming for the new non-secret config. +- The migration packaging for the nullable-OIDC-identity + claimed-marker schema change. +- Whether runtime config reads from app_config are cached per-process or read per-request. + +### Deferred Ideas (OUT OF SCOPE) + +- **Local-auth / no-OIDC operating mode** — its own future phase. Phase 12's pre-OIDC local-user provisioning (D-07) is the deliberate foundation that phase extends — capture now, build later. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| SETUP-01 | On first run, operator is guided through a setup wizard to define bootstrap configuration (app URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) | Pre-auth GET /api/setup/status + /setup PWA route + D-02 app_config collect-and-write; replaces hand-editing env | +| SETUP-02 | The wizard validates each input before completing — DB connects, VAPID private key decodes to 32 bytes and pairs with public key, OIDC discovery resolves, Fastmail app password reaches CalDAV (PROPFIND) | Validation routes: /api/setup/validate/db, /api/setup/validate/oidc, /api/setup/validate/vapid; SC-2 detailed in Validation Architecture | +| SETUP-03 | The wizard generates secrets for the operator to copy into env; secrets never written to DB or returned in a persistent response | D-05 deviation: generation moves to npm run generate-secrets repo helper using web-push.generateVAPIDKeys() + crypto.randomBytes(32).toString('hex'); wizard never generates or receives secrets | +| SETUP-04 | Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup) | D-10 defense-in-depth guard: 423 on setup_complete OR (member_credentials row exists AND VAPID env present); re-evaluated fresh per call | + + +--- + +## Summary + +Phase 12 delivers the pre-auth first-run setup wizard for FamilySync — the only part of the app that bypasses the OIDC guard. It rests on Phase 10's completed foundation: the `app_config` table (with `setup_complete`), the `validateEncryptAndStoreCredential` helper, and the `first-login-wins` bootstrap in `auth/user.ts` (which already has a comment naming Phase 12 as its tightening step). All key assets are verified in the codebase and ready to extend. + +The wizard introduces four distinct architectural concerns that must be planned as separate work streams: (1) a **minimal-env-kernel + DB-backed-config model** — moving non-secret runtime config from env into `app_config` so the operator's bootstrap shrinks to an irreducible floor of secrets and DB credentials; (2) a **pre-OIDC local user** — a new `users` row with nullable `oidc_iss`/`oidc_sub` and a `claimed` marker, claimed at first login; (3) a **pre-auth `/api/setup/*` route surface** mounted before the OIDC middleware (like `/health`), internally reusing `validateEncryptAndStoreCredential`; and (4) a **defense-in-depth 423 guard** evaluated fresh on every call, never cached. + +SETUP-03's "wizard generates secrets" wording is deliberately superseded by D-05: generation lives in a repo helper script (`npm run generate-secrets`) using `web-push.generateVAPIDKeys()` and `crypto.randomBytes(32).toString('hex')`. The wizard neither generates nor receives any secret values. The UI-SPEC Step 2 ("Generate Secrets") is dropped from the wizard flow; Steps 3/4 are revised to collect non-secret config inputs rather than validating pre-placed env values. + +**Primary recommendation:** Work in four waves — (Wave 0: schema migration + generate-secrets script) → (Wave 1: pre-auth route surface + 423 guard) → (Wave 2: complete happy path including local user + first-login-claims rework) → (Wave 3: PWA /setup page with revised step flow). + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Setup status check (unconfigured?) | API / Backend | — | Pre-auth endpoint; determines if wizard should render; gate lives at the server | +| 423 guard (setup locked) | API / Backend | — | Security boundary; must re-evaluate every call; cannot be trusted to the client | +| Non-secret config collection (OIDC issuer, VAPID public key, app URL) | API / Backend | — | app_config upsert is a backend write; config fields are DB-backed | +| VAPID / OIDC / DB / CalDAV validation | API / Backend | — | All involve live network calls (PROPFIND, OIDC discovery, DB ping) that only the server can safely make | +| Local user creation + credential storage | API / Backend | — | Calls validateEncryptAndStoreCredential; writes to users + member_credentials | +| setup_complete flip | API / Backend | — | Atomically writes to app_config; must be done after all pre-conditions pass | +| First-login-claims (OIDC identity merge) | API / Backend | — | Modifies upsertUser in auth/user.ts; runs on OIDC callback path | +| /setup route rendering (wizard UI) | Browser / Client | Frontend Server (SSR) | React PWA SPA; no SSR in this stack | +| Setup gate redirect (/ → /setup) | Browser / Client | — | App.tsx queries GET /api/setup/status on load; redirects if unconfigured | +| Secret generation helper | CLI / Build | — | npm run generate-secrets; runs at provisioning time, not in app runtime | + +--- + +## Standard Stack + +### Core (all already installed — no new packages) + +| Library | Version (installed) | Purpose | Why Standard | +|---------|---------------------|---------|--------------| +| hono | 4.12.23 | New /api/setup/* router | Project-standard HTTP framework [VERIFIED: codebase] | +| drizzle-orm | 0.45.2 | Schema migration + app_config reads/writes | Project-standard ORM [VERIFIED: codebase] | +| drizzle-kit | 0.31.10 | generate + migrate for schema change | Project-standard DDL workflow [VERIFIED: codebase] | +| web-push | ^3.6.7 | generateVAPIDKeys() in generate-secrets script | Already installed; generateVAPIDKeys() confirmed present [VERIFIED: codebase] | +| zod + @hono/zod-validator | ^3.25.0 / 0.8.0 | Request validation for /api/setup/* routes | Project-standard; noEchoHook pattern from admin.ts [VERIFIED: codebase] | +| @tanstack/react-query | 5.x | PWA: /api/setup/status query + step mutation calls | Project-standard server state [VERIFIED: codebase] | +| react-router | (installed in apps/pwa) | /setup route addition in App.tsx | Project-standard PWA routing [VERIFIED: codebase] | +| node:crypto | built-in | randomBytes(32).toString('hex') for SESSION_SECRET + APP_PASSWORD_ENCRYPTION_KEY in generate-secrets | Already used in crypto.ts [VERIFIED: codebase] | + +### No New Packages Required + +Phase 12 reuses the entire existing stack. There are no new npm dependencies. The generate-secrets script uses only Node.js built-ins (`node:crypto`) and the already-installed `web-push`. + +**Package Legitimacy Audit:** Not applicable — this phase installs zero new packages. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Operator (browser, pre-OIDC) + | + | GET /api/setup/status (pre-auth, before OIDC guard) + | | + | returns { setupComplete: false } + | | + v v + PWA /setup route (no AppNav/BottomTabBar) + | + | Step 1: Welcome + | Step 2: Config collect (OIDC issuer, client_id, VAPID pubkey, app URL) + | POST /api/setup/config ──→ app_config upserts + | Step 3: Validate + | POST /api/setup/validate/db ──→ DB ping (mysql2) + | POST /api/setup/validate/oidc ──→ OIDC discovery fetch + | POST /api/setup/validate/vapid ──→ base64url decode + 32-byte check + | Step 4: Credential + | POST /api/setup/credential ──→ validateEncryptAndStoreCredential + | | + | createFastmailClient → fetchCalendars (PROPFIND) + | encryptPassword (AES-256-GCM) + | INSERT users (oidc_iss=NULL, claimed=false, is_admin=true) + | INSERT member_credentials + | Step 5: Complete + | POST /api/setup/complete ──→ app_config.setup_complete = 'true' + | + | [All setup routes: 423 if setup_complete OR (member_credentials row + VAPID env set)] + | + v + Surface 7: "Setup complete" — "Sign in" → / → OIDC redirect → Authelia + | + v + First OIDC login → upsertUser (first-login-claims: finds unclaimed local user, populates oidc_iss + oidc_sub) +``` + +### Recommended Project Structure + +``` +apps/api/src/ +├── routes/ +│ └── setup.ts # new: setupRouter (all /api/setup/* handlers) +├── auth/ +│ └── user.ts # modify: upsertUser gains first-login-claims branch +├── db/ +│ ├── schema.ts # modify: users.oidcIss/oidcSub → nullable; add claimed marker +│ └── migrations/ +│ └── 0002_*.sql # drizzle-kit generate output for nullable + claimed +├── lib/ +│ └── setupGuard.ts # new: isSetupLocked() — the 423 re-evaluation per call +scripts/ +└── generate-secrets.ts (or .mjs) # new: npm run generate-secrets +apps/pwa/src/ +├── App.tsx # modify: add setup gate (fetch /api/setup/status on load) +└── routes/ + └── SetupPage.tsx # new: the multi-step wizard UI +``` + +### Pattern 1: Pre-Auth Route Mounting (established, must follow) + +**What:** Routes mounted BEFORE `devAuthBypass()` and `oidcAuthMiddleware()` in `apps/api/src/index.ts` are accessible without authentication. + +**How it works in the codebase:** +```typescript +// Source: apps/api/src/index.ts (VERIFIED: codebase) +// Current pre-auth surface: /health and /callback +app.route('/health', healthRouter); + +// OIDC middleware (only /api/* routes behind it): +app.use('/api/*', devAuthBypass()); +if (!devBypassActive) { + app.use('/api/*', oidcAuthMiddleware()); +} + +// /api/setup/* must mount BEFORE the /api/* middleware chain. +// The pattern: mount the setup router at /api/setup explicitly before +// the devAuthBypass/oidcAuthMiddleware use() calls, or mount outside /api/* +// entirely. The cleanest approach: mount at app level before the /api/* middleware: +app.route('/api/setup', setupRouter); // BEFORE app.use('/api/*', devAuthBypass()) +``` + +**Critical:** `/api/setup/*` must not be caught by the OIDC middleware. Mount it before `app.use('/api/*', ...)`. [VERIFIED: codebase — same as /health pattern] + +### Pattern 2: app_config Key/Value Read (established, follow exactly) + +**What:** All non-secret runtime config reads from `app_config` follow the `getHouseholdTimezone` pattern. + +```typescript +// Source: apps/api/dist/lib/householdTimezone.js (VERIFIED: codebase) +// Pattern for reading any app_config key: +const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); +return row?.value ?? fallback; + +// app_config upsert pattern (from routes/admin.ts for household_timezone): +// INSERT INTO app_config (key, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ? +// In Drizzle: db.insert(appConfig).values({key, value}).onDuplicateKeyUpdate({set: {value}}) +``` + +**New keys Phase 12 writes:** +- `'oidc_issuer'` — OIDC provider issuer URL +- `'oidc_client_id'` — OIDC client ID +- `'vapid_public_key'` — VAPID public key (non-secret; sent to browser for push subscribe) +- `'app_external_url'` — the operator's external URL for the app +- `'setup_complete'` — `'true'` after completion; `null`/absent = not yet set + +### Pattern 3: 423 Guard — Fresh Per-Call Evaluation (new, critical) + +**What:** Every `/api/setup/*` route must re-evaluate whether setup is already locked before doing any work. Failure to do this allows a second POST after completion to return 200 (Pitfall 8). + +```typescript +// Source: CONTEXT.md D-10, ROADMAP.md Pitfall 8 (VERIFIED: planning docs) +// Proposed implementation: + +// apps/api/src/lib/setupGuard.ts +import { db } from '../db/client.js'; +import { appConfig, memberCredentials } from '../db/schema.js'; +import { eq, sql } from 'drizzle-orm'; + +/** Returns true if the wizard is already locked (setup complete or effectively configured). */ +export async function isSetupLocked(): Promise { + // Check 1: explicit setup_complete flag + const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); + if (flagRow?.value === 'true') return true; + + // Check 2: effective configuration — credential exists AND VAPID env is present + const [credRow] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .limit(1); + const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY; + return !!credRow && vapidPresent; +} + +// In setupRouter — FIRST statement in every handler: +const locked = await isSetupLocked(); +if (locked) return c.json({ error: 'Setup already complete' }, 423); +``` + +**Test this guard BEFORE the happy path** (ROADMAP Pitfall 8 — a second POST must return 423, not 200). + +### Pattern 4: Pre-OIDC Local User + First-Login-Claims + +**What:** The wizard provisions a local user row with nullable OIDC fields and a `claimed` marker. The first OIDC login claims it. + +**Schema change needed** (Drizzle generate+migrate, NEVER push): +```typescript +// Proposed schema additions to apps/api/src/db/schema.ts +export const users = mysqlTable('users', { + // ... existing fields unchanged ... + // Make oidcIss + oidcSub nullable (currently .notNull()) + oidcIss: varchar('oidc_iss', { length: 512 }), // WAS .notNull() → nullable + oidcSub: varchar('oidc_sub', { length: 256 }), // WAS .notNull() → nullable + // New: claimed marker for the pending-admin local user + claimed: boolean('claimed').default(false).notNull(), // false = pending; true = merged +}); +``` + +**Migration concern:** `oidc_iss` and `oidc_sub` are currently `NOT NULL` with a `UNIQUE` constraint. Making them nullable and keeping the unique constraint requires care — MariaDB treats NULLs as distinct in unique indexes (multiple NULL rows are allowed), which is correct here (only one unclaimed user expected, but the DB won't reject it). The existing unique constraint `uniq_oidc_identity ON (oidc_iss, oidc_sub)` stays but is safe with nullable columns. [VERIFIED: codebase — current schema.ts + MariaDB NULL-in-unique behavior] + +**First-login-claims logic in upsertUser** (the hook named in the code comment): +```typescript +// Source: apps/api/src/auth/user.ts lines 112-122 (VERIFIED: codebase) +// Current comment: "Phase 12 will tighten this to: first user after app_config.setup_complete" + +// Revised upsertUser logic (Phase 12): +export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string | null) { + // 1. Look up by composite identity key (existing rows with oidc identity) + const existing = await db.select().from(users) + .where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))) + .limit(1); + if (existing[0]) { /* ... update displayName if needed ... */ return existing[0]; } + + // 2. Check setup_complete; if true, look for unclaimed local user (first-login-claims) + const [flagRow] = await db.select({ value: appConfig.value }) + .from(appConfig).where(eq(appConfig.key, 'setup_complete')).limit(1); + if (flagRow?.value === 'true') { + const [unclaimed] = await db.select().from(users) + .where(and(isNull(users.oidcIss), eq(users.claimed, false))) + .limit(1); + if (unclaimed) { + // Claim: populate oidc_iss + oidc_sub, set claimed=true, update displayName + await db.update(users).set({ + oidcIss, oidcSub, claimed: true, + displayName: displayName ?? unclaimed.displayName, + }).where(eq(users.id, unclaimed.id)); + return { ...unclaimed, oidcIss, oidcSub, claimed: true }; + } + } + + // 3. No unclaimed user (or setup not complete) — normal new-user insert path + // ... existing color + isAdmin logic (isAdmin gated: only when setup_complete is false) ... +} +``` + +**Identity rule preserved:** no email-keyed matching in the claim path. D-10 is not violated. [VERIFIED: codebase — D-10 decision in STATE.md and user.ts comments] + +### Pattern 5: validateEncryptAndStoreCredential Reuse in Setup + +**What:** The setup credential endpoint calls the same shared helper as admin.ts and me.ts — no new crypto, no duplicated validation logic. + +```typescript +// Source: apps/api/src/broker/credentialSync.ts (VERIFIED: codebase) +// Signature: +export async function validateEncryptAndStoreCredential( + userId: number, // ← the local user id created by the wizard + fastmailEmail: string, + appPassword: string, + providerType: string, +): Promise + +// In the setup credential handler: +// 1. Create the local user row first (or it should already be created in a prior step) +// 2. Call: await validateEncryptAndStoreCredential(localUserId, email, password, 'caldav') +// This does: createFastmailClient → fetchCalendars (PROPFIND) → encryptPassword → DB upsert → initial sync +// CredentialValidationError maps to 400; any other error maps to 503 +``` + +The helper's `userId` parameter must be a real DB row. The wizard must insert the local user BEFORE calling the credential step, so the FK constraint on `member_credentials.user_id` is satisfied. [VERIFIED: codebase — FK defined in schema.ts line 63] + +### Pattern 6: generate-secrets Script + +**What:** A standalone Node.js script (ESM, in the monorepo root or a scripts/ dir) that prints all four bootstrap secrets in a copy-paste-friendly format. + +```typescript +// Proposed: scripts/generate-secrets.ts (or .mjs) +// Source: web-push.generateVAPIDKeys() API confirmed working (VERIFIED: live execution) +import { generateVAPIDKeys } from 'web-push'; +import { randomBytes } from 'node:crypto'; + +const vapid = generateVAPIDKeys(); +const sessionSecret = randomBytes(32).toString('hex'); +const encKey = randomBytes(32).toString('hex'); + +console.log(` +# FamilySync Bootstrap Secrets — generated ${new Date().toISOString()} +# Paste these into your docker-compose.yml environment block. +# Keep this output safe — these values cannot be recovered if lost. + +SESSION_SECRET=${sessionSecret} +APP_PASSWORD_ENCRYPTION_KEY=${encKey} +VAPID_PUBLIC_KEY=${vapid.publicKey} +VAPID_PRIVATE_KEY=${vapid.privateKey} +`); +``` + +**VAPID key format confirmed (VERIFIED: live execution):** +- `generateVAPIDKeys()` returns `{ publicKey: string, privateKey: string }` — both base64url, no padding +- `publicKey` decodes to 65 bytes (uncompressed EC P-256 point) +- `privateKey` decodes to 32 bytes (raw P-256 scalar) +- `setVapidDetails()` validates both; the decode+32-byte check in SETUP-02 can reuse the same logic + +**Wire into package.json:** Add `"generate-secrets": "tsx scripts/generate-secrets.ts"` (or `"node --input-type=module scripts/generate-secrets.mjs"`) to the root `package.json` scripts. No new dependency needed if using the already-installed `web-push` and `node:crypto`. `tsx` may not be available; using `node --loader ts-node/esm` or compiling to JS first avoids adding a dev dep. The simplest option: a plain `.mjs` file that imports `web-push` from node_modules (avoids TypeScript compilation). + +### Pattern 7: VAPID Structural Validation (SC-2) + +**What:** The SETUP-02 requirement for "VAPID private key decodes to exactly 32 bytes and pairs with the public key" is satisfied by calling `webpush.setVapidDetails()` in the validation route. This is the same internal check web-push itself performs before signing. + +```typescript +// In POST /api/setup/validate/vapid: +import webpush from 'web-push'; +const privateKey = process.env.VAPID_PRIVATE_KEY ?? ''; +const publicKey = process.env.VAPID_PUBLIC_KEY ?? ''; // or read from app_config if D-02 already written +const subject = process.env.VAPID_SUBJECT ?? ''; + +try { + // setVapidDetails calls validatePrivateKey (32-byte check) and validatePublicKey (65-byte check) + webpush.setVapidDetails(subject || 'mailto:validate@familysync.local', publicKey, privateKey); + // Keys are structurally valid AND pair correctly (same generation) + return c.json({ ok: true }); +} catch (err) { + return c.json({ ok: false, error: err instanceof Error ? err.message : 'VAPID validation failed' }, 400); +} +``` + +**Note:** `setVapidDetails` does NOT make a network call — it only validates structure. It does NOT confirm the keys were generated together (a public key from a different pair would still pass the 32-byte and 65-byte structural checks). The ROADMAP's "pairs with the public key" requirement is therefore interpreted as a structural match (both decode to correct lengths via the same format — base64url, no padding), not a cryptographic proof of pairing. The wizard generated them together and the operator pastes both; a mismatched pair produces an error at push-send time, not at validation time. [VERIFIED: web-push source vapid-helper.js] + +### Pattern 8: OIDC Discovery Validation (SC-2) + +**What:** Validate OIDC issuer by fetching `{issuer}/.well-known/openid-configuration`. + +```typescript +// In POST /api/setup/validate/oidc: +// Read oidc_issuer from app_config (already written in config step) or from form body +const issuer = ...; // from app_config or request body +try { + const res = await fetch(`${issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const config = await res.json() as { issuer?: string }; + // Optional: verify config.issuer matches submitted issuer + return c.json({ ok: true }); +} catch (err) { + return c.json({ ok: false, error: 'OIDC discovery failed' }, 400); +} +``` + +`fetch` is available in Node.js 22 LTS natively. [VERIFIED: Node.js 22 built-in] + +### Anti-Patterns to Avoid + +- **Mounting /api/setup/* after app.use('/api/*', oidcAuthMiddleware())** — this silently makes setup routes require auth. Must mount before. [VERIFIED: codebase index.ts] +- **Caching the 423 guard result at startup** — the guard must re-query the DB on every call. A startup-evaluated flag can be stale if multiple instances or a manual DB edit changes setup_complete. [CONTEXT.md D-10] +- **Calling drizzle-kit push** — always use generate + migrate on MariaDB. Push has a known false-destructive-diff bug on MariaDB 11. [VERIFIED: STATE.md D-Task5-DDL; REQUIREMENTS.md Out of Scope] +- **Email-keyed identity in first-login-claims** — the claim must match by `claimed=false AND oidcIss IS NULL`. No email field lookup. [VERIFIED: STATE.md identity decision] +- **Logging appPassword or encryptedPassword** in setup credential handler — same rule as admin.ts and me.ts. [VERIFIED: credentialSync.ts security contract] +- **Calling /api/admin/credentials from the pre-auth wizard** — physically impossible (403 because OIDC guard hasn't run). Use the shared helper directly. [CONTEXT.md D-09] +- **Putting VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY in app_config** — SC-3 / Pitfall 10 / D-01. These must stay in env. [CONTEXT.md D-01] + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| CalDAV PROPFIND credential validation | Custom HTTP + XML parser | `createFastmailClient` + `fetchCalendars` in `validateEncryptAndStoreCredential` | Already exists, tested, handles auth failure → CredentialValidationError | +| AES-256-GCM encryption | Any new crypto | `encryptPassword` in `broker/crypto.ts` | Existing tested implementation; APP_PASSWORD_ENCRYPTION_KEY key reads correctly | +| VAPID structural validation | Byte-count logic | `webpush.setVapidDetails()` | Runs the library's own internal `validatePrivateKey` (32-byte) + `validatePublicKey` (65-byte) checks | +| OIDC discovery fetch | Custom OpenID client | `fetch('{issuer}/.well-known/openid-configuration')` | Standard endpoint; one fetch call + HTTP status check is sufficient for the setup validation | +| DB connectivity test | Raw mysql2 query | Drizzle: `await db.select({v: sql`1`}).from(appConfig).limit(1)` | Exercises the real pool; minimal surface area | +| app_config upsert | Hand-crafted INSERT/ON DUPLICATE | Drizzle `insert().values().onDuplicateKeyUpdate()` | Established pattern from `routes/admin.ts` (household_timezone) | +| Secret generation | Custom base64url encoding | `webpush.generateVAPIDKeys()` + `crypto.randomBytes(32).toString('hex')` | Library handles EC P-256 key generation and padding correctly | + +**Key insight:** Phase 12 assembles existing parts — it adds almost no new logic. The security-sensitive operations (encrypt, validate, store) are already tested and must not be duplicated. + +--- + +## Requirements Deviation Reconciliation + +This section explicitly addresses the flagged deviations from SETUP-03 and the ROADMAP constraint. + +### Deviation 1: SETUP-03 "The wizard generates secrets" + +**Requirement wording:** "The wizard generates secrets (session secret, encryption key, VAPID keypair) for the operator to copy into env; secrets are never written to the database or returned in a response body." + +**Chosen model (D-05):** Generation moves OUT of the wizard to a `npm run generate-secrets` repo helper script. The wizard never sees or generates secrets. + +**How SETUP-03 intent is satisfied:** +- The helper script generates all four values using the same `web-push` library that the app uses, ensuring VAPID format compatibility. +- Secrets are never written to the DB or returned in a persistent response (they are printed once to stdout and discarded). +- The script's output is formatted for direct pasting into docker-compose.yml environment blocks. +- SETUP-03's "for the operator to copy into env" is literally satisfied — the helper prints values the operator copies. The _medium_ changes (pre-boot instead of in-wizard), but the outcome and security properties are the same. + +**UI-SPEC Step 2 impact:** The "Generated Secrets" step (wizard Step 2 with four Secret Blocks and acknowledgment checkboxes) is DROPPED. The revised wizard steps are: Welcome → Config Collect → Validate → Credential → Complete (exact naming is Claude's discretion per CONTEXT.md). + +### Deviation 2: ROADMAP "do NOT create /api/setup/credentials — reuse the Phase 10 admin routes" + +**Roadmap literal constraint:** "do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes" + +**Reality:** A pre-auth wizard physically cannot call `/api/admin/credentials` — the OIDC guard would reject the request with 302 before the handler runs. + +**Chosen model (D-09):** Create a `/api/setup/credential` (pre-auth) endpoint that internally calls `validateEncryptAndStoreCredential(localUserId, ...)` — the same shared helper used by both admin and self-service paths. + +**How the constraint's spirit is honored:** +- Zero new crypto code (reuses `encryptPassword` from `broker/crypto.ts` unchanged via the shared helper). +- Zero duplicated validation logic (reuses `createFastmailClient` + `fetchCalendars` via the shared helper). +- The constraint was about preventing a second, divergent credential-storage path — that is preserved. The helper is the single source of truth; the setup endpoint just calls it. + +--- + +## Env Kernel vs DB Config Split + +### The Irreducible Env Floor (D-01) — CANNOT go in app_config + +| Env Var | Why it stays in env | +|---------|---------------------| +| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Chicken-and-egg: needed to reach the DB where app_config lives | +| `APP_PASSWORD_ENCRYPTION_KEY` | Storing the key beside its ciphertext defeats AES-256-GCM (SC-3 / Pitfall 10) | +| `VAPID_PRIVATE_KEY` | Must never enter the DB (SC-3); signs push requests server-side only | +| `SESSION_SECRET` (OIDC_AUTH_SECRET) | Used to sign the OIDC session JWT cookie; needed before any OIDC flow can complete | +| `OIDC_CLIENT_SECRET` | Secrets by definition; protocol requires it as a confidential value | + +### Non-Secret Config (D-02) — MOVES to app_config (written by the wizard) + +| app_config Key | Description | Runtime Consumer | +|----------------|-------------|-----------------| +| `'oidc_issuer'` | Authelia issuer URL | `auth/middleware.ts` boot config (must be refactored to read from app_config) | +| `'oidc_client_id'` | OIDC client ID | `auth/middleware.ts` boot config | +| `'vapid_public_key'` | VAPID public key (non-secret) | Push routes (send to browser); PWA (subscribe) | +| `'app_external_url'` | External URL for OIDC redirect_uri and OIDC_AUTH_EXTERNAL_URL | `auth/middleware.ts` boot config | +| `'setup_complete'` | Wizard completion flag | 423 guard + first-login-claims gate in `upsertUser` | +| `'household_timezone'` | Timezone (already in app_config, written by Phase 10 admin UI) | `lib/householdTimezone.ts` — already reading from app_config | + +**Critical implication for auth middleware:** `@hono/oidc-auth` currently reads `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_AUTH_EXTERNAL_URL` from env at middleware initialization time. If these move to app_config, the middleware initialization must be deferred until after app_config is populated (i.e., after setup is complete), or the middleware reads app_config on first request. **This is the trickiest integration point in Phase 12** and must be planned explicitly. One clean approach: in `index.ts`, check if setup is complete before mounting oidcAuthMiddleware; if not, mount a "redirect to /setup" fallback for /api/* routes instead. The planner must decide the exact deferral/boot pattern. [ASSUMED — exact oidcAuthMiddleware deferral strategy not yet designed; the CONTEXT.md does not specify it] + +**Env precedence (D-03):** Docker-provided `process.env` → `.env` file. Standard dotenv behavior: `process.env` values are NOT overwritten by dotenv if already set. This is the default behavior of the `dotenv` package. FamilySync already reads from `process.env` directly (no explicit dotenv call seen in source) — if env vars come from Docker's `environment:` block, they are already in process.env. A `.env` file would require an explicit `dotenv.config()` call for fallback. **The planner must verify whether dotenv is currently called and where the .env fallback wiring lives.** [ASSUMED for .env fallback mechanism — not seen in source files reviewed] + +--- + +## Common Pitfalls + +### Pitfall 1: /api/setup/* Mounted After OIDC Guard +**What goes wrong:** Routes catch a 302 redirect to Authelia before any handler runs. +**Why it happens:** `app.use('/api/*', oidcAuthMiddleware())` applies to all /api/* including /api/setup/*. +**How to avoid:** Mount `app.route('/api/setup', setupRouter)` before `app.use('/api/*', devAuthBypass())`. [VERIFIED: index.ts mounting order] +**Warning signs:** `GET /api/setup/status` returns 302; network tab shows Authelia redirect. + +### Pitfall 2: 423 Guard Evaluated Once at Startup +**What goes wrong:** A second POST after completion returns 200 instead of 423. +**Why it happens:** Startup evaluation caches a false "not locked" state before setup completes. +**How to avoid:** `isSetupLocked()` must be called at the top of EVERY setup handler, reading from DB fresh each time. Never hoist to a module-level variable. +**Warning signs:** Vitest test: `POST /api/setup/complete` twice; second call returns 200. + +### Pitfall 3: drizzle-kit push on Nullable Column Migration +**What goes wrong:** Drizzle-kit push on MariaDB misreads metadata and schedules a destructive operation. +**Why it happens:** Known MariaDB mysql dialect bug (STATE.md D-Task5-DDL). +**How to avoid:** `drizzle-kit generate` to emit SQL, review the migration file, then `drizzle-kit migrate`. NEVER push. +**Warning signs:** `drizzle-kit push` output mentions DROP or truncate on existing tables. + +### Pitfall 4: First-Login-Claims Matching by Email +**What goes wrong:** Email claim from Authelia matches the wrong user or creates a coupling. +**Why it happens:** Shortcut to avoid a nullable-field query. +**How to avoid:** The claim query is `WHERE oidc_iss IS NULL AND claimed = false LIMIT 1`. No email field. [VERIFIED: STATE.md identity decision] +**Warning signs:** `upsertUser` reading `claims.email` to find the local user. + +### Pitfall 5: FK Violation on member_credentials Insert +**What goes wrong:** `validateEncryptAndStoreCredential(localUserId, ...)` fails with FK constraint error. +**Why it happens:** The local user row was not inserted before calling the credential helper. +**How to avoid:** The setup credential step must first ensure a local user row exists (created in the wizard flow), then pass that row's id. The credential helper assumes the user row pre-exists (FK on member_credentials.user_id references users.id). [VERIFIED: schema.ts line 63] +**Warning signs:** MySQL error code 1452 (foreign key constraint failure). + +### Pitfall 6: VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY Written to app_config +**What goes wrong:** Encryption key stored beside its ciphertext; private key exposed in DB. +**Why it happens:** Confusion between the non-secret config (goes to app_config) and the secret floor (stays in env). +**How to avoid:** The D-01 table above is authoritative. These env vars are kernel-only; the wizard never reads, writes, or returns them. +**Warning signs:** Any `INSERT INTO app_config WHERE key IN ('vapid_private_key', 'app_password_encryption_key')`. + +### Pitfall 7: App Password Echoed in 400 Response +**What goes wrong:** Zod validation error leaks the submitted password in `issues[].received`. +**Why it happens:** Default zod-validator error response includes `received` field. +**How to avoid:** Use `noEchoHook` pattern from admin.ts — return `{ error: 'Invalid request' }` 400, no Zod details. [VERIFIED: admin.ts noEchoHook] +**Warning signs:** Network response body contains `"received"` or the credential value. + +### Pitfall 8: oidcAuthMiddleware Boot-Time OIDC Config Reads +**What goes wrong:** The app crashes at boot (before setup is complete) because OIDC_ISSUER / OIDC_CLIENT_ID are not in env. +**Why it happens:** If these values move to app_config (D-02), env won't have them at boot time for a fresh instance. +**How to avoid:** The planner must choose one of: (a) keep OIDC_ISSUER + OIDC_CLIENT_ID as optional env with app_config override (env OR app_config at boot), or (b) defer oidcAuthMiddleware mounting until after setup_complete is confirmed, or (c) make the middleware lazy-read config on first request. This requires explicit planning before implementation. +**Warning signs:** Crash at startup with "Cannot read OIDC_ISSUER" on a fresh instance. + +### Pitfall 9: Unique Constraint on oidc_iss/oidc_sub with NULL Values +**What goes wrong:** Migration that changes `NOT NULL` to nullable fails because the existing unique index definition changes semantics. +**Why it happens:** Some MariaDB versions reject NULLs in a unique index defined as NOT NULL at schema creation time. +**How to avoid:** The migration must: (1) ALTER COLUMN oidc_iss/oidc_sub to allow NULL, (2) possibly DROP and re-CREATE the unique constraint. Drizzle-kit generate will produce correct SQL; review it before applying. +**Warning signs:** `drizzle-kit generate` output includes DROP CONSTRAINT before ADD CONSTRAINT on the unique index. + +--- + +## Runtime State Inventory + +This is a migration phase in the sense that the schema changes (nullable fields + claimed marker). However, it is not a rename/refactor. + +| Category | Items Found | Action Required | +|----------|-------------|-----------------| +| Stored data | `app_config`: `household_timezone` and `setup_complete` keys already exist (Phase 10). Existing users table has `oidc_iss NOT NULL`, `oidc_sub NOT NULL`. | Schema migration: make oidc_iss/oidc_sub nullable, add `claimed` column. Existing rows are real OIDC users — they get `claimed=true` in the migration (they already have oidc identity, so they are "effectively claimed"). | +| Live service config | None beyond MariaDB schema. | None | +| OS-registered state | None | None | +| Secrets/env vars | VAPID_PRIVATE_KEY, APP_PASSWORD_ENCRYPTION_KEY, SESSION_SECRET — remain in env. OIDC_ISSUER, OIDC_CLIENT_ID may be refactored to app_config. | If moving to app_config: add backwards-compat env fallback in consumers before removing from env. | +| Build artifacts | None | None | + +**Migration backfill for `claimed` column:** Existing user rows (real OIDC users with oidc_iss/oidc_sub) should have `claimed = true` set in the migration so the first-login-claims logic only ever finds rows where `claimed = false AND oidc_iss IS NULL`. SQL: `UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL`. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest (API integration tests in apps/api/tests/) | +| Config file | apps/api/vitest.config.ts | +| Quick run command | `pnpm --filter @familysync/api test` | +| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` | +| E2E command | `pnpm test:e2e` (Playwright — apps/pwa) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | Notes | +|--------|----------|-----------|-------------------|-------| +| SETUP-01 | GET /api/setup/status returns { setupComplete: false } on fresh instance | API integration | `pnpm --filter @familysync/api test -- setup` | Test file: apps/api/tests/setup.test.ts (Wave 0 gap) | +| SETUP-01 | GET /api/setup/status returns { setupComplete: true } after completion | API integration | `pnpm --filter @familysync/api test -- setup` | Same file | +| SETUP-01 | /setup route renders wizard when unconfigured (no AppNav/BottomTabBar) | Playwright smoke | `pnpm test:e2e` | Requires DEV_AUTH_BYPASS bypass for the setup route (it's pre-auth; bypass is irrelevant here — setup is accessible without auth) | +| SETUP-02 | POST /api/setup/validate/vapid returns 200 for valid keys, 400 for truncated key | Unit | `pnpm --filter @familysync/api test -- setup.validate` | Can test without real VAPID env — mock process.env | +| SETUP-02 | POST /api/setup/validate/db returns 200 when DB reachable | API integration | `pnpm --filter @familysync/api test -- setup.validate` | Requires MariaDB (existing test infra) | +| SETUP-02 | POST /api/setup/validate/oidc returns 400 for unreachable issuer | Unit (fetch mock) | `pnpm --filter @familysync/api test -- setup.validate` | Mock fetch | +| SETUP-02 | POST /api/setup/credential: CalDAV PROPFIND failure → 400 | Unit (mock client) | `pnpm --filter @familysync/api test -- setup.credential` | Same mock pattern as admin.test.ts | +| SETUP-03 | generate-secrets script outputs SESSION_SECRET (64 hex chars), APP_PASSWORD_ENCRYPTION_KEY (64 hex chars), VAPID_PUBLIC_KEY (base64url 87 chars), VAPID_PRIVATE_KEY (base64url 43 chars) | Unit (script invocation) | `node scripts/generate-secrets.mjs 2>&1` | Smoke test via Bash in test; parse output | +| SETUP-04 | POST /api/setup/complete twice → first 200, second 423 | API integration | `pnpm --filter @familysync/api test -- setup.guard` | The critical Pitfall 8 regression | +| SETUP-04 | POST any /api/setup/* route when member_credentials exists + VAPID env set → 423 | API integration | `pnpm --filter @familysync/api test -- setup.guard` | Tests D-10 "effective configuration" branch | +| D-08 | First OIDC login after setup_complete → claims unclaimed local user, is_admin preserved | API integration | `pnpm --filter @familysync/api test -- user.upsert` | Mock upsertUser with setup_complete = 'true' in app_config | + +### Sampling Rate +- **Per task commit:** `pnpm --filter @familysync/api test` +- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` +- **Phase gate:** Full suite + `pnpm test:e2e` green before `/gsd-verify-work` + +### Wave 0 Gaps (files that must be created before implementation) + +- [ ] `apps/api/tests/setup.test.ts` — covers SETUP-01/02/03/04, the 423 guard (Pitfall 8), and first-login-claims (D-08) +- [ ] `apps/api/src/routes/setup.ts` — stub (empty Hono router) so imports don't break Wave 1 tests +- [ ] `apps/api/src/lib/setupGuard.ts` — stub for the 423 guard + +--- + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | Yes | 423 guard prevents replay; local user + first-login-claims; no password auth in wizard | +| V3 Session Management | No | Setup routes are stateless (no session cookie created/required) | +| V4 Access Control | Yes | 423 guard (every call); no admin routes callable pre-auth | +| V5 Input Validation | Yes | zod + noEchoHook on credential endpoint; email/password max length enforced | +| V6 Cryptography | Yes | AES-256-GCM via existing encryptPassword; VAPID private key stays in env; NEVER in DB | + +### Known Threat Patterns for this Phase + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Setup endpoint replay after completion | Tampering | D-10 423 guard, re-evaluated per call, never cached | +| App password echoed in validation error | Information Disclosure | noEchoHook (same as admin.ts) — Zod error details never returned | +| VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY written to DB | Information Disclosure | D-01 env floor; no app_config key for these values | +| Race: two concurrent setup completions | Tampering | POST /api/setup/complete must be idempotent (second call returns 423 immediately after first sets setup_complete) | +| First-login-claims claiming wrong user | Spoofing | Claim query: `WHERE oidc_iss IS NULL AND claimed = false LIMIT 1` — in a 2-person household there is exactly one pending user; the threat model notes OIDC reach requires household membership | +| OIDC issuer SSRF via config step | Tampering | Validate the issuer URL format (must be https://); the discovery fetch is server-side | + +**Security constraint inherited from CONTEXT.md D-01/SC-3:** `APP_PASSWORD_ENCRYPTION_KEY` and `VAPID_PRIVATE_KEY` must NEVER appear in the database, in any API response, or in any log line. The wizard validates VAPID keys structurally (via `setVapidDetails`) and validates the encryption key functionally (the fact that `encryptPassword` doesn't throw proves the key is the correct length), but neither value is returned to the client. + +--- + +## Project Constraints (from CLAUDE.md) + +| Directive | Impact on Phase 12 | +|-----------|-------------------| +| MariaDB only (no PostgreSQL) | All schema migrations use mysql2 dialect in drizzle-kit; no Postgres-specific DDL | +| Drizzle ORM | Schema changes via drizzle-kit generate + migrate (never push) | +| Hono 4.12.23 | setupRouter is a `new Hono()` mounted before the OIDC guard | +| React 19 PWA (no React Native) | SetupPage.tsx is a React component in apps/pwa/src/routes/ | +| No dangerouslySetInnerHTML | UI-SPEC's security contract — all copy is plain-text JSX children | +| playwright-cli skill | Wizard UI must be validated with playwright-cli (desktop Chromium); iOS-specific behaviors remain human checkpoints | +| Authelia OIDC (authorization_code + PKCE, client_secret_basic) | First-login-claims must not break the existing OIDC callback path | +| Identity: oidc_iss + oidc_sub, never email | first-login-claims uses `WHERE oidc_iss IS NULL AND claimed = false`, no email join | +| No email features | Out of scope | + +--- + +## UI-SPEC Revision Requirements + +The planner MUST revise the `12-UI-SPEC.md` Wizard Steps and Interaction Contract sections before finalizing plans. The design system, tokens, surfaces, copywriting, and a11y contract still hold. What changes: + +| UI-SPEC Section | Required Revision | +|-----------------|-------------------| +| Step 2: Generate Secrets | **DROP this step entirely.** Generation is pre-boot (D-05). No Secret Blocks, no checkboxes, no `POST /api/setup/generate`. The 5-step indicator becomes 4 steps (or re-numbered). | +| Step 3: Database | **Remove "No operator input fields" assumption** if config-collect is a separate step. If config-collect is Step 2 (new), Step 3 is the validation-only DB check — this section stays largely the same. | +| Step 4: OIDC & Push | **Add input fields.** This step now collects OIDC issuer + client_id and VAPID public key (non-secret inputs) AND validates them. The "description: verify that OIDC_ISSUER is in place" assumption is superseded — the wizard writes these values first, then validates. Ref: D-02. | +| Step 4 VAPID copy | Revise: VAPID public key is now an **input field** (entered by the operator from the generate-secrets output); VAPID private key stays in env (structural validation only — read from process.env.VAPID_PRIVATE_KEY). | +| Step labels | Revised set (planner's call): Welcome / Config / Validate / Credential / Complete | +| Routing gate | Step 1 description copy references "You'll need your OIDC client credentials and Fastmail app password" — remove "copy of docker-compose.yml to paste generated secrets into" reference since secrets are pre-boot. | + +**What stays unchanged in UI-SPEC:** All design tokens, spacing scale, typography, color palette, surface definitions (1–3, 5–8), a11y contract, responsive behavior, security display rules, copywriting for the non-secrets steps, the Credential step (Step 5 → Step 4 if secrets step removed), and the Terminal/Locked screens. + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| MariaDB | All /api/setup/validate/db + /api/setup/credential + /api/setup/complete routes | ✓ (existing dev stack) | 10.x/11.x (docker) | None — DB is the kernel floor | +| Node.js 22 LTS | generate-secrets script, API | ✓ | 22 LTS | None | +| web-push (generateVAPIDKeys) | generate-secrets script | ✓ | ^3.6.7 in apps/api/node_modules | None needed | +| Authelia OIDC | /api/setup/validate/oidc (live) | Deployment-dependent | — | Test against a local Authelia or mock the discovery endpoint in tests | +| Fastmail CalDAV | /api/setup/credential (PROPFIND) | Deployment-dependent | — | Tests use the existing mock (vi.mock for createFastmailClient) as in admin tests | +| playwright-cli | PWA /setup route smoke test | ✓ | /usr/local/bin/playwright-cli | N/A | + +**Missing dependencies with no fallback:** None that block development — Authelia and Fastmail are only needed for live integration; unit/integration tests mock them (same pattern as existing admin.test.ts). + +--- + +## Open Questions + +1. **oidcAuthMiddleware boot-time config reads** + - What we know: `@hono/oidc-auth` reads OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_AUTH_EXTERNAL_URL at initialization. If these move to app_config (D-02), a fresh unconfigured instance has no env values. + - What's unclear: Does the planner want to (a) keep these as optional env with app_config override, (b) defer middleware mounting until setup_complete, or (c) make the middleware lazy? + - Recommendation: Option (a) is the safest first pass — keep env as a fallback for boot-before-setup, then app_config becomes the primary source once written. This avoids a crash on fresh boot and does not require middleware deferral. + +2. **generate-secrets script location and toolchain** + - What we know: The root package.json has only devDependencies (no `tsx`). `apps/api` has TypeScript but the script needs to run before the API is built. + - What's unclear: Should the script be a plain `.mjs` (no compilation needed), a compiled TypeScript file, or added to apps/api/src and run via `pnpm --filter @familysync/api` with a tsx/node script? + - Recommendation: A plain `.mjs` at `scripts/generate-secrets.mjs` in the monorepo root, importing `web-push` from `apps/api/node_modules/web-push`. Add to root `package.json`: `"generate-secrets": "node scripts/generate-secrets.mjs"`. No new toolchain needed. + +3. **App_config consumer refactoring scope** + - What we know: OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_AUTH_EXTERNAL_URL are currently env-only. The householdTimezone module already reads from app_config. auth/middleware.ts exports from @hono/oidc-auth directly (no config logic visible in the file). + - What's unclear: How deep does @hono/oidc-auth's config reading go? Is it read at import time or call time? + - Recommendation: Research this at planning time by reading @hono/oidc-auth source. If config is read at middleware initialization (call to oidcAuthMiddleware()), a lazy-initialization pattern (initialize on first request, read app_config at that point) may be needed. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | The .env fallback for kernel env vars requires an explicit dotenv.config() call — FamilySync may not currently call this | Env Kernel vs DB Config Split (D-03) | If dotenv is already wired, the fallback already works. If not, planner must add dotenv.config() call or document that .env fallback is Docker-only. | +| A2 | @hono/oidc-auth reads OIDC_ISSUER etc. at oidcAuthMiddleware() call time (not import time) | Pitfall 8 / Open Question 1 | If read at import time, every import of middleware.ts on a fresh instance would fail. If call-time, lazy initialization is possible. | +| A3 | VAPID "pairs with the public key" in SETUP-02 means structural validation only (both decode correctly), not cryptographic proof | Pattern 7 (VAPID validation) | If exact key-pair proof is required, a full ECDH derivation check is needed — more complex. The ROADMAP wording "pairs with the public key" is ambiguous; current interpretation is structural. | +| A4 | The migration backfill `UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL` correctly handles existing prod users | Runtime State Inventory | If prod has no users yet (fresh post-Phase-10 deploy), this is a no-op and safe. If somehow users exist with null oidc_iss for other reasons, they would stay unclaimed — unlikely given current schema. | + +**If this table is empty:** All claims in this research were verified or cited. The four assumptions above are low-risk for a 2-person household app; the planner should confirm A1 and A2 by reading the relevant source/docs before finalizing Wave 1 tasks. + +--- + +## Sources + +### Primary (HIGH confidence — verified in codebase) +- `apps/api/src/auth/user.ts` — upsertUser implementation; first-login-wins comment naming Phase 12 +- `apps/api/src/db/schema.ts` — current users/app_config/member_credentials schema +- `apps/api/src/routes/admin.ts` — validateEncryptAndStoreCredential usage + noEchoHook pattern +- `apps/api/src/broker/credentialSync.ts` — shared helper: signature, CredentialValidationError, flow +- `apps/api/src/broker/crypto.ts` — encryptPassword/decryptPassword (AES-256-GCM) +- `apps/api/src/broker/client.ts` — createFastmailClient +- `apps/api/src/index.ts` — route mounting order (pre-auth vs OIDC-guarded) +- `apps/api/src/lib/requireAdmin.ts` — requireAdmin pattern +- `apps/api/src/routes/me.ts` — noEchoHook on self-service credential; resolveUserId +- `apps/api/dist/lib/householdTimezone.js` — app_config read pattern +- `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` — Phase 10 migration (app_config creation confirmed) +- `apps/pwa/src/App.tsx` — existing routing structure; SetupBanner/CredentialSheet usage +- `apps/pwa/src/components/SetupBanner.tsx` — self-service credential UX +- `apps/api/node_modules/web-push/src/vapid-helper.js` + live execution — generateVAPIDKeys() format, validatePrivateKey (32-byte), validatePublicKey (65-byte) +- `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` — locked decisions +- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — design system (valid) + steps (partially superseded) +- `.planning/phases/10-admin-role-settings/10-CONTEXT.md` — Phase 10 decisions this phase builds on +- `.planning/REQUIREMENTS.md` — SETUP-01..04 full wording +- `.planning/ROADMAP.md` — Phase 12 success criteria, pitfalls, constraints +- `.planning/STATE.md` — D-Task5-DDL (drizzle-kit push unsafe), D-10 identity model + +### Secondary (MEDIUM confidence) +- Node.js 22 LTS built-in `fetch` — used for OIDC discovery validation [ASSUMED — confirmed by Node.js 22 docs] + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all packages verified in codebase; zero new packages needed +- Architecture: HIGH — grounded in direct source file inspection; patterns are established in the codebase +- Pitfalls: HIGH — drawn from ROADMAP.md explicitly-named pitfalls + codebase review +- Schema migration: HIGH — current schema.ts read directly; migration path clear +- oidcAuthMiddleware config-read timing: ASSUMED (A2) — requires @hono/oidc-auth source review to confirm + +**Research date:** 2026-06-15 +**Valid until:** 2026-07-15 (stable stack; Phase 12 is the only consumer of these patterns in this codebase) -- 2.54.0 From 48acf3ac9528bea2c52b7e37e79e4a0b2852ecf3 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:34:00 -0400 Subject: [PATCH 03/73] docs(12): add research and validation strategy Co-Authored-By: Claude Opus 4.8 (1M context) --- .../12-initial-setup-wizard/12-VALIDATION.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .planning/phases/12-initial-setup-wizard/12-VALIDATION.md diff --git a/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md new file mode 100644 index 0000000..37d4499 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md @@ -0,0 +1,76 @@ +--- +phase: 12 +slug: initial-setup-wizard +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-06-15 +--- + +# Phase 12 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} | +| **Config file** | {path or "none — Wave 0 installs"} | +| **Quick run command** | `{quick command}` | +| **Full suite command** | `{full command}` | +| **Estimated runtime** | ~{N} seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `{quick run command}` +- **After every plan wave:** Run `{full suite command}` +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** {N} seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `{tests/test_file.py}` — stubs for REQ-{XX} +- [ ] `{tests/conftest.py}` — shared fixtures +- [ ] `{framework install}` — if no framework detected + +*If none: "Existing infrastructure covers all phase requirements."* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| {behavior} | REQ-{XX} | {reason} | {steps} | + +*If none: "All phase behaviors have automated verification."* + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < {N}s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** {pending / approved YYYY-MM-DD} -- 2.54.0 From ea4017692053e122238f35d9779362f5c54c7102 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:37:37 -0400 Subject: [PATCH 04/73] docs(12): map patterns to existing analogs Co-Authored-By: Claude Opus 4.8 (1M context) --- .../12-initial-setup-wizard/12-PATTERNS.md | 564 ++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 .planning/phases/12-initial-setup-wizard/12-PATTERNS.md diff --git a/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md b/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md new file mode 100644 index 0000000..b4542b8 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-PATTERNS.md @@ -0,0 +1,564 @@ +# Phase 12: Initial Setup Wizard - Pattern Map + +**Mapped:** 2026-06-15 +**Files analyzed:** 10 new/modified files +**Analogs found:** 10 / 10 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `apps/api/src/routes/setup.ts` | route | request-response | `apps/api/src/routes/admin.ts` | exact | +| `apps/api/src/lib/setupGuard.ts` | utility | request-response | `apps/api/src/lib/householdTimezone.ts` (compiled: `apps/api/dist/lib/householdTimezone.js`) | role-match | +| `apps/api/src/index.ts` (modify) | config | request-response | itself — pre-auth `/health` mounting pattern | exact | +| `apps/api/src/db/schema.ts` (modify) | model | CRUD | itself — `users`, `appConfig`, `memberCredentials` table definitions | exact | +| `apps/api/src/auth/user.ts` (modify) | service | request-response | itself — `upsertUser` first-login-wins block (lines 112–142) | exact | +| `apps/api/src/db/migrations/0002_*.sql` | migration | batch | `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` | role-match | +| `scripts/generate-secrets.mjs` | utility | batch | `scripts/check-audit.mjs` (structure only; content is new) | partial | +| `apps/pwa/src/routes/SetupPage.tsx` | component | request-response | `apps/pwa/src/routes/AdminPage.tsx` | role-match | +| `apps/pwa/src/App.tsx` (modify) | component | request-response | itself — existing `Routes` block + `meQuery` gate pattern | exact | +| `apps/api/tests/setup.test.ts` | test | request-response | `apps/api/src/routes/admin.ts` (test patterns from same codebase convention) | role-match | + +--- + +## Pattern Assignments + +### `apps/api/src/routes/setup.ts` (route, request-response) + +**Analog:** `apps/api/src/routes/admin.ts` + +**Imports pattern** (admin.ts lines 22–36): +```typescript +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, memberCredentials, appConfig } from '../db/schema.js'; +import { + validateEncryptAndStoreCredential, + CredentialValidationError, +} from '../broker/credentialSync.js'; + +export const setupRouter = new Hono(); +``` + +**noEchoHook pattern — copy exactly** (admin.ts lines 54–64): +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +**Zod schema pattern for credential step** (admin.ts lines 47–52): +```typescript +const credentialSchema = z.object({ + userId: z.number().int().positive(), + providerType: z.literal('caldav'), + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); +``` + +**validateEncryptAndStoreCredential call + error-handling pattern** (admin.ts lines 102–122): +```typescript +adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => { + const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json'); + // T-10-10: NEVER log appPassword or c.req.valid('json') here + + try { + await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); + } catch (err) { + if (err instanceof CredentialValidationError) { + return c.json({ error: 'Invalid request' }, 400); + } + console.error( + '[admin/POST /credentials] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + + return c.json({ ok: true }, 200); +}); +``` + +**app_config upsert pattern** (from compiled `apps/api/dist/lib/householdTimezone.js`, confirmed by admin.ts app_config usage): +```typescript +// Drizzle onDuplicateKeyUpdate upsert — the project standard for app_config writes +await db + .insert(appConfig) + .values({ key: 'oidc_issuer', value: issuer }) + .onDuplicateKeyUpdate({ set: { value: issuer } }); +``` + +**Guard pattern — FIRST statement in every handler** (D-10, per RESEARCH.md Pattern 3): +```typescript +// Copy this call at the top of every setup route handler — before any other logic +const locked = await isSetupLocked(); +if (locked) return c.json({ error: 'Setup already complete' }, 423); +``` + +**VAPID structural validation pattern** (RESEARCH.md Pattern 7): +```typescript +import webpush from 'web-push'; +try { + webpush.setVapidDetails( + subject || 'mailto:validate@familysync.local', + publicKey, // from process.env.VAPID_PUBLIC_KEY or already-written app_config + privateKey, // from process.env.VAPID_PRIVATE_KEY only — NEVER from app_config + ); + return c.json({ ok: true }); +} catch (err) { + return c.json({ ok: false, error: err instanceof Error ? err.message : 'VAPID validation failed' }, 400); +} +``` + +**DB connectivity validation pattern** (health.ts lines 16–27): +```typescript +import { sql } from 'drizzle-orm'; +// ... +try { + await db.execute(sql`SELECT 1`); + return c.json({ ok: true }); +} catch (err) { + console.error('[setup/validate/db] DB round-trip failed:', err); + return c.json({ ok: false, error: 'DB unavailable' }, 503); +} +``` + +--- + +### `apps/api/src/lib/setupGuard.ts` (utility, request-response) + +**Analog:** `apps/api/dist/lib/householdTimezone.js` (compiled output of `householdTimezone.ts`) + +**app_config read pattern** (householdTimezone, confirmed from RESEARCH.md Pattern 2): +```typescript +import { db } from '../db/client.js'; +import { appConfig, memberCredentials } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +/** Returns true if the wizard is already locked. Re-evaluated fresh — NEVER cache at module level. */ +export async function isSetupLocked(): Promise { + // Check 1: explicit setup_complete flag in app_config + const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); + if (flagRow?.value === 'true') return true; + + // Check 2: effective configuration — member_credentials row exists AND VAPID env set + const [credRow] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .limit(1); + const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY; + return !!credRow && vapidPresent; +} +``` + +**Key constraint:** The return value MUST NOT be hoisted to a module-level variable. Callers must call `isSetupLocked()` as the first line of each handler. This is the same per-call freshness pattern as `db.select()` in the health router — no startup caching. + +--- + +### `apps/api/src/index.ts` (modify — route mounting order) + +**Analog:** itself, lines 35–55 + +**Pre-auth mount pattern to replicate** (index.ts lines 35–55): +```typescript +// OIDC callback — must be registered BEFORE oidcAuthMiddleware (T-02-02) +app.get('/callback', (c) => processOAuthCallback(c)); + +// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) +app.route('/health', healthRouter); + +// Dev-auth bypass — must be mounted BEFORE oidcAuthMiddleware (T-02-01) +app.use('/api/*', devAuthBypass()); + +// OIDC guard — protects all /api/* routes +if (!devBypassActive) { + app.use('/api/*', oidcAuthMiddleware()); + app.use('/api/*', persistSessionCookie()); +} +``` + +**New mount line to insert — before `app.use('/api/*', devAuthBypass())`:** +```typescript +// /api/setup/* — pre-auth wizard surface; must mount BEFORE the /api/* middleware chain. +// Inserting here mirrors the /health pattern: pre-auth, no OIDC, no devAuthBypass needed. +import { setupRouter } from './routes/setup.js'; +app.route('/api/setup', setupRouter); // ← INSERT before app.use('/api/*', devAuthBypass()) +``` + +--- + +### `apps/api/src/db/schema.ts` (modify — users table + new app_config keys) + +**Analog:** itself, lines 35–51 (users table) and lines 282–286 (appConfig table) + +**Current users table definition** (schema.ts lines 35–51): +```typescript +export const users = mysqlTable( + 'users', + { + id: int().primaryKey().autoincrement(), + oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), // ← change to nullable + oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), // ← change to nullable + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + isAdmin: boolean('is_admin').default(false).notNull(), + }, + (t) => [ + unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub), + ], +); +``` + +**Required schema changes (D-07):** +```typescript +// Make oidcIss and oidcSub nullable (remove .notNull()): +oidcIss: varchar('oidc_iss', { length: 512 }), // WAS .notNull() +oidcSub: varchar('oidc_sub', { length: 256 }), // WAS .notNull() +// Add claimed marker: +claimed: boolean('claimed').default(false).notNull(), // false = pending wizard user +``` + +**appConfig table — unchanged, but new keys documented** (schema.ts lines 282–286): +```typescript +export const appConfig = mysqlTable('app_config', { + key: varchar('key', { length: 128 }).primaryKey(), + value: text('value'), // nullable + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), +}); +// Phase 12 new keys: 'oidc_issuer', 'oidc_client_id', 'vapid_public_key', +// 'app_external_url', 'setup_complete' (already exists from Phase 10) +// DO NOT add: 'vapid_private_key', 'app_password_encryption_key' — D-01 / SC-3 +``` + +**Migration backfill requirement** (RESEARCH.md Runtime State Inventory): +```sql +-- In 0002_*.sql — after altering the columns: +UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL; +-- Existing OIDC users are "effectively claimed" — prevents the claim query from matching them. +``` + +--- + +### `apps/api/src/auth/user.ts` (modify — upsertUser first-login-claims) + +**Analog:** itself, lines 76–142 + +**Current first-login-wins block to replace** (user.ts lines 112–123): +```typescript +// 3. First-login-wins is_admin bootstrap (D-01). +// Phase 12 will tighten this to: first user after app_config.setup_complete. +const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)) + .limit(1); +const shouldBeAdmin = Number(count) === 0; +``` + +**Replacement pattern (D-08 first-login-claims)** — insert between existing step 1 (look up by iss+sub) and existing step 4 (insert new user): +```typescript +// 2. Check setup_complete; if true, look for unclaimed local user (first-login-claims, D-08) +// MUST use oidcIss IS NULL + claimed=false — never email-keyed (D-10) +import { isNull } from 'drizzle-orm'; // add to imports at top of file +import { appConfig } from '../db/schema.js'; // add to imports + +const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); +if (flagRow?.value === 'true') { + const [unclaimed] = await db + .select() + .from(users) + .where(and(isNull(users.oidcIss), eq(users.claimed, false))) + .limit(1); + if (unclaimed) { + await db.update(users).set({ + oidcIss, + oidcSub, + claimed: true, + displayName: displayName ?? unclaimed.displayName, + }).where(eq(users.id, unclaimed.id)); + return { ...unclaimed, oidcIss, oidcSub, claimed: true }; + } +} + +// 3. No unclaimed user found — normal insert path +// isAdmin: only when setup_complete is false (no unclaimed user exists yet) +const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0; +``` + +**Import additions needed at top of user.ts** (add to existing `import { and, eq, sql } from 'drizzle-orm'`): +```typescript +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { users, appConfig } from '../db/schema.js'; // add appConfig +``` + +--- + +### `apps/api/src/db/migrations/0002_*.sql` (migration, batch) + +**Analog:** `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` + +**Migration workflow (NEVER drizzle-kit push — D-Task5-DDL):** +1. Edit `schema.ts` with the nullable + claimed changes. +2. Run: `pnpm --filter @familysync/api exec drizzle-kit generate` +3. Review the generated SQL — confirm it contains `ALTER COLUMN` (not DROP/recreate of existing data). +4. Run: `pnpm --filter @familysync/api exec drizzle-kit migrate` + +**Expected SQL shape** (Pitfall 9 awareness — check for DROP CONSTRAINT before ADD CONSTRAINT on the unique index): +```sql +ALTER TABLE `users` + MODIFY COLUMN `oidc_iss` varchar(512), -- remove NOT NULL + MODIFY COLUMN `oidc_sub` varchar(256), -- remove NOT NULL + ADD COLUMN `claimed` boolean NOT NULL DEFAULT false; + +-- Backfill: existing OIDC users are already "claimed" +UPDATE `users` SET `claimed` = true WHERE `oidc_iss` IS NOT NULL; +``` + +--- + +### `scripts/generate-secrets.mjs` (utility, batch) + +**Analog:** `scripts/check-audit.mjs` (structure — plain ESM `.mjs`, no compilation) + +**Core pattern** (RESEARCH.md Pattern 6): +```javascript +// scripts/generate-secrets.mjs — plain ESM; no TypeScript compilation needed +import { generateVAPIDKeys } from '../apps/api/node_modules/web-push/src/index.js'; +import { randomBytes } from 'node:crypto'; + +const vapid = generateVAPIDKeys(); +const sessionSecret = randomBytes(32).toString('hex'); +const encKey = randomBytes(32).toString('hex'); + +console.log(` +# FamilySync Bootstrap Secrets — generated ${new Date().toISOString()} +# Paste into docker-compose.yml environment block. +# Keep this output safe — these values cannot be recovered if lost. + +SESSION_SECRET=${sessionSecret} +APP_PASSWORD_ENCRYPTION_KEY=${encKey} +VAPID_PUBLIC_KEY=${vapid.publicKey} +VAPID_PRIVATE_KEY=${vapid.privateKey} +`); +``` + +**Root package.json script addition:** +```json +"generate-secrets": "node scripts/generate-secrets.mjs" +``` + +**VAPID output format** (VERIFIED: live execution per RESEARCH.md): +- `publicKey`: base64url, 87 chars (uncompressed EC P-256, 65 bytes) +- `privateKey`: base64url, 43 chars (raw P-256 scalar, 32 bytes) + +--- + +### `apps/pwa/src/routes/SetupPage.tsx` (component, request-response) + +**Analog:** `apps/pwa/src/routes/AdminPage.tsx` + +**Imports pattern** (AdminPage.tsx lines 26–37): +```typescript +import { useState, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +// For SetupPage — replace admin-specific imports with setup-specific: +import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; // no router needed inside +// SetupPage-specific: +import { fetchSetupStatus, postSetupConfig, postSetupCredential, postSetupComplete } from '../api/client.js'; +``` + +**TanStack Query mutation pattern** (AdminPage.tsx uses `useMutation`): +```typescript +const configMutation = useMutation({ + mutationFn: postSetupConfig, + onSuccess: () => { + // advance to next step + setStep((s) => s + 1); + }, + onError: () => { + setError('Configuration failed. Check your inputs and try again.'); + }, +}); +``` + +**Step state pattern** (Claude's discretion per CONTEXT.md — use local state, not URL params): +```typescript +const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1); +// Steps: 1=Welcome, 2=Config, 3=Validate, 4=Credential, 5=Complete +``` + +**Security constraint:** All copy is plain-text JSX children — no `dangerouslySetInnerHTML` (UI-SPEC security contract). Pattern confirmed in SetupBanner.tsx lines 81–117. + +**No AppNav / BottomTabBar** — SetupPage renders standalone (per UI-SPEC §Routing). The App.tsx gate prevents authenticated routes from showing when unconfigured. + +--- + +### `apps/pwa/src/App.tsx` (modify — setup gate + /setup route) + +**Analog:** itself, lines 58–168 + +**Existing `meQuery` pattern to extend** (App.tsx lines 65–70): +```typescript +const meQuery = useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + retry: false, + staleTime: 5 * 60 * 1000, +}); +``` + +**New setup status query to add alongside meQuery:** +```typescript +const setupQuery = useQuery({ + queryKey: ['setupStatus'], + queryFn: () => fetch('/api/setup/status').then((r) => r.json()) as Promise<{ setupComplete: boolean }>, + retry: false, + staleTime: 0, // always fresh — guard must not be stale (mirrors D-10 spirit on client) +}); +``` + +**Gate pattern to add in Routes block** (App.tsx lines 133–153 show the existing isAdmin gate pattern to copy): +```typescript +// New /setup route — rendered standalone (no AppNav/BottomTabBar) +} /> + +// Redirect gate: if setup not complete, send all routes to /setup +// Mirror the isAdmin loading-gate pattern (lines 144–150) for the loading state +{setupQuery.data?.setupComplete === false && } +``` + +**Import addition:** +```typescript +import { SetupPage } from './routes/SetupPage.js'; +``` + +--- + +### `apps/api/tests/setup.test.ts` (test, request-response) + +**Analog:** Existing test files under `apps/api/tests/` (same Vitest + Hono test convention) + +**Test structure pattern** (from RESEARCH.md Validation Architecture — mirrors admin.test.ts conventions): +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { app } from '../src/index.js'; + +// Mock the DB and external calls — same pattern as admin.test.ts +vi.mock('../src/db/client.js', () => ({ db: mockDb })); +vi.mock('../src/broker/credentialSync.js', () => ({ + validateEncryptAndStoreCredential: vi.fn(), + CredentialValidationError: class extends Error {}, +})); + +describe('POST /api/setup/complete — 423 guard (SETUP-04)', () => { + it('first call returns 200', async () => { /* ... */ }); + it('second call returns 423', async () => { /* ... */ }); +}); + +describe('POST /api/setup/* when effectively configured (D-10)', () => { + it('returns 423 when member_credentials row exists AND VAPID env set', async () => { /* ... */ }); +}); +``` + +--- + +## Shared Patterns + +### 1. app_config Key/Value Read +**Source:** `apps/api/dist/lib/householdTimezone.js` (compiled) + `apps/api/src/routes/admin.ts` (upsert usage) +**Apply to:** `setup.ts` (all config reads), `setupGuard.ts` (setup_complete read), `auth/user.ts` (setup_complete read in upsertUser) +```typescript +// READ: +const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'some_key')) + .limit(1); +const val = row?.value ?? null; + +// WRITE (upsert): +await db + .insert(appConfig) + .values({ key: 'some_key', value: theValue }) + .onDuplicateKeyUpdate({ set: { value: theValue } }); +``` + +### 2. noEchoHook (credential endpoints) +**Source:** `apps/api/src/routes/admin.ts` lines 54–64 +**Apply to:** `setup.ts` POST /api/setup/credential handler only +```typescript +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; +``` + +### 3. CredentialValidationError error mapping +**Source:** `apps/api/src/routes/admin.ts` lines 108–119, `apps/api/src/broker/credentialSync.ts` lines 31–36 +**Apply to:** `setup.ts` credential handler +```typescript +} catch (err) { + if (err instanceof CredentialValidationError) { + return c.json({ error: 'Invalid request' }, 400); // no echo, no Zod details + } + console.error('[setup/credential] Unexpected error:', err instanceof Error ? err.message : String(err)); + return c.json({ error: 'Service unavailable' }, 503); +} +``` + +### 4. mysql2 insert + $returningId() re-select +**Source:** `apps/api/src/auth/user.ts` lines 126–141 +**Apply to:** `setup.ts` local user creation step (mysql2 has no RETURNING clause) +```typescript +const [inserted] = await db + .insert(users) + .values({ /* ... */ }) + .$returningId(); +const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1); +``` + +### 5. Hono router export + file-level doc comment +**Source:** `apps/api/src/routes/admin.ts` lines 1–37, `apps/api/src/routes/health.ts` lines 1–6 +**Apply to:** `setup.ts`, all new route files +```typescript +export const setupRouter = new Hono(); +// Mounted in index.ts: app.route('/api/setup', setupRouter) +// Mounted BEFORE app.use('/api/*', devAuthBypass()) — pre-auth surface. +``` + +--- + +## No Analog Found + +All Phase 12 files have close analogs in the codebase. No new patterns need to be sourced from RESEARCH.md examples alone — all implementation patterns are grounded in existing code. + +| File | Note | +|---|---| +| `scripts/generate-secrets.mjs` | Script structure from `scripts/check-audit.mjs` but the web-push + crypto logic is net-new. RESEARCH.md Pattern 6 is the authoritative reference for the output format. | + +--- + +## Metadata + +**Analog search scope:** `apps/api/src/routes/`, `apps/api/src/auth/`, `apps/api/src/db/`, `apps/api/src/lib/`, `apps/api/src/broker/`, `apps/pwa/src/`, `scripts/` +**Files read:** 14 source files +**Pattern extraction date:** 2026-06-15 -- 2.54.0 From fe40de83dbaaae6fce4c0af6b2810b53eea8bb4a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:46:30 -0400 Subject: [PATCH 05/73] docs(12): create initial-setup-wizard phase plan (4 plans, 3 waves) --- .planning/ROADMAP.md | 9 +- .../12-initial-setup-wizard/12-01-PLAN.md | 270 ++++++++++++++++++ .../12-initial-setup-wizard/12-02-PLAN.md | 266 +++++++++++++++++ .../12-initial-setup-wizard/12-03-PLAN.md | 152 ++++++++++ .../12-initial-setup-wizard/12-04-PLAN.md | 247 ++++++++++++++++ .../12-initial-setup-wizard/12-VALIDATION.md | 64 +++-- 6 files changed, 982 insertions(+), 26 deletions(-) create mode 100644 .planning/phases/12-initial-setup-wizard/12-01-PLAN.md create mode 100644 .planning/phases/12-initial-setup-wizard/12-02-PLAN.md create mode 100644 .planning/phases/12-initial-setup-wizard/12-03-PLAN.md create mode 100644 .planning/phases/12-initial-setup-wizard/12-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 5627321..4c46790 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -249,7 +249,14 @@ Plans: - **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password. - Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration). -**Plans**: TBD +**Plans**: 4 plans in 3 waves + +Plans: +- [ ] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds +- [ ] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04) +- [ ] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01) +- [ ] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02) + **UI hint**: yes ### Phase 13: Real Lint Gate (ESLint) diff --git a/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md new file mode 100644 index 0000000..d425e12 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md @@ -0,0 +1,270 @@ +--- +phase: 12-initial-setup-wizard +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/db/schema.ts + - apps/api/src/db/migrations/0002_*.sql + - apps/api/src/db/migrations/meta/_journal.json + - scripts/generate-secrets.mjs + - package.json + - apps/api/src/routes/setup.ts + - apps/api/src/lib/setupGuard.ts + - apps/api/tests/routes/setup.test.ts + - apps/api/tests/auth/user.test.ts +autonomous: true +requirements: [SETUP-03] +must_haves: + truths: + - "Schema migration makes users.oidc_iss/oidc_sub nullable, adds users.claimed, and is APPLIED to the dev DB" + - "Existing OIDC users are backfilled claimed=true so first-login-claims never matches them" + - "npm run generate-secrets prints SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY for pasting into env — never to the DB" + - "Stub setup.ts router + setupGuard.ts exist so Wave-1 imports resolve" + - "Wave-0 test files exist with at least one failing/red placeholder per SETUP requirement" + artifacts: + - path: "apps/api/src/db/migrations/0002_*.sql" + provides: "nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE" + contains: "claimed" + - path: "scripts/generate-secrets.mjs" + provides: "Bootstrap secret generation helper" + contains: "generateVAPIDKeys" + - path: "apps/api/src/lib/setupGuard.ts" + provides: "isSetupLocked stub (real impl in plan 02)" + exports: ["isSetupLocked"] + - path: "apps/api/src/routes/setup.ts" + provides: "setupRouter stub Hono router" + exports: ["setupRouter"] + - path: "apps/api/tests/routes/setup.test.ts" + provides: "Wave-0 test scaffold for SETUP-01/02/03/04 + 423 guard" + key_links: + - from: "apps/api/src/db/schema.ts" + to: "apps/api/src/db/migrations/0002_*.sql" + via: "drizzle-kit generate" + pattern: "claimed" + - from: "package.json" + to: "scripts/generate-secrets.mjs" + via: "generate-secrets npm script" + pattern: "generate-secrets" +--- + + +Lay the Phase 12 foundation: the schema migration (nullable OIDC identity + `claimed` marker, applied +via Drizzle generate+migrate with the existing-user backfill), the `npm run generate-secrets` repo +helper (SETUP-03, D-05), and the Wave-0 scaffolds (stub `setup.ts` router, stub `setupGuard.ts`, and +the `setup.test.ts` + `user.test.ts` test files) so Wave-1 plans import cleanly and write tests RED-first. + +Purpose: Plans 02 and 03 both depend on the migrated schema (`users.claimed`, nullable `oidc_iss`) +and on the stub router/guard existing as import targets. SETUP-03 (secret generation) is fully owned here. +Output: Applied 0002 migration, `scripts/generate-secrets.mjs`, package.json script, stub setup.ts + +setupGuard.ts, and red test scaffolds. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md +@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md +@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md +@apps/api/src/db/schema.ts +@apps/api/src/db/migrations/0001_famous_mad_thinker.sql + + +## Artifacts this phase produces (Plan 01 portion) + +- `users.claimed` column (boolean, default false, NOT NULL) +- `users.oidc_iss` / `users.oidc_sub` → nullable (was NOT NULL) +- Migration `apps/api/src/db/migrations/0002_*.sql` + journal entry — APPLIED +- `scripts/generate-secrets.mjs` + root `package.json` `"generate-secrets"` script +- `apps/api/src/lib/setupGuard.ts` exporting `isSetupLocked()` (stub → real impl in Plan 02) +- `apps/api/src/routes/setup.ts` exporting `setupRouter` (stub → real impl in Plan 02) +- `apps/api/tests/routes/setup.test.ts` (Wave-0 scaffold) + + + + + Task 1: [BLOCKING] Schema change + generate+migrate (nullable OIDC identity, claimed marker, backfill) + apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_*.sql, apps/api/src/db/migrations/meta/_journal.json + + - apps/api/src/db/schema.ts (the `users` table at lines ~35-51 and `appConfig` at ~282-286 — the file being modified) + - apps/api/src/db/migrations/0001_famous_mad_thinker.sql (analog: prior migration shape, PATTERNS.md §0002_*.sql) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §`apps/api/src/db/schema.ts` and §`0002_*.sql` (exact field edits + backfill SQL) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Runtime State Inventory + Pitfall 9 (unique-constraint/NULL behavior) + + + In apps/api/src/db/schema.ts, edit the `users` table (D-07): remove `.notNull()` from `oidcIss` + (`varchar('oidc_iss', { length: 512 })`) and `oidcSub` (`varchar('oidc_sub', { length: 256 })`), + and add `claimed: boolean('claimed').default(false).notNull()`. Leave the `uniq_oidc_identity` + unique constraint on (oidcIss, oidcSub) unchanged (MariaDB treats NULLs as distinct in unique + indexes — multiple NULLs allowed, which is correct). Add a comment above `appConfig` documenting the + new Phase 12 keys ('oidc_issuer', 'oidc_client_id', 'vapid_public_key', 'app_external_url'; + 'setup_complete' already exists) and the prohibition: NEVER add 'vapid_private_key' or + 'app_password_encryption_key' (D-01 / SC-3). + Then generate the migration: `pnpm --filter @familysync/api exec drizzle-kit generate`. NEVER use + `drizzle-kit push` (D-Task5-DDL — false destructive diff on MariaDB 11). Open the produced + 0002_*.sql and (a) confirm it contains MODIFY/ALTER making oidc_iss/oidc_sub nullable + ADD COLUMN + claimed (not a DROP/recreate of users data), and (b) APPEND the backfill statement + `UPDATE \`users\` SET \`claimed\` = true WHERE \`oidc_iss\` IS NOT NULL;` so existing OIDC users are + marked claimed (prevents first-login-claims from matching them). If drizzle emits a + DROP CONSTRAINT/ADD CONSTRAINT pair on the unique index (Pitfall 9), keep it — it is safe with + nullable columns. + Apply the migration: `pnpm --filter @familysync/api exec drizzle-kit migrate`. The apply step is + mandatory and non-skippable: typecheck/build pass from schema.ts types WITHOUT the live DB change, + so verification below must prove the column exists in the DB. + + + - source: `grep -c "claimed" apps/api/src/db/schema.ts` returns >= 1 + - source: `grep -v '^#' apps/api/src/db/schema.ts | grep -E "oidc_iss.*notNull\(\)|oidc_sub.*notNull\(\)"` returns nothing (notNull removed from both) + - source: a file matching `apps/api/src/db/migrations/0002_*.sql` exists and `grep -i "claimed" $(ls apps/api/src/db/migrations/0002_*.sql)` matches + - source: `grep -ic "UPDATE .users. SET .claimed. = true WHERE .oidc_iss. IS NOT NULL" $(ls apps/api/src/db/migrations/0002_*.sql)` returns 1 + - CLI: migration applied — the dev DB `users` table has a `claimed` column (verified by drizzle-kit migrate exiting 0 and a follow-up `SELECT claimed FROM users LIMIT 1` style check via the test DB harness in Task 4) + - source: `apps/api/src/db/migrations/meta/_journal.json` references the 0002 migration + + + cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck + + schema.ts has nullable oidc_iss/oidc_sub + claimed; 0002 migration generated, contains the backfill UPDATE, and is applied to the dev DB; typecheck green. + + + + Task 2: generate-secrets repo helper (SETUP-03 / D-05) + scripts/generate-secrets.mjs, package.json + + - scripts/check-audit.mjs (analog: plain-ESM .mjs script structure, PATTERNS.md §generate-secrets.mjs) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 6 + §Open Question 2 (VAPID format, script location/toolchain) + - package.json (the root scripts block being modified) + + + Create scripts/generate-secrets.mjs as a plain ESM script (no TypeScript compilation): import + `generateVAPIDKeys` from web-push (resolve from apps/api/node_modules, e.g. + `'../apps/api/node_modules/web-push/src/index.js'`), and `randomBytes` from `node:crypto`. Compute + `SESSION_SECRET = randomBytes(32).toString('hex')`, `APP_PASSWORD_ENCRYPTION_KEY = + randomBytes(32).toString('hex')`, and `const vapid = generateVAPIDKeys()`. Print a copy-paste block + to stdout with a header comment ("FamilySync Bootstrap Secrets", timestamp, "Paste into your + docker-compose.yml environment block", "cannot be recovered if lost") followed by the four lines + `SESSION_SECRET=...`, `APP_PASSWORD_ENCRYPTION_KEY=...`, `VAPID_PUBLIC_KEY=${vapid.publicKey}`, + `VAPID_PRIVATE_KEY=${vapid.privateKey}`. The script ONLY prints to stdout — it MUST NOT write any + file, touch the DB, or call any API (SC-3: secrets never persisted). Add to the ROOT package.json + scripts: `"generate-secrets": "node scripts/generate-secrets.mjs"`. + + + - source: `grep -c "generateVAPIDKeys" scripts/generate-secrets.mjs` returns >= 1 + - source: `grep -c "randomBytes(32).toString('hex')" scripts/generate-secrets.mjs` returns >= 2 (session secret + enc key) + - source: scripts/generate-secrets.mjs contains no `writeFile`/`appendFile`/`fetch`/`db` (`grep -E "writeFile|appendFile|fetch\(|from '.*db" scripts/generate-secrets.mjs` returns nothing) + - source: root package.json scripts has `"generate-secrets"` (`node -e "process.exit(require('./package.json').scripts['generate-secrets']?0:1)"` exits 0) + - behavior: `node scripts/generate-secrets.mjs` prints SESSION_SECRET (64 hex chars), APP_PASSWORD_ENCRYPTION_KEY (64 hex chars), VAPID_PUBLIC_KEY (base64url ~87 chars), VAPID_PRIVATE_KEY (base64url ~43 chars) + + + node scripts/generate-secrets.mjs | grep -E "^SESSION_SECRET=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^APP_PASSWORD_ENCRYPTION_KEY=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PUBLIC_KEY=.{80,}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PRIVATE_KEY=.{40,}$" + + `node scripts/generate-secrets.mjs` prints all four correctly-shaped values; nothing is written to disk or DB; root package.json wires the script. + + + + Task 3: Stub setupGuard.ts + setup.ts router (Wave-0 import targets) + apps/api/src/lib/setupGuard.ts, apps/api/src/routes/setup.ts + + - apps/api/src/routes/health.ts (analog: minimal Hono router export + file-doc-comment, PATTERNS.md §Shared Pattern 5) + - apps/api/dist/lib/householdTimezone.js (analog: app_config read shape for the real impl in Plan 02) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts and §setup.ts (the import patterns Plan 02 fills in) + + + Create apps/api/src/lib/setupGuard.ts exporting an async `isSetupLocked(): Promise`. For + this Wave-0 stub, return `false` (real per-call DB evaluation lands in Plan 02). Add a doc comment: + "Re-evaluated fresh on every call — NEVER cache at module level (D-10). Real impl: Plan 02." + Create apps/api/src/routes/setup.ts exporting `setupRouter = new Hono()` with a file-doc-comment + noting it mounts at /api/setup BEFORE the /api/* OIDC chain (pre-auth surface, like /health). Leave + it as an empty router (handlers added in Plan 02). Do NOT mount it in index.ts yet (Plan 02 owns + the index.ts mount to keep file ownership clean). + + + - source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1 + - source: `grep -c "export const setupRouter" apps/api/src/routes/setup.ts` returns 1 + - test: typecheck passes (`cd apps/api && pnpm typecheck`) + + + cd apps/api && pnpm typecheck + + setupGuard.ts exports isSetupLocked (stub returns false); setup.ts exports an empty setupRouter; typecheck green. + + + + Task 4: Wave-0 test scaffolds (setup.test.ts + user.test.ts claim placeholder) + apps/api/tests/routes/setup.test.ts, apps/api/tests/auth/user.test.ts + + - apps/api/tests/routes/admin.test.ts (analog: Vitest + Hono route test conventions, mock of credentialSync + db) + - apps/api/tests/auth/user.test.ts (the existing upsertUser test file being extended) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Validation Architecture (Phase Requirements → Test Map + Wave 0 Gaps) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.test.ts + + + Create apps/api/tests/routes/setup.test.ts following the admin.test.ts mock conventions (mock + ../src/db/client.js and ../src/broker/credentialSync.js). Add describe/it scaffolds — each marked + with `it.todo(...)` or a placeholder `expect(true).toBe(false)` so they are visibly RED until Plan + 02 implements them — covering: GET /api/setup/status fresh→{setupComplete:false}; status after + complete→{setupComplete:true}; POST /api/setup/validate/vapid 200 valid / 400 truncated; POST + /api/setup/validate/oidc 400 unreachable; POST /api/setup/credential PROPFIND-fail→400; the 423 + guard (Pitfall 8): POST /api/setup/complete twice → first 200, second 423; and D-10 effective-config + branch: any /api/setup/* → 423 when a member_credentials row exists AND VAPID env present. The 423 + guard test (SETUP-04) MUST be written here in Wave 0 so it is RED before the happy path is built. + In apps/api/tests/auth/user.test.ts, add a describe block (it.todo placeholders) for D-08 + first-login-claims: when setup_complete='true', the first OIDC login claims the single unclaimed + local user (oidc_iss IS NULL AND claimed=false), populates oidc_iss/oidc_sub, sets claimed=true, + preserves is_admin; and asserts NO email-keyed lookup. + + + - source: `grep -c "423" apps/api/tests/routes/setup.test.ts` returns >= 1 (the Pitfall 8 guard test present) + - source: `grep -Ec "validate/vapid|validate/oidc|/credential|/complete|/status" apps/api/tests/routes/setup.test.ts` returns >= 4 (all setup routes referenced) + - source: `grep -Ec "claimed|first-login-claim|unclaimed" apps/api/tests/auth/user.test.ts` returns >= 1 + - test: the suite runs without import/collection errors (`pnpm --filter @familysync/api test -- setup` exits with test results, not a load error — todos/red placeholders are expected at this stage) + + + cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "Tests|todo|passed|failed" + + setup.test.ts scaffolds all SETUP-01..04 cases incl. the RED 423-guard test; user.test.ts has the D-08 claim scaffold; the suite collects without import errors. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator shell → repo | generate-secrets output crosses to the operator's clipboard/env; must never reach DB or logs | +| schema.ts → live DB | migration applied to a populated `users` table; a destructive diff would orphan/lose user rows | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-12-01 | Information Disclosure | generate-secrets.mjs | mitigate | Script prints to stdout only — no writeFile/appendFile/fetch/db access (acceptance-checked); SC-3 secrets never persisted | +| T-12-02 | Tampering | 0002 migration on populated users | mitigate | Drizzle generate+migrate (NEVER push); review generated SQL for MODIFY (not DROP); backfill `claimed=true WHERE oidc_iss IS NOT NULL` so existing rows are not orphaned | +| T-12-03 | Information Disclosure | schema.ts app_config keys | mitigate | Comment + acceptance gate forbidding vapid_private_key / app_password_encryption_key columns (D-01) | +| T-12-SC | Tampering | npm/pip/cargo installs | accept | This plan installs ZERO new packages (web-push + node:crypto already present, RESEARCH §No New Packages) — no legitimacy checkpoint needed | + + + +- `cd apps/api && pnpm exec drizzle-kit migrate` exits 0 and the dev DB `users.claimed` column exists +- `node scripts/generate-secrets.mjs` prints all four correctly-shaped secret lines +- `cd apps/api && pnpm typecheck` green +- `pnpm --filter @familysync/api test -- setup` collects (red scaffolds expected) + + + +- Migration applied: nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE in 0002_*.sql +- SETUP-03 satisfied: generate-secrets prints session secret, encryption key, VAPID pair; nothing persisted +- Stub setupGuard.ts + setup.ts exist as Wave-1 import targets +- RED test scaffolds exist (incl. the 423 guard test before the happy path) + + + +Create `.planning/phases/12-initial-setup-wizard/12-01-SUMMARY.md` when done + diff --git a/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md new file mode 100644 index 0000000..f5bf4e0 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md @@ -0,0 +1,266 @@ +--- +phase: 12-initial-setup-wizard +plan: 02 +type: tdd +wave: 2 +depends_on: ["12-01"] +files_modified: + - apps/api/src/lib/setupGuard.ts + - apps/api/src/routes/setup.ts + - apps/api/src/index.ts + - apps/api/src/auth/middleware.ts + - apps/api/tests/routes/setup.test.ts +autonomous: true +requirements: [SETUP-01, SETUP-02, SETUP-04] +must_haves: + truths: + - "GET /api/setup/status returns {setupComplete:false} on a fresh instance and {setupComplete:true} after completion, reachable WITHOUT auth (before the OIDC guard)" + - "The wizard collects non-secret config (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url) into app_config via POST /api/setup/config" + - "Each input validates before completing: DB connects, VAPID structurally valid (32/65-byte via setVapidDetails), OIDC discovery resolves, Fastmail app password reaches CalDAV PROPFIND" + - "A second call to any setup endpoint after completion returns 423 (guard re-evaluated fresh every call — Pitfall 8)" + - "POST /api/setup/complete promotes the local user to admin, sets app_config.setup_complete, after which the guard locks" + - "OIDC boot config reads env OR app_config so a fresh unconfigured instance does not crash at boot" + artifacts: + - path: "apps/api/src/lib/setupGuard.ts" + provides: "isSetupLocked() — real per-call DB evaluation (setup_complete OR effectively-configured)" + exports: ["isSetupLocked"] + - path: "apps/api/src/routes/setup.ts" + provides: "setupRouter: /status, /config, /validate/db, /validate/oidc, /validate/vapid, /credential, /complete" + exports: ["setupRouter"] + - path: "apps/api/src/index.ts" + provides: "setupRouter mounted at /api/setup BEFORE the /api/* OIDC chain" + contains: "app.route('/api/setup', setupRouter)" + key_links: + - from: "apps/api/src/routes/setup.ts" + to: "apps/api/src/lib/setupGuard.ts" + via: "isSetupLocked() first statement in every handler" + pattern: "isSetupLocked" + - from: "apps/api/src/routes/setup.ts" + to: "apps/api/src/broker/credentialSync.ts" + via: "validateEncryptAndStoreCredential(localUserId, ...)" + pattern: "validateEncryptAndStoreCredential" + - from: "apps/api/src/index.ts" + to: "apps/api/src/routes/setup.ts" + via: "pre-auth mount before devAuthBypass()" + pattern: "api/setup" +--- + + +Build the pre-auth `/api/setup/*` API surface: the real `isSetupLocked()` 423 guard (D-10), the +setup router (status / config-collect / validate db|oidc|vapid / credential / complete), the +index.ts pre-auth mount, and the OIDC boot-config env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2). +This is a TDD plan: the 423 guard test (Pitfall 8) is the canonical RED-first test, written and failing +before the happy path is implemented. + +Purpose: This is the security-critical core of Phase 12 — the only app surface outside the OIDC guard. +SETUP-01 (collect/guided), SETUP-02 (validate-each-input), and SETUP-04 (per-call 423 lock) all land here. +Output: A working, tested pre-auth setup API; local-user + credential provisioning via the shared helper. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md +@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md +@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md +@apps/api/src/routes/admin.ts +@apps/api/src/routes/health.ts +@apps/api/src/broker/credentialSync.ts +@apps/api/src/index.ts + + +## Artifacts this phase produces (Plan 02 portion) + +- `isSetupLocked()` — real impl: 423 if `app_config.setup_complete='true'` OR (a `member_credentials` row exists AND `VAPID_PRIVATE_KEY` + `VAPID_PUBLIC_KEY` env present); re-queried every call +- Routes: `GET /api/setup/status`, `POST /api/setup/config`, `POST /api/setup/validate/db`, `POST /api/setup/validate/oidc`, `POST /api/setup/validate/vapid`, `POST /api/setup/credential`, `POST /api/setup/complete` +- app_config keys written: `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url`, `setup_complete` +- `apps/api/src/index.ts`: `app.route('/api/setup', setupRouter)` mounted before `app.use('/api/*', devAuthBypass())` +- OIDC boot config: reads `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_AUTH_EXTERNAL_URL` from env OR app_config fallback + + + + + Task 1: isSetupLocked() guard + the RED-first 423 tests (SETUP-04, Pitfall 8) + apps/api/src/lib/setupGuard.ts, apps/api/tests/routes/setup.test.ts + + - apps/api/src/lib/setupGuard.ts (the Wave-0 stub being made real) + - apps/api/tests/routes/setup.test.ts (the Wave-0 scaffold to turn green) + - apps/api/dist/lib/householdTimezone.js (analog: app_config read pattern) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts (exact read shape) + §Shared Pattern 1 + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 3 (fresh-per-call) + Pitfall 2 + + + - isSetupLocked() returns true when app_config.setup_complete === 'true' + - isSetupLocked() returns true when a member_credentials row exists AND both VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env are set (D-10 effective-config branch) + - isSetupLocked() returns false on a fresh instance (no flag, no credential) + - RED-first: POST /api/setup/complete twice → first 200, second 423 (Pitfall 8) — write this test against the not-yet-real router and confirm it fails before Task 2 + - The guard re-queries the DB on every call (no module-level cache) — a test that flips setup_complete between two calls sees the change + + + Implement the real isSetupLocked() in setupGuard.ts per PATTERNS.md §setupGuard.ts: read app_config + `setup_complete` (return true if value==='true'); else select one member_credentials row and check + `!!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY`, returning `!!credRow && + vapidPresent`. MUST NOT hoist the result to a module-level variable — every call re-queries (D-10). + Turn the Wave-0 guard tests GREEN against the real helper, and write the RED-first + `POST /api/setup/complete` twice → 200 then 423 test (it will fail until Task 2's /complete handler + exists — that RED state is the point). Mock db.select per the admin.test.ts convention. + + + - source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1 + - source: setupGuard.ts has no module-level `let locked`/cache (`grep -E "^(let|const) .*=.*isSetupLocked|cachedLock" apps/api/src/lib/setupGuard.ts` returns nothing) + - source: setupGuard reads both VAPID env vars (`grep -c "VAPID_PRIVATE_KEY" apps/api/src/lib/setupGuard.ts` and `grep -c "VAPID_PUBLIC_KEY" apps/api/src/lib/setupGuard.ts` each >= 1) + - test: the guard unit tests (setup_complete branch + effective-config branch + fresh-false) pass + + + cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "passed|failed" + + isSetupLocked() is real, fresh-per-call; guard branch tests pass; the 423-after-complete test exists and is RED pending Task 2. + + + + Task 2: setup router — status, config-collect, validate/{db,oidc,vapid}, credential, complete (SETUP-01/02) + apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts + + - apps/api/src/routes/setup.ts (the Wave-0 stub router being filled) + - apps/api/src/routes/admin.ts (analog: noEchoHook l.54-64, credentialSchema l.47-52, validateEncryptAndStoreCredential call + error mapping l.102-122, app_config upsert) + - apps/api/src/routes/health.ts (analog: DB connectivity check `db.execute(sql\`SELECT 1\`)`) + - apps/api/src/broker/credentialSync.ts (signature: validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); CredentialValidationError) + - apps/api/src/auth/user.ts (analog: mysql2 $returningId() + re-select for the local-user insert, l.126-141) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.ts (all handler patterns) + §Shared Patterns 1-5 + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 5 (helper reuse) + §Pattern 7 (VAPID) + §Pattern 8 (OIDC discovery) + Pitfalls 1,5,7 + + + - GET /api/setup/status → {setupComplete: boolean} derived from app_config.setup_complete; reachable pre-auth + - POST /api/setup/config → upserts oidc_issuer, oidc_client_id, vapid_public_key, app_external_url into app_config; validates issuer is an https URL (reject non-https → 400) + - POST /api/setup/validate/db → 200 on `SELECT 1` success, 503 on failure + - POST /api/setup/validate/oidc → fetch {issuer}/.well-known/openid-configuration (5s timeout); 200 ok, 400 on unreachable/non-2xx + - POST /api/setup/validate/vapid → setVapidDetails(subject, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); 200 valid, 400 on structural failure; reads private key ONLY from process.env (never app_config/DB) + - POST /api/setup/credential → inserts the pre-OIDC local user (oidc_iss NULL, claimed=false, is_admin=true) FIRST, then calls validateEncryptAndStoreCredential(localUserId, email, password, 'caldav'); CredentialValidationError→400 (no echo), other→503 + - POST /api/setup/complete → sets app_config.setup_complete='true'; returns 200 first call, 423 second (guard) + - EVERY handler: isSetupLocked() is the FIRST statement; if locked → 423 + - app password NEVER logged/echoed (noEchoHook; no console.log of c.req.valid('json')) + + + Fill setupRouter in setup.ts. Import { isSetupLocked } from '../lib/setupGuard.js'; copy the + admin.ts noEchoHook (l.54-64) and the credential error-mapping idiom (l.102-122). The FIRST statement + in every handler: `const locked = await isSetupLocked(); if (locked) return c.json({ error: 'Setup + already complete' }, 423);`. Implement each route per the §setup.ts patterns: + /status reads app_config.setup_complete and returns {setupComplete}; /config zod-validates + {oidcIssuer:https-url, oidcClientId, vapidPublicKey, appExternalUrl} and upserts each via + `db.insert(appConfig).values({key,value}).onDuplicateKeyUpdate({set:{value}})` with keys + 'oidc_issuer'|'oidc_client_id'|'vapid_public_key'|'app_external_url'; /validate/db does + `db.execute(sql\`SELECT 1\`)`; /validate/oidc fetches the discovery doc with + `AbortSignal.timeout(5000)`; /validate/vapid calls `webpush.setVapidDetails(subject || + 'mailto:validate@familysync.local', process.env.VAPID_PUBLIC_KEY ?? '', process.env.VAPID_PRIVATE_KEY + ?? '')` in try/catch — NEVER read the private key from app_config or return it; /credential inserts + the local user via $returningId()+re-select (oidcIss:null, oidcSub:null, claimed:false, isAdmin:true, + color: first unused from COLOR_PALETTE) THEN calls the shared helper with that id and providerType + 'caldav' (Pitfall 5 — user row must exist before the FK insert); use noEchoHook + CredentialValidationError→400/503; + /complete upserts setup_complete='true' then returns 200. Do NOT create new crypto and do NOT call + /api/admin/credentials (D-09 — reuse the shared helper directly). Turn the Wave-0 + Task-1 RED tests + GREEN, including the 423-after-complete and the validate 200/400/503 cases. + + + - source: every handler calls the guard first — `grep -c "isSetupLocked" apps/api/src/routes/setup.ts` returns >= 7 (one per route) + - source: setup.ts reuses the shared helper, no new crypto (`grep -c "validateEncryptAndStoreCredential" apps/api/src/routes/setup.ts` >= 1; `grep -Ec "createCipheriv|createHash|randomBytes|encryptPassword" apps/api/src/routes/setup.ts` returns 0) + - source: setup.ts never calls the admin route (`grep -c "api/admin" apps/api/src/routes/setup.ts` returns 0) + - source: VAPID private key read only from env (`grep -E "VAPID_PRIVATE_KEY" apps/api/src/routes/setup.ts` shows only `process.env.VAPID_PRIVATE_KEY`; no app_config read of a private key) + - source: noEchoHook present (`grep -c "noEchoHook" apps/api/src/routes/setup.ts` >= 1) and no log of the password (`grep -Ec "console\.(log|error|warn)\(.*appPassword|console\.(log|error|warn)\(.*valid\('json'\)" apps/api/src/routes/setup.ts` returns 0) + - source: the four new app_config keys written (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url" apps/api/src/routes/setup.ts` >= 4) + - test: all setup route tests pass incl. POST /complete twice → 200 then 423 + + + cd apps/api && pnpm test -- setup && pnpm typecheck + + setupRouter implements all 7 routes; guard is first in each; credential reuses the shared helper (no new crypto, no admin-route call); VAPID private key never leaves env; all setup tests green incl. the Pitfall-8 423 regression. + + + + Task 3: Mount setupRouter pre-auth + OIDC boot env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2) + apps/api/src/index.ts, apps/api/src/auth/middleware.ts + + - apps/api/src/index.ts (the file being modified — mount order l.33-55, VAPID boot l.117-139) + - apps/api/src/auth/middleware.ts (oidcAuthMiddleware / processOAuthCallback — where OIDC config is read at boot) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §index.ts (exact insert point) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Env Kernel vs DB Config Split + Open Question 1 + Pitfall 8 (Recommendation: option (a) env-OR-app_config fallback) + Assumptions A1/A2 + + + In apps/api/src/index.ts, add `import { setupRouter } from './routes/setup.js';` and insert + `app.route('/api/setup', setupRouter);` BEFORE `app.use('/api/*', devAuthBypass())` (mirrors the + /health pre-auth pattern, PATTERNS.md §index.ts) so /api/setup/* is never caught by the OIDC guard + (Pitfall 1). For Pitfall 8 / Open Question 1: confirm where @hono/oidc-auth reads OIDC_ISSUER / + OIDC_CLIENT_ID / OIDC_AUTH_EXTERNAL_URL (read auth/middleware.ts and verify A2 — call-time vs + import-time). Implement Recommendation (a): the OIDC config used by oidcAuthMiddleware resolves from + env first (Docker process.env, then .env fallback per D-03), falling back to the app_config keys (oidc_issuer, oidc_client_id, app_external_url) when + the env var is absent — so a fresh unconfigured instance does not crash at boot (no env, no + app_config yet, OIDC simply unconfigured until setup completes) and a wizard-configured instance + reads the app_config values. Keep the existing devBypass/persistSessionCookie ordering intact. Do + NOT defer the middleware mount (option b) or rewrite to lazy-per-request (option c) unless A2 review + proves env values are read at import time AND a fresh boot crashes — if so, document the chosen + deviation in the SUMMARY. + + + - source: `grep -c "app.route('/api/setup', setupRouter)" apps/api/src/index.ts` returns 1 + - source: the setup mount precedes the devAuthBypass mount — `awk '/api\/setup., setupRouter/{s=NR} /devAuthBypass\(\)/{d=NR} END{exit !(s>0 && s= 1) OR the SUMMARY documents A2 found import-time reads requiring option (b)/(c) + - test: full API suite green and the app boots without OIDC env set (a fresh-boot test or the existing boot path does not throw) + + + cd apps/api && pnpm typecheck && pnpm test + + setupRouter mounted pre-auth before the /api/* OIDC chain; OIDC boot config resolves env-OR-app_config so a fresh instance does not crash; full API suite green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| unauthenticated client → /api/setup/* | The ONLY pre-auth API surface; the 423 lock is the only thing protecting it once configured | +| client form → app_config | operator-supplied oidc_issuer/client_id/vapid_public_key/app_external_url written to DB | +| client form → CalDAV / member_credentials | Fastmail app password validated + encrypted; must never be logged/echoed/stored plaintext | + +## Pre-auth exposure (before vs after setup_complete) + +- **Before setup_complete:** an unauthenticated caller can reach all /api/setup/* routes — this is by design (the wizard is pre-auth). Reachable actions: read status, write non-secret app_config, run validations, provision the single local user + credential, flip setup_complete. No secret is ever returned. Only the household operator standing up the instance is expected here; the instance is not yet publicly routed until the operator finishes. +- **After setup_complete:** isSetupLocked() returns true → every /api/setup/* route returns 423. The lock is the sole protection; it is re-evaluated fresh per call (no startup cache) so a manual DB edit or a second instance cannot get a stale "unlocked". +- **First-login-claims window (D-08, handled in Plan 03):** only household members can reach Authelia OIDC at all, so the single unclaimed local user can only be claimed by a household member — acceptable for a 2-person self-hosted app. + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-12-04 | Tampering | setup endpoint replay after completion | mitigate | isSetupLocked() first statement in every handler; 423; re-evaluated per call, never cached (D-10); RED-first Pitfall-8 test | +| T-12-05 | Information Disclosure | app password echoed in 400 | mitigate | noEchoHook (admin.ts) — Zod error details never returned; no console.log of password or valid('json') | +| T-12-06 | Information Disclosure | VAPID_PRIVATE_KEY / APP_PASSWORD_ENCRYPTION_KEY in DB or response | mitigate | D-01 env floor — no app_config key for these; /validate/vapid reads private key only from process.env, returns only {ok} | +| T-12-07 | Spoofing | first-login-claims claiming wrong user | accept | Claim query (Plan 03) is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user in a 2-person household; OIDC reach requires household membership | +| T-12-08 | Tampering | OIDC issuer SSRF via /config | mitigate | Validate issuer is https:// at /config; discovery fetch is server-side with a 5s timeout | +| T-12-09 | Tampering | /api/setup/* caught by OIDC guard (302) | mitigate | Mounted before app.use('/api/*', devAuthBypass()) — acceptance-checked ordering (Pitfall 1) | +| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan (RESEARCH §No New Packages) — no legitimacy checkpoint needed | + + + +- `pnpm --filter @familysync/api test -- setup` green incl. POST /complete twice → 200 then 423 +- `cd apps/api && pnpm typecheck` green; full `pnpm --filter @familysync/api test` green +- Source greps: guard-first in every handler; no new crypto; no admin-route call; VAPID private key env-only; no password log +- /api/setup mount precedes devAuthBypass; OIDC boot has env-OR-app_config fallback + + + +- SETUP-01: GET /api/setup/status pre-auth + config-collect into app_config +- SETUP-02: DB / OIDC / VAPID / CalDAV validations each gate the flow +- SETUP-04: per-call 423 guard (Pitfall 8 regression green) +- Fresh instance boots without OIDC env (env-OR-app_config fallback) + + + +Create `.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md` when done + diff --git a/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md new file mode 100644 index 0000000..c997c56 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md @@ -0,0 +1,152 @@ +--- +phase: 12-initial-setup-wizard +plan: 03 +type: tdd +wave: 2 +depends_on: ["12-01"] +files_modified: + - apps/api/src/auth/user.ts + - apps/api/tests/auth/user.test.ts +autonomous: true +requirements: [SETUP-01] +must_haves: + truths: + - "The first OIDC login AFTER app_config.setup_complete='true' claims the single unclaimed local user (oidc_iss IS NULL AND claimed=false), populating oidc_iss/oidc_sub and setting claimed=true" + - "The claimed user keeps its is_admin and credential — no new admin row is created" + - "The claim NEVER keys on email — match is by oidc_iss IS NULL AND claimed=false only (D-10)" + - "Existing OIDC users (claimed=true from the Plan-01 backfill) are matched by identity as before and never re-claimed" + - "When setup_complete is not yet true (or no unclaimed user exists), upsertUser falls through to the normal new-user insert path" + artifacts: + - path: "apps/api/src/auth/user.ts" + provides: "upsertUser with the first-login-claims branch (repurposed first-login-wins)" + contains: "claimed" + - path: "apps/api/tests/auth/user.test.ts" + provides: "D-08 first-login-claims tests (claim, no-email-key, no-double-claim, fallthrough)" + contains: "claimed" + key_links: + - from: "apps/api/src/auth/user.ts" + to: "app_config.setup_complete" + via: "read before the claim branch" + pattern: "setup_complete" + - from: "apps/api/src/auth/user.ts" + to: "users (oidc_iss IS NULL AND claimed=false)" + via: "claim query" + pattern: "isNull\\(users.oidcIss\\)" +--- + + +Rework `upsertUser` in `apps/api/src/auth/user.ts` to implement first-login-claims (D-08): the first +OIDC login after `app_config.setup_complete='true'` claims the single unclaimed pre-OIDC local user +(provisioned by the wizard in Plan 02) instead of minting a fresh admin. This repurposes the Phase 10 +first-login-wins bootstrap — the WR-01 rework the code comment at user.ts l.114 explicitly defers to +Phase 12. TDD plan: claim behavior tests are written before/with the logic change. + +Purpose: Without this, the wizard-created local user (oidc_iss NULL, is_admin=true, holding the +validated credential) would be orphaned and the first OIDC login would create a second admin. SETUP-01's +"first run → guided bootstrap" only closes the loop once the operator's OIDC identity adopts that local user. +Output: A claim-aware upsertUser that preserves the identity model (no email keying) and the credential + admin status. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md +@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md +@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md +@apps/api/src/auth/user.ts + + +## Artifacts this phase produces (Plan 03 portion) + +- `upsertUser` first-login-claims branch in `apps/api/src/auth/user.ts`: + - reads `app_config.setup_complete` + - when true, claims the unclaimed local user (`WHERE oidc_iss IS NULL AND claimed=false LIMIT 1`), sets `oidc_iss`/`oidc_sub`/`claimed=true`, preserves `is_admin` + credential + - `shouldBeAdmin` for the normal insert path becomes `setup_complete !== 'true' && admin count === 0` +- `apps/api/tests/auth/user.test.ts` — D-08 claim test cases (turning the Plan-01 scaffolds green) + + + + + Task 1: First-login-claims branch in upsertUser (D-08) + apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts + + - apps/api/src/auth/user.ts (the file being modified — identity lookup l.76-97, first-login-wins block l.112-123, insert path l.125-141) + - apps/api/tests/auth/user.test.ts (existing upsertUser tests + the Plan-01 D-08 scaffold) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §auth/user.ts (the exact replacement pattern, import additions, claim query) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 4 + Pitfall 4 (no email keying) + §Migration backfill (claimed=true for existing OIDC users) + + + - Existing identity match (oidc_iss+oidc_sub present) → returns/updates that row as today (unchanged); never re-claims + - setup_complete='true' AND an unclaimed user exists (oidc_iss IS NULL AND claimed=false) → claim it: set oidc_iss, oidc_sub, claimed=true, keep is_admin; return the claimed row + - setup_complete='true' AND no unclaimed user → normal insert path, NOT auto-admin (an admin already exists from the claim model) + - setup_complete !== 'true' → existing first-login-wins behavior preserved (shouldBeAdmin = admin count === 0) + - Claim query uses isNull(users.oidcIss) AND eq(users.claimed,false) — asserts NO claims.email / no email column lookup + + + Per PATTERNS.md §auth/user.ts: add `isNull` to the drizzle-orm import and `appConfig` to the + schema import. After the existing identity lookup (step 1, l.76-97) and before the insert (step 4), + read `app_config.setup_complete`. If its value === 'true', select the single unclaimed user + `WHERE isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1`; if found, `db.update(users).set({ + oidcIss, oidcSub, claimed: true, displayName: displayName ?? unclaimed.displayName }).where(eq( + users.id, unclaimed.id))` and return `{ ...unclaimed, oidcIss, oidcSub, claimed: true }` (is_admin + preserved — not overwritten). Replace the `shouldBeAdmin = Number(count) === 0` line with + `shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0` so the normal insert path no + longer self-promotes once setup is complete. MUST NOT introduce any email-keyed matching (D-10 / + Pitfall 4). Turn the Plan-01 D-08 scaffolds GREEN and add: claim success (fields + is_admin + preserved), no-double-claim (a claimed user is not re-claimed), no-email-key (assert the query path + references no email), and the setup_complete-false fallthrough. + + + - source: `grep -c "isNull(users.oidcIss)" apps/api/src/auth/user.ts` returns >= 1 + - source: claim path reads setup_complete (`grep -c "setup_complete" apps/api/src/auth/user.ts` >= 1) + - source: NO email keying in the claim — `grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts` returns 0 + - source: shouldBeAdmin gated on setup_complete (`grep -Ec "value !== 'true'.*count|flagRow.*shouldBeAdmin|shouldBeAdmin =.*!= 'true'" apps/api/src/auth/user.ts` >= 1) + - source: the claim sets claimed=true (`grep -c "claimed: true" apps/api/src/auth/user.ts` >= 1) + - test: user.test.ts D-08 cases pass (claim success/admin-preserved, no-double-claim, fallthrough) + + + cd apps/api && pnpm test -- user && pnpm typecheck + + upsertUser claims the unclaimed local user after setup_complete, preserves is_admin, never keys on email, and falls through correctly when setup is incomplete; user.test.ts green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Authelia OIDC callback → upsertUser | claims supplied by the IdP drive the claim/merge of a pre-existing local user | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-12-10 | Spoofing | first-login-claims claiming the wrong user | accept | Claim query is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user exists in a 2-person household; OIDC reach requires Authelia household membership (documented claim-window assumption, D-08) | +| T-12-11 | Elevation of Privilege | unexpected auto-admin after setup | mitigate | shouldBeAdmin gated to `setup_complete !== 'true'` — once setup completes, new logins do not self-promote; admin comes only from the claimed local user | +| T-12-12 | Tampering | email-keyed identity coupling | mitigate | Acceptance gate forbids claims.email/users.email lookups (D-10 / Pitfall 4); match is identity-null + claimed-false only | +| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan — no legitimacy checkpoint needed | + + + +- `pnpm --filter @familysync/api test -- user` green (claim, no-double-claim, no-email-key, fallthrough) +- `cd apps/api && pnpm typecheck` green +- Source greps: isNull(users.oidcIss) present; no email keying; shouldBeAdmin gated on setup_complete + + + +- D-08 first-login-claims: first OIDC login after setup_complete claims the unclaimed local user, preserving is_admin + credential +- No email coupling; existing OIDC users (backfilled claimed=true) never re-claimed +- Normal insert path no longer auto-promotes admin once setup is complete + + + +Create `.planning/phases/12-initial-setup-wizard/12-03-SUMMARY.md` when done + diff --git a/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md new file mode 100644 index 0000000..aaf3152 --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md @@ -0,0 +1,247 @@ +--- +phase: 12-initial-setup-wizard +plan: 04 +type: execute +wave: 3 +depends_on: ["12-02"] +files_modified: + - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md + - apps/pwa/src/api/client.ts + - apps/pwa/src/routes/SetupPage.tsx + - apps/pwa/src/App.tsx + - apps/pwa/src/App.test.tsx +autonomous: false +requirements: [SETUP-01, SETUP-02] +must_haves: + truths: + - "On a fresh instance (GET /api/setup/status → {setupComplete:false}), the app redirects to /setup and renders the wizard with no AppNav/BottomTabBar" + - "The revised wizard collects non-secret config (OIDC issuer/client_id, VAPID public key, app URL) as input fields, then validates DB/OIDC/VAPID/CalDAV before completing" + - "There is no in-wizard secret-generation step (D-05 — generation is the repo helper, pre-boot)" + - "Completing the wizard (POST /api/setup/complete) shows the terminal 'Setup complete' screen with a Sign in link to /" + - "Navigating to /setup after completion (423) renders the 'Already Locked' screen" + - "When setupComplete:true, normal app boot proceeds (no /setup redirect)" + artifacts: + - path: ".planning/phases/12-initial-setup-wizard/12-UI-SPEC.md" + provides: "Revised Wizard-Steps + Interaction-Contract (Step 2 dropped, Steps 3/4 collect config)" + contains: "config" + - path: "apps/pwa/src/routes/SetupPage.tsx" + provides: "The standalone multi-step wizard component" + min_lines: 80 + - path: "apps/pwa/src/App.tsx" + provides: "setup-status gate + /setup route" + contains: "setup" + key_links: + - from: "apps/pwa/src/App.tsx" + to: "/api/setup/status" + via: "setupQuery on load → redirect to /setup when unconfigured" + pattern: "setup/status|setupStatus" + - from: "apps/pwa/src/routes/SetupPage.tsx" + to: "/api/setup/* (config, validate, credential, complete)" + via: "TanStack Query mutations" + pattern: "setup/(config|validate|credential|complete)" +--- + + +Deliver the PWA side of the wizard: revise `12-UI-SPEC.md` (drop the Generate-Secrets step per D-05; +make the OIDC/VAPID step collect non-secret config inputs per D-02), build `SetupPage.tsx` (the +standalone full-page wizard following the revised UI-SPEC and the AdminPage/CredentialSheet patterns), +add the App.tsx setup-status gate + `/setup` route, and wire the `apps/pwa/src/api/client.ts` setup +client functions. Verify the flow with playwright-cli (desktop Chromium) per the CLAUDE.md convention. + +Purpose: This is the operator-facing surface that closes SETUP-01 (guided bootstrap instead of +hand-editing files) and surfaces SETUP-02's per-input validation. The API routes (Plan 02) are the +contract this consumes. +Output: A working /setup wizard, the App-level gate, and a revised UI-SPEC matching D-02/D-04/D-05. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md +@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md +@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md +@.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md +@apps/pwa/src/routes/AdminPage.tsx +@apps/pwa/src/components/CredentialSheet.tsx +@apps/pwa/src/App.tsx +@apps/pwa/src/api/client.ts + + +## Artifacts this phase produces (Plan 04 portion) + +- Revised `12-UI-SPEC.md`: Step 2 (Generate Secrets) dropped; the OIDC/VAPID step gains input fields for oidc_issuer/oidc_client_id/vapid_public_key (+ app URL); 4-step flow (Welcome / Config / Validate / Credential — or planner-chosen equivalent) consistent with D-02/D-04/D-05 +- `apps/pwa/src/api/client.ts`: `fetchSetupStatus`, `postSetupConfig`, `validateSetupDb/Oidc/Vapid`, `postSetupCredential`, `postSetupComplete` +- `apps/pwa/src/routes/SetupPage.tsx`: standalone wizard (no AppNav/BottomTabBar), Surfaces 1-8 per the revised UI-SPEC, plain-text JSX (no dangerouslySetInnerHTML) +- `apps/pwa/src/App.tsx`: `setupQuery` on /api/setup/status (staleTime 0) + `/setup` route + redirect gate when `setupComplete:false` + + + + + Task 1: Revise 12-UI-SPEC.md (drop Generate-Secrets; config-collect inputs per D-02/D-04/D-05) + .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md + + - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the file being revised — §Surface 2 step labels, §Surface 4 Generated-Secret block, §Wizard Steps, §Copywriting Contract) + - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §UI-SPEC Revision Requirements (the authoritative table of what changes vs stays) + - .planning/phases/12-initial-setup-wizard/12-CONTEXT.md D-02/D-04/D-05 + the ⚠ Supersedes notes + + + Revise ONLY the Wizard-Steps, Interaction-Contract, Step-Indicator labels, Surface-4, and + Copywriting sections per RESEARCH.md §UI-SPEC Revision Requirements. DROP Step 2 "Generate Secrets" + entirely (no Secret Blocks, no acknowledgement checkboxes, no POST /api/setup/generate — generation + is the pre-boot repo helper, D-05); remove the Surface-4 Generated-Secret-Block section (or mark it + removed). Re-number the step indicator to the revised set (planner's call per CONTEXT discretion, + e.g. Welcome / Config / Validate / Credential — 4 steps). Convert the OIDC/VAPID step to COLLECT + non-secret config via input fields (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url) + that POST to /api/setup/config, THEN validate (D-02). Update Step-1 description copy to remove the + "copy of docker-compose.yml to paste generated secrets into" reference. Leave the design system, + tokens, spacing, typography, color, a11y contract, security display rules, the Credential step, and + the Terminal/Locked screens UNCHANGED — do NOT re-derive the design system. + + + - source: the Generated-Secrets step is gone (`grep -ic "Generate Secrets\|Generated Secrets" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0, or any remaining hit is explicitly marked "REMOVED") + - source: the OIDC/config step now references input fields for the config keys (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 2) + - source: no in-wizard generate endpoint (`grep -c "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0) + - source: design-system sections retained (`grep -c "Design System\|Spacing Scale\|Accessibility Contract" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 3) + + + ! grep -iq "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md && grep -Eq "oidc_issuer|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md + + UI-SPEC steps revised: no generate-secrets step, config-collect inputs for the OIDC/VAPID step, step indicator re-numbered; design system untouched. + + + + Task 2: Setup API client + SetupPage wizard component + apps/pwa/src/api/client.ts, apps/pwa/src/routes/SetupPage.tsx + + - apps/pwa/src/api/client.ts (the file being extended — fetchMe l.74, saveCredential l.429 patterns) + - apps/pwa/src/routes/AdminPage.tsx (analog: page component, useQuery/useMutation, section-label/button styles, PATTERNS.md §SetupPage.tsx) + - apps/pwa/src/components/CredentialSheet.tsx (analog: credential field layout, validation-state row, helper link, plain-text JSX — Step Credential reuses this exactly) + - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the REVISED contract from Task 1 — surfaces, copy, a11y) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §SetupPage.tsx (imports, mutation, step-state patterns) + + + - fetchSetupStatus() GETs /api/setup/status → { setupComplete: boolean } + - postSetupConfig(payload) POSTs the four non-secret config values to /api/setup/config + - validateSetupDb/Oidc/Vapid() POST the three validation routes; map non-200 to a typed failure + - postSetupCredential({fastmailEmail, appPassword}) POSTs /api/setup/credential + - postSetupComplete() POSTs /api/setup/complete + - SetupPage renders the revised steps (Welcome → Config → Validate → Credential), the step indicator (Surface 2), per-step validation-state rows (Surface 5), the terminal "Setup complete" screen (Surface 7) on success, and the "Already Locked" screen (Surface 8) when status/complete returns 423 + - No AppNav/BottomTabBar; role="main"; step heading h2; aria-live status rows; all copy plain-text JSX (no dangerouslySetInnerHTML) + + + Add the setup client functions to apps/pwa/src/api/client.ts following the existing fetch/JSON + conventions (same error-shape handling as fetchMe/saveCredential). Build + apps/pwa/src/routes/SetupPage.tsx per the REVISED UI-SPEC (Task 1) and PATTERNS.md §SetupPage.tsx: + local `useState` step cursor (no URL params, D-06 stateless); a TanStack `useMutation` per + POST step advancing the cursor onSuccess and surfacing a Surface-5 failure row onError; reuse the + CredentialSheet field/validation idiom verbatim for the Credential step; render Surface 7 on + /complete success and Surface 8 when an API call returns 423. Use the existing tokens.css custom + properties and lucide-react icons named in the UI-SPEC. All copy must be plain-text JSX children — + NO dangerouslySetInnerHTML (UI-SPEC security contract). Render standalone — no AppNav/BottomTabBar. + + + - source: client.ts exports the setup functions (`grep -Ec "fetchSetupStatus|postSetupConfig|postSetupComplete|postSetupCredential" apps/pwa/src/api/client.ts` >= 4) + - source: SetupPage references all setup routes (`grep -Ec "setup/config|setup/validate|setup/credential|setup/complete|setup/status" apps/pwa/src/routes/SetupPage.tsx` >= 4 — directly or via the client imports) + - source: no dangerouslySetInnerHTML (`grep -c "dangerouslySetInnerHTML" apps/pwa/src/routes/SetupPage.tsx` returns 0) + - source: standalone — SetupPage does not import AppNav/BottomTabBar (`grep -Ec "AppNav|BottomTabBar" apps/pwa/src/routes/SetupPage.tsx` returns 0) + - source: a11y — role="main" + aria-live present (`grep -Ec "role=\"main\"|aria-live" apps/pwa/src/routes/SetupPage.tsx` >= 1) + - test: `pnpm --filter @familysync/pwa typecheck` and `pnpm --filter @familysync/pwa build` green + + + cd apps/pwa && pnpm typecheck && pnpm build + + Setup client functions added; SetupPage renders the revised 4-step wizard standalone with terminal/locked screens, no dangerouslySetInnerHTML; pwa typecheck + build green. + + + + Task 3: App.tsx setup-status gate + /setup route + redirect + apps/pwa/src/App.tsx, apps/pwa/src/App.test.tsx + + - apps/pwa/src/App.tsx (the file being modified — meQuery l.65-70, Routes block l.133-153, isAdmin loading-gate l.144-150) + - apps/pwa/src/App.test.tsx (existing App routing tests to extend, if present; else mirror the meQuery test setup) + - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §App.tsx (setupQuery + gate + Navigate pattern) + - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md §Routing & App-Level Gate + + + In apps/pwa/src/App.tsx add `import { SetupPage } from './routes/SetupPage.js';` and a + `setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false, + staleTime: 0 })` alongside meQuery (staleTime 0 — the gate must not be stale, mirrors D-10 spirit). + Add `} />` to the Routes block. Add the redirect gate: + while setupQuery is loading render nothing (prevent flash, mirror the isAdmin loading-gate l.144-150); + when `setupQuery.data?.setupComplete === false`, redirect all non-/setup routes to /setup + (``); when true, normal app boot proceeds. The /setup route renders + standalone — ensure the gate prevents AppNav/BottomTabBar from rendering over the wizard when + unconfigured (per UI-SPEC §Routing). Extend App.test.tsx: setupComplete:false → SetupPage/redirect + rendered; setupComplete:true → normal calendar route. + + + - source: setupQuery present (`grep -Ec "setupStatus|fetchSetupStatus" apps/pwa/src/App.tsx` >= 1) + - source: /setup route added (`grep -c "/setup" apps/pwa/src/App.tsx` >= 1) + - source: SetupPage imported (`grep -c "SetupPage" apps/pwa/src/App.tsx` >= 1) + - source: redirect gate keyed on setupComplete (`grep -Ec "setupComplete === false|setupComplete\\?" apps/pwa/src/App.tsx` >= 1) + - test: `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches) + + + cd apps/pwa && pnpm test -- App && pnpm typecheck + + App.tsx queries /api/setup/status, exposes the /setup route, and redirects to /setup when unconfigured (no flash, no nav over wizard); App.test.tsx covers both branches. + + + + Task 4: Verify the /setup wizard flow end-to-end (playwright-cli desktop) + Drive the /setup flow with playwright-cli (desktop Chromium) against a fresh/unconfigured DB per the verification steps below; escalate to the human only for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip). + The /setup wizard flow end-to-end in the PWA: redirect-to-/setup when unconfigured, the revised 4-step flow (Welcome → Config → Validate → Credential), validation-state rows, and the terminal "Setup complete" screen. Per CLAUDE.md the executor MUST first drive this with playwright-cli (desktop Chromium) — only fall back to a human if a step genuinely cannot be driven headlessly. + + 1. Bring up the dev stack against a FRESH/unconfigured DB (no setup_complete, no member_credentials) — see MEMORY familysync-dev-stack-setup; the API + PWA dev servers + MariaDB. + 2. Using playwright-cli (`/usr/local/bin/playwright-cli`), navigate to the app root and confirm it redirects to /setup and renders the wizard with NO AppNav/BottomTabBar. + 3. Drive the wizard: Config step accepts the OIDC issuer/client_id + VAPID public key + app URL inputs and POSTs /api/setup/config; Validate step shows pending→success rows for DB/OIDC/VAPID (mock or live as available); Credential step accepts a Fastmail email + app password (use a known-good or mocked credential) and shows "Credential verified."; Complete shows the "Setup complete" terminal screen with a Sign in link to /. + 4. Re-navigate to /setup after completion and confirm the "Already Locked" screen renders (API 423). + 5. Capture screenshots of the wizard, a validation-success row, and the terminal screen into the phase dir for the SUMMARY. + Only escalate to the human for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip if no mock is wired) — note any such steps explicitly. + + Type "approved" or describe the issues observed + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator browser → /api/setup/* | the wizard is the unauthenticated client of the pre-auth API; it submits non-secret config + the Fastmail app password | +| SetupPage render → DOM | operator-supplied copy/config values rendered; XSS risk if not plain-text | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-12-13 | Information Disclosure | wizard never displays/handles secrets | mitigate | D-05 — no generate-secrets step; the wizard never receives SESSION_SECRET/encryption key/VAPID private key; only the non-secret VAPID public key is an input | +| T-12-14 | Tampering (XSS) | SetupPage rendering operator input | mitigate | No dangerouslySetInnerHTML (acceptance-checked); all copy + config values rendered as plain-text JSX children (UI-SPEC security contract) | +| T-12-15 | Information Disclosure | app password in the Credential step | mitigate | type="password" input (UI-SPEC); reuses CredentialSheet idiom; server-side noEchoHook (Plan 02) ensures the value is never echoed back | +| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages — lucide-react/react-query/react-router already installed (RESEARCH §Standard Stack) | + + + +- `cd apps/pwa && pnpm typecheck && pnpm build` green +- `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches) +- UI-SPEC revised: no generate-secrets step, config-collect inputs present +- playwright-cli desktop smoke: redirect→wizard→config→validate→credential→complete + locked screen + + + +- SETUP-01: fresh instance redirects to /setup; guided multi-step wizard renders standalone +- SETUP-02: each input validates (DB/OIDC/VAPID/CalDAV) before the step completes +- D-05 honored: no in-wizard secret generation +- Terminal + Already-Locked screens behave per UI-SPEC; playwright-cli smoke passes + + + +Create `.planning/phases/12-initial-setup-wizard/12-04-SUMMARY.md` when done + diff --git a/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md index 37d4499..756b337 100644 --- a/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md +++ b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md @@ -1,8 +1,8 @@ --- phase: 12 slug: initial-setup-wizard -status: draft -nyquist_compliant: false +status: ready +nyquist_compliant: true wave_0_complete: false created: 2026-06-15 --- @@ -17,20 +17,20 @@ created: 2026-06-15 | Property | Value | |----------|-------| -| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} | -| **Config file** | {path or "none — Wave 0 installs"} | -| **Quick run command** | `{quick command}` | -| **Full suite command** | `{full command}` | -| **Estimated runtime** | ~{N} seconds | +| **Framework** | Vitest (API: `apps/api/tests/`; PWA: `apps/pwa`) + Playwright (e2e) | +| **Config file** | `apps/api/vitest.config.ts`, `apps/pwa/vitest` config, `apps/pwa/playwright.config.ts` | +| **Quick run command** | `pnpm --filter @familysync/api test -- setup` | +| **Full suite command** | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` | +| **Estimated runtime** | ~30–60 seconds (API + PWA unit) | --- ## Sampling Rate -- **After every task commit:** Run `{quick run command}` -- **After every plan wave:** Run `{full suite command}` -- **Before `/gsd-verify-work`:** Full suite must be green -- **Max feedback latency:** {N} seconds +- **After every task commit:** Run `pnpm --filter @familysync/api test -- setup` (API tasks) or `pnpm --filter @familysync/pwa test -- App` (PWA tasks) +- **After every plan wave:** Run `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` +- **Before `/gsd-verify-work`:** Full suite + `pnpm test:e2e` green +- **Max feedback latency:** 60 seconds --- @@ -38,7 +38,18 @@ created: 2026-06-15 | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| -| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending | +| 12-01-01 | 01 | 1 | SETUP-03 (schema) | T-12-02 | Migration MODIFY (not DROP) + backfill; no orphaned rows | integration | `cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck` | ✅ | ⬜ pending | +| 12-01-02 | 01 | 1 | SETUP-03 | T-12-01 | Secrets to stdout only — never DB/file/log | unit (script) | `node scripts/generate-secrets.mjs \| grep -E ...` | ✅ | ⬜ pending | +| 12-01-03 | 01 | 1 | — (scaffold) | — | Import targets only | typecheck | `cd apps/api && pnpm typecheck` | ✅ | ⬜ pending | +| 12-01-04 | 01 | 1 | SETUP-01/02/03/04 | T-12-04 | RED 423-guard test before happy path | unit (scaffold) | `cd apps/api && pnpm test -- setup` | ✅ W0 | ⬜ pending | +| 12-02-01 | 02 | 2 | SETUP-04 | T-12-04 | Per-call 423; no startup cache | unit | `cd apps/api && pnpm test -- setup` | ✅ | ⬜ pending | +| 12-02-02 | 02 | 2 | SETUP-01/02 | T-12-05/06/08 | noEchoHook; VAPID priv env-only; https issuer; no new crypto | integration | `cd apps/api && pnpm test -- setup && pnpm typecheck` | ✅ | ⬜ pending | +| 12-02-03 | 02 | 2 | SETUP-01 | T-12-09 | Pre-auth mount before OIDC guard; env-OR-app_config boot | integration | `cd apps/api && pnpm typecheck && pnpm test` | ✅ | ⬜ pending | +| 12-03-01 | 03 | 2 | SETUP-01 (D-08) | T-12-10/11/12 | Claim by oidc_iss IS NULL+claimed=false; no email key; admin gated | unit | `cd apps/api && pnpm test -- user && pnpm typecheck` | ✅ | ⬜ pending | +| 12-04-01 | 04 | 3 | SETUP-01/02 | T-12-13 | UI-SPEC: no generate-secrets step | doc grep | `grep -Eq "oidc_issuer\|/api/setup/config" 12-UI-SPEC.md` | ✅ | ⬜ pending | +| 12-04-02 | 04 | 3 | SETUP-01/02 | T-12-14/15 | No dangerouslySetInnerHTML; password input | typecheck+build | `cd apps/pwa && pnpm typecheck && pnpm build` | ✅ | ⬜ pending | +| 12-04-03 | 04 | 3 | SETUP-01 | — | Redirect gate; no flash | unit | `cd apps/pwa && pnpm test -- App && pnpm typecheck` | ✅ | ⬜ pending | +| 12-04-04 | 04 | 3 | SETUP-01/02 | T-12-14 | End-to-end wizard flow (playwright-cli desktop) | e2e / human | playwright-cli drive `/setup` (see plan) | ✅ | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* @@ -46,11 +57,12 @@ created: 2026-06-15 ## Wave 0 Requirements -- [ ] `{tests/test_file.py}` — stubs for REQ-{XX} -- [ ] `{tests/conftest.py}` — shared fixtures -- [ ] `{framework install}` — if no framework detected +- [ ] `apps/api/tests/routes/setup.test.ts` — scaffolds SETUP-01/02/03/04 incl. the RED 423-guard test (Pitfall 8) — created in Plan 01 Task 4 +- [ ] `apps/api/tests/auth/user.test.ts` — D-08 first-login-claims scaffold — extended in Plan 01 Task 4 +- [ ] `apps/api/src/routes/setup.ts` — stub Hono router (import target) — Plan 01 Task 3 +- [ ] `apps/api/src/lib/setupGuard.ts` — stub isSetupLocked (import target) — Plan 01 Task 3 -*If none: "Existing infrastructure covers all phase requirements."* +*Existing Vitest + Playwright infrastructure covers all other phase requirements.* --- @@ -58,19 +70,21 @@ created: 2026-06-15 | Behavior | Requirement | Why Manual | Test Instructions | |----------|-------------|------------|-------------------| -| {behavior} | REQ-{XX} | {reason} | {steps} | +| Live Authelia OIDC discovery round-trip | SETUP-02 | No live Authelia in test env; mock the discovery fetch in unit tests | If a live Authelia is available, validate /api/setup/validate/oidc against the real issuer; otherwise rely on the fetch-mock unit test | +| Live Fastmail CalDAV PROPFIND | SETUP-02 | Requires a real Fastmail app password; unit tests mock createFastmailClient | Optional live check with a known-good app password during the playwright-cli smoke (Plan 04 Task 4) | +| iOS-Safari standalone behavior | — | Not in scope this phase; wizard is desktop-driven | N/A — desktop Chromium via playwright-cli covers the wizard per CLAUDE.md | -*If none: "All phase behaviors have automated verification."* +*All automatable phase behaviors have automated verification; the above need live services or are out of scope.* --- ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < {N}s -- [ ] `nyquist_compliant: true` set in frontmatter +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify (the only checkpoint, 12-04-04, follows three automated PWA tasks) +- [x] Wave 0 covers all MISSING references (setup.test.ts, user.test.ts, setup.ts stub, setupGuard.ts stub — all in Plan 01) +- [x] No watch-mode flags +- [x] Feedback latency < 60s +- [x] `nyquist_compliant: true` set in frontmatter -**Approval:** {pending / approved YYYY-MM-DD} +**Approval:** approved 2026-06-15 -- 2.54.0 From 513fc887e426ce90527f8c098001d8eb3d6a87e1 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:49:53 -0400 Subject: [PATCH 06/73] =?UTF-8?q?docs(12):=20finalize=20plan=20=E2=80=94?= =?UTF-8?q?=20validation=20map,=20research=20resolution,=20roadmap=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve plan-checker warning (mark RESEARCH Open Questions RESOLVED), fill VALIDATION.md Nyquist map (nyquist_compliant), record planning complete in STATE, annotate ROADMAP wave dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 8 ++++++++ .planning/STATE.md | 16 ++++++++-------- .../12-initial-setup-wizard/12-RESEARCH.md | 4 +++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4c46790..df6153c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -252,9 +252,17 @@ Plans: **Plans**: 4 plans in 3 waves Plans: +**Wave 1** + - [ ] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds + +**Wave 2** *(blocked on Wave 1 completion)* + - [ ] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04) - [ ] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01) + +**Wave 3** *(blocked on Wave 2 completion)* + - [ ] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02) **UI hint**: yes diff --git a/.planning/STATE.md b/.planning/STATE.md index b52d203..5f8163e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish status: executing -stopped_at: Phase 18 context gathered -last_updated: "2026-06-15T01:25:46.051Z" -last_activity: 2026-06-14 +stopped_at: Phase 12 UI-SPEC approved +last_updated: "2026-06-15T15:49:47.164Z" +last_activity: 2026-06-15 -- Phase 12 planning complete progress: total_phases: 23 completed_phases: 9 - total_plans: 32 + total_plans: 36 completed_plans: 32 percent: 39 --- @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-06-10) Phase: 13 Plan: Not started Status: Ready to execute -Last activity: 2026-06-14 +Last activity: 2026-06-15 -- Phase 12 planning complete ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -252,9 +252,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-15T01:25:46.029Z -Stopped at: Phase 18 context gathered -Resume file: .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md +Last session: 2026-06-15T15:15:40.328Z +Stopped at: Phase 12 UI-SPEC approved +Resume file: .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md ## Operator Next Steps diff --git a/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md b/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md index af920d4..1632902 100644 --- a/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md +++ b/.planning/phases/12-initial-setup-wizard/12-RESEARCH.md @@ -699,7 +699,9 @@ The planner MUST revise the `12-UI-SPEC.md` Wizard Steps and Interaction Contrac --- -## Open Questions +## Open Questions (RESOLVED) + +> All four assumptions are addressed by the Phase 12 plans. **A1** (`generate-secrets` location/toolchain) and **A3** (app_config consumer scope) are pre-resolved in Plan 01 Task 2 (plain `.mjs` at `scripts/generate-secrets.mjs`) and PATTERNS.md (`app_config` key/value read pattern). **A2** (oidcAuthMiddleware config-read timing) is confirmed during execution in **Plan 02 Task 3** via explicit acceptance criteria: implement the env-OR-app_config fallback (Recommendation (a)), OR — if `@hono/oidc-auth` is found to read config at import time — apply option (b)/(c) and document the deviation in the SUMMARY. **A4** (claimed-column backfill) is specified by RESEARCH §Runtime State Inventory + Plan 01 Task 1. No question is deferred beyond execution. 1. **oidcAuthMiddleware boot-time config reads** - What we know: `@hono/oidc-auth` reads OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_AUTH_EXTERNAL_URL at initialization. If these move to app_config (D-02), a fresh unconfigured instance has no env values. -- 2.54.0 From 0f3c3784e68d40f620bc8d0a05f32e29cf82e7a9 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 11:14:36 -0400 Subject: [PATCH 07/73] docs(12): revise UI-SPEC for collect-and-write wizard model Drop Step 2 "Generate Secrets" (secrets move to pre-boot helper script per D-05), rework Step 2 to collect non-secret config via form fields (app URL, OIDC issuer, client_id, VAPID public key) written to app_config per D-02, and reduce wizard from 5 steps to 4. All design tokens, surfaces, and a11y contracts unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../12-initial-setup-wizard/12-UI-SPEC.md | 294 ++++++++++-------- 1 file changed, 157 insertions(+), 137 deletions(-) diff --git a/.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md b/.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md index ac2d489..d7c08e5 100644 --- a/.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md +++ b/.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md @@ -5,12 +5,18 @@ status: draft shadcn_initialized: false preset: none created: 2026-06-14 +updated: 2026-06-15 --- # Phase 12 — UI Design Contract: Initial Setup Wizard > Visual and interaction contract for the first-run setup wizard. > Generated by gsd-ui-researcher. Verified by gsd-ui-checker. +> +> **Revision note (2026-06-15):** CONTEXT.md (D-04/D-05/D-06) supersedes the original +> validate-only model. Step 2 "Generate Secrets" is dropped (generation is pre-boot via repo +> helper script). Steps 3–4 are reworked to collect config via input fields, not just validate +> env. All other design tokens, surfaces, and a11y contracts are unchanged. --- @@ -60,10 +66,8 @@ Uses the existing 4px-based scale. No new tokens. Values from `tokens.css`: Exceptions: - Wizard card max-width: 540px (slightly wider than CredentialSheet 480px to accommodate - multi-field steps and generated-secret blocks). + multi-field steps). - Step indicator touch targets: 44px minimum (accessibility). -- Copy-to-clipboard button: 36px height is acceptable since it is paired with an adjacent - textarea (which itself is large enough), but the copy button must have `minWidth: 44px`. --- @@ -80,11 +84,10 @@ All values from `tokens.css`. No new sizes or weights. Usage in this phase: - Wizard page title ("FamilySync Setup"): Display (24px/600/1.2) -- Step heading (e.g. "Generate Secrets"): Heading (18px/600/1.25) +- Step heading (e.g. "OIDC & App URL"): Heading (18px/600/1.25) - Step description / helper text: Body (15px/400/1.5) - Field labels, step counter, status badges: Label (13px/400/1.4) — labels use weight 600 -- Generated secret value (monospace block): 13px/400/1.4 with `font-family: monospace` override -- Section labels (uppercase caps, e.g. "STEP 2 OF 5"): Label (13px/600) with +- Section labels (uppercase caps, e.g. "STEP 2 OF 4"): Label (13px/600) with `text-transform: uppercase; letter-spacing: 0.06em` (AdminPage `sectionLabelStyle` pattern) --- @@ -96,8 +99,8 @@ All values from `tokens.css`. No new hex values. | Role | Value | Variable | Usage | |------|-------|----------|-------| | Dominant (60%) | #ffffff | var(--color-surface) | Page background, card background | -| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Step sidebar/tracker background, generated-secret block background, inactive step indicator | -| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA buttons, active step indicator fill, spinner, copy button, links | +| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Step sidebar/tracker background, inactive step indicator | +| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA buttons, active step indicator fill, spinner, links | | Destructive | #dc2626 | var(--color-destructive) | Validation failure border + helper text (same CredentialSheet pattern) | Accent reserved for: @@ -105,7 +108,6 @@ Accent reserved for: - Active wizard step indicator (filled circle) - Inline spinner (`Loader2`) during async validation - Hyperlinks (e.g. "Get an app password") -- Copy-to-clipboard button icon - Focus ring (`var(--color-focus-ring): #4a90d9`) Additional semantic colors (not new — already in tokens.css): @@ -120,13 +122,11 @@ Additional semantic colors (not new — already in tokens.css): ## Surface Architecture The wizard is a **standalone full-page route** (`/setup`) mounted in a separate React root or -an App-level gate (see Interaction Contract). It renders none of the AppNav / BottomTabBar / +an App-level gate (see Routing section). It renders none of the AppNav / BottomTabBar / SetupBanner chrome. ### Surface 1 — Wizard Page Shell -The wizard page itself. - - Background: `var(--color-surface)` (#ffffff) - Layout: vertically centered column, `min-height: 100dvh` - Content column: `maxWidth: 540px`, `margin: 0 auto`, @@ -140,7 +140,7 @@ The wizard page itself. Linear step tracker shown above the active step card. -- Horizontal row of N step circles connected by lines +- Horizontal row of 4 step circles connected by lines - Completed step: filled circle `var(--color-member-0)` with white `Check` icon (16px) - Active step: filled circle `var(--color-member-0)` with white step number (13px/600) - Upcoming step: circle with `var(--color-border)` 2px border, `var(--color-text-muted)` step @@ -152,12 +152,11 @@ Linear step tracker shown above the active step card. - Circle size: 28px diameter; connector height: 1px; minimum row height: 44px touch target achieved by centering in a 44px tall row -Step labels (5 steps total): +Step labels (4 steps total): 1. Welcome -2. Secrets -3. Database -4. OIDC -5. Calendar +2. Instance +3. Calendar +4. Complete ### Surface 3 — Step Card @@ -174,27 +173,23 @@ The active step's input/content area. One card rendered at a time. `marginBottom: var(--space-6)` (24px) - Field group spacing: `var(--space-4)` (16px) between fields -### Surface 4 — Generated-Secret Block +### Surface 4 — Input Field -Used in Step 2 (Secrets) for values the operator must copy into env. +Standard text input used across steps 2–3 to collect config. -- Background: `var(--color-surface-dim)` (#f7f7f8) -- Border: `1px solid var(--color-border-subtle)` (#eceef2) -- Border-radius: 4px (`var(--space-1)`) -- Padding: `var(--space-3) var(--space-4)` (12px 16px) -- Secret value: monospace, 13px/400, `var(--color-text-primary)`, word-break: break-all - (VAPID keys are long strings) -- Label above block: Label (13px/600), `var(--color-text-primary)` -- Copy button: icon-only (`Copy` icon 16px, `var(--color-member-0)`), positioned top-right - inside the block, `minWidth: 44px`, `minHeight: 36px` (acceptable — paired with large block) -- After copy: icon swaps to `Check` (16px, `var(--color-member-0)`) for 2 seconds, then reverts -- Acknowledgement checkbox below each secret block: standard checkbox input, Label (13px/400), - "I have copied this value into my `.env` file." — the Continue button is disabled until all - checkboxes on the step are checked. +- Width: 100%, `box-sizing: border-box` +- Padding: `var(--space-3, 12px) var(--space-4, 16px)` (matches CredentialSheet pattern) +- Border: `1px solid var(--color-border)` default; `1px solid var(--color-destructive)` on + validation error +- Border-radius: `var(--space-1, 4px)` (4px) +- Font: 15px/400, `var(--color-text-primary)`, `var(--font-family-base)` +- Background: `var(--color-surface)` +- Label above: 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px) +- Helper text below: 13px/400, `var(--color-text-secondary)` ### Surface 5 — Validation State Row -Shown after the operator submits a validation step (DB / OIDC / CalDAV). +Shown after a validation request is triggered (DB connectivity / OIDC discovery / CalDAV PROPFIND). - Pending: `Loader2` icon (16px, `var(--color-member-0)`, `animation: spin 1s linear infinite`) + Body (15px/400) status text in `var(--color-text-secondary)` — inline row @@ -203,6 +198,7 @@ Shown after the operator submits a validation step (DB / OIDC / CalDAV). - Failure: `AlertCircle` icon (16px, `var(--color-destructive)`) + error text Body (15px/400) in `var(--color-destructive)` — same pattern as CredentialSheet `FAILURE_TEXT` - Layout: `display: flex; alignItems: center; gap: var(--space-2, 8px)` (CredentialSheet pattern) +- Container: `role="status"` with `aria-live="polite"` ### Surface 6 — Action Row @@ -218,11 +214,11 @@ Bottom of each step card. `borderRadius: var(--space-1)` (4px), `transition: background 0.15s ease` — disabled state: `background: var(--color-border)` (#e2e4e9), `cursor: default` (AdminPage / CredentialSheet pattern) -- "Continue" label on steps 1–4; "Complete Setup" label on step 5 +- "Continue" label on steps 1–3; "Complete Setup" label on step 4 ### Surface 7 — Terminal "Setup Complete" Screen -Replaces the wizard card after step 5 completes successfully. +Replaces the wizard card after step 4 completes successfully. - Icon: `ShieldCheck` (48px, `var(--color-member-0)`) centered - Heading: "Setup complete" — Display (24px/600), centered, `marginTop: var(--space-4)`, @@ -249,78 +245,91 @@ Shown when the operator navigates to `/setup` after `setup_complete = true` (423 ## Wizard Steps — Detailed Interaction Contract +> **REVISION (2026-06-15, CONTEXT.md D-04/D-05):** The original 5-step model included a +> "Generate Secrets" step (Step 2). This step is **dropped**. Secret generation (SESSION_SECRET, +> APP_PASSWORD_ENCRYPTION_KEY, VAPID public/private keys) is done pre-boot via the +> `npm run generate-secrets` repo helper script. The wizard never generates, displays, or +> requests acknowledgement of secrets. Steps are now 4 total. + ### Step 1: Welcome Purpose: orient the operator; no inputs; no validation. - Heading: "Welcome to FamilySync Setup" -- Description: "This wizard will guide you through configuring your self-hosted instance. - You'll need: your OIDC client credentials (Authelia), a Fastmail account with an app password, - and a copy of your `docker-compose.yml` to paste generated secrets into. This takes about - 5 minutes." +- Description: "This wizard will guide you through configuring your self-hosted instance. Before + continuing, run `npm run generate-secrets` from the repo to generate your instance secrets and + add them to your Docker environment. You'll also need: your OIDC client credentials (Authelia) + and a Fastmail account with an app password. This takes about 5 minutes." +- Informational note block (Surface 3 — inside the card, `background: var(--color-surface-dim)`, + `border-radius: 4px`, `padding: var(--space-3) var(--space-4)`, `marginBottom: var(--space-4)`): + - Label (13px/600, `var(--color-text-primary)`): "Before you start" + - Body: "Run `npm run generate-secrets` and add the output to your Docker environment block. + These secrets cannot be recovered if lost." - No input fields. - Continue button: always enabled. -### Step 2: Generate Secrets +### Step 2: Instance Configuration -Purpose: display generated session secret, encryption key, and VAPID keypair; operator copies -each into env. +Purpose: collect non-secret runtime config that the wizard writes to `app_config`. No secrets +are collected here. Validates DB connectivity and OIDC discovery. -- Heading: "Generated Secrets" -- Description: "These values are generated once and displayed now. Copy each into your - `docker-compose.yml` environment block before continuing. They will never be shown again and - are not stored in the database." -- Four generated-secret blocks (Surface 4), each with acknowledgement checkbox: - 1. `SESSION_SECRET` — label "Session secret", 64-char hex string - 2. `APP_PASSWORD_ENCRYPTION_KEY` — label "Encryption key", 64-char hex string - 3. `VAPID_PUBLIC_KEY` — label "VAPID public key" - 4. `VAPID_PRIVATE_KEY` — label "VAPID private key" -- Continue button disabled until all 4 checkboxes are checked. -- No async validation on this step. Secrets are generated client-side or fetched from - `POST /api/setup/generate` (backend choice — UI treats them as string values to display). +- Heading: "Instance Configuration" +- Description: "Enter your instance's connection details. These are written to the database — + not your environment file." -### Step 3: Database +**Fields (collected and written to `app_config`):** -Purpose: verify the DB connection configured in env is reachable. +1. **App URL** + - Label: "App URL" + - Type: `text`, placeholder: `https://familysync.example.com` + - Helper: "The public URL where FamilySync is reachable." + - `app_config` key: `app_url` -- Heading: "Database Connection" -- Description: "Verify that the app can reach the MariaDB database configured in your - environment. No changes are made — this is a read-only connectivity check." -- No operator input fields (DB creds come from env, not from this UI). -- "Test Connection" button (primary filled, full-width on this step — replace normal action row): - triggers `POST /api/setup/validate/db` -- Validation state row (Surface 5) shown below the description during/after the test: - - Pending: "Testing database connection…" - - Success: "Database connection verified." - - Failure: "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your - `docker-compose.yml` and try again." -- Continue button appears after success; disabled during pending; hidden on failure (operator - must retry first). +2. **OIDC Issuer** + - Label: "OIDC issuer URL" + - Type: `text`, placeholder: `https://auth.example.com` + - Helper: "Your Authelia instance URL. FamilySync will fetch `/.well-known/openid-configuration` + from this URL." + - `app_config` key: `oidc_issuer` + +3. **OIDC Client ID** + - Label: "OIDC client ID" + - Type: `text`, placeholder: `familysync` + - Helper: "The client ID registered in Authelia for this application." + - `app_config` key: `oidc_client_id` + +4. **VAPID Public Key** + - Label: "VAPID public key" + - Type: `text`, placeholder: `BH…` (URL-safe base64, 87 chars) + - Helper: "Paste the `VAPID_PUBLIC_KEY` value from `npm run generate-secrets`." + - `app_config` key: `vapid_public_key` + +**Validations (triggered by "Save & Validate" button):** + +Two sequential checks run after the operator taps the action button: + +1. **Database** — `POST /api/setup/validate/db` + - Validation state row (Surface 5): + - Pending: "Testing database connection…" + - Success: "Database connection verified." + - Failure: "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your + Docker environment and try again." + +2. **OIDC discovery** — `POST /api/setup/validate/oidc` (runs after DB success) + - Validation state row (Surface 5): + - Pending: "Checking OIDC discovery…" + - Success: "OIDC discovery resolved." + - Failure: "OIDC discovery failed. Check the issuer URL and that Authelia is reachable from + the server." + +- Action button label on this step: "Save & Validate" (primary filled, full-width on this + step — replace the normal right-aligned action row with a full-width button above the validation + state rows, then normal Continue/Back row appears below once both pass) +- Continue appears (enabled) only when both validation rows show success and config has been saved + (`POST /api/setup/config` call completes before validation begins). - Back is available. -### Step 4: OIDC & VAPID - -Purpose: verify OIDC discovery resolves and the VAPID keypair in env is structurally valid. - -- Heading: "OIDC & Push" -- Description: "Verify that the Authelia OIDC issuer is reachable and that the VAPID keypair - you copied in Step 2 is in place." -- Two validation rows, triggered sequentially by "Validate" button: - - OIDC: label "Authelia issuer discovery", `POST /api/setup/validate/oidc` - - Pending: "Checking OIDC discovery…" - - Success: "OIDC discovery resolved." - - Failure: "OIDC discovery failed. Check OIDC_ISSUER in your environment and that Authelia - is reachable." - - VAPID: label "VAPID keypair", `POST /api/setup/validate/vapid` - - Pending: "Checking VAPID keypair…" - - Success: "VAPID keypair is valid." - - Failure: "VAPID private key could not be verified. Ensure you copied both keys from Step 2 - into your environment and restarted the container." -- "Validate" button triggers both checks in sequence. -- Continue appears (and is enabled) only when both rows show success. -- Back is available. - -### Step 5: Calendar Credential +### Step 3: Calendar Credential Purpose: set the first member's Fastmail app password; validate against CalDAV PROPFIND. Reuses the CredentialSheet interaction pattern (same fields, same validation feedback, @@ -344,10 +353,16 @@ same copy). - Failure: "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." (in `var(--color-destructive)`) - Continue button label on this step: "Complete Setup" -- On continue: `POST /api/setup/complete` — promotes operator to admin, sets - `app_config.setup_complete`, redirects to Surface 7 (Terminal Screen) +- On continue: `POST /api/setup/complete` — provisions the pre-OIDC local user + credential, + flips `app_config.setup_complete`, redirects to Surface 7 (Terminal Screen) - Back is available. +### Step 4 — no longer a step card; becomes the Terminal Screen + +After `POST /api/setup/complete` succeeds, the wizard card is replaced by Surface 7 (Terminal +"Setup Complete" screen). There is no separate "Step 4" card — the terminal screen IS the +completion state. + --- ## Routing & App-Level Gate @@ -374,8 +389,9 @@ contract requires: no AppNav, no BottomTabBar, no SetupBanner, no PermissionDeni |---------|------| | Page title | "FamilySync Setup" | | Page subtitle | "Let's get your instance ready." | -| Primary CTA (steps 1–4) | "Continue" | -| Primary CTA (step 5) | "Complete Setup" | +| Primary CTA (steps 1–2) | "Continue" | +| Step 2 action button | "Save & Validate" | +| Primary CTA (step 3) | "Complete Setup" | | Secondary action | "Back" | | Terminal heading | "Setup complete" | | Terminal body | "Your FamilySync instance is ready. Sign in to continue." | @@ -384,36 +400,40 @@ contract requires: no AppNav, no BottomTabBar, no SetupBanner, no PermissionDeni | Locked body | "This instance has already been configured. Sign in to continue." | | Locked link | "Sign in" | | Step 1 heading | "Welcome to FamilySync Setup" | -| Step 1 description | "This wizard will guide you through configuring your self-hosted instance. You'll need: your OIDC client credentials (Authelia), a Fastmail account with an app password, and a copy of your `docker-compose.yml` to paste generated secrets into. This takes about 5 minutes." | -| Step 2 heading | "Generated Secrets" | -| Step 2 description | "These values are generated once and displayed now. Copy each into your `docker-compose.yml` environment block before continuing. They will never be shown again and are not stored in the database." | -| Step 2 acknowledgement | "I have copied this value into my `.env` file." | -| Step 3 heading | "Database Connection" | -| Step 3 description | "Verify that the app can reach the MariaDB database configured in your environment. No changes are made — this is a read-only connectivity check." | -| Step 3 CTA | "Test Connection" | -| Step 3 pending | "Testing database connection…" | -| Step 3 success | "Database connection verified." | -| Step 3 failure | "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your `docker-compose.yml` and try again." | -| Step 4 heading | "OIDC & Push" | -| Step 4 description | "Verify that the Authelia OIDC issuer is reachable and that the VAPID keypair you copied in Step 2 is in place." | -| Step 4 CTA | "Validate" | -| Step 4 OIDC pending | "Checking OIDC discovery…" | -| Step 4 OIDC success | "OIDC discovery resolved." | -| Step 4 OIDC failure | "OIDC discovery failed. Check OIDC_ISSUER in your environment and that Authelia is reachable." | -| Step 4 VAPID pending | "Checking VAPID keypair…" | -| Step 4 VAPID success | "VAPID keypair is valid." | -| Step 4 VAPID failure | "VAPID private key could not be verified. Ensure you copied both keys from Step 2 into your environment and restarted the container." | -| Step 5 heading | "Fastmail Credential" | -| Step 5 description | "Add the Fastmail app password for the first household member. This credential is validated against Fastmail CalDAV before saving. The password is never stored in plain text." | -| Step 5 helper text | "Enter the Fastmail app password scoped to Calendars/CalDAV." | -| Step 5 helper link text | "Get an app password" | -| Step 5 helper link suffix | " — choose the 'Calendars & Contacts (CalDAV)' scope." | -| Step 5 pending | "Validating against CalDAV…" | -| Step 5 success | "Credential verified." | -| Step 5 failure | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." | -| Empty state (none for wizard — every step has explicit content) | N/A | +| Step 1 description | "This wizard will guide you through configuring your self-hosted instance. Before continuing, run `npm run generate-secrets` from the repo to generate your instance secrets and add them to your Docker environment. You'll also need: your OIDC client credentials (Authelia) and a Fastmail account with an app password. This takes about 5 minutes." | +| Step 1 pre-start label | "Before you start" | +| Step 1 pre-start body | "Run `npm run generate-secrets` and add the output to your Docker environment block. These secrets cannot be recovered if lost." | +| Step 2 heading | "Instance Configuration" | +| Step 2 description | "Enter your instance's connection details. These are written to the database — not your environment file." | +| Step 2 field: App URL label | "App URL" | +| Step 2 field: App URL placeholder | "https://familysync.example.com" | +| Step 2 field: App URL helper | "The public URL where FamilySync is reachable." | +| Step 2 field: OIDC issuer label | "OIDC issuer URL" | +| Step 2 field: OIDC issuer placeholder | "https://auth.example.com" | +| Step 2 field: OIDC issuer helper | "Your Authelia instance URL. FamilySync will fetch `/.well-known/openid-configuration` from this URL." | +| Step 2 field: OIDC client ID label | "OIDC client ID" | +| Step 2 field: OIDC client ID placeholder | "familysync" | +| Step 2 field: OIDC client ID helper | "The client ID registered in Authelia for this application." | +| Step 2 field: VAPID public key label | "VAPID public key" | +| Step 2 field: VAPID public key placeholder | "BH…" | +| Step 2 field: VAPID public key helper | "Paste the `VAPID_PUBLIC_KEY` value from `npm run generate-secrets`." | +| Step 2 DB pending | "Testing database connection…" | +| Step 2 DB success | "Database connection verified." | +| Step 2 DB failure | "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again." | +| Step 2 OIDC pending | "Checking OIDC discovery…" | +| Step 2 OIDC success | "OIDC discovery resolved." | +| Step 2 OIDC failure | "OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server." | +| Step 3 heading | "Fastmail Credential" | +| Step 3 description | "Add the Fastmail app password for the first household member. This credential is validated against Fastmail CalDAV before saving. The password is never stored in plain text." | +| Step 3 helper text | "Enter the Fastmail app password scoped to Calendars/CalDAV." | +| Step 3 helper link text | "Get an app password" | +| Step 3 helper link suffix | " — choose the 'Calendars & Contacts (CalDAV)' scope." | +| Step 3 pending | "Validating against CalDAV…" | +| Step 3 success | "Credential verified." | +| Step 3 failure | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." | +| Empty state | N/A — every step has explicit content | | Error state (network/unexpected) | "Something went wrong. Please try again." (generic fallback, shown in Surface 5 failure style) | -| Destructive actions | None — wizard has no destructive actions. The "Complete Setup" action is irreversible in effect but not destructive; no confirmation dialog required. | +| Destructive actions | None — wizard has no destructive actions. "Complete Setup" is irreversible in effect but not destructive; no confirmation dialog required. | --- @@ -426,7 +446,6 @@ contract requires: no AppNav, no BottomTabBar, no SetupBanner, no PermissionDeni - Heading hierarchy: `

` for page title, `

` for step heading - All inputs: explicit `