Commit Graph
100 Commits
Author SHA1 Message Date
Lucas Berger 55cd5cf698 chore: merge executor worktree (worktree-agent-a177cd3aa5422e109) 2026-06-17 16:41:18 -04:00
Lucas Berger f167031292 docs(19-02): complete admin+me account management plan summary 2026-06-17 16:40:50 -04:00
Lucas Berger efb80c8c1a feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
  (UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)

apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
  ({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
2026-06-17 16:38:14 -04:00
Lucas Berger 8ced2d0a20 test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidc
RED phase for Task 3:
- Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials
- Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion
- Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl)
2026-06-17 16:35:19 -04:00
Lucas Berger c88f7d41e5 feat(19-02): self-change password and hasLocalCredential on GET /api/me
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
2026-06-17 16:34:10 -04:00
Lucas Berger 80b5906bb8 test(19-02): add failing tests for self-change password and hasLocalCredential on /api/me
RED phase for Task 2:
- Test 1: POST /api/me/password correct current → 200, new hash verifies newPassword
- Test 2: wrong currentPassword → 401, UPDATE not called (hash unchanged)
- Test 3: no local_credentials row → 404
- Test 4 (GET /api/me): hasLocalCredential:true/false based on local_credentials existence
2026-06-17 16:32:48 -04:00
Lucas Berger 6232aa0d68 feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
2026-06-17 16:31:37 -04:00
Lucas Berger b2c7902e9e test(19-02): add failing tests for admin create-member, reset-password, hasLocalCredential
RED phase for Task 1:
- Test 1: POST /api/admin/members creates users row + local_credentials, hash verifies
- Test 2: duplicate username returns 409, transaction rolled back (no orphaned user row)
- Test 3: admin reset password updates hash, old password no longer verifies
- Test 4: non-admin gets 403 on both POST /members and POST /members/:id/password
- Test 5: GET /api/admin/members returns hasLocalCredential:true/false per local cred existence
2026-06-17 16:29:43 -04:00
Lucas Berger 13e3757e88 docs(phase-19): update tracking after wave 1 2026-06-17 16:26:23 -04:00
Lucas Berger 12f5fb5991 chore: merge executor worktree (worktree-agent-a2e0909f9686032ab) 2026-06-17 16:24:55 -04:00
Lucas Berger d22da015cb docs(19-01): complete local-auth foundation plan (checkpoint reached at Task 4) 2026-06-17 16:19:25 -04:00
Lucas Berger 96f0991605 feat(19-01): add local_credentials schema, 0003 migration, generate-secrets LOCAL_SESSION_SECRET, .dockerignore D-15
- schema.ts: export localCredentials = mysqlTable('local_credentials', {...})
  - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt
  - UNIQUE(user_id), UNIQUE(username), INDEX(user_id)
- 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables)
  - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0
- test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering)
- generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05)
- .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded
2026-06-17 16:17:04 -04:00
Lucas Berger 7d61148415 feat(19-01): implement localSession JWT cookie helpers and assertLocalSessionSecretSet boot guard
- localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie
  - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify)
  - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4)
  - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9)
  - verifyLocalSessionCookie returns null (never throws) on any error
- bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars
  - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies)
- index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction()
- All 5 unit tests pass; typecheck exits 0
2026-06-17 16:14:48 -04:00
Lucas Berger 0d8f3fa051 test(19-01): add failing tests for localSession JWT cookie helpers and assertLocalSessionSecretSet 2026-06-17 16:13:33 -04:00
Lucas Berger 85b01b5c26 feat(19-01): implement hashPassword/verifyPassword with scrypt + timingSafeEqual
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08)
- 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url
- timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01)
- verifyPassword returns false on any error (never throws); passwords never logged
- All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
2026-06-17 16:12:59 -04:00
Lucas Berger 7ece96688d test(19-01): add failing tests for hashPassword/verifyPassword scrypt primitives 2026-06-17 16:12:14 -04:00
Lucas Berger f96282a767 docs(19): add PATTERNS.md (codebase analog map for planning) 2026-06-17 16:08:36 -04:00
Lucas Berger cb23603c83 docs(19): record phase planned (5 plans, 4 waves) 2026-06-17 16:04:38 -04:00
Lucas Berger dc40ba9fb8 docs(19): create local-auth phase plan (5 plans, 4 waves) 2026-06-17 15:35:43 -04:00
Lucas Berger 4b461cbaab docs(19): add validation strategy 2026-06-17 15:19:08 -04:00
Lucas BergerandClaude Sonnet 4.6 29f4a2e623 docs(19): research phase 19 local auth domain
Covers password hashing (node:crypto scrypt), JWT session cookies
(hono/utils/jwt), middleware ordering, local_credentials schema,
OIDC-link flow, dev-bypass rework (option C), and break-glass CLI.
Resolves all five open questions from CONTEXT.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:17:46 -04:00
Lucas BergerandClaude Opus 4.8 9ef7eaada8 docs(state): record phase 19 UI-SPEC approval; ignore local claude settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:21:25 -04:00
Lucas BergerandClaude Sonnet 4.6 4dd6068dcc docs(19): mark UI-SPEC approved after checker verification
All 6 design dimensions PASS plus Phase 17 brand-slot readiness contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:19:45 -04:00
Lucas BergerandClaude Sonnet 4.6 71bf21634c docs(19): UI design contract for local auth login screen and admin additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:18:04 -04:00
Lucas Berger 45fca0ed6b docs(state): record phase 19 context session 2026-06-16 21:04:57 -04:00
Lucas Berger 64fa4653da docs(19): capture phase context 2026-06-16 21:04:52 -04:00
Lucas BergerandClaude Opus 4.8 7354f3ec4f fix(12): unblock CI security + harness jobs
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 1m56s
CI / api (pull_request) Successful in 1m44s
CI / harness (pull_request) Successful in 6m28s
CI / security (pull_request) Successful in 1m11s
CI / gate (pull_request) Successful in 0s
security/gitleaks: allowlist apps/api/tests/routes/setup.test.ts — synthetic
  VAPID test pair (verified absent from .env), same class as existing fixture
  allowlist entries.
security/audit: waive GHSA-88fw-hqm2-52qc (hono CORS) — not exploitable, the
  app uses no hono cors() middleware; newly-published vs pinned hono 4.12.23.
harness/e2e: seed app_config.setup_complete='true' + a dev-admin credential in
  global-setup so the Phase-12 setup gate no longer redirects every spec to
  /setup (was causing all 95 e2e failures) and no onboarding banner renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:24:10 -04:00
Lucas BergerandClaude Opus 4.8 717c859f3c fix(12): make api test suite hermetic — provide OIDC env so fallback skips DB
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m56s
CI / api (pull_request) Successful in 1m27s
CI / security (pull_request) Has been cancelled
CI / gate (pull_request) Has been cancelled
CI / harness (pull_request) Has been cancelled
oidcConfigFallbackMiddleware (Phase 12) reads OIDC config from app_config on
every /api/* request when OIDC_ISSUER/CLIENT_ID/AUTH_EXTERNAL_URL are absent.
CI's api job sets no OIDC env, so events/login tests (which mock db with a
partial query chain) 500'd on every request. Local runs passed only because
ambient .env supplied the vars. Set dummy OIDC config in vitest test.env so the
middleware always takes the env path — hermetic across CI and local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:43:48 -04:00
Lucas BergerandClaude Opus 4.8 a193bc8236 fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s
- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:53:14 -04:00
Lucas BergerandClaude Opus 4.8 f485b38324 docs(12): ship phase 12 — PR #22
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:24:41 -04:00
Lucas BergerandClaude Opus 4.8 e821515d25 docs(12): resolve VERIFICATION human-needed — wizard e2e satisfied via UAT re-verify
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Failing after 1m4s
CI / api (pull_request) Failing after 1m30s
CI / harness (pull_request) Failing after 1h2m7s
CI / security (pull_request) Failing after 13s
CI / gate (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:23:15 -04:00
Lucas BergerandClaude Opus 4.8 932fcb6e3f chore(12): mark Phase 12 complete — UAT re-verified, all 6 gaps closed
- ROADMAP/STATE advanced to Phase 13 (real-lint-gate-eslint)
- Archived diagnosed UAT marked superseded (historical only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:17:18 -04:00
Lucas BergerandClaude Opus 4.8 5eef074a57 test(12): re-verify UAT after gap-closure — 6 passed, 1 env-blocked, all 6 gaps confirmed closed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:04:22 -04:00
Lucas Berger 6409c9c3c2 chore: merge executor worktree (worktree-agent-ab0c78658da0b8f33) 2026-06-15 21:33:30 -04:00
Lucas Berger eed76de37f docs(12-05): complete Instance-step gap-closure plan (gaps 1, 3, 4) 2026-06-15 21:32:53 -04:00
Lucas Berger a13fc11556 feat(12-05): preserve Instance fields across Back navigation (gap 4)
- Lift appUrl/oidcIssuer/oidcClientId/vapidPublicKey into SetupPage so Step2 unmount preserves them
- Step2Config now reads/writes these via fields/setFields props
- Fastmail app password stays in Step3 local state, never lifted/persisted, cleared on unmount (T-12-15)
- Tests: Back from Calendar restores all four Instance values; password not persisted across nav
2026-06-15 21:32:06 -04:00
Lucas Berger 35db5c57e6 feat(12-05): drop DB-vs-env aside, add read-only DB-name field (gaps 1, 3)
- Remove the 'written to the database — not your environment file' aside from the Instance step intro
- Render a read-only, disabled DB-name field under App URL, populated from GET /api/setup/status dbName
- Helper text explains DB is configured via Docker env; only dbName is surfaced (T-12-3DB)
- Tests: assert aside absent, DB field readOnly/disabled with mocked dbName, existing DB validation row intact
2026-06-15 21:31:26 -04:00
Lucas Berger 846ae17182 chore: merge executor worktree (worktree-agent-ad2593f5ac87f6852) 2026-06-15 21:25:57 -04:00
Lucas Berger 7c94558de4 docs(12-07): append self-check result to SUMMARY 2026-06-15 21:25:03 -04:00
Lucas Berger 96c49138cb docs(12-07): complete UAT gap-closure plan (gaps 5 & 6 — /setup reverse-gate + ['me'] freshness) 2026-06-15 21:24:42 -04:00
Lucas Berger 2b3569ff20 fix(12-07): make ['me'] fresh on shell entry so post-wizard banner clears (gap 6)
- Root cause confirmed = mechanism (ii): ['me'] staleness, NOT a backend linking gap
  (upsertUser claim preserves users.id → credential stays linked → DB needsProviderSetup=false)
- SetupBanner ['me'] query staleTime 5min → 0 so a pre-claim stale cache entry is
  refetched on mount; banner hides once needsProviderSetup resolves false
- App.tsx boot ['me'] staleTime also set to 0 (committed with Task 1) for the same reason
- Add SetupBanner.test.tsx regression: absent when false, present (no dismiss) when true,
  stale-cache refetch hides banner; success-only dismissal contract preserved (no X button)
- Log pre-existing PWA lint errors (SetupPage.test.tsx, setupClient.contract.test.ts) to deferred-items.md
2026-06-15 21:23:46 -04:00
Lucas Berger fdcb4dc442 feat(12-07): gate /setup route on setupComplete (gap 5)
- Reverse-gate the /setup route: setupComplete===true → SetupPage alreadyLocked
  (Surface 8 'Setup already complete'); loading → no-flash placeholder; else wizard
- Add App.test.tsx reverse-gate tests (already-complete surface + active wizard on /setup)
- SetupPage mock now respects the alreadyLocked prop
2026-06-15 21:21:29 -04:00
Lucas Berger 67c17a58eb docs(12-06): complete setup-route gap-closure plan (VAPID equality + DB name) 2026-06-15 21:15:18 -04:00
Lucas Berger fbd3b77bde feat(12-06): expose non-secret DB name via GET /api/setup/status (gap 3)
- status returns { setupComplete, dbName } from process.env.DB_NAME (null fallback)
- only the DB name; never DB_HOST/DB_USER/DB_PASSWORD
- SetupStatusResponse carries dbName?: string | null for the PWA read-only field
2026-06-15 21:13:47 -04:00
Lucas Berger e46e80a15c feat(12-06): validate/vapid asserts submitted key matches env public key (gap 2)
- read app_config.vapid_public_key and compare to process.env.VAPID_PUBLIC_KEY
- mismatched/absent submitted key → 400 before the structural check
- VAPID_PRIVATE_KEY still env-only, never compared or returned (T-12-06)
2026-06-15 21:12:35 -04:00
Lucas Berger e9d07b38fb test(12-06): add failing tests for vapid public-key equality assertion (gap 2)
- mismatched submitted key (BH123) → 400, no VAPID_PRIVATE_KEY leak
- absent app_config.vapid_public_key row → 400
- happy path seeds matching app_config row
2026-06-15 21:11:41 -04:00
Lucas Berger f0fb31348d docs(12): gap-closure plans 05-07 for 6 UAT gaps 2026-06-15 21:05:45 -04:00
Lucas Berger dc7f8d2aa9 test(12): complete UAT - 0 passed, 6 issues across 3 tests 2026-06-15 21:00:31 -04:00
Lucas BergerandClaude Opus 4.8 df93f4fe95 docs(12): code review clean after --fix --auto (10 findings fixed across 3 iterations)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:50:36 -04:00
Lucas BergerandClaude Sonnet 4.6 687f9dc9fa fix(12): WR-01 narrow TOCTOU guard and set claimed=true for OIDC inserts
- apps/api/src/auth/user.ts: upsertUser step-5 insert now sets claimed=true
  for all OIDC-created users. An identity-bound OIDC user is never a pending
  wizard bootstrap user; explicit claimed=true prevents ambiguity with the
  (oidcIss IS NULL AND claimed=false) sentinel used by the TOCTOU guard and
  isSetupLocked. First-login-claims path is unaffected (it updates a
  pre-existing oidcIss=null row; this change only touches the fresh insert).

- apps/api/src/routes/setup.ts: TOCTOU guard in POST /credential now queries
  WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE, matching the exact
  definition of a pending wizard bootstrap user. This provides defense-in-depth
  against any future path that could produce claimed=false OIDC rows.

- apps/api/tests/auth/user.test.ts: new WR-01 test asserts that the fresh
  OIDC insert sets claimed=true in the values passed to db.insert().

- apps/api/tests/routes/setup.test.ts: new WR-01 integration test seeds an
  OIDC user with claimed=false (oidcIss NOT NULL) and verifies POST /credential
  still succeeds (guard ignores the OIDC row, only counts local wizard rows).

All 402 API tests, 253 PWA tests, and typecheck pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:46:41 -04:00
Lucas BergerandClaude Sonnet 4.6 22d1581484 fix(12): IN-01 require https:// on appExternalUrl in configSchema
appExternalUrl is injected as OIDC_AUTH_EXTERNAL_URL (the redirect URI
base); Authelia rejects non-https redirect URIs in production. Added
.refine() guard matching the existing oidcIssuer pattern. Added test
that verifies http:// appExternalUrl is rejected with 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:37:03 -04:00
Lucas BergerandClaude Sonnet 4.6 61a869ca7d fix(12): CR-01 guard effective-config branch during wizard in-progress
isSetupLocked() now checks for an unclaimed local wizard user
(oidcIss IS NULL, claimed=false) before firing the effective-config
branch. During the credential→complete window, this sentinel prevents
a production container with VAPID env set from blocking POST /complete
with 423. The explicit setup_complete flag (Check 1) still locks
unconditionally once written. Adds regression test that sets VAPID env
explicitly (no beforeEach clearing) to reproduce the production scenario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:36:52 -04:00
Lucas BergerandClaude Sonnet 4.6 ed4e64a06a fix(12): WR-02 fix TypeScript type annotation for execute() count result
Drizzle mysql2 execute() returns [rows, fields] for SELECT queries; the
generic type parameter alone does not correctly type the result. Use
unknown cast pattern consistent with admin.ts to access the count row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:25:05 -04:00
Lucas BergerandClaude Sonnet 4.6 c86cff5dad fix(12): IN-04 trim email before sending to /api/setup/credential
email.trim() was already used in the saveDisabled guard but not applied to
the mutate call payload. A non-empty value with leading/trailing spaces would
pass the guard and reach the server untrimmed, causing Zod's z.string().email()
to reject it with a generic 400 and no diagnostic path for the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:22:47 -04:00
Lucas BergerandClaude Sonnet 4.6 b0b5bceaed fix(12): IN-03 replace private web-push source import with Node.js built-in crypto
generate-secrets.mjs was importing from ../apps/api/node_modules/web-push/src/index.js
(a private source path) which breaks if web-push restructures internally or
workspace hoisting moves the package. Replace with Node.js built-in createECDH
('prime256v1') which produces identical base64url-encoded keys, including the
same defensive padding logic as web-push for short key buffers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:22:07 -04:00
Lucas BergerandClaude Sonnet 4.6 3babbfa20e fix(12): IN-02 guard /setup/complete against skipping the credential step
Without a prerequisite check, an operator could call POST /api/setup/complete
directly, setting setup_complete=true with no admin user or credential row,
leaving no recovery path without manual DB surgery.

Add an inner join check for an unclaimed user with an associated credential;
return 422 if absent. Update /complete tests to seed the prerequisite for
the success path and add an explicit 422 regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:20:55 -04:00
Lucas BergerandClaude Sonnet 4.6 d9dfe72aab fix(12): IN-01 return generic error from /validate/oidc instead of raw network detail
The catch block previously echoed err.message (which may contain internal
network addresses like ECONNREFUSED 192.168.1.50:9091) to the pre-auth
caller. Log the raw message server-side only and return a generic user-
facing string with no internal network detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:18:10 -04:00
Lucas BergerandClaude Sonnet 4.6 7a512a9726 fix(12): WR-03 document and log single-write process.env semantics in oidcFallback
The oidcConfigFallbackMiddleware permanently mutates process.env on first
request then never re-reads from DB. Log an explicit info message when each
value is written so operators can see when a restart is required to pick up
config changes, and add inline documentation of the single-write semantics
to prevent silent misconfiguration after a re-run of the wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:17:41 -04:00
Lucas BergerandClaude Sonnet 4.6 a4e0ea4f14 fix(12): WR-02 serialise concurrent /credential calls with FOR UPDATE transaction
Two concurrent POST /api/setup/credential requests could both pass
isSetupLocked(), observe no unclaimed row, and both insert — leaving two
unclaimed admin rows with no recovery path. Wrap the count-check + user
insert in a transaction with SELECT COUNT(*) ... FOR UPDATE to acquire a
row/gap lock, ensuring at most one unclaimed admin row is created.
Returns 409 when a concurrent request already holds an unclaimed row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:17:12 -04:00
Lucas BergerandClaude Sonnet 4.6 3bc38bf1a6 fix(12): WR-01 delete orphaned user row on re-select 503 path in /credential
After $returningId() insert, if the re-select returns null the handler
returned 503 without deleting the just-inserted user row, leaving an
unclaimed admin row with no credential. Delete before returning 503 to
mirror the cleanup already present in the catch block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:14:39 -04:00
Lucas BergerandClaude Sonnet 4.6 066b69f2be fix(12): CR-01 remove extraneous providerType from postSetupCredential wire body
The server's credentialSchema does not declare providerType; it was being
silently stripped by Zod. Remove it from the request body and add a
contract test suite asserting the exact wire keys sent, mirroring the
existing BUG-1 tests for postSetupConfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:14:18 -04:00
Lucas Berger ef3e9810a9 docs(12): add code review report 2026-06-15 16:11:50 -04:00
Lucas Berger 836cb38934 docs(phase-12): add security threat verification — 15/15 closed, 0 open 2026-06-15 16:05:14 -04:00
Lucas BergerandClaude Opus 4.8 a07bb5bc67 fix(12): keep phase checkbox open — verification is human_needed, completion via verify-work
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:22:46 -04:00
Lucas BergerandClaude Opus 4.8 8db5b236c4 test(12): persist human verification items as UAT
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:22:18 -04:00
Lucas BergerandClaude Opus 4.8 4f81ccdbfd docs(12): verification passed (9/9) after VAPID gap closure — one human Fastmail UAT remains
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:22:00 -04:00
Lucas Berger 49b82d2ef4 docs(12-04): update SUMMARY + STATE + ROADMAP for CR-01 VAPID gap closure 2026-06-15 15:16:41 -04:00
Lucas BergerandClaude Sonnet 4.6 0d53249b02 fix(12-04): wire validateSetupVapid into setup wizard (close CR-01 / SETUP-02 gap)
- Import validateSetupVapid from api/client.ts in SetupPage.tsx
- Add vapid: ValidationRowState to validationRows state (alongside db/oidc)
- Extend configMutation.onSuccess chain: DB → OIDC → VAPID (sequential)
- Add ValidationRow for VAPID with pending/success/failure text
- Gate setBothPassed(true) on all three rows passing (db AND oidc AND vapid)
- Update anyPending and handleSaveAndValidate reset to include vapid state
- All 249 PWA tests pass; TypeScript clean

Closes CR-01; satisfies SETUP-02 "VAPID private key decodes to 32 bytes"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:13:40 -04:00
Lucas BergerandClaude Sonnet 4.6 7d0205df05 test(12-04): RED — wizard must validate VAPID before proceeding (SETUP-02 gap)
- Add 4 VAPID validation tests to SetupPage.test.tsx (CR-01 gap closure)
- Tests assert: validateSetupVapid is called, VAPID row renders, Continue
  is blocked when VAPID fails, Continue appears only after all 3 pass
- 3 tests currently FAIL (RED) — current code lacks validateSetupVapid import
  and has no vapid ValidationRow or vapid gate on bothPassed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:12:03 -04:00
Lucas BergerandClaude Opus 4.8 523742c489 fix(12): revert premature phase-complete checkbox — verification pending
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:03:25 -04:00
Lucas BergerandClaude Opus 4.8 d8b4c98592 docs(12): code review — 1 critical, 5 warnings, 4 info
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:02:27 -04:00
Lucas BergerandClaude Opus 4.8 d96dfae17b chore(12-04): drop committed verification screenshot from planning dir
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:54:19 -04:00
Lucas Berger 7a1801f47d docs(12-04): complete setup-wizard PWA plan — BUG 1+2 fixed, playwright verified
Update SUMMARY.md with bug fix details, TDD gate compliance, and playwright-cli
verification confirming /api/setup/config returns 200 with camelCase payload.
Update STATE.md session info. Phase 12 all 4 plans complete.
2026-06-15 14:52:17 -04:00
Lucas Berger 120ce85a59 fix(12-04): align setup config client to camelCase API contract + readable errors
BUG 1: Rename SetupConfigPayload fields from snake_case to camelCase to match
the API configSchema (appExternalUrl, oidcIssuer, oidcClientId, vapidPublicKey).
Update SetupPage.tsx handleSaveAndValidate to send the correct camelCase keys.

BUG 2: Extract human-readable message from ZodError object in postSetupConfig
error handler. When body.error is an object with issues[], use issues[0].message
instead of stringifying the object (which produces "[object Object]").

All 245 PWA tests pass; TypeScript clean.
2026-06-15 14:48:07 -04:00
Lucas Berger 9f20c8b7cc test(12-04): RED regression for setup /config payload contract + error rendering
- Add setupClient.contract.test.ts with 9 tests targeting two bugs:
  BUG 1: SetupConfigPayload interface must use camelCase keys matching
    the API configSchema (appExternalUrl, oidcIssuer, oidcClientId,
    vapidPublicKey) — TypeScript compile error confirms mismatch
  BUG 2: postSetupConfig must throw readable string when API returns
    ZodError object in error field (not [object Object])
- Tests 7-8 fail (BUG 2 confirmed); TypeScript errors confirm BUG 1
2026-06-15 14:47:13 -04:00
Lucas Berger 1d8eed309b docs(12-04): complete setup-wizard PWA plan — paused at human-verify checkpoint 2026-06-15 14:36:20 -04:00
Lucas Berger 1587bca9a0 feat(12-04): App.tsx setup-status gate + /setup route + redirect
- Add setupQuery (queryKey: setupStatus, staleTime: 0) alongside meQuery;
  queries GET /api/setup/status via fetchSetupStatus on every app load
- Add <Route path="/setup" element={<SetupPage />}> as standalone pre-auth route
- Add redirect gate: while loading → aria-hidden div (no flash); setupComplete===false
  → <Navigate to="/setup"> (no AppNav/BottomTabBar rendered); true → normal shell
- Add App.test.tsx covering both branches (setupComplete false/true) + loading state;
  236 tests pass, typecheck clean
2026-06-15 14:31:03 -04:00
Lucas Berger 62d80f6c46 feat(12-04): setup API client functions + SetupPage wizard component
- Add 7 setup functions to client.ts: fetchSetupStatus, postSetupConfig,
  validateSetupDb, validateSetupOidc, validateSetupVapid, postSetupCredential,
  postSetupComplete; plus SetupAlreadyLockedError for 423 handling
- Add SetupPage.tsx: standalone 4-step wizard (Welcome → Instance Configuration
  → Calendar Credential → Terminal/Locked) with Surface 2 step indicator,
  Surface 5 validation rows, Surface 6 action row, Surface 7 terminal screen,
  Surface 8 already-locked screen; role=main, aria-live, no nav shell
- No dangerouslySetInnerHTML; no AppNav/BottomTabBar imports
- All 230 pwa tests pass; typecheck clean; build green
2026-06-15 14:27:25 -04:00
Lucas Berger eb84e6e8e2 test(12-04): add failing tests for SetupPage wizard and setup API client (RED)
- Tests for all 7 setup client function exports (fetchSetupStatus, postSetupConfig,
  validateSetupDb, validateSetupOidc, validateSetupVapid, postSetupCredential, postSetupComplete)
- Tests for SetupPage rendering: page title, Welcome step heading, 4-step indicator,
  Continue button, role=main, aria-live region
- Tests for no AppNav/BottomTabBar in wizard output
- Tests for Already Locked screen (alreadyLocked prop) and Sign-in link
2026-06-15 14:22:19 -04:00
Lucas BergerandClaude Opus 4.8 ee21611607 docs(12-03): record Self-Check PASSED in summary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:41 -04:00
Lucas Berger a36f9ddb78 docs(12-03): complete first-login-claims plan summary 2026-06-15 14:16:59 -04:00
Lucas BergerandClaude Sonnet 4.6 c8894adc3f feat(12-03): upsertUser first-login-claims branch (D-08)
- Add isNull import from drizzle-orm; add appConfig to schema imports
- After identity lookup, read app_config.setup_complete per call (D-10 freshness)
- When setup_complete='true' and unclaimed user exists (isNull(oidcIss) AND claimed=false):
  claim it via db.update() — binds oidcIss/oidcSub, sets claimed=true, preserves is_admin
- shouldBeAdmin gated: flagRow?.value !== 'true' AND admin COUNT === 0 (T-12-11)
- No email keying in claim branch — isNull(oidcIss) AND claimed=false only (D-10/T-12-12)
- 399 tests pass; typecheck clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:14:48 -04:00
Lucas BergerandClaude Sonnet 4.6 7a26b4aa06 test(12-03): D-08 first-login-claims failing tests (RED gate)
- Expand 5 it.todo() scaffolds into real failing tests for first-login-claims
- Add db.update mock to the mock factory; add makeUpdateChain helper
- Update existing new-user insert tests to account for new app_config.setup_complete read (selectCallCount shift +1)
- 11 tests fail: 5 D-08 claim tests + 6 existing insert tests await feature implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:12:50 -04:00
Lucas Berger 9d0aa6296a docs(12-02): complete setup-routes plan — guard + router + OIDC fallback 2026-06-15 14:06:29 -04:00
Lucas Berger 67a9d29dc1 feat(12-02): OIDC boot env-OR-app_config fallback + pre-auth mount verification
- A2 CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL
  at per-request call time (env(c) → process.env), NOT at import time — fresh instance
  boots cleanly without OIDC env vars
- Implement oidcConfigFallbackMiddleware in auth/middleware.ts: reads OIDC_ISSUER,
  OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when process.env is absent,
  injects into process.env before oidcAuthMiddleware() reads it (D-02/D-03/Recommendation a)
- Mount oidcConfigFallbackMiddleware before oidcAuthMiddleware() in index.ts so
  wizard-configured instances work before a container restart
- Verify /api/setup mount order: line 49 < devAuthBypass line 54 (T-12-09/Pitfall 1)
- Fix push.test.ts vi.doMock for middleware.js: add oidcConfigFallbackMiddleware stub
- 394 tests pass | 5 todo (D-08 RED scaffolds); typecheck clean
2026-06-15 14:03:15 -04:00
Lucas Berger 20f91e4548 feat(12-02): implement setup router — all 7 pre-auth routes + guard-first pattern
- Fill setupRouter: GET /status, POST /config, POST /validate/{db,oidc,vapid},
  POST /credential, POST /complete (SETUP-01/02)
- isSetupLocked() is FIRST statement in every handler; returns 423 if locked (SETUP-04/D-10)
- /status uses isSetupLocked() directly: covers both explicit + effective-config branches
- /config: zod-validates {oidcIssuer:https, oidcClientId, vapidPublicKey, appExternalUrl};
  upserts oidc_issuer|oidc_client_id|vapid_public_key|app_external_url into app_config
- /validate/db: db.execute(sql`SELECT 1`); 200 ok, 503 on failure
- /validate/oidc: fetches discovery doc with AbortSignal.timeout(5000); reads oidc_issuer
  from app_config; 200 ok, 400 on unreachable/non-2xx
- /validate/vapid: webpush.setVapidDetails() structural check; reads ONLY from process.env
  (VAPID_PRIVATE_KEY never from app_config, never returned; T-12-06/SC-3)
- /credential: inserts local user (oidcIss=null, claimed=false, isAdmin=true) FIRST
  (Pitfall 5 FK), then calls validateEncryptAndStoreCredential(); noEchoHook + error map
- /complete: upserts setup_complete='true'; 200 first call, 423 second (Pitfall 8/D-10)
- Mount setupRouter pre-auth in index.ts BEFORE devAuthBypass() (T-12-09/Pitfall 1)
- All 394 tests pass (5 todo = D-08 RED scaffolds); typecheck clean
2026-06-15 13:58:51 -04:00
Lucas Berger 4748d578e7 test(12-02): isSetupLocked() real impl + RED-first setup route tests
- Implement real isSetupLocked() in setupGuard.ts: reads app_config.setup_complete
  (returns true if value==='true'); else checks member_credentials row + VAPID env
  for effective-config branch (D-10)
- Re-queries DB fresh every call — no module-level cache (D-10/Pitfall 8)
- Convert Wave-0 it.todo() scaffolds into real integration tests (17 tests RED)
- RED-first 423 guard test: POST /complete twice → first 200, second 423 (Pitfall 8)
- D-10 effective-config tests: 423 when credRow AND VAPID env; NOT 423 otherwise
- 2 'does NOT return 423' tests pass (404 ≠ 423); all others RED pending Task 2 router
2026-06-15 13:53:53 -04:00
Lucas Berger c6d0db0119 docs(12-01): complete Phase 12 Plan 01 plan 2026-06-15 13:47:04 -04:00
Lucas Berger e098be3929 test(12-01): Wave-0 test scaffolds — setup.test.ts + user.test.ts (RED)
- Add apps/api/tests/routes/setup.test.ts with it.todo() scaffolds for:
  SETUP-01 (GET /api/setup/status), SETUP-02 (validate/vapid + validate/oidc),
  SETUP-01 (POST /api/setup/credential), SETUP-04 (POST /api/setup/complete
  × 2 → first 200, second 423), D-10 effective-config 423 guard.
  All 15 cases RED (it.todo) so Plan 02 implements against real failing tests.
- Extend apps/api/tests/auth/user.test.ts with D-08 first-login-claims describe
  block (5 it.todo() cases): unclaimed user bind, is_admin preservation,
  setup_complete=false fallthrough, no unclaimed fallthrough, no email lookup (D-10)
- Suite collects clean: 375 passed | 20 todo — no import errors
2026-06-15 13:43:47 -04:00
Lucas Berger 11e8102a71 feat(12-01): stub setupGuard.ts + setup.ts router — Wave-0 import targets
- Add apps/api/src/lib/setupGuard.ts exporting isSetupLocked(): Promise<boolean>
  (Wave-0 stub returns false; real DB impl ships in Plan 02)
  Doc comment enforces D-10: re-evaluate fresh on every call, never module-cache
- Add apps/api/src/routes/setup.ts exporting setupRouter = new Hono()
  (empty router; handlers + index.ts mount added in Plan 02)
  Doc comment notes pre-auth surface position — before /api/* OIDC chain
2026-06-15 13:40:04 -04:00
Lucas Berger 2d6dc14a4c feat(12-01): generate-secrets helper — SETUP-03 / D-05
- Add scripts/generate-secrets.mjs: plain ESM script that prints
  SESSION_SECRET + APP_PASSWORD_ENCRYPTION_KEY (32 random bytes each,
  hex-encoded) and VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY from web-push
  generateVAPIDKeys() — all to stdout only (SC-3: nothing written to disk)
- Resolve web-push as CommonJS default import from apps/api/node_modules
  (avoids a root-level dependency; named-export ESM form not supported)
- Wire root package.json "generate-secrets" script: node scripts/generate-secrets.mjs
2026-06-15 13:39:32 -04:00
Lucas Berger 703fad2ca2 feat(12-01): schema nullable oidc identity + claimed marker + 0002 migration
- Remove .notNull() from users.oidc_iss and users.oidc_sub (wizard creates
  local rows before OIDC identity is known; first-login-claims binds later)
- Add users.claimed boolean (default false NOT NULL) to distinguish pending
  wizard rows from OIDC-bound rows (D-07)
- Add Phase 12 app_config key documentation + prohibition comment (D-01/SC-3)
- Generate migration 0002_lethal_millenium_guard.sql via drizzle-kit generate
  (MODIFY COLUMN for nullable, ADD COLUMN claimed — no DROP/recreate)
- Append backfill: UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL
  so existing OIDC users cannot be matched by first-login-claims (D-08)
- Apply migration via drizzle-kit migrate — users.claimed column verified in dev DB
2026-06-15 13:38:46 -04:00
Lucas BergerandClaude Opus 4.8 743e83c2f2 docs(phase-12): reconcile roadmap/state after merging origin/main; begin phase 12 execution
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:31:15 -04:00
Lucas Berger f5bb71cd99 Merge remote-tracking branch 'origin/main' into gsd/phase-12-initial-setup-wizard
# Conflicts:
#	.planning/ROADMAP.md
#	.planning/STATE.md
2026-06-15 13:30:32 -04:00
Lucas Berger 2bf5a42ac2 docs(roadmap): add Phase 19 Local Auth (No-OIDC Mode) 2026-06-15 13:11:34 -04:00
Lucas BergerandClaude Sonnet 4.6 0f3c3784e6 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 <noreply@anthropic.com>
2026-06-15 13:09:25 -04:00
Lucas BergerandClaude Opus 4.8 513fc887e4 docs(12): finalize plan — validation map, research resolution, roadmap waves
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) <noreply@anthropic.com>
2026-06-15 11:49:53 -04:00
Lucas Berger fe40de83db docs(12): create initial-setup-wizard phase plan (4 plans, 3 waves) 2026-06-15 11:46:30 -04:00
Lucas BergerandClaude Opus 4.8 ea40176920 docs(12): map patterns to existing analogs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:37:37 -04:00
Lucas BergerandClaude Opus 4.8 48acf3ac95 docs(12): add research and validation strategy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:34:00 -04:00