- 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>
- 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>
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.
- 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
- 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>
- 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
- 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
- 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
- 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
- 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
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>
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>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
The timezone spec mutates the single household_timezone row, but e2e global-setup
resets it only once per run. Running on all three device profiles (iphone/pixel/
desktop) let one profile's "Save persists" write leak into another profile's
first-run assertions, failing the harness job in CI (workers=1, serial). The admin
timezone UI is desktop-focused, so skip the spec on non-desktop profiles — matching
the layout.spec.ts desktop-only pattern. Full harness: 107 passed, 19 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These two files (from the WR-01 / IN-03 review fixes) had formatting that
failed `pnpm format:check`. No logic change — whitespace/wrapping only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the picker with an accessible combobox (role=combobox + role=listbox):
focusing shows the full zone list (no typing/erasing needed), typing filters it
case-insensitively (underscores ignored, so "york" matches America/New_York),
with arrow-key navigation, Enter/click to select, and Escape to close. Fixes the
datalist limitation where a pre-filled value collapsed the dropdown to one match.
e2e updated to type+click options and a type-to-search case added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add .bergerhouse.net (apex + subdomains) to server.allowedHosts so the dev
PWA is reachable through the reverse proxy / tunnel (e.g.
familysync-dev.bergerhouse.net). Dev-server only; production builds ignore it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IANA picker was an <input list=datalist>, which filters the dropdown by
whatever text is already in the field — so with the stored zone pre-filled a
user only saw a single option and had to erase the value (undiscoverable) to
browse. datalist is also unreliable in iOS Safari.
Replace it with a native <select> grouped by region (<optgroup>): tapping
shows the whole list with no typing/erasing, and it renders as the native
wheel picker on iOS. The "Use detected" one-tap shortcut still covers the
common case. Option labels are shortened (region stripped, underscores → spaces)
while values remain full IANA ids. e2e updated from fill() to selectOption().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review after fixes: status clean (0 Critical/Warning). All 5 findings
from the prior pass resolved across 4 atomic fix commits:
- WR-01: treat empty/blank TZ as unset in the D-06 fallback chain
- WR-02: derive seed `seeded` flag from INSERT IGNORE affectedRows (accurate
under concurrent race; D-03 no-overwrite preserved)
- IN-01/02: reuse fetched row on GET unset path; centralize D-06 fallback
- IN-03: memoize household timezone per outbox drain cycle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The UPDATE and CREATE all-day branches each called getHouseholdTimezone(db)
independently, so a drain processing both an all-day create row and an
all-day update row issued two identical app_config SELECTs. Add a lazy
per-cycle TimezoneResolver (mirroring the existing clientCache thread-through)
created in runOutboxDrain and passed into dispatchRow. The read stays lazy —
cycles with no all-day work never touch the DB — but is shared across all
all-day rows in a cycle. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed handler computed seeded from a pre-flight SELECT then returned
seeded:!alreadySet. Under a genuine concurrent race both requests can
SELECT the empty table, both enter the insert branch, and both return
seeded:true though only one row was actually written. Replace the
SELECT + conditional onDuplicateKeyUpdate with a single INSERT IGNORE
and derive seeded from affectedRows (1 = inserted, 0 = ignored/existing
row preserved, D-03). On MariaDB onDuplicateKeyUpdate(value=value)
reports affectedRows 1 for both insert and no-op, so it cannot
distinguish them; INSERT IGNORE can. timezone is bound via a
parameterized sql template and is already IANA-validated by zod. Adds a
test asserting seeded:false for a directly-pre-inserted row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GET /config/timezone handler SELECTed app_config then, on the unset
path, called getHouseholdTimezone(db) which re-issued the identical
SELECT before falling back (IN-01). The fallback decision also lived in
two places (IN-02). Route the handler through the centralized
resolveHouseholdTimezone(row?.value) added for WR-01: no redundant
round-trip, single source for the D-06 policy. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>