docs(18): create phase plan (4 plans, 3 waves)
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
---
|
||||
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||
plan: 01
|
||||
type: tdd
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/src/lib/householdTimezone.ts
|
||||
- apps/api/tests/lib/householdTimezone.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-05, D-06]
|
||||
must_haves:
|
||||
truths:
|
||||
- "getHouseholdTimezone(db) returns the stored household_timezone value when the app_config row is present (D-05)"
|
||||
- "getHouseholdTimezone(db) falls back to process.env.TZ when no row is stored, then to Intl.DateTimeFormat().resolvedOptions().timeZone when TZ is also unset (D-06)"
|
||||
- "isValidIanaTimezone accepts real IANA zones including 'UTC', 'Etc/UTC', and 'America/Chicago', and rejects garbage like 'Not/AZone'"
|
||||
artifacts:
|
||||
- path: "apps/api/src/lib/householdTimezone.ts"
|
||||
provides: "Shared stored-timezone accessor (D-05 single accessor) + IANA validator"
|
||||
exports: ["getHouseholdTimezone", "isValidIanaTimezone"]
|
||||
- path: "apps/api/tests/lib/householdTimezone.test.ts"
|
||||
provides: "RED→GREEN unit coverage for accessor fallback chain + validator"
|
||||
key_links:
|
||||
- from: "apps/api/src/lib/householdTimezone.ts"
|
||||
to: "app_config row key='household_timezone'"
|
||||
via: "drizzle select on appConfig"
|
||||
pattern: "appConfig.*household_timezone"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the single shared accessor and IANA validator that every other Phase 18 task
|
||||
depends on. `getHouseholdTimezone(db)` reads the `household_timezone` key from `app_config`
|
||||
and applies the D-06 fallback chain; `isValidIanaTimezone(tz)` validates an IANA string via
|
||||
`Intl.DateTimeFormat` try/catch.
|
||||
|
||||
Purpose: D-05 mandates ONE accessor so `reminderScheduler.ts` and `outboxWorker.ts` cannot
|
||||
drift; D-06 mandates the exact `process.env.TZ ?? Intl…` fallback so existing all-day tests
|
||||
stay green with no modification. This plan is the foundation both later waves consume.
|
||||
Output: `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` + `isValidIanaTimezone`, fully unit-tested.
|
||||
</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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tdd" tdd="true">
|
||||
<name>Task 1: RED — failing unit tests for getHouseholdTimezone fallback chain + isValidIanaTimezone</name>
|
||||
<files>apps/api/tests/lib/householdTimezone.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/tests/lib/requireAdmin.test.ts (vitest structure + how a mock Drizzle db is built for a lib helper)
|
||||
- apps/api/tests/broker/reminderScheduler.test.ts (how existing tests pin process.env.TZ in beforeEach/afterEach — mirror the env save/restore)
|
||||
- apps/api/src/db/schema.ts §282 (appConfig key/value/updatedAt shape — the row this accessor reads)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 = why 'UTC' must pass; Validation Architecture → Test Map)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- getHouseholdTimezone: when the mocked db returns a row { value: 'America/Chicago' } for key 'household_timezone' → resolves to 'America/Chicago'.
|
||||
- getHouseholdTimezone: when the mocked db returns no row (empty array) AND process.env.TZ='America/New_York' → resolves to 'America/New_York'.
|
||||
- getHouseholdTimezone: when no row AND process.env.TZ is deleted → resolves to Intl.DateTimeFormat().resolvedOptions().timeZone (assert it equals that exact runtime value, not a hardcoded string).
|
||||
- getHouseholdTimezone: when the row exists but value is null → falls through to the process.env.TZ branch (null is treated as unset).
|
||||
- isValidIanaTimezone: returns true for 'UTC', 'Etc/UTC', 'America/Chicago', 'Europe/London'.
|
||||
- isValidIanaTimezone: returns false for 'Not/AZone', '', 'Mars/Phobos'.
|
||||
</behavior>
|
||||
<action>
|
||||
Create the test file mocking the Drizzle query chain the same way requireAdmin.test.ts mocks
|
||||
`db` (select→from→where→limit returning a Promise of an array). Save and restore
|
||||
`process.env.TZ` around each test (capture original in beforeEach, restore in afterEach) so the
|
||||
fallback-branch tests do not leak env state. Import the not-yet-existing symbols
|
||||
`getHouseholdTimezone` and `isValidIanaTimezone` from `../../src/lib/householdTimezone.js`.
|
||||
Run the suite and confirm it FAILS because the module does not exist yet (import/resolution
|
||||
error is the expected RED). Commit as `test(18-01): add failing tests for household timezone accessor + IANA validator`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts 2>&1 | grep -qi "fail\|error\|cannot find" && echo RED-OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `apps/api/tests/lib/householdTimezone.test.ts` exists with the six behavior cases above.
|
||||
- The test run fails (RED) prior to any implementation — failure is module-not-found / missing-export, not a syntax error in the test.
|
||||
- A `test(18-01): ...` commit precedes the implementation commit.
|
||||
</acceptance_criteria>
|
||||
<done>RED gate met: failing test committed, fails for the right reason (no implementation).</done>
|
||||
</task>
|
||||
|
||||
<task type="tdd" tdd="true">
|
||||
<name>Task 2: GREEN — implement getHouseholdTimezone + isValidIanaTimezone</name>
|
||||
<files>apps/api/src/lib/householdTimezone.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/lib/requireAdmin.ts (analog: single-purpose lib helper, drizzle select→from→where→limit(1), typed return — copy import + query style)
|
||||
- apps/api/src/db/schema.ts §282 (appConfig — import { appConfig } from '../db/schema.js')
|
||||
- apps/api/src/broker/reminderScheduler.ts §247 (the EXACT fallback expression `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` to reproduce verbatim per D-06)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (householdTimezone.ts section — full target shape)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `apps/api/src/lib/householdTimezone.ts`. Export `async getHouseholdTimezone(db: MySql2Database<typeof schema>): Promise<string>` that selects `{ value: appConfig.value }` from `appConfig`
|
||||
where `eq(appConfig.key, 'household_timezone')` `.limit(1)`, destructures `const [row]`, and returns
|
||||
`row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` — the D-06 fallback
|
||||
reproduced verbatim from the bare lookups. Use the literal key string `'household_timezone'` (D-01).
|
||||
Export `isValidIanaTimezone(tz: string): boolean` that returns true if `Intl.DateTimeFormat(undefined, { timeZone: tz })` does not throw, false on catch — do NOT use `Intl.supportedValuesOf` (it omits 'UTC', RESEARCH Pitfall 2).
|
||||
Import types via `import type { MySql2Database } from 'drizzle-orm/mysql2'` and `import type * as schema from '../db/schema.js'`.
|
||||
Run the test; iterate until GREEN. Commit as `feat(18-01): implement household timezone accessor + IANA validator`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `apps/api/src/lib/householdTimezone.ts` exports `getHouseholdTimezone` and `isValidIanaTimezone`.
|
||||
- Source contains the literal key `'household_timezone'` and the verbatim fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||
- Source does NOT reference `Intl.supportedValuesOf` (assert: `grep -L "supportedValuesOf" apps/api/src/lib/householdTimezone.ts`).
|
||||
- All six behavior cases pass (GREEN).
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` passes for the new file.
|
||||
- A `feat(18-01): ...` commit follows the RED commit.
|
||||
</acceptance_criteria>
|
||||
<done>GREEN gate met: implementation passes all unit tests; typecheck clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| scheduler/outbox → DB | trusted server process reads a stored config value; the value was written through the validated admin/seed path (Plan 02) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-18-01 | Tampering | stored household_timezone consumed by computeAlertInstantUtc | mitigate | This plan only READS the value; all writes go through Plan 02's Zod `isValidIanaTimezone` refine, so a garbage zone can never be stored to break the scheduler. The accessor additionally never passes an unvalidated value (write path is the gate). |
|
||||
| T-18-02 | Denial of Service | getHouseholdTimezone DB read per scheduler tick | accept | Single PK lookup on a 60s interval; negligible. Read-per-run chosen for correctness (changes propagate within one tick) — RESEARCH A1. |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed this plan (RESEARCH Package Legitimacy Audit: zero installs). No legitimacy checkpoint needed. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` green.
|
||||
- `pnpm --filter @familysync/api test -- --run` still green (no regression in existing suites).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `getHouseholdTimezone` + `isValidIanaTimezone` exist, exported, unit-tested.
|
||||
- D-06 fallback verbatim; D-05 single accessor established for both broker sites to import.
|
||||
- RED then GREEN commits present.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
---
|
||||
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>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
---
|
||||
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||
plan: 03
|
||||
type: tdd
|
||||
wave: 2
|
||||
depends_on: ["18-01"]
|
||||
files_modified:
|
||||
- apps/api/src/broker/reminderScheduler.ts
|
||||
- apps/api/src/broker/outboxWorker.ts
|
||||
- apps/api/tests/broker/reminderScheduler.test.ts
|
||||
- apps/api/tests/broker/outboxWorker.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-05, D-06, D-07]
|
||||
must_haves:
|
||||
truths:
|
||||
- "reminderScheduler.ts all-day branch computes the 9 AM-local alert instant using the stored household_timezone when set (D-05)"
|
||||
- "outboxWorker.ts both all-day branches (create + update) compute the alert instant using the stored household_timezone when set (D-05)"
|
||||
- "When household_timezone is unset, all three sites fall back to process.env.TZ ?? Intl — existing all-day tests pass unmodified (D-06)"
|
||||
- "Both files route through the single getHouseholdTimezone accessor — the read/fallback logic is NOT duplicated at the call sites (D-05)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/broker/reminderScheduler.ts"
|
||||
provides: "all-day TZ now sourced from getHouseholdTimezone(db) (line ~247 rewire)"
|
||||
- path: "apps/api/src/broker/outboxWorker.ts"
|
||||
provides: "all-day TZ now sourced from getHouseholdTimezone(db) (lines ~501, ~607 rewire)"
|
||||
key_links:
|
||||
- from: "apps/api/src/broker/reminderScheduler.ts"
|
||||
to: "apps/api/src/lib/householdTimezone.ts"
|
||||
via: "import { getHouseholdTimezone }"
|
||||
pattern: "getHouseholdTimezone\\(db\\)"
|
||||
- from: "apps/api/src/broker/outboxWorker.ts"
|
||||
to: "apps/api/src/lib/householdTimezone.ts"
|
||||
via: "import { getHouseholdTimezone }"
|
||||
pattern: "getHouseholdTimezone\\(db\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Rewire the three all-day "9 AM local" timezone lookups — one in `reminderScheduler.ts` (line ~247)
|
||||
and two in `outboxWorker.ts` (lines ~501 and ~607) — to read the stored timezone through the
|
||||
single `getHouseholdTimezone(db)` accessor, replacing the bare
|
||||
`process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reads.
|
||||
|
||||
Purpose: D-05 makes the stored `household_timezone` the source of truth for the all-day reminder
|
||||
computation, and mandates ONE shared accessor at both sites (no duplicated read/fallback). D-06
|
||||
keeps the exact `process.env.TZ ?? Intl` behavior when nothing is stored, so the existing all-day
|
||||
tests pass unmodified. D-07 is the hard boundary: this plan must NOT touch display/timed-write paths.
|
||||
Output: three rewired call sites + new stored-TZ tests; existing all-day tests green unchanged.
|
||||
</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 stored-TZ all-day tests for scheduler + outbox</name>
|
||||
<files>apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/tests/broker/reminderScheduler.test.ts (lines ~656–729: existing all-day tests that pin process.env.TZ='America/New_York' — DO NOT modify them; they must keep passing via D-06 fallback)
|
||||
- apps/api/tests/broker/outboxWorker.test.ts (how its mock db chain is built for the all-day branches)
|
||||
- apps/api/src/broker/vevent.ts §240 (computeAlertInstantUtc(eventDateStr, leadDays, tz): Date — contract unchanged; assert the 9 AM-local instant for the stored zone)
|
||||
- apps/api/src/lib/householdTimezone.ts (the accessor whose stored-value path you are now exercising)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 5: why existing tests stay green; new tests mock db to return the app_config row)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- reminderScheduler all-day: when the mock db ALSO returns { key: 'household_timezone', value: 'America/Chicago' }, the computed alert instant equals 9 AM America/Chicago for the event date (a different UTC instant than 9 AM America/New_York would give).
|
||||
- outboxWorker all-day (create branch): with stored 'America/Chicago', the create-branch alert instant is 9 AM Chicago for the event date.
|
||||
- outboxWorker all-day (update branch): with stored 'America/Chicago', the update-branch alert instant is 9 AM Chicago for the event date.
|
||||
- Backward-compat (assert, do not modify): the pre-existing all-day tests that pin process.env.TZ and return NO app_config row still pass (fallback fires).
|
||||
</behavior>
|
||||
<action>
|
||||
Add new stored-TZ test cases alongside the existing all-day tests. Where the existing tests mock
|
||||
the Drizzle query for events, extend the mock so the household_timezone SELECT resolves to a row
|
||||
with value 'America/Chicago' (match the accessor's query shape so getHouseholdTimezone returns it).
|
||||
Assert the resulting UTC instant equals 9 AM Chicago for the event date (compute the expected UTC
|
||||
via a fixed reference, e.g. using computeAlertInstantUtc with 'America/Chicago' directly, or an
|
||||
explicit ISO instant). Do NOT alter the existing process.env.TZ-pinned cases. Run the two suites;
|
||||
confirm the NEW cases FAIL (code still reads process.env.TZ, so stored 'America/Chicago' has no
|
||||
effect). Commit as `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts 2>&1 | grep -qi "fail" && echo RED-OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- New stored-TZ cases exist in both test files and FAIL before the rewire (code still reads process.env.TZ).
|
||||
- The existing process.env.TZ-pinned all-day cases are unchanged (no edits to those test bodies).
|
||||
- `test(18-03): ...` commit precedes the rewire commit.
|
||||
</acceptance_criteria>
|
||||
<done>RED gate met: stored-TZ tests fail because the call sites are not yet rewired.</done>
|
||||
</task>
|
||||
|
||||
<task type="tdd" tdd="true">
|
||||
<name>Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)</name>
|
||||
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/broker/reminderScheduler.ts (line ~247 serverTz lookup; line ~256 computeAlertInstantUtc call; db already imported line 46)
|
||||
- apps/api/src/broker/outboxWorker.ts (lines ~501 + ~607 tz lookups; runOutboxDrain line ~710; db already imported line 31)
|
||||
- apps/api/src/lib/householdTimezone.ts (import getHouseholdTimezone)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (reminderScheduler + outboxWorker sections: exact replacement lines)
|
||||
</read_first>
|
||||
<action>
|
||||
In `reminderScheduler.ts`: add `import { getHouseholdTimezone } from '../lib/householdTimezone.js'`
|
||||
and replace the line-247 `const serverTz = process.env.TZ ?? Intl…` with
|
||||
`const serverTz = await getHouseholdTimezone(db);` (the enclosing function is already async / awaits
|
||||
DB queries; `db` is the module import on line 46). Leave the line-256 computeAlertInstantUtc call
|
||||
unchanged — serverTz is still a string.
|
||||
In `outboxWorker.ts`: add the same import. Replace BOTH bare `const tz = process.env.TZ ?? Intl…`
|
||||
lookups (~501 and ~607) with `const tz = await getHouseholdTimezone(db);`. `db` is the module import
|
||||
on line 31; `runOutboxDrain` is async. (If both branches are reachable in one pass and lint allows,
|
||||
you may hoist a single `const tz = await getHouseholdTimezone(db)` to the top of the all-day block
|
||||
and reuse it — but do NOT duplicate the read/fallback inline; route through the accessor only.)
|
||||
HARD BOUNDARY (D-07): do not open, import from, or modify `apps/pwa/src/lib/eventDateTime.ts` or
|
||||
`apps/pwa/src/lib/hydrateEvents.ts`, and do not change any timed-event serialization or display path.
|
||||
Run both suites; iterate to GREEN. Confirm the existing process.env.TZ-pinned tests still pass.
|
||||
Commit as `feat(18-03): route all-day reminder TZ through stored household_timezone`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "getHouseholdTimezone(db)" apps/api/src/broker/reminderScheduler.ts` ≥ 1 and same for outboxWorker.ts ≥ 1.
|
||||
- No bare `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` remains at the rewired all-day sites (assert: `grep -v '^\s*\*' apps/api/src/broker/reminderScheduler.ts | grep -c "process.env.TZ ?? Intl"` → 0; same for outboxWorker.ts). The fallback now lives only inside getHouseholdTimezone.
|
||||
- D-07 boundary: `git diff --name-only` for this plan does NOT include `apps/pwa/src/lib/eventDateTime.ts` or `apps/pwa/src/lib/hydrateEvents.ts` (assert they are absent from the changed-files list).
|
||||
- New stored-TZ tests GREEN; existing process.env.TZ all-day tests still GREEN (unmodified).
|
||||
- `pnpm --filter @familysync/api test -- --run` fully green; `tsc --noEmit` clean.
|
||||
- `feat(18-03): ...` commit follows the RED commit.
|
||||
</acceptance_criteria>
|
||||
<done>GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| scheduler/outbox → DB | trusted server worker reads the validated stored timezone to compute fire times |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-18-08 | Tampering | stored TZ fed to computeAlertInstantUtc could be garbage | mitigate | The value can only have been written via Plan 02's IANA-validated PUT/seed; the accessor returns either a validated stored string or the D-06 fallback. No unvalidated value reaches computeAlertInstantUtc. |
|
||||
| T-18-09 | Tampering (regression) | accidental change to display/timed-write path | mitigate | D-07 boundary enforced as an acceptance criterion: changed-files list must exclude eventDateTime.ts + hydrateEvents.ts; no timed-event serialization touched. Blast radius confined to the all-day branches. |
|
||||
| T-18-10 | Denial of Service | extra DB read per tick at 3 call sites | accept | PK lookups on a 60s interval; negligible (RESEARCH A1). Read-per-run keeps changes propagating within one tick without a worker restart. |
|
||||
| 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/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts` green.
|
||||
- `pnpm --filter @familysync/api test -- --run` green (all 244+ existing tests, including unmodified all-day cases).
|
||||
- D-07: changed-files exclude the two PWA display-path files.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three all-day TZ sites route through getHouseholdTimezone(db); no duplicated read/fallback.
|
||||
- D-05 source-of-truth, D-06 backward-compat, D-07 boundary all satisfied.
|
||||
- RED then GREEN commits present.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
---
|
||||
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18-02"]
|
||||
files_modified:
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
autonomous: false
|
||||
requirements: [D-02, D-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "An admin sees a Timezone section in /admin Settings showing the current household timezone (D-04)"
|
||||
- "The admin can search/pick an IANA zone and save it; the change persists across reload (D-04)"
|
||||
- "The picker pre-offers the browser-detected zone (Intl.DateTimeFormat().resolvedOptions().timeZone) so first-run seeding is one tap (D-02)"
|
||||
- "The section indicates when the timezone is using the system default (isExplicitlySet=false)"
|
||||
artifacts:
|
||||
- path: "apps/pwa/src/api/client.ts"
|
||||
provides: "fetchAdminTimezone() + setAdminTimezone() + AdminTimezoneResponse"
|
||||
exports: ["fetchAdminTimezone", "setAdminTimezone", "AdminTimezoneResponse"]
|
||||
- path: "apps/pwa/src/routes/AdminPage.tsx"
|
||||
provides: "Timezone section (searchable IANA picker + save) after Shared Calendar section"
|
||||
key_links:
|
||||
- from: "apps/pwa/src/routes/AdminPage.tsx"
|
||||
to: "/api/admin/config/timezone"
|
||||
via: "useQuery(fetchAdminTimezone) + useMutation(setAdminTimezone)"
|
||||
pattern: "fetchAdminTimezone|setAdminTimezone"
|
||||
- from: "apps/pwa/src/api/client.ts"
|
||||
to: "PUT /api/admin/config/timezone"
|
||||
via: "fetch with JSON body"
|
||||
pattern: "PUT.*config/timezone"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the admin-facing Timezone UI: a searchable IANA picker in the existing `/admin` Settings page,
|
||||
backed by two new client functions that call the Plan 02 endpoints. The picker pre-offers the
|
||||
browser-detected zone so an admin (or the Phase 12 wizard, later) can seed the household timezone in
|
||||
one tap.
|
||||
|
||||
Purpose: D-04 surfaces the change-timezone control in the role-gated admin Settings; D-02 wires the
|
||||
browser detection (`Intl.DateTimeFormat().resolvedOptions().timeZone`) as the suggested value. This
|
||||
is UI + glue against the contract Plan 02 already established and tested.
|
||||
Output: `fetchAdminTimezone`/`setAdminTimezone` in client.ts + a Timezone section in AdminPage.tsx,
|
||||
verified end-to-end with playwright-cli.
|
||||
</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-PATTERNS.md
|
||||
@apps/pwa/src/api/client.ts
|
||||
@apps/pwa/src/routes/AdminPage.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add fetchAdminTimezone + setAdminTimezone to the PWA API client</name>
|
||||
<files>apps/pwa/src/api/client.ts</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/api/client.ts (lines ~413–469: fetchAdminMembers / saveCredential / fetchAdminCalendars / setSharedCalendar — exact GET + PUT-with-body patterns; handleAuthResponse at lines ~51–58; the `// ── /api/admin/* ──` section header at line 355)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (client.ts section: AdminTimezoneResponse interface + both function bodies)
|
||||
</read_first>
|
||||
<action>
|
||||
In the `// ── /api/admin/* ──` section of client.ts, export `interface AdminTimezoneResponse { timezone: string; isExplicitlySet: boolean }`.
|
||||
Add `fetchAdminTimezone()` — GET `/api/admin/config/timezone` with `credentials: 'include', redirect: 'manual'`,
|
||||
call `handleAuthResponse(res, 'GET /api/admin/config/timezone')`, return `res.json()` as `AdminTimezoneResponse`.
|
||||
Add `setAdminTimezone(timezone: string)` — PUT `/api/admin/config/timezone` with `Content-Type: application/json`,
|
||||
`credentials: 'include', redirect: 'manual'`, body `JSON.stringify({ timezone })`, then
|
||||
`handleAuthResponse(res, 'PUT /api/admin/config/timezone')`. Match the existing wrappers' style exactly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "fetchAdminTimezone" apps/pwa/src/api/client.ts && grep -q "PUT" apps/pwa/src/api/client.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- client.ts exports `fetchAdminTimezone`, `setAdminTimezone`, and `AdminTimezoneResponse`.
|
||||
- Both wrappers call `handleAuthResponse` and use `credentials: 'include', redirect: 'manual'` (same auth handling as sibling admin calls).
|
||||
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
|
||||
</acceptance_criteria>
|
||||
<done>Client functions compile and mirror the established admin fetch wrappers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add the Timezone section (searchable IANA picker + save) to AdminPage</name>
|
||||
<files>apps/pwa/src/routes/AdminPage.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/routes/AdminPage.tsx (Shared Calendar section lines ~183–288: section structure, sectionLabelStyle at ~40, calendarsQuery at ~72, sharedCalMutation at ~86, save-button style ~244–285)
|
||||
- apps/pwa/src/api/client.ts (the fetchAdminTimezone / setAdminTimezone / AdminTimezoneResponse added in Task 1)
|
||||
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (AdminPage.tsx section: query/mutation/section template, datalist picker UX, import additions)
|
||||
- CLAUDE.md (Browser-based verification convention — use playwright-cli, NOT @playwright/test, for the manual check)
|
||||
</read_first>
|
||||
<action>
|
||||
Import `fetchAdminTimezone, setAdminTimezone, type AdminTimezoneResponse` from `../api/client.js`.
|
||||
Add `timezoneQuery = useQuery({ queryKey: ['admin','timezone'], queryFn: fetchAdminTimezone, retry: false, staleTime: 60*1000 })`
|
||||
and `timezoneMutation = useMutation({ mutationFn: (tz: string) => setAdminTimezone(tz), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin','timezone'] }) })`.
|
||||
Add a `<section aria-label="Timezone">` after the Shared Calendar section (give the preceding section
|
||||
`marginBottom: 'var(--space-8, 32px)'`), with `<div style={sectionLabelStyle}>Timezone</div>` and the
|
||||
four states (loading / error / data). Picker: a controlled `<input type="text" list="iana-zones">` +
|
||||
`<datalist id="iana-zones">` populated from `Intl.supportedValuesOf('timeZone')` (guard for absence →
|
||||
empty list). Initialize the input from `timezoneQuery.data?.timezone`. Compute
|
||||
`const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone` and render a small
|
||||
"Use detected: {detectedTz}" affordance that sets the input to detectedTz (D-02 one-tap seed). When
|
||||
`timezoneQuery.data?.isExplicitlySet === false`, show a subtle "using system default" note. Save button
|
||||
(reuse the existing save-button style; disabled while pending or when the input equals the stored value)
|
||||
calls `timezoneMutation.mutate(inputValue)`; label `timezoneMutation.isPending ? 'Saving…' : 'Save'`.
|
||||
Do NOT touch any other section or the display/timed-event path (D-07).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test -- --run && grep -q 'aria-label="Timezone"' apps/pwa/src/routes/AdminPage.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- AdminPage renders a `<section aria-label="Timezone">` with the stored value pre-filled and a datalist-backed searchable input.
|
||||
- A "Use detected: <zone>" affordance sets the input to `Intl.DateTimeFormat().resolvedOptions().timeZone` (D-02).
|
||||
- Save calls setAdminTimezone and invalidates the ['admin','timezone'] query on success; the section shows the "system default" note when isExplicitlySet is false.
|
||||
- `pnpm --filter @familysync/pwa exec tsc --noEmit` and `pnpm --filter @familysync/pwa test -- --run` pass.
|
||||
- No change to eventDateTime.ts / hydrateEvents.ts / any other AdminPage section (D-07).
|
||||
</acceptance_criteria>
|
||||
<done>Timezone section renders, saves, persists, and offers the detected zone.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: playwright-cli round-trip of the admin timezone picker</name>
|
||||
<files>apps/pwa/src/routes/AdminPage.tsx</files>
|
||||
<what-built>A Timezone section in /admin Settings: searchable IANA picker, "use detected zone" affordance, save + persist via the Plan 02 endpoints.</what-built>
|
||||
<action>
|
||||
Drive the verification with the global `playwright-cli` binary (NOT @playwright/test) against the
|
||||
host-side dev stack with DEV_AUTH_BYPASS (the dev-bypass user must be admin — see the MEMORY
|
||||
dev-bypass note; unlock admin if the section is not visible). This is an automation-first check the
|
||||
executor runs, then presents the result to the operator for sign-off.
|
||||
</action>
|
||||
<how-to-verify>
|
||||
1. Open the PWA, navigate to /admin Settings.
|
||||
2. Confirm a "Timezone" section shows the current household timezone (or "system default").
|
||||
3. Type a city (e.g. "Chicago"), pick "America/Chicago" from the datalist, click Save.
|
||||
4. Reload /admin and confirm the section now shows "America/Chicago" with no "system default" note.
|
||||
5. Confirm the "Use detected: <zone>" affordance fills the input with the browser zone.
|
||||
Expected: the value persists across reload; no console errors; the save button disables while pending.
|
||||
</how-to-verify>
|
||||
<verify>
|
||||
<human-check>playwright-cli round-trip confirms the timezone saves and persists across reload, and the detected-zone affordance works.</human-check>
|
||||
</verify>
|
||||
<resume-signal>Type "approved" or describe what rendered/persisted incorrectly.</resume-signal>
|
||||
<done>Operator confirms the admin timezone picker saves, persists across reload, and offers the detected zone.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser PWA → /api/admin/config/timezone | the UI is a convenience; the server requireAdmin + IANA validation (Plan 02) is the real boundary |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-18-11 | Elevation of Privilege | client renders admin UI based on isAdmin flag | accept | Client `isAdmin` is UX-only (Phase 10 D-03); a non-admin who forges the request still hits server-side requireAdmin → 403 (Plan 02 T-18-03). The UI gate is not a security control and is not relied upon as one. |
|
||||
| T-18-12 | Tampering | user types a non-IANA string into the free-text input | mitigate | The datalist offers only valid zones; a manually-typed invalid string is rejected by the server's Zod `isValidIanaTimezone` refine (Plan 02) → 400 surfaced to the user. Client may additionally pre-check before enabling Save, but the server is authoritative. |
|
||||
| T-18-13 | Information Disclosure | XSS via rendered timezone value | mitigate | The value is rendered as plain-text JSX children (no dangerouslySetInnerHTML), consistent with the existing AdminPage convention. IANA strings are constrained anyway. |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs; picker uses built-in Intl). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/pwa exec tsc --noEmit` green.
|
||||
- `pnpm --filter @familysync/pwa test -- --run` green.
|
||||
- playwright-cli round-trip (Task 3) confirms save + persist + detected-zone affordance.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin Timezone section reads/writes the household timezone and persists across reload (D-04).
|
||||
- Browser-detected zone offered for one-tap seeding (D-02).
|
||||
- No regression to other sections or the display/timed-write path (D-07).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Phase 18 — Plan Index & Coverage Audit
|
||||
|
||||
**Created:** 2026-06-15
|
||||
**Plans:** 4 (3 waves)
|
||||
**Mode:** standard · TDD mode ON · MVP off
|
||||
|
||||
---
|
||||
|
||||
## Wave Structure
|
||||
|
||||
| Wave | Plans | Autonomous | Files (disjoint per wave) |
|
||||
|------|-------|------------|----------------------------|
|
||||
| 1 | 18-01 (accessor + IANA validator, TDD) | yes | `lib/householdTimezone.ts` (+test) |
|
||||
| 2 | 18-02 (admin endpoints, TDD), 18-03 (broker wiring, TDD) | yes, yes | 02: `routes/admin.ts` (+test); 03: `broker/reminderScheduler.ts` + `broker/outboxWorker.ts` (+tests) — no overlap with 02 |
|
||||
| 3 | 18-04 (PWA picker UI) | no (human-verify checkpoint) | `pwa/api/client.ts`, `pwa/routes/AdminPage.tsx` |
|
||||
|
||||
Dependency rationale: 02 and 03 both depend only on the Wave-1 accessor and touch disjoint files →
|
||||
parallel in Wave 2. 04 depends on the 02 endpoint contract → Wave 3.
|
||||
|
||||
---
|
||||
|
||||
## Artifacts This Phase Produces (MANDATORY)
|
||||
|
||||
| Symbol / Artifact | Kind | File | Plan |
|
||||
|-------------------|------|------|------|
|
||||
| `getHouseholdTimezone(db)` | function (async, → Promise<string>) | `apps/api/src/lib/householdTimezone.ts` | 01 |
|
||||
| `isValidIanaTimezone(tz)` | function (→ boolean) | `apps/api/src/lib/householdTimezone.ts` | 01 |
|
||||
| `'household_timezone'` | app_config key string (new additive row, no migration) | `apps/api/src/db/schema.ts` appConfig (existing table) | 01/02 |
|
||||
| `GET /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
|
||||
| `PUT /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
|
||||
| `POST /api/admin/config/timezone/seed` | route (no-overwrite seed, D-02/D-03) | `apps/api/src/routes/admin.ts` | 02 |
|
||||
| `timezoneSchema` | Zod schema | `apps/api/src/routes/admin.ts` | 02 |
|
||||
| `fetchAdminTimezone()` | client fn | `apps/pwa/src/api/client.ts` | 04 |
|
||||
| `setAdminTimezone(timezone)` | client fn | `apps/pwa/src/api/client.ts` | 04 |
|
||||
| `AdminTimezoneResponse` | interface | `apps/pwa/src/api/client.ts` | 04 |
|
||||
| Timezone section (`aria-label="Timezone"`) | UI component/section | `apps/pwa/src/routes/AdminPage.tsx` | 04 |
|
||||
| `apps/api/tests/lib/householdTimezone.test.ts` | new test file | — | 01 |
|
||||
|
||||
Test files extended (not new): `apps/api/tests/routes/admin.test.ts` (02),
|
||||
`apps/api/tests/broker/reminderScheduler.test.ts` + `apps/api/tests/broker/outboxWorker.test.ts` (03).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Source Coverage Audit
|
||||
|
||||
### GOAL (ROADMAP Phase 18 goal)
|
||||
"Auto-detect the household timezone and allow changing it" → COVERED: detection seed (D-02, Plans 02 seed
|
||||
endpoint + 04 detected-zone affordance); change (D-04, Plans 02 PUT + 04 picker); behavioral wiring
|
||||
(D-05, Plan 03).
|
||||
|
||||
### REQ (phase_req_ids from REQUIREMENTS.md)
|
||||
No REQ-IDs are mapped to Phase 18 in ROADMAP.md (confirmed: "Requirements: TBD"). Per the planning
|
||||
context, the D-01..D-07 decision set is the coverage contract — see CONTEXT below. Not a gap.
|
||||
|
||||
### RESEARCH (18-RESEARCH.md features/constraints)
|
||||
- Single accessor `getHouseholdTimezone` + IANA validator → Plan 01.
|
||||
- GET/PUT endpoints on adminRouter, no new router → Plan 02.
|
||||
- Seed endpoint, additive/non-blocking (Phase 12 not yet executed) → Plan 02.
|
||||
- Rewire reminderScheduler:247 + outboxWorker:501,607 → Plan 03.
|
||||
- PWA picker, no new package, Intl-based → Plan 04.
|
||||
- No new npm installs (Package Legitimacy Audit: zero) → honored across all plans; T-18-SC = no-op.
|
||||
|
||||
### CONTEXT (D-01..D-07 — the trackable decision contract)
|
||||
|
||||
| Decision | Covered by | Where |
|
||||
|----------|-----------|-------|
|
||||
| D-01 single household app_config key `household_timezone` | Plan 01 (key literal), Plan 02 (upsert) | accessor + endpoints |
|
||||
| D-02 browser-detect + seed at first run | Plan 02 (seed endpoint), Plan 04 (detected-zone affordance) | endpoints + UI |
|
||||
| D-03 no auto-overwrite after seed | Plan 02 (seed writes only if unset) | seed endpoint |
|
||||
| D-04 change via role-gated /admin Settings | Plan 02 (requireAdmin endpoints), Plan 04 (Timezone section) | endpoints + UI |
|
||||
| D-05 stored TZ = source of truth, single accessor at both scheduler sites | Plan 01 (accessor), Plan 03 (both sites route through it) | accessor + broker |
|
||||
| D-06 fallback chain when unset | Plan 01 (verbatim fallback), Plan 03 (existing tests stay green) | accessor + broker |
|
||||
| D-07 do not touch display/timed-write path | Plan 03 + Plan 04 (boundary as acceptance criterion) | broker + UI |
|
||||
|
||||
**No unplanned items.** Deferred Ideas (per-member timezones; driving display/timed off stored TZ) are
|
||||
correctly absent from all plans.
|
||||
Reference in New Issue
Block a user