- 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)
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>
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>
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>
- 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
- 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
- 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
- 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
- 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)
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>