SUMMARY.md documenting GET/PUT /api/me/timezone shapes, IANA validation rule, threat-model dispositions, and self-check results.
9.3 KiB
9.3 KiB
| phase | plan | subsystem | tags | requires | provides | affects | tech-stack | key-files | key-decisions | patterns-established | requirements-completed | duration | completed | |||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.1-user-timezone-fix-inserted-urgent | 02 | api |
|
|
|
|
|
|
|
|
|
1min | 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.tsexportingGETandPUThandlers - Both handlers gated by
requireAuth()fromlib/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 — nouserIdparameter 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.tsdoes not whitelist/api/me/*(Task 1 grep audit returned zero matches)
Task Commits
- Task 1: Confirm middleware.ts does not whitelist /api/me — no commit (no file changes; verified by grep audit)
- 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"}(fromrequireAuth()) - 200 success:
{"timezone": "<IANA zone>", "source": "user" | "default"}source: 'user'when the row'stimezonecolumn is non-empty AND not equal to the env defaultsource: 'default'when the column is empty/null OR equal toprocess.env.DEFAULT_TIMEZONE(falls back to'UTC')
- 500 on DB error:
{"error":"Failed to read timezone","message":"<detail>"}
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": "<echo of accepted IANA zone>"} - 500 on DB error:
{"error":"Failed to update timezone","message":"<detail>"}
Validation Rule
A candidate timezone is accepted iff ALL hold:
typeof === 'string'length > 0length <= 64(belt-and-suspenders before whitelist scan)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. sourcediscriminator 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 canORDER BY updated_at DESCwithout a separatetz_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 basebee35e0. Resolved withgit reset --hard bee35e0per 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.timezoneknowing PUT is the only write surface and the value is IANA-valid. - Phase 9 timezone picker UI has a stable contract:
GET { timezone, source }andPUT { timezone }.
Self-Check
- File created:
/opt/stacks/pulse/app/api/me/timezone/route.ts— FOUND - Commit
f50215fexists 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'srequireAuth)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