Commit Graph
380 Commits
Author SHA1 Message Date
Lucas Berger 7ece96688d test(19-01): add failing tests for hashPassword/verifyPassword scrypt primitives 2026-06-17 16:12:14 -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 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 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 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 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 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 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 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 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 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 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 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 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 1b4ff3cf93 test(18): scope timezone e2e to desktop profile (fix harness cross-profile leak)
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m28s
CI / api (pull_request) Successful in 1m6s
CI / harness (pull_request) Successful in 4m22s
CI / security (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 1s
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>
2026-06-15 09:30:55 -04:00
Lucas Berger c5b892c3a5 Merge remote-tracking branch 'origin/main' into gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 1m27s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m44s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Failing after 1s
# Conflicts:
#	apps/pwa/vite.config.ts
2026-06-15 09:20:54 -04:00
Lucas BergerandClaude Opus 4.8 1f6ad076c1 style(18): prettier-format household timezone accessor + outbox test
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Successful in 1m31s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m48s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
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>
2026-06-15 08:36:49 -04:00
Lucas BergerandClaude Opus 4.8 d6f6a5ae6f fix(18): searchable timezone combobox with type-to-search
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>
2026-06-15 08:36:49 -04:00
Lucas BergerandClaude Opus 4.8 46d7fcc2d2 chore(pwa): allow internal split-DNS host on the vite dev server
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>
2026-06-15 08:19:42 -04:00
Lucas BergerandClaude Opus 4.8 a8d6142566 fix(18): timezone picker shows full list on tap (native select)
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>
2026-06-15 08:19:42 -04:00
Lucas BergerandClaude Opus 4.8 1fb431e8da refactor(18): IN-03 memoize household timezone per outbox drain cycle
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>
2026-06-15 07:43:01 -04:00
Lucas BergerandClaude Opus 4.8 93217b58fe fix(18): WR-02 derive seed flag from DB write, not a stale pre-flight SELECT
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>
2026-06-15 07:41:52 -04:00
Lucas BergerandClaude Opus 4.8 692fe2ad9a refactor(18): IN-01/IN-02 reuse fetched row for GET timezone fallback
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>
2026-06-15 07:38:45 -04:00
Lucas BergerandClaude Opus 4.8 d168da71cf fix(18): WR-01 treat empty/blank TZ as unset in household timezone fallback
The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or '   ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:38:39 -04:00
Lucas BergerandClaude Opus 4.8 ddeb87cdba test(18): make timezone e2e deterministic and align with WR-01 fix
The timezone-verify spec assumed a first-run (unset) starting state, but
e2e global-setup truncated only the list/event tables — never app_config —
so a prior run's saved household_timezone leaked across runs. Clear that key
in global-setup so the spec always starts from isExplicitlySet:false.

Also repurpose the stale "Save disabled when unchanged" assertion: after the
WR-01 fix, first-run Save is correctly ENABLED when the input matches the
displayed default (saving confirms the detected zone). The disabled-when-
unchanged-and-explicit case remains covered by the persist-across-reload test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:07:36 -04:00
Lucas Berger bda31a33bd fix(18): make timezone seed idempotent under concurrent race (WR-02)
- Import sql from drizzle-orm in admin.ts
- Add onDuplicateKeyUpdate({ set: { value: sql\`value\` } }) to the
  conditional INSERT in POST /config/timezone/seed so a concurrent seed
  (or seed racing a PUT) cannot 500 on the app_config.key PK constraint
- Existing value is preserved per D-03 no-overwrite (no-op ODKU)
- seeded flag still reflects the pre-flight SELECT (winner: true, loser: false)
- Add tests: 403 access control, seeded:true on first seed, seeded:false
  on second seed without throw (WR-02 idempotent race)
2026-06-14 22:56:18 -04:00
Lucas Berger 173e06ea77 fix(18): enable first-run timezone save when not explicitly set (WR-01)
- Derive isExplicit from timezoneQuery.data?.isExplicitlySet
- Apply the input===stored no-op guard only when isExplicit is true
- Keep pending and empty-input guards unconditional
- Add unit tests (AdminPage.timezone.test.ts) verifying first-run Save is
  enabled when isExplicitlySet:false and input matches stored fallback value
2026-06-14 22:53:44 -04:00