wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md
lorentz 1b80b3f7ab 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.
2026-05-07 07:37:25 -04:00

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
timezone
user-settings
iana
intl
better-auth
postgres
phase provides
07.1-user-timezone-fix-inserted-urgent (Plan 01) user.timezone column on "user" table; session.user.timezone typed string
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
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)
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
created modified
app/api/me/timezone/route.ts
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)
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
TZ-03
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.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": "<IANA zone>", "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":"<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:

  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