- 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>
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>
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>
Phase-goal verification: 7/7 decision-contract truths confirmed (D-01..D-07).
Browser round-trip re-confirmed via Playwright e2e against the live stack.
Mark phase 18 complete in STATE.md and ROADMAP.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- 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)
- 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
- 6 desktop tests covering the full 18-04 acceptance criteria:
timezone section visible, combobox pre-filled, save disabled when
unchanged, save enables on change, persists across reload, use-detected
affordance sets browser zone
- All 6 pass against the real 18-02 API endpoints
- Import fetchAdminTimezone + setAdminTimezone from api/client.js
- timezoneQuery: useQuery(['admin','timezone'], fetchAdminTimezone, retry:false, staleTime:60s)
- timezoneMutation: useMutation(setAdminTimezone) with invalidateQueries on success
- Timezone <section aria-label="Timezone"> after Shared Calendar (with marginBottom on preceding section)
- Searchable <input type=text list=iana-zones> + <datalist> from Intl.supportedValuesOf (guarded)
- 'Use detected: <zone>' affordance for D-02 one-tap seed
- 'Using system default' note when isExplicitlySet === false (D-06)
- Save button disabled while pending or when input equals stored value
- No touch to eventDateTime.ts / hydrateEvents.ts / other sections (D-07)
- Export AdminTimezoneResponse interface (timezone: string, isExplicitlySet: boolean)
- fetchAdminTimezone(): GET /api/admin/config/timezone with credentials/redirect pattern
- setAdminTimezone(timezone): PUT /api/admin/config/timezone with JSON body
- Both wrappers call handleAuthResponse (same auth handling as sibling admin calls)
- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
(~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
(getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
- reminderScheduler: new describe block with mockThreeQueries helper that
extends mockTwoQueries to mock getHouseholdTimezone app_config SELECT
(select({value}).from(appConfig).where(...).limit(1) chain)
- reminderScheduler: D-05 test expects dispatch at 14:00 UTC (Chicago CDT)
when stored zone is America/Chicago; fails RED (code still reads process.env.TZ=America/New_York)
- reminderScheduler: D-05 NOT-fire test expects no dispatch at 13:00 UTC (NY time)
when stored zone overrides to Chicago; fails RED (code fires at NY time)
- outboxWorker: new describe block with wireMockChainWithTz that extends
mockFromFn to handle app_config table via where().limit() chain
- outboxWorker: D-05 create-branch test expects VALARM TRIGGER 20260619T140000Z
(Chicago CDT); fails RED (code emits 20260619T130000Z using UTC fallback)
- outboxWorker: D-05 update-branch test same assertion, also fails RED
- Existing process.env.TZ-pinned all-day tests untouched; all 72 pass
- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
- describe('admin timezone config') covers 8 cases:
- GET and PUT 403 for non-admin authenticated user (T-18-03)
- GET with no stored row returns 200 with isExplicitlySet: false
- PUT America/Chicago then GET round-trip with isExplicitlySet: true
- PUT UTC returns 200 (Pitfall 2)
- PUT Not/AZone returns 400 and does not write to app_config (T-18-04)
- POST seed when unset stores the value (D-02)
- POST seed when already set does NOT overwrite (D-03)
- appConfig imported from db/schema for per-test cleanup
- afterEach removes household_timezone row to prevent test bleed
- 6 new cases FAIL (404 — endpoints not yet implemented); 19 existing pass
- getHouseholdTimezone(db): selects household_timezone from app_config
- D-06 fallback chain: stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone
- isValidIanaTimezone: try/catch Intl.DateTimeFormat (no Intl.supportedValuesOf per RESEARCH Pitfall 2)
- Exports match D-05 single-accessor contract for reminderScheduler + outboxWorker
- All 11 unit tests pass; 358/358 total suite green; tsc --noEmit clean