Extend the existing Phase 10 `adminRouter` with three timezone endpoints — GET (read current +
isExplicitlySet), PUT (validate + upsert), and a seed endpoint (write only if unset) — all behind
the established `requireAdmin` guard.
Purpose: D-04 puts the timezone behind the role-gated admin surface; D-01 stores it as the single
household_timezone app_config row; D-02/D-03 add a first-run seed path that never overwrites an
already-set value (so Phase 18 is self-contained without Phase 12, and silent drift is impossible).
Output: three new routes on adminRouter + integration tests proving the access-control and
validation boundaries.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/api/src/lib/householdTimezone.ts
Task 1: RED — failing integration tests for the three timezone endpoints
apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/admin.test.ts (existing: how a non-admin 403 case is set up; how admin requests are authed; beforeEach/afterEach DB cleanup against familysync_test)
- apps/api/src/routes/admin.ts (existing GET /calendars + PUT /calendars/:id/shared handlers; requireAdmin at line 41; zValidator import at line 24)
- apps/api/src/lib/householdTimezone.ts (the validator + accessor this route consumes)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 'UTC' must pass; Pitfall 3 requireAdmin coverage; Pitfall 6 seed no-overwrite; Security Domain → no noEchoHook needed)
- GET /api/admin/config/timezone as a non-admin authenticated user → 403.
- PUT /api/admin/config/timezone as a non-admin authenticated user → 403.
- GET as admin with no stored row → 200 with { isExplicitlySet: false } and a non-empty timezone string (the fallback).
- PUT as admin { timezone: 'America/Chicago' } → 200; subsequent GET → { timezone: 'America/Chicago', isExplicitlySet: true }.
- PUT as admin { timezone: 'UTC' } → 200 (UTC must be accepted — Pitfall 2).
- PUT as admin { timezone: 'Not/AZone' } → 400; app_config has no household_timezone change as a result.
- POST /api/admin/config/timezone/seed { timezone: 'Europe/London' } when unset → 200 and stored value becomes 'Europe/London'.
- POST seed { timezone: 'Asia/Tokyo' } when already set to 'America/Chicago' → 200 (or 409 if chosen) but stored value REMAINS 'America/Chicago' (no overwrite, D-03).
Append a `describe('admin timezone config', ...)` block to the existing integration test file,
reusing its established admin-auth and DB-cleanup helpers. For the no-overwrite seed test, first
PUT (or directly insert) 'America/Chicago', then POST seed 'Asia/Tokyo', then GET and assert the
value is still 'America/Chicago'. Ensure each test cleans the household_timezone row in
afterEach so cases do not bleed. Run the suite; confirm the new cases FAIL (routes return 404 —
not yet implemented). Commit as `test(18-02): add failing integration tests for admin timezone endpoints`.
pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts 2>&1 | grep -qi "fail\|404" && echo RED-OK
- New describe block covers all eight behavior cases above, including both 403 cases and the seed no-overwrite case.
- Run is RED because the endpoints do not exist yet (404), not because of a test bug.
- `test(18-02): ...` commit precedes implementation.
RED gate met for the endpoint contract.
Task 2: GREEN — implement GET/PUT/seed timezone endpoints on adminRouter
apps/api/src/routes/admin.ts
- apps/api/src/routes/admin.ts (FULL file — append after existing routes so requireAdmin at line 41 covers them; mirror GET /calendars and the zValidator PUT shape)
- apps/api/src/lib/householdTimezone.ts (import isValidIanaTimezone + getHouseholdTimezone)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (admin.ts section: exact schema placement, import additions, upsert pattern)
- apps/api/src/db/schema.ts §282 (appConfig — add to the schema import on line 28)
Extend `apps/api/src/routes/admin.ts`. Add `appConfig` to the `'../db/schema.js'` import and
`import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js'`.
Define `timezoneSchema = z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }) })`
in the Zod schema block (NO noEchoHook — timezone is non-sensitive, RESEARCH Security Domain).
Append three routes AFTER the existing ones (so the line-41 `requireAdmin` covers them):
• `adminRouter.get('/config/timezone', ...)` → select value where key='household_timezone' limit 1; return `c.json({ timezone: row?.value ?? process.env.TZ ?? Intl…, isExplicitlySet: row?.value != null })`. (Reuse the exact fallback; you may call getHouseholdTimezone(db) for the value and compute isExplicitlySet from a separate row read or a single read.)
• `adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), ...)` → upsert via `db.insert(appConfig).values({ key: 'household_timezone', value: timezone }).onDuplicateKeyUpdate({ set: { value: timezone } })`; return `c.json({ ok: true }, 200)`.
• `adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), ...)` → SELECT existing; if `row?.value` is null/undefined, insert the value; else no-op. Always return `c.json({ ok: true, seeded: }, 200)` (D-03 no-overwrite). Do NOT use a bare onDuplicateKeyUpdate that would overwrite.
Run the integration tests; iterate to GREEN. Commit as `feat(18-02): admin timezone GET/PUT/seed endpoints`.
pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts
- admin.ts registers `/config/timezone` (GET, PUT) and `/config/timezone/seed` (POST) AFTER line 41 so requireAdmin covers them (both 403 tests pass).
- PUT body validated by `timezoneSchema` using `isValidIanaTimezone`; 'UTC' → 200, 'Not/AZone' → 400.
- Upsert uses `onDuplicateKeyUpdate` on key `'household_timezone'`; seed path conditionally writes only when unset (no-overwrite test passes).
- admin.ts does NOT add a noEchoHook to the timezone routes (timezone is non-sensitive).
- All eight RED cases now pass (GREEN); `tsc --noEmit` clean.
- `feat(18-02): ...` commit follows RED commit.
GREEN gate met: endpoints enforce requireAdmin + IANA validation + no-overwrite seed.
<threat_model>
Trust Boundaries
Boundary
Description
client → /api/admin/config/timezone
untrusted authenticated request crosses into a privileged config write
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-18-03
Elevation of Privilege
non-admin sets/reads household timezone
mitigate
adminRouter.use('*', requireAdmin) (line 41) is the FIRST statement; new routes appended after inherit it. Integration tests assert 403 for a non-admin authenticated user on both GET and PUT (Pitfall 3). Client isAdmin is UX-only (Phase 10 D-03); the DB-backed requireAdmin is the real boundary.
T-18-04
Tampering
arbitrary/garbage timezone string in PUT body breaks scheduler
mitigate
Zod .refine(isValidIanaTimezone) rejects non-IANA strings with 400 before any DB write; try/catch Intl.DateTimeFormat is eval-free. Test asserts 'Not/AZone' → 400 and no stored change.
T-18-05
Tampering
unauthenticated/first-run seed clobbers an already-set value (silent drift)
mitigate
seed endpoint reads existing value and writes ONLY when unset (D-03); it is also behind requireAdmin. Test asserts a second seed with a different zone leaves the stored value unchanged.
T-18-06
Information Disclosure
log/echo of submitted value
accept
Timezone strings are non-sensitive (not credentials/PII — RESEARCH Security Domain); noEchoHook not required. No console.log of bodies added.
T-18-07
Injection
app_config key/value write
mitigate
Drizzle parameterizes the insert/upsert; the key is a hard-coded literal 'household_timezone'; the value is IANA-validated. No string-concatenated SQL.
T-18-SC
Tampering
npm/pip/cargo installs
mitigate
No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs).
</threat_model>
- `pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` green (no regression).
<success_criteria>
Three admin-gated timezone endpoints exist with server-side IANA validation and a no-overwrite seed.
D-01/D-02/D-03/D-04 enforced and tested.
RED then GREEN commits present.
</success_criteria>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md` when done.