docs(09-02): complete per-user API surface plan
- Theme GET/PUT, channels CRUD + test, notification-subscriptions matrix - All routes requireAuth(), session.user.id only write target - updated_at (not updatedAt) confirmed - tsc exits 0
This commit is contained in:
parent
55a80a07ad
commit
17a189e564
1 changed files with 144 additions and 0 deletions
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
phase: 09-user-profile-preferences-new
|
||||
plan: "02"
|
||||
subsystem: api-routes
|
||||
tags: [api, auth, channels, theme, notifications, phase-9]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "user.theme column (Plan 01 — migration 084)"
|
||||
- "notification_channels.owner_user_id + partial unique index (Plan 01 — migration 085)"
|
||||
- "notify_event_keys + user_event_subscriptions tables (Plan 01 — migration 086)"
|
||||
- "NotifyEventKey, UserEventSubscription, NotificationChannel types (Plan 01)"
|
||||
provides:
|
||||
- "GET + PUT /api/me/theme — { theme, source } and validated write"
|
||||
- "GET /api/me/channels — user's personal channels, camelCase"
|
||||
- "PUT /api/me/channels/[type] — UPSERT + best-effort test send"
|
||||
- "DELETE /api/me/channels/[type] — remove personal channel"
|
||||
- "POST /api/me/channels/[type]/test — re-send test to existing channel"
|
||||
- "GET + PUT /api/me/notification-subscriptions — full matrix + single row UPSERT"
|
||||
- "lib/services/personal-channels.ts — shared validation + test-send helpers"
|
||||
affects:
|
||||
- "Plans 03/04/05/06 — consume these endpoints and the personal-channels service"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "WITH-CTE UPSERT pattern scoped to (owner_user_id, channel_type) with 23505 → 409 fallback"
|
||||
- "Default-enabled matrix (row absence = true) for subscription opt-out model"
|
||||
- "Best-effort test send on every channel save — never blocks success"
|
||||
- "SSRF mitigation: Teams URL hostname allowlist via URL() parsing"
|
||||
- "Snake_case column names in DB, camelCase in API responses (manual transform)"
|
||||
key_files:
|
||||
created:
|
||||
- app/api/me/theme/route.ts
|
||||
- app/api/me/channels/route.ts
|
||||
- app/api/me/channels/[type]/route.ts
|
||||
- app/api/me/channels/[type]/test/route.ts
|
||||
- app/api/me/notification-subscriptions/route.ts
|
||||
- lib/services/personal-channels.ts
|
||||
modified: []
|
||||
decisions:
|
||||
- "theme source='default' when stored value is 'system' (matches /api/me/timezone semantic)"
|
||||
- "WITH-CTE UPSERT (not INSERT ... ON CONFLICT) because the partial unique index only fires on INSERT — the CTE update arm is the preferred path, with INSERT as fallback when no existing row"
|
||||
- "sendChannelTest does NOT log channel.config to avoid leaking webhook URLs / auth tokens in server logs"
|
||||
- "Custom ntfy topic 400 response body carries both error and message keys so Plan 05 UI can render inline error below the input"
|
||||
metrics:
|
||||
duration_minutes: 15
|
||||
completed_date: "2026-05-10"
|
||||
tasks_completed: 3
|
||||
files_created: 6
|
||||
files_modified: 0
|
||||
---
|
||||
|
||||
# Phase 9 Plan 02: Per-User API Surface Summary
|
||||
|
||||
One-liner: Six new files deliver the complete /api/me/* API surface for theme, personal channels (Teams + ntfy), and notification subscription matrix — all gated by requireAuth(), writing only session.user.id, with Teams SSRF prevention and default-enabled subscription fallback.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | GET + PUT /api/me/theme | dc5dc91 | app/api/me/theme/route.ts |
|
||||
| 2 | Personal channels service + /api/me/channels routes | c35b968 | lib/services/personal-channels.ts, app/api/me/channels/route.ts, app/api/me/channels/[type]/route.ts, app/api/me/channels/[type]/test/route.ts |
|
||||
| 3 | GET + PUT /api/me/notification-subscriptions | 55a80a0 | app/api/me/notification-subscriptions/route.ts |
|
||||
|
||||
## Route Files
|
||||
|
||||
### app/api/me/theme/route.ts
|
||||
- **Exports:** `GET`, `PUT`
|
||||
- **GET:** `SELECT theme FROM "user" WHERE id = $1` → `{ theme: 'light'|'dark'|'system', source: 'user'|'default' }`. Source is `'user'` when stored value differs from `'system'` (the default).
|
||||
- **PUT:** Validates `body.theme` against `ALLOWED_THEMES = new Set(['light', 'dark', 'system'])`. Returns 400 on invalid value. `UPDATE "user" SET theme = $1, updated_at = NOW() WHERE id = $2 RETURNING theme` — uses `updated_at` (snake_case, unquoted, matches migration 012). Does NOT contain `"updatedAt"`.
|
||||
- **Auth:** `requireAuth()` on both handlers. Write target: `session!.user.id` only.
|
||||
|
||||
### app/api/me/channels/route.ts
|
||||
- **Exports:** `GET`
|
||||
- **GET:** `SELECT ... FROM notification_channels WHERE owner_user_id = $1 ORDER BY channel_type ASC` → `{ channels: [...camelCase rows] }`. Scoped strictly to calling user.
|
||||
|
||||
### app/api/me/channels/[type]/route.ts
|
||||
- **Exports:** `PUT`, `DELETE`
|
||||
- **PUT teams:** Validates `webhook_url` via `isValidTeamsWebhookUrl`. Name: `"Personal Teams (<email>)"`. Config: `{ webhook_url }`.
|
||||
- **PUT ntfy:** Validates custom topic via `isValidNtfyTopic` if supplied; otherwise mints via `mintNtfyTopic()`. Name: `"Personal ntfy (<email>)"`. Config: `{ server_url: 'https://ntfy.sh', topic }`.
|
||||
- **PUT UPSERT SQL:** WITH-CTE pattern — UPDATE existing row if present, INSERT if not; params `[$1=owner_user_id, $2=channel_type, $3=name, $4=config]`. On `23505` unique_violation → 409 `{ error: 'Conflict', message: '...' }`.
|
||||
- **PUT response:** `{ channel: <camelCase row>, test: <ChannelTestResult> }`.
|
||||
- **DELETE SQL:** `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id` → 404 if rowCount=0, else `{ deleted: true, channelType }`.
|
||||
- **Auth:** `requireAuth()` on both. No `userId`/`user_id`/`owner_user_id` accepted from body.
|
||||
|
||||
### app/api/me/channels/[type]/test/route.ts
|
||||
- **Exports:** `POST`
|
||||
- **POST:** `SELECT ... WHERE owner_user_id = $1 AND channel_type = $2` → 404 if missing. Calls `sendChannelTest(channel)` → `{ test: ChannelTestResult }`. Returns 200 even on test failure.
|
||||
|
||||
### app/api/me/notification-subscriptions/route.ts
|
||||
- **Exports:** `GET`, `PUT`
|
||||
- **GET implementation:** Three sequential queries:
|
||||
1. `SELECT key, display_label, description, sort_order FROM notify_event_keys WHERE is_active = true ORDER BY sort_order ASC, key ASC`
|
||||
2. `SELECT channel_type FROM notification_channels WHERE owner_user_id = $1 AND is_active = true`
|
||||
3. `SELECT event_key, channel_type, enabled FROM user_event_subscriptions WHERE user_id = $1`
|
||||
- Builds matrix defaulting to `true` when no row exists (D-15 opt-out model).
|
||||
- **GET response shape:** `{ eventKeys: [{key, displayLabel, description, sortOrder}], channelTypes: string[], matrix: { [event_key]: { [channel_type]: boolean } } }`
|
||||
- **PUT validation:** `event_key` non-empty string ≤ 128 chars; `channel_type` via `isPersonalChannelType`; `enabled` via `typeof === 'boolean'` (400 on failure).
|
||||
- **PUT SQL:** `INSERT INTO user_event_subscriptions ... ON CONFLICT (user_id, event_key, channel_type) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW() RETURNING ...`
|
||||
- **PUT response:** `{ subscription: { eventKey, channelType, enabled } }`
|
||||
|
||||
## lib/services/personal-channels.ts — Exported Symbols
|
||||
|
||||
| Symbol | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `TEST_MESSAGE_BODY` | `const string` | `'Pulse channel verified — you can ignore this message.'` |
|
||||
| `isValidTeamsWebhookUrl` | `(input: unknown) => input is string` | https:// + hostname must match *.webhook.office.com or *.logic.azure.com |
|
||||
| `isValidNtfyTopic` | `(input: unknown) => input is string` | Regex `^[A-Za-z0-9_-]{6,64}$` |
|
||||
| `mintNtfyTopic` | `() => string` | Returns `pulse-XXXXXXXX` (8 hex chars from crypto.randomUUID()) |
|
||||
| `sendChannelTest` | `(channel: NotificationChannel) => Promise<ChannelTestResult>` | Best-effort test send to teams or ntfy |
|
||||
| `isPersonalChannelType` | `(t: unknown) => t is 'teams' \| 'ntfy'` | Type guard for allowed personal channel types |
|
||||
| `PERSONAL_CHANNEL_TYPES` | `ChannelType[]` | `['teams', 'ntfy']` |
|
||||
|
||||
## Security Confirmations
|
||||
|
||||
- **No route accepts userId/user_id/owner_user_id from request input.** All writes are scoped to `session!.user.id` from `requireAuth()`.
|
||||
- **Theme route uses `updated_at` (NOT `"updatedAt"`).** Verified: the file contains `updated_at = NOW()` and does not contain the quoted camelCase identifier.
|
||||
- **Teams SSRF prevention:** `isValidTeamsWebhookUrl` uses `new URL(input)` to parse then checks `u.protocol === 'https:'` and hostname against three host patterns. Internal hosts cannot match.
|
||||
- **Custom ntfy topic 400 shape:** `{ error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }` — Plan 05 UI renders `message` inline below the custom-topic Input.
|
||||
- **sendChannelTest does not log channel.config** — only logs channel id/type on failure paths.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new network endpoints, auth paths, file access patterns, or schema changes beyond what was declared in the plan's threat model. All ten threats documented (T-09-02-01 through T-09-02-10) are addressed by the implementation.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Files exist:
|
||||
- app/api/me/theme/route.ts: FOUND
|
||||
- app/api/me/channels/route.ts: FOUND
|
||||
- app/api/me/channels/[type]/route.ts: FOUND
|
||||
- app/api/me/channels/[type]/test/route.ts: FOUND
|
||||
- app/api/me/notification-subscriptions/route.ts: FOUND
|
||||
- lib/services/personal-channels.ts: FOUND
|
||||
|
||||
Commits exist:
|
||||
- dc5dc91: FOUND (Task 1 — theme route)
|
||||
- c35b968: FOUND (Task 2 — channels service + routes)
|
||||
- 55a80a0: FOUND (Task 3 — notification-subscriptions route)
|
||||
|
||||
TypeScript: `npx tsc --noEmit --pretty` exit 0 — no errors.
|
||||
Loading…
Add table
Add a link
Reference in a new issue