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

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED — failing integration tests for the three timezone endpoints</name>
  <files>apps/api/tests/routes/admin.test.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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).
  </behavior>
  <action>
    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`.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts 2>&1 | grep -qi "fail\|404" && echo RED-OK</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>RED gate met for the endpoint contract.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: GREEN — implement GET/PUT/seed timezone endpoints on adminRouter</name>
  <files>apps/api/src/routes/admin.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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: <bool> }, 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`.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>GREEN gate met: endpoints enforce requireAdmin + IANA validation + no-overwrite seed.</done>
</task>

</tasks>

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

<verification>
- `pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` green (no regression).
</verification>

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

<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md` when done.
</output>
