wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-PLAN.md
lorentz f9ab954518 docs(phase-07.1): plan urgent user timezone fix (TZ-01..TZ-04)
Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters
rendering wrong dates because day/week boundary math runs in server UTC
instead of the viewing user's timezone. Persistence stays UTC; only the
read/display path changes.

5 plans in 3 waves:
- 07.1-01 (Wave 1): migration 083 + Better Auth additionalField timezone
- 07.1-02 (Wave 1): /api/me/timezone GET+PUT with IANA validation
- 07.1-03 (Wave 2): server-side AT TIME ZONE migration across 6 routes,
  including auth-gate fix on /api/mobile/finance and trends route
- 07.1-04 (Wave 2): useUserTimezone() hook + 2 mobile pages + codebase audit
- 07.1-05 (Wave 3): codebase-wide useUserTimezone() adoption per audit

Add Phase 9 stub (User Profile & Preferences) to roadmap for the picker UI
that reuses 7.1's hook + endpoint.

REQUIREMENTS.md TZ-02 carves out engagement_snapshots UTC bucketing as a
documented exception (≤24h drift acceptable for admin overview).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:02:07 -04:00

18 KiB

phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
07.1-user-timezone-fix-inserted-urgent 02 execute 1
app/api/me/timezone/route.ts
middleware.ts
true
TZ-03
TZ-03
truths artifacts key_links
Authenticated GET /api/me/timezone returns { timezone: string, source: 'user' | 'default' }
Authenticated PUT /api/me/timezone with a valid IANA tz persists to the calling user's row and returns the new value
PUT with an invalid tz string returns 400 (rejected via Intl.supportedValuesOf('timeZone'))
Unauthenticated GET or PUT returns 401 (via requireAuth())
PUT only updates the calling user's row — no userId parameter is accepted
/api/me/* is NOT in middleware.ts publicRoutes
path provides exports
app/api/me/timezone/route.ts GET + PUT /api/me/timezone handlers
GET
PUT
path provides contains
middleware.ts Confirms /api/me/* is excluded from publicRoutes (no change needed unless an audit reveals it leaked in) publicRoutes
from to via pattern
app/api/me/timezone/route.ts lib/auth-utils.ts requireAuth() import { requireAuth } from '@/lib/auth-utils' import.*requireAuth.*from.*auth-utils
from to via pattern
PUT handler UPDATE user SET timezone WHERE id = session.user.id session-scoped UPDATE UPDATE "user" SET timezone
from to via pattern
PUT validation Intl.supportedValuesOf timeZone whitelist runtime IANA whitelist Intl.supportedValuesOf
Ship the authenticated GET + PUT endpoint for a user's timezone. This is the read/write surface that the (Phase 9) timezone picker UI will eventually call; in 7.1 it's API-only — admins / curl can set tz before the UI lands. Validation uses Intl.supportedValuesOf('timeZone') so callers can't store an arbitrary string that would crash toLocaleString downstream.

Purpose: Resolve TZ-03. Provide the only writeable surface for user.timezone — no other code path mutates this column. Output: New app/api/me/timezone/route.ts exporting GET and PUT.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @CLAUDE.md @lib/auth-utils.ts @lib/services/postgres-client.ts @middleware.ts @app/api/mobile/engagement/summary/route.ts Existing helpers in this codebase that the new route must use verbatim:
  • requireAuth() from @/lib/auth-utils returns { session, error }. When unauthenticated, error is a NextResponse with status 401 — return it as-is.
  • postgresClient from @/lib/services/postgres-client exposes query<RowShape>(sql, params) returning a pg QueryResult. There is no ORM.
  • After Plan 01, session.user.timezone is typed string and session.user.id is string (TEXT primary key in the "user" table).
  • API response convention: NextResponse.json({ error: 'short', message: 'detail' }, { status: N }) for failures; bare NextResponse.json(payload) for success. No Zod.

middleware.ts publicRoutes list (lines 6-43 at planning time): NONE of the entries is a prefix of /api/me/..., so the route is correctly auth-gated by the existing middleware + requireAuth() combo.

Task 1: Confirm middleware.ts does not whitelist /api/me middleware.ts - middleware.ts (the publicRoutes array, lines 6-43 — verify no entry has a prefix that would match /api/me; specifically look at every string and confirm none is a prefix of /api/me/timezone) Read middleware.ts and confirm by inspection that none of the publicRoutes entries is a prefix of /api/me. The current list (verified at planning time) contains entries like /api/auth, /api/webhooks, /api/kiosk, /api/mobile, /api/sync, etc. — none of which match /api/me/.
Action: NO file changes are required. Run a verification grep to PROVE no
entry is a prefix of /api/me:

    grep -nE '"/api/me' middleware.ts

The grep MUST return zero matches. If it does match, STOP — that's a
surprise that needs investigation before Task 2 (some past commit may have
whitelisted /api/me which would defeat the auth gate).

If grep returns zero matches: do not edit middleware.ts. The next task can
proceed knowing the route handler's own requireAuth() is the authoritative
auth gate.
! grep -nE '"/api/me' middleware.ts - Command `grep -nE '"/api/me' middleware.ts` exits non-zero (no matches) - middleware.ts is unchanged (`git diff --quiet middleware.ts`) Confirmed by automated grep that /api/me/* is NOT exempt from auth in middleware.ts. Plan 02 Task 2 may proceed knowing the route handler's own requireAuth() is the authoritative gate. Task 2: Create app/api/me/timezone/route.ts (GET + PUT) app/api/me/timezone/route.ts - lib/auth-utils.ts (`requireAuth()` at lines 31-45 — copy the call shape exactly: `const { session, error } = await requireAuth(); if (error) return error;`) - lib/services/postgres-client.ts (the `query()` method signature; this codebase uses `postgresClient.query(sql, params)` and gets back a `QueryResult`) - app/api/mobile/engagement/summary/route.ts (canonical Pulse API route shape: imports, requireAuth, parametrized query, NextResponse.json with `error`/`message` envelope on failure, no Zod) - CLAUDE.md ("API routes" section: no Zod, manual try/catch, status code conventions — 401 from auth helper, 400 for bad input, 500 for runtime, 503 for missing config) Create the new file `app/api/me/timezone/route.ts` with EXACTLY the following content. No Zod. Manual validation. Matches the engagement/summary route shape.
    import { NextRequest, NextResponse } from 'next/server';
    import { requireAuth } from '@/lib/auth-utils';
    import { postgresClient } from '@/lib/services/postgres-client';

    // GET  /api/me/timezone   -> { timezone: string, source: 'user' | 'default' }
    // PUT  /api/me/timezone   -> body { timezone: string } -> { timezone: string }
    //
    // TZ-03. Authentication: requireAuth(). The PUT handler updates ONLY the
    // calling user's row — there is no `userId` query param or body field. The
    // write target is always `session.user.id`.
    //
    // Validation: the input timezone must appear in
    // `Intl.supportedValuesOf('timeZone')`. Anything else is rejected with 400
    // before touching the database.

    function getDefaultTimezone(): string {
      return process.env.DEFAULT_TIMEZONE || 'UTC';
    }

    function isValidIanaTimezone(tz: unknown): tz is string {
      if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false;
      try {
        const zones = Intl.supportedValuesOf('timeZone');
        return zones.includes(tz);
      } catch {
        return false;
      }
    }

    export async function GET(): Promise<NextResponse> {
      const { session, error } = await requireAuth();
      if (error) return error;

      try {
        const result = await postgresClient.query<{ timezone: string | null }>(
          'SELECT timezone FROM "user" WHERE id = $1',
          [session!.user.id],
        );
        const stored = result.rows[0]?.timezone;
        const fallback = getDefaultTimezone();
        const timezone = stored && stored.length > 0 ? stored : fallback;
        const source: 'user' | 'default' =
          stored && stored.length > 0 && stored !== fallback ? 'user' : 'default';
        return NextResponse.json({ timezone, source });
      } catch (e) {
        console.error('GET /api/me/timezone failed:', e);
        return NextResponse.json(
          { error: 'Failed to read timezone', message: e instanceof Error ? e.message : 'unknown' },
          { status: 500 },
        );
      }
    }

    export async function PUT(request: NextRequest): Promise<NextResponse> {
      const { session, error } = await requireAuth();
      if (error) return error;

      let body: unknown;
      try {
        body = await request.json();
      } catch {
        return NextResponse.json(
          { error: 'Invalid JSON', message: 'Request body must be JSON' },
          { status: 400 },
        );
      }

      const candidate =
        body && typeof body === 'object' && 'timezone' in body
          ? (body as { timezone: unknown }).timezone
          : undefined;

      if (!isValidIanaTimezone(candidate)) {
        return NextResponse.json(
          {
            error: 'Invalid timezone',
            message: "timezone must be an IANA zone present in Intl.supportedValuesOf('timeZone')",
          },
          { status: 400 },
        );
      }

      try {
        // Authoritative write target: session.user.id. NO userId from body.
        const result = await postgresClient.query<{ timezone: string }>(
          'UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2 RETURNING timezone',
          [candidate, session!.user.id],
        );
        if (result.rowCount === 0) {
          return NextResponse.json(
            { error: 'User not found', message: 'No user row matched the session' },
            { status: 404 },
          );
        }
        return NextResponse.json({ timezone: result.rows[0].timezone });
      } catch (e) {
        console.error('PUT /api/me/timezone failed:', e);
        return NextResponse.json(
          { error: 'Failed to update timezone', message: e instanceof Error ? e.message : 'unknown' },
          { status: 500 },
        );
      }
    }

Notes:
- The route is `/api/me/timezone` (matches the orchestrator's spec and the
  Task 1 audit).
- `Intl.supportedValuesOf('timeZone')` is called per request. It returns a
  static ~600-entry array; V8 caches internally. No module-scope memo
  needed (would also miss tzdata updates between Node restarts).
- The `source` field on GET helps the future Phase 9 picker show "(default)".
  A row equal to the env default is reported as 'default' even if it was a
  no-op write — intentional and acceptable.
- 64-char length cap is belt-and-suspenders before the IANA whitelist.
- Do NOT add Zod (Pulse convention, CLAUDE.md API routes section).
- UPDATE writes `updated_at = NOW()` to match audit-column conventions used
  throughout the codebase.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ] && grep -q "export async function GET" app/api/me/timezone/route.ts && grep -q "export async function PUT" app/api/me/timezone/route.ts && grep -q "Intl.supportedValuesOf('timeZone')" app/api/me/timezone/route.ts && grep -q 'UPDATE "user" SET timezone' app/api/me/timezone/route.ts - File exists at exact path `app/api/me/timezone/route.ts` - File exports `GET` (no params) and `PUT` (NextRequest param) - File imports `requireAuth` from `@/lib/auth-utils` and uses it as the FIRST line of each handler - File contains the literal `Intl.supportedValuesOf('timeZone')` - File contains the literal `UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2` - File contains NO `userId` parameter parsing — only `session.user.id` is used as the WHERE id target - File does NOT import `zod` or `z` from `zod` - `npx tsc --noEmit --pretty` reports no NEW errors in this file - Behavioral (manual once running): - `curl -X GET http://localhost:3100/api/me/timezone` (no cookie) returns HTTP 401 - `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"Etc/Garbage"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 400 - `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"America/New_York"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 200 with `{"timezone":"America/New_York"}` - `curl -X GET http://localhost:3100/api/me/timezone` (with valid cookie, after the PUT above) returns HTTP 200 with `{"timezone":"America/New_York","source":"user"}` GET /api/me/timezone returns the calling user's stored tz (or env default) with a `source` discriminator. PUT validates the input against `Intl.supportedValuesOf('timeZone')`, persists only to `session.user.id`'s row, and returns the stored value. Unauthenticated calls return 401. Invalid tz strings return 400. The route is the SOLE write surface for `user.timezone`.

<threat_model>

Trust Boundaries

Boundary Description
Browser → API route Untrusted JSON body crosses here on PUT
Session cookie → handler Better Auth cookie carries the authoritative user identity
Handler → Postgres Parametrized writes; the handler's id parameter MUST come from the verified session, never from the request body

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07.1-02-01 Tampering PUT body — arbitrary timezone string mitigate isValidIanaTimezone() rejects anything not in Intl.supportedValuesOf('timeZone') and anything longer than 64 chars; returns 400 before any DB call.
T-07.1-02-02 Spoofing Cross-user write (PUT updating someone else's row) mitigate UPDATE WHERE clause uses session!.user.id exclusively. The handler does NOT read or accept any userId field from query string, body, or headers. Test: a request body of {"timezone":"Etc/UTC","userId":"someone-else"} writes to the caller's own row only.
T-07.1-02-03 Information Disclosure Unauthenticated read of user's tz mitigate requireAuth() is the FIRST statement of GET. Returns 401 without touching the DB.
T-07.1-02-04 Denial of Service Repeated PUTs spamming the user table accept Rate limiting is out of scope for this phase; Pulse has no global rate limiter today. The UPDATE is O(1) on a tiny table. If abuse becomes a concern, add a per-session limiter in a follow-up.
T-07.1-02-05 Repudiation Audit of who set what tz accept updated_at = NOW() records when the change happened. We do NOT log the old→new value pair; user-controlled timezone is low-sensitivity.
T-07.1-02-06 Elevation of Privilege An admin endpoint masquerading as /api/me accept Route lives at the user-self path; no admin-targeted user-id parameter is accepted, so there is no role-confusion surface here.
T-07.1-02-07 Tampering SQL injection via timezone string mitigate Parameterized query ($1, $2); the value is also pre-validated against the IANA whitelist (no injection-shaped strings will pass Intl.supportedValuesOf membership).
T-07.1-02-08 Tampering JSON parse errors crashing the handler mitigate try { await request.json() } catch returns a 400 on invalid JSON instead of letting the framework return a 500.
T-07.1-02-09 Information Disclosure Middleware leaking /api/me as public mitigate Task 1 audits middleware.ts and asserts no publicRoutes entry prefixes /api/me.
</threat_model>
End-to-end checks for this plan:
  1. Static: grep -nE '"/api/me' middleware.ts returns nothing.
  2. Static: grep -E "Intl.supportedValuesOf\\('timeZone'\\)" app/api/me/timezone/route.ts returns one line.
  3. Static: grep -E 'WHERE id = \$2' app/api/me/timezone/route.ts returns the PUT handler's UPDATE.
  4. Static: grep -E 'userId|user_id' app/api/me/timezone/route.ts returns nothing (no cross-user write surface).
  5. Type: ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ] (TS errors in this file fail the check; pre-existing errors elsewhere in the codebase are out of scope for Phase 7.1).
  6. Runtime (with the dev server running and a logged-in cookie in curl):
    • GET unauthenticated → 401 JSON {"error":"Unauthorized"}
    • PUT with {"timezone":"Etc/Garbage"} → 400 JSON {"error":"Invalid timezone",...}
    • PUT with {"timezone":"America/New_York"} → 200 JSON {"timezone":"America/New_York"}
    • GET after the successful PUT → 200 JSON {"timezone":"America/New_York","source":"user"}

<success_criteria>

  • New app/api/me/timezone/route.ts exports working GET and PUT handlers
  • Validation rejects non-IANA strings with HTTP 400
  • Auth gates reject unauthenticated requests with HTTP 401
  • Write target is exclusively session.user.id — no user-supplied id parameter
  • middleware.ts is unchanged and confirmed not to leak /api/me/* to publicRoutes
  • TypeScript compiles for the new file </success_criteria>
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md` documenting: the exact response shapes for GET and PUT, the validation rule (IANA whitelist + length cap), the threat-model dispositions actually implemented, and any deviation from the plan (e.g. did the middleware audit turn up something unexpected?).