From 1b80b3f7abc861c05e556d3d59377d6cc9834966 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 07:37:25 -0400 Subject: [PATCH] docs(07.1-02): complete user timezone endpoint plan SUMMARY.md documenting GET/PUT /api/me/timezone shapes, IANA validation rule, threat-model dispositions, and self-check results. --- .../07.1-02-SUMMARY.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md new file mode 100644 index 0000000..4fe82db --- /dev/null +++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md @@ -0,0 +1,162 @@ +--- +phase: 07.1-user-timezone-fix-inserted-urgent +plan: 02 +subsystem: api + +tags: [timezone, user-settings, iana, intl, better-auth, postgres] + +# Dependency graph +requires: + - phase: 07.1-user-timezone-fix-inserted-urgent (Plan 01) + provides: "user.timezone column on \"user\" table; session.user.timezone typed string" +provides: + - GET /api/me/timezone (read calling user's stored tz with `source` discriminator) + - PUT /api/me/timezone (write calling user's tz, IANA-validated) + - Sole writeable surface for user.timezone +affects: + - 07.1 Plan 03 (mobile timezone formatter — reads user.timezone via session) + - 07.1 Plan 04 (server-side timezone formatter — same) + - Phase 9 (future timezone picker UI in profile/account drawer — calls these endpoints) + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Authenticated user-self API at /api/me/* — write target derived from session, never request body" + - "IANA timezone whitelist via Intl.supportedValuesOf('timeZone') as runtime guard" + +key-files: + created: + - app/api/me/timezone/route.ts + modified: [] + +key-decisions: + - "Validation uses Intl.supportedValuesOf('timeZone') per request — V8 caches internally, ~600 entries, no module-scope memo so tzdata updates take effect on Node restart without redeploy logic" + - "GET response includes a `source: 'user' | 'default'` discriminator so the future Phase 9 picker can render '(default)' without a second round-trip" + - "60-char belt-and-suspenders length cap (64) before whitelist check — bounds memory in adversarial JSON before the includes() scan" + - "Manual JSON parse in try/catch returns 400 on bad JSON (not framework's 500)" + - "UPDATE writes updated_at = NOW() to match Pulse audit-column conventions, even though row creation isn't happening here" + - "No userId field accepted from body or query — write target is exclusively session.user.id (T-07.1-02-02 mitigation)" + +patterns-established: + - "User-self endpoint shape: requireAuth() first → parse body → validate → parametrized UPDATE WHERE id = session.user.id" + - "Validation envelope: NextResponse.json({ error: 'short', message: 'detail' }, { status: 400 }) before DB call" + +requirements-completed: [TZ-03] + +# Metrics +duration: 1min +completed: 2026-05-07 +--- + +# Phase 07.1 Plan 02: User Timezone Endpoint Summary + +**Authenticated GET + PUT /api/me/timezone with IANA whitelist validation and session-scoped writes — sole writeable surface for user.timezone.** + +## Performance + +- **Duration:** ~1 min +- **Started:** 2026-05-07T11:35:02Z +- **Completed:** 2026-05-07T11:36:08Z +- **Tasks:** 2 +- **Files created:** 1 +- **Files modified:** 0 + +## Accomplishments + +- New `app/api/me/timezone/route.ts` exporting `GET` and `PUT` handlers +- Both handlers gated by `requireAuth()` from `lib/auth-utils.ts` (401 unauthenticated, no DB hit) +- Input validation via `Intl.supportedValuesOf('timeZone')` whitelist + 64-char length cap (400 on miss) +- PUT writes only to `session.user.id`'s row — no `userId` parameter accepted from any source +- Manual JSON parse with try/catch → 400 on invalid JSON (not framework 500) +- Updates `updated_at = NOW()` per Pulse audit-column convention +- Confirmed `middleware.ts` does not whitelist `/api/me/*` (Task 1 grep audit returned zero matches) + +## Task Commits + +1. **Task 1: Confirm middleware.ts does not whitelist /api/me** — no commit (no file changes; verified by grep audit) +2. **Task 2: Create app/api/me/timezone/route.ts (GET + PUT)** — `f50215f` (feat) + +## Files Created/Modified + +- `app/api/me/timezone/route.ts` — GET + PUT handlers for the calling user's timezone (created) + +## Response Shapes + +**GET /api/me/timezone** +- 401 unauthenticated: `{"error":"Unauthorized"}` (from `requireAuth()`) +- 200 success: `{"timezone": "", "source": "user" | "default"}` + - `source: 'user'` when the row's `timezone` column is non-empty AND not equal to the env default + - `source: 'default'` when the column is empty/null OR equal to `process.env.DEFAULT_TIMEZONE` (falls back to `'UTC'`) +- 500 on DB error: `{"error":"Failed to read timezone","message":""}` + +**PUT /api/me/timezone** +- 401 unauthenticated: `{"error":"Unauthorized"}` +- 400 invalid JSON: `{"error":"Invalid JSON","message":"Request body must be JSON"}` +- 400 invalid timezone: `{"error":"Invalid timezone","message":"timezone must be an IANA zone present in Intl.supportedValuesOf('timeZone')"}` +- 404 user row not found (session points to a deleted row): `{"error":"User not found","message":"No user row matched the session"}` +- 200 success: `{"timezone": ""}` +- 500 on DB error: `{"error":"Failed to update timezone","message":""}` + +## Validation Rule + +A candidate `timezone` is accepted iff ALL hold: +1. `typeof === 'string'` +2. `length > 0` +3. `length <= 64` (belt-and-suspenders before whitelist scan) +4. `Intl.supportedValuesOf('timeZone').includes(timezone)` (runtime IANA whitelist; ~600 entries; V8-internal cache) + +## Threat Model Dispositions (Implemented) + +| Threat ID | Disposition | Implementation | +|-----------|-------------|----------------| +| T-07.1-02-01 (Tampering — arbitrary tz string) | mitigate | `isValidIanaTimezone()` length cap + `Intl.supportedValuesOf` whitelist check before any DB call | +| T-07.1-02-02 (Spoofing — cross-user write) | mitigate | UPDATE WHERE id = `session!.user.id`; no `userId` field is read from body, query, or headers (verified by `grep -nE 'userId\|user_id'` — only matches are explanatory comments) | +| T-07.1-02-03 (Info disclosure — unauth read) | mitigate | `requireAuth()` is the FIRST statement of GET; returns 401 without touching DB | +| T-07.1-02-04 (DoS — PUT spam) | accept | No rate limiter introduced (out of scope for 7.1; UPDATE is O(1)) | +| T-07.1-02-05 (Repudiation — audit) | accept | `updated_at = NOW()` records when, not old/new pair | +| T-07.1-02-06 (EoP — admin masquerade) | accept | Route lives at user-self path; no admin-targeted parameter surface | +| T-07.1-02-07 (Tampering — SQL injection) | mitigate | Parameterized query (`$1`, `$2`); whitelist precludes injection-shaped strings reaching the driver | +| T-07.1-02-08 (Tampering — JSON parse crash) | mitigate | `try { await request.json() } catch` returns 400 on invalid JSON | +| T-07.1-02-09 (Info disclosure — middleware leak of /api/me) | mitigate | Task 1 audited middleware.ts; `grep -nE '"/api/me' middleware.ts` returned zero matches | + +## Decisions Made + +- **Per-request `Intl.supportedValuesOf('timeZone')`** — chose runtime call over module-scope memoization. Trade: ~600-entry array allocation per request vs. potential staleness if tzdata updates between Node restarts. V8's internal cache makes the cost negligible. +- **`source` discriminator on GET** — added because a future picker UI needs to distinguish "user explicitly set" from "fell back to env default" without another endpoint. A row equal to the env default is reported as `'default'` even after a no-op explicit write — intentional and acceptable; the user got what they asked for and "default" remains accurate. +- **64-char length cap** — bound memory before the whitelist scan. Longest legitimate IANA zone is ~30 chars; 64 leaves comfortable headroom while rejecting pathological inputs cheaply. +- **`updated_at = NOW()` on UPDATE** — match Pulse audit conventions even though Plan 01 already added the column. Future audit queries can `ORDER BY updated_at DESC` without a separate `tz_updated_at`. + +## Deviations from Plan + +None — plan executed exactly as written. The Task 1 middleware audit returned zero matches as predicted by the planner (no surprises). The route file matches the plan's literal source verbatim. + +## Issues Encountered + +- **Worktree branch base mismatch (pre-execution).** The worktree's HEAD was at `db375fb` (a master commit) instead of the expected base `bee35e0`. Resolved with `git reset --hard bee35e0` per the worktree branch check protocol — no lost work because all changes on the prior HEAD were tracked on master and unrelated to this plan. Documented for the orchestrator's awareness; it does not affect this plan's correctness. + +## User Setup Required + +None — endpoint is online once the route file is deployed. No env-var changes required (the route honors `DEFAULT_TIMEZONE` if set, falls back to `'UTC'`, but Plan 01 already wired this). + +## Next Phase Readiness + +- Plans 03 (server-side formatter) and 04 (mobile formatter) can now read `session.user.timezone` knowing PUT is the only write surface and the value is IANA-valid. +- Phase 9 timezone picker UI has a stable contract: `GET { timezone, source }` and `PUT { timezone }`. + +## Self-Check + +- File created: `/opt/stacks/pulse/app/api/me/timezone/route.ts` — FOUND +- Commit `f50215f` exists in git log — FOUND +- Type-check: no NEW errors in `app/api/me/timezone/route.ts` +- Static checks pass: + - `grep -nE '"/api/me' middleware.ts` → no matches (auth gate is the route handler's `requireAuth`) + - `grep -E "Intl.supportedValuesOf\('timeZone'\)" app/api/me/timezone/route.ts` → 3 matches (comment, runtime call, error message) + - `grep -E 'WHERE id = \$2' app/api/me/timezone/route.ts` → 1 match (PUT UPDATE) + - `grep -nE 'userId|user_id' app/api/me/timezone/route.ts` → only matches are in comments (no code parameter surface) + +## Self-Check: PASSED + +--- +*Phase: 07.1-user-timezone-fix-inserted-urgent* +*Completed: 2026-05-07*