diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index b8866e4..9068b60 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -32,10 +32,10 @@ Each requirement maps to exactly one roadmap phase (see Traceability). ### Setup — First-run configuration wizard -- [ ] **SETUP-01**: On first run (no admin/credentials configured), the operator is guided through a setup wizard to define bootstrap configuration (app/external URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) instead of hand-editing `.env` / `docker-compose.yml`. -- [ ] **SETUP-02**: The wizard **validates each input before completing** — DB connectivity test, VAPID private key decodes to 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND). +- [x] **SETUP-01**: On first run (no admin/credentials configured), the operator is guided through a setup wizard to define bootstrap configuration (app/external URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) instead of hand-editing `.env` / `docker-compose.yml`. +- [x] **SETUP-02**: The wizard **validates each input before completing** — DB connectivity test, VAPID private key decodes to 32 bytes and pairs with the public key, OIDC discovery resolves, and the Fastmail app password reaches CalDAV (PROPFIND). - [x] **SETUP-03**: 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**. -- [ ] **SETUP-04**: Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup). +- [x] **SETUP-04**: Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup). ### CI — Gitea continuous integration @@ -84,9 +84,9 @@ Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended a | NOTIF-04 | Phase 11 (Per-Event Reminders) | Complete | | NOTIF-05 | Phase 11 (Per-Event Reminders) | Complete | | NOTIF-06 | Phase 11 (Per-Event Reminders) | Complete | -| SETUP-01 | Phase 12 (Initial Setup Wizard) | Pending | -| SETUP-02 | Phase 12 (Initial Setup Wizard) | Pending | +| SETUP-01 | Phase 12 (Initial Setup Wizard) | Complete | +| SETUP-02 | Phase 12 (Initial Setup Wizard) | Complete | | SETUP-03 | Phase 12 (Initial Setup Wizard) | Complete | -| SETUP-04 | Phase 12 (Initial Setup Wizard) | Pending | +| SETUP-04 | Phase 12 (Initial Setup Wizard) | Complete | **DB foundation note:** The v1.1 schema migration (`users.is_admin`, `calendar_events.reminder_lead_minutes`, `app_config` table) is not a standalone requirement — it is carried by **Phase 10 (Admin Role & Settings)** (which owns is_admin + app_config) and consumed by **Phase 11 (Per-Event Reminders)** (reminder_lead_minutes) and **Phase 12 (Initial Setup Wizard)** (app_config.setup_complete). Folded per ARCHITECTURE.md ordering rather than created as a migration-only phase. This makes Phase 10 the head of the admin chain (10 → 11, 10 → 12). diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4eb7374..d9770c8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -258,7 +258,7 @@ Plans: **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) +- [x] 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)* @@ -424,7 +424,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx` | 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 | | 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 | | 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 | -| 12. Initial Setup Wizard | v1.1 | 1/4 | In Progress| | +| 12. Initial Setup Wizard | v1.1 | 2/4 | In Progress| | | 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | | 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 | | 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 | diff --git a/.planning/STATE.md b/.planning/STATE.md index 4c69dfc..670de43 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish status: executing -stopped_at: Phase 12 Plan 01 complete — schema + secrets + stubs + Wave-0 tests done; plan 2 of 4 is next -last_updated: "2026-06-15T17:46:33.294Z" +stopped_at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next +last_updated: "2026-06-15T18:06:14.513Z" last_activity: 2026-06-15 progress: total_phases: 24 completed_phases: 10 total_plans: 40 - completed_plans: 37 + completed_plans: 38 percent: 42 --- @@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-10) ## Current Position Phase: 12 (initial-setup-wizard) — EXECUTING -Plan: 2 of 4 +Plan: 3 of 4 Status: Ready to execute Last activity: 2026-06-15 @@ -112,6 +112,7 @@ _Updated after each plan completion_ | Phase 18 P03 | 28 | 2 tasks | 4 files | | Phase 18 P04 | 15 | 3 tasks | 3 files | | Phase 12 P01 | 8 | 4 tasks | 10 files | +| Phase 12 P02 | 15 | 3 tasks | 6 files | ## Accumulated Context @@ -261,7 +262,7 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-15T17:46:33.276Z +Last session: 2026-06-15T18:06:14.495Z Stopped at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next Resume file: None diff --git a/.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md b/.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md new file mode 100644 index 0000000..dcc18ac --- /dev/null +++ b/.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md @@ -0,0 +1,151 @@ +--- +phase: 12-initial-setup-wizard +plan: 02 +subsystem: api, auth, testing +tags: [hono, drizzle, vitest, tdd, setup-wizard, oidc, vapid, pre-auth, guard] + +# Dependency graph +requires: + - phase: 12-01 + provides: setupGuard.ts stub, setup.ts stub router, Wave-0 RED test scaffolds, schema claimed column +provides: + - apps/api/src/lib/setupGuard.ts — real isSetupLocked() per-call DB evaluation (SETUP-04/D-10) + - apps/api/src/routes/setup.ts — setupRouter with all 7 pre-auth handlers + - apps/api/src/index.ts — setupRouter mounted pre-auth before devAuthBypass + - apps/api/src/auth/middleware.ts — oidcConfigFallbackMiddleware (env-OR-app_config, D-02/D-03) + - apps/api/tests/routes/setup.test.ts — 17 integration tests all GREEN +affects: + - 12-03-pwa-setup-page (consumes /api/setup/* routes, esp. GET /status) + - 12-04-integration (full setup flow) + +# Tech tracking +tech-stack: + added: [] # Zero new packages (RESEARCH §No New Packages) + patterns: + - isSetupLocked() per-call freshness pattern (D-10) — imported in every handler, no module-cache + - guard-first handler pattern — isSetupLocked() is the FIRST await in every setup handler + - noEchoHook anti-echo pattern (from admin.ts) — Zod error details never returned on credential routes + - validateEncryptAndStoreCredential reuse (D-09) — no new crypto; shared helper for PROPFIND+encrypt+store + - env-OR-app_config fallback middleware — reads DB per-request when env absent; injects into process.env + - mysql2 $returningId() + re-select for local user insert (Pattern 4 from user.ts) + - onDuplicateKeyUpdate upsert for app_config writes (Shared Pattern 1 from admin.ts) + +key-files: + created: [] + 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 + - apps/api/tests/routes/push.test.ts + +key-decisions: + - "A2-CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL at per-request call time via env(c)→process.env — NOT at import time; fresh boot without OIDC env is safe (HTTP 500 only on protected /api/* requests)" + - "D-02-FALLBACK: env-OR-app_config Recommendation (a) implemented: oidcConfigFallbackMiddleware reads from app_config when process.env absent, injects into process.env before oidcAuthMiddleware() per-request read" + - "GUARD-ON-STATUS: GET /api/setup/status uses isSetupLocked() directly (covers effective-config branch too) — returns {setupComplete:true} when locked, {setupComplete:false} when not; aligns with must_haves.truths" + - "LOCAL-USER-ROLLBACK: POST /api/setup/credential rolls back the local user insert if validateEncryptAndStoreCredential throws, preventing orphaned unclaimed user rows" + +# Metrics +duration: 15min +completed: 2026-06-15 +--- + +# Phase 12 Plan 02: Setup Routes Summary + +**Real isSetupLocked() 423 guard + all 7 pre-auth /api/setup/* routes + OIDC env-OR-app_config fallback; 394 tests green including Pitfall 8 regression** + +## Performance + +- **Duration:** 15 min +- **Started:** 2026-06-15T17:48:42Z +- **Completed:** 2026-06-15T18:03:21Z +- **Tasks:** 3 +- **Files modified:** 6 + +## Accomplishments + +- Implemented real `isSetupLocked()` in `setupGuard.ts`: reads `app_config.setup_complete` (check 1) and then checks `member_credentials` row + `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` env for effective-config branch (D-10 check 2). Re-queries DB fresh every call — no module-level cache. +- Converted all 20 Wave-0 `it.todo()` scaffolds in `setup.test.ts` into real integration tests (17 tests) — all GREEN after Task 2. +- Implemented full `setupRouter` in `setup.ts` with all 7 routes: + - `GET /status` — uses `isSetupLocked()` directly; returns `{setupComplete: boolean}` + - `POST /config` — zod-validates https-URL issuer; upserts `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url` + - `POST /validate/db` — `SELECT 1` connectivity check; 200/503 + - `POST /validate/oidc` — fetches discovery doc with 5s timeout; 200/400 + - `POST /validate/vapid` — `webpush.setVapidDetails()` structural check; env-only key read; 200/400 + - `POST /credential` — inserts local user first (Pitfall 5 FK), calls shared helper; noEchoHook; rollback on failure + - `POST /complete` — upserts `setup_complete='true'`; 200 first call, 423 second (Pitfall 8/SETUP-04) +- Mounted `setupRouter` in `index.ts` BEFORE `devAuthBypass()` (line 49 < line 54, T-12-09/Pitfall 1 acceptance-checked). +- Implemented `oidcConfigFallbackMiddleware` in `auth/middleware.ts`: reads OIDC config from `app_config` when env absent, injects into `process.env` for downstream `oidcAuthMiddleware()` pickup. Mounted before OIDC guard when `!devBypassActive`. +- Confirmed A2: `@hono/oidc-auth` reads env at per-request call time — boot is safe without OIDC env. +- Fixed `push.test.ts` `vi.doMock` to include `oidcConfigFallbackMiddleware` stub (Rule 3 auto-fix). + +## Task Commits + +1. **Task 1: isSetupLocked() real impl + RED-first setup tests** — `4748d57` (test) +2. **Task 2: Setup router — all 7 routes + pre-auth mount** — `20f91e4` (feat) +3. **Task 3: OIDC boot env-OR-app_config fallback + mount verification** — `67a9d29` (feat) + +## Files Created/Modified + +- `apps/api/src/lib/setupGuard.ts` — real `isSetupLocked()`: `setup_complete` check + effective-config branch (D-10); no module-level cache +- `apps/api/src/routes/setup.ts` — `setupRouter` with 7 handlers; guard-first; noEchoHook; shared helper reuse; VAPID env-only +- `apps/api/src/index.ts` — `setupRouter` import + pre-auth mount; `oidcConfigFallbackMiddleware` import + mount before OIDC guard +- `apps/api/src/auth/middleware.ts` — `oidcConfigFallbackMiddleware` added (env-OR-app_config fallback); re-exports unchanged +- `apps/api/tests/routes/setup.test.ts` — 17 real integration tests (all GREEN); full mock scaffolding +- `apps/api/tests/routes/push.test.ts` — `vi.doMock` updated to include `oidcConfigFallbackMiddleware` stub + +## Decisions Made + +- **A2-CONFIRMED:** `@hono/oidc-auth` reads OIDC env vars at per-request call time via `env(c) → process.env` (source: `@hono/oidc-auth` dist/index.js line 30). NOT at import time. A fresh unconfigured instance boots without crashing; HTTP 500 only occurs on OIDC-protected `/api/*` requests when env is absent — acceptable since `/api/setup/*` is pre-auth and is the only pre-setup surface. Recommendation (a) implemented. + +- **D-02-FALLBACK:** `oidcConfigFallbackMiddleware` injects `oidc_issuer` / `oidc_client_id` / `app_external_url` from `app_config` into `process.env` when the env var is absent, before `oidcAuthMiddleware()` reads it per-request. Non-secret values only (D-01 env floor: `OIDC_CLIENT_SECRET`, `OIDC_AUTH_SECRET` stay in env always). Options (b) and (c) (defer mount, lazy-per-request) not needed — option (a) is simpler and correct per A2 confirmation. + +- **GUARD-ON-STATUS:** `GET /api/setup/status` calls `isSetupLocked()` to populate `setupComplete`. This makes the status response consistent with the guard state (covers the effective-config branch too) and satisfies the must_haves truth that `/status` returns `{setupComplete:true}` after setup is complete. The route never returns 423 — it always returns 200 with the boolean. + +- **LOCAL-USER-ROLLBACK:** `POST /api/setup/credential` deletes the inserted local user row if `validateEncryptAndStoreCredential()` throws, preventing orphaned `claimed=false` rows in the `users` table that would permanently increment color slot usage and confuse the first-login-claims query. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] push.test.ts vi.doMock missing oidcConfigFallbackMiddleware** +- **Found during:** Task 3 test run +- **Issue:** `push.test.ts` uses `vi.doMock('../../src/auth/middleware.js', ...)` but the mock omitted the new `oidcConfigFallbackMiddleware` export. Vitest raises `No "oidcConfigFallbackMiddleware" export is defined on the mock` at runtime. +- **Fix:** Added `oidcConfigFallbackMiddleware: async (_c, next) => next()` to the doMock factory. +- **Files modified:** `apps/api/tests/routes/push.test.ts` +- **Commit:** `67a9d29` (Task 3) + +--- + +**Total deviations:** 1 auto-fixed (Rule 3 blocking — test mock missing new export) +**Impact on plan:** Zero scope creep. Fix was mechanical and localized to a test file. + +## Threat Surface Scan + +No new threat surface beyond what is explicitly modeled in the plan's ``. All mitigations verified: + +| Threat | Mitigation | Verified | +|--------|-----------|---------| +| T-12-04: Setup endpoint replay after completion | `isSetupLocked()` first in every handler; 423; re-queried per call | All 7 handlers call `isSetupLocked()` — source-grep ≥7 passed | +| T-12-05: App password echoed in 400 | `noEchoHook`; no `console.log` of password or `valid('json')` | grep returns 0 echo/log hits | +| T-12-06: VAPID_PRIVATE_KEY in DB or response | `/validate/vapid` reads ONLY from `process.env`; never from app_config; never returned | grep confirms env-only read | +| T-12-08: OIDC issuer SSRF via /config | Zod `.refine(v => v.startsWith('https://'))` rejects non-https URLs | Test `returns 400 when oidcIssuer is not an https URL` passes | +| T-12-09: /api/setup/* caught by OIDC guard | Mounted at line 49, `devAuthBypass()` at line 54 — ordering verified | awk mount-order acceptance gate passes | + +## Self-Check: PASSED + +Files exist: +- `apps/api/src/lib/setupGuard.ts` — FOUND +- `apps/api/src/routes/setup.ts` — FOUND +- `apps/api/src/auth/middleware.ts` — FOUND +- `apps/api/src/index.ts` — FOUND +- `apps/api/tests/routes/setup.test.ts` — FOUND + +Commits exist: +- `4748d57` — FOUND +- `20f91e4` — FOUND +- `67a9d29` — FOUND + +Test suite: 394 passed | 5 todo | 0 failed +TypeCheck: clean (0 errors)