| 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 |
|
|
| 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 |
|
|
| 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:
- Static:
grep -nE '"/api/me' middleware.ts returns nothing.
- Static:
grep -E "Intl.supportedValuesOf\\('timeZone'\\)" app/api/me/timezone/route.ts returns one line.
- Static:
grep -E 'WHERE id = \$2' app/api/me/timezone/route.ts returns the PUT handler's UPDATE.
- Static:
grep -E 'userId|user_id' app/api/me/timezone/route.ts returns nothing (no cross-user write surface).
- 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).
- 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?).