Files
2026-06-18 22:21:38 -04:00

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
18-auto-timezone-detection-and-ability-to-change-timezone 02 tdd 2
18-01
apps/api/src/routes/admin.ts
apps/api/tests/routes/admin.test.ts
true
D-01
D-02
D-03
D-04
truths artifacts key_links
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)
path provides
apps/api/src/routes/admin.ts GET + PUT + seed timezone endpoints on the existing requireAdmin-gated adminRouter
path provides
apps/api/tests/routes/admin.test.ts Integration coverage: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite
from to via pattern
apps/api/src/routes/admin.ts apps/api/src/lib/householdTimezone.ts import isValidIanaTimezone + getHouseholdTimezone isValidIanaTimezone|getHouseholdTimezone
from to via pattern
PUT /api/admin/config/timezone app_config key='household_timezone' drizzle insert().onDuplicateKeyUpdate 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 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.