--- phase: 18-auto-timezone-detection-and-ability-to-change-timezone plan: 02 type: tdd wave: 2 depends_on: ["18-01"] files_modified: - apps/api/src/routes/admin.ts - apps/api/tests/routes/admin.test.ts autonomous: true requirements: [D-01, D-02, D-03, D-04] must_haves: truths: - "GET /api/admin/config/timezone returns the current timezone + isExplicitlySet flag, gated by requireAdmin (D-04)" - "PUT /api/admin/config/timezone validates the IANA string server-side and upserts household_timezone in app_config (D-01, D-04)" - "A non-admin authenticated user gets 403 on both GET and PUT (server is the real boundary — Phase 10 D-03)" - "PUT with an invalid IANA string returns 400 and writes nothing; PUT with 'UTC' returns 200" - "POST /api/admin/config/timezone/seed writes household_timezone ONLY if currently unset (D-02 seed, D-03 no-overwrite)" artifacts: - path: "apps/api/src/routes/admin.ts" provides: "GET + PUT + seed timezone endpoints on the existing requireAdmin-gated adminRouter" - path: "apps/api/tests/routes/admin.test.ts" provides: "Integration coverage: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite" key_links: - from: "apps/api/src/routes/admin.ts" to: "apps/api/src/lib/householdTimezone.ts" via: "import isValidIanaTimezone + getHouseholdTimezone" pattern: "isValidIanaTimezone|getHouseholdTimezone" - from: "PUT /api/admin/config/timezone" to: "app_config key='household_timezone'" via: "drizzle insert().onDuplicateKeyUpdate" pattern: "household_timezone.*onDuplicateKeyUpdate" --- 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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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. ## 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). | - `pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts` green. - `pnpm --filter @familysync/api test -- --run` green (no regression). - 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. Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md` when done.