chore: merge 07.1-02 worktree commits
This commit is contained in:
commit
3a3564fc91
2 changed files with 265 additions and 0 deletions
|
|
@ -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": "<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*
|
||||
103
app/api/me/timezone/route.ts
Normal file
103
app/api/me/timezone/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue