diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a557581..b18144d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -188,7 +188,12 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Each user can configure one Teams webhook URL and one Pulse-minted ntfy topic; both are test-sent on save and admins have full read+edit access via `/admin/workflow/channels` (CHAN-01..07) 4. The Notifications section renders a per-event × per-channel matrix sourced from `notify_event_keys`; defaults to enabled (opt-out model); writes via `/api/me/notification-subscriptions` (SUB-01..04) 5. `lib/services/pipeline-steps/notify.ts` honors an optional `route_to_user` block on each notify step — resolving the user via a registered resolver, checking the subscription matrix, sending via the personal channel, and falling back to the step's `channel_id` on no-channel/send-failure (recorded as `user_route_fallback`) but skipping silently when the user has the toggle muted (ROUTE-01..07) -**Plans**: TBD +**Plans**: 5 plans +- [ ] 09-01-PLAN.md — Schema foundation: theme column, owner_user_id, notify_event_keys, user_event_subscriptions (THEME-01, THEME-05, CHAN-01, SUB-01, SUB-02) +- [ ] 09-02-PLAN.md — /api/me/* endpoints: theme, channels (Teams + ntfy), notification-subscriptions matrix (THEME-02, CHAN-02..05, CHAN-07, SUB-04) +- [ ] 09-03-PLAN.md — notify.ts route_to_user branch + resolver registry + fallback semantics (ROUTE-01..06) +- [ ] 09-04-PLAN.md — /mobile/profile UI: 4 Cards + theme bridge + drawer link + ThemeToggle write-through (PROF-01..04, TZ-CHOOSER-01..02, THEME-03, THEME-04, SUB-03) +- [ ] 09-05-PLAN.md — Admin surfaces: channels Owner column + filter, event-keys CRUD page, pipeline executions fallback filter (CHAN-06, ROUTE-07) **UI hint**: yes ## Progress @@ -207,7 +212,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral | 7. Engagement Overview | 0/3 | Not started | - | | 7.1. User Timezone Fix | 0/5 | Not started | - | | 8. Engagement User Profile | 0/2 | Not started | - | -| 9. User Profile & Preferences | 0/TBD | Not started | - | +| 9. User Profile & Preferences | 0/5 | Not started | - | --- *Roadmap created: 2026-05-03* diff --git a/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md new file mode 100644 index 0000000..707157b --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md @@ -0,0 +1,388 @@ +--- +phase: 09-user-profile-preferences-new +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - migrations/084_add_user_theme.sql + - migrations/085_personal_notification_channels.sql + - migrations/086_notify_event_keys_and_subscriptions.sql + - lib/auth.ts + - lib/types/pipeline.ts +autonomous: true +requirements: [THEME-01, THEME-05, CHAN-01, SUB-01, SUB-02] + +must_haves: + truths: + - "user table has theme TEXT column with default 'system' and existing rows backfilled to 'system'" + - "session.user.theme is exposed via Better Auth additionalFields the same way session.user.timezone is" + - "notification_channels has owner_user_id TEXT REFERENCES user(id) ON DELETE CASCADE column; existing rows have owner_user_id IS NULL (global)" + - "notify_event_keys table exists with admin-CRUD-able rows (key, display_label, description, sort_order, is_active)" + - "user_event_subscriptions table exists with composite PK (user_id, event_key, channel_type)" + artifacts: + - path: "migrations/084_add_user_theme.sql" + provides: "theme column on user with backfill" + contains: 'ALTER TABLE "user"' + - path: "migrations/085_personal_notification_channels.sql" + provides: "owner_user_id column on notification_channels" + contains: "ALTER TABLE notification_channels" + - path: "migrations/086_notify_event_keys_and_subscriptions.sql" + provides: "two new tables for event taxonomy and per-user subscriptions" + contains: "CREATE TABLE IF NOT EXISTS notify_event_keys" + - path: "lib/auth.ts" + provides: "theme additionalField" + contains: "theme:" + - path: "lib/types/pipeline.ts" + provides: "NotificationChannel.owner_user_id, NotifyEventKey, UserEventSubscription" + contains: "owner_user_id" + key_links: + - from: "migrations/084_add_user_theme.sql" + to: "user table" + via: "ALTER TABLE ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'" + pattern: "DEFAULT 'system'" + - from: "lib/auth.ts additionalFields" + to: "session.user.theme" + via: "Better Auth field exposure" + pattern: "theme:.*type:.*string" +--- + + +Land the foundational schema and types for Phase 9. This plan adds: +1. `theme` column on `"user"` (Better Auth) + Better Auth `additionalFields` exposure (THEME-01, THEME-05) +2. `owner_user_id` column on `notification_channels` (CHAN-01) +3. New tables `notify_event_keys` and `user_event_subscriptions` (SUB-01, SUB-02) +4. TypeScript type updates in `lib/types/pipeline.ts` so downstream code compiles against the new shapes + +No API or UI in this plan — just schema, type contracts, and the Better Auth field wiring so `session.user.theme` becomes available the same way `session.user.timezone` is today (Phase 7.1 precedent). + +Purpose: Every subsequent plan reads or writes one of these tables/columns, or imports one of these types. Putting all schema in Wave 1 unblocks Wave 2 (APIs) and Wave 3 (notify.ts + UI) to run in parallel. + +Output: Three new migrations, an extended `lib/auth.ts`, and an updated `lib/types/pipeline.ts`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/REQUIREMENTS.md +@CLAUDE.md +@migrations/083_add_user_timezone.sql +@migrations/033_create_pipeline_engine_tables.sql +@migrations/012_create_auth_tables.sql +@lib/auth.ts +@lib/types/pipeline.ts + + + + +From lib/types/pipeline.ts (current — needs `owner_user_id`): +```typescript +export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook'; + +export interface NotificationChannel { + id: number; + name: string; + channel_type: ChannelType; + config: Record; + is_active: boolean; + created_at: Date; + updated_at: Date; +} +``` + +From lib/auth.ts (current additionalFields): +```typescript +user: { + additionalFields: { + role: { type: "string", defaultValue: "user" }, + requires_setup: { type: "boolean", defaultValue: false }, + timezone: { type: "string", defaultValue: process.env.DEFAULT_TIMEZONE || "UTC" }, + }, +}, +``` + +From migrations/083_add_user_timezone.sql (precedent — mirror for theme): +```sql +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'; +UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL; +COMMENT ON COLUMN "user".timezone IS '...'; +``` + + + + + + + Task 1: Add theme column to user + Better Auth additionalField + migrations/084_add_user_theme.sql, lib/auth.ts + + + - migrations/083_add_user_timezone.sql (mirror this exact structure) + - migrations/012_create_auth_tables.sql (confirm "user" table is the Better Auth table and it uses snake_case-style quoted identifier) + - lib/auth.ts (current additionalFields block) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — column shape and default) + + + + Step A — Create `migrations/084_add_user_theme.sql` with exactly: + + ```sql + -- ============================================================================= + -- Per-user theme preference (Phase 9 — THEME-01, THEME-05) + -- ============================================================================= + -- Adds a `theme` column to the Better Auth "user" table so light/dark/system + -- preference persists server-side and applies on sign-in across devices. + -- + -- Allowed values: 'light' | 'dark' | 'system' (validated in API layer). + -- Default 'system' means next-themes detects OS preference at render time — + -- zero behavior change for existing signed-in users. + -- ============================================================================= + + ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'; + + -- Defensive backfill (DEFAULT covers new inserts, but on managed Postgres a + -- column added with DEFAULT may briefly show NULL in flight on some replicas). + UPDATE "user" SET theme = 'system' WHERE theme IS NULL; + + COMMENT ON COLUMN "user".theme IS + 'User theme preference: light | dark | system. Applied app-wide via next-themes; server is canonical.'; + ``` + + Step B — In `lib/auth.ts` add a `theme` entry to `user.additionalFields` immediately after the existing `timezone` entry: + + ```typescript + timezone: { + type: "string", + defaultValue: process.env.DEFAULT_TIMEZONE || "UTC", + }, + theme: { + type: "string", + defaultValue: "system", + }, + ``` + + Do NOT change anything else in `lib/auth.ts`. The new field becomes available as `session.user.theme` on the next session refresh — same mechanism that exposed `session.user.timezone` in Phase 7.1. + + + + grep -q "ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'" migrations/084_add_user_theme.sql && grep -q 'theme:' lib/auth.ts && grep -q "defaultValue: \"system\"" lib/auth.ts && npx tsc --noEmit --pretty 2>&1 | grep -E "(auth.ts|error)" | head + + + + - `migrations/084_add_user_theme.sql` exists + - File contains the exact string `ALTER TABLE "user"` (with double quotes around `user`) + - File contains `ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'` + - File contains `UPDATE "user" SET theme = 'system' WHERE theme IS NULL` + - File contains a `COMMENT ON COLUMN "user".theme IS` line + - `lib/auth.ts` `additionalFields` block contains a `theme:` key with `defaultValue: "system"` and `type: "string"` + - `lib/auth.ts` still contains the existing `role`, `requires_setup`, and `timezone` keys (no entries removed) + - `npx tsc --noEmit --pretty` reports no new errors in `lib/auth.ts` + + + + Migration file written with the exact ALTER + backfill + COMMENT structure of 083. `lib/auth.ts` exposes `theme` as a fourth additionalField. TypeScript compiles cleanly. + + + + + Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables + migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts + + + - migrations/033_create_pipeline_engine_tables.sql (current notification_channels schema) + - lib/types/pipeline.ts (current NotificationChannel interface — must extend, not replace) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled) + + + + Step A — Create `migrations/085_personal_notification_channels.sql`: + + ```sql + -- ============================================================================= + -- Personal notification channels (Phase 9 — CHAN-01) + -- ============================================================================= + -- Adds owner_user_id to notification_channels so a row can be either: + -- - Global (owner_user_id IS NULL) — existing rows, admin-managed + -- - Personal (owner_user_id = "user".id) — created via /api/me/channels + -- + -- Cascade delete: removing a Better Auth user removes their personal channels. + -- Multi-channel-per-type-per-user is forbidden by API-layer UPSERT logic + -- keyed by (owner_user_id, channel_type) — D-02 / CHAN-02. We deliberately + -- do NOT add a partial unique index so existing global rows (NULL owner) and + -- multiple users can coexist. + -- ============================================================================= + + ALTER TABLE notification_channels + ADD COLUMN IF NOT EXISTS owner_user_id TEXT + REFERENCES "user"(id) ON DELETE CASCADE; + + CREATE INDEX IF NOT EXISTS idx_notification_channels_owner + ON notification_channels(owner_user_id, channel_type) + WHERE owner_user_id IS NOT NULL; + + COMMENT ON COLUMN notification_channels.owner_user_id IS + 'Personal channel owner. NULL = global channel (admin-managed). NOT NULL = personal channel reachable only by the owner and admins.'; + ``` + + Step B — Create `migrations/086_notify_event_keys_and_subscriptions.sql`: + + ```sql + -- ============================================================================= + -- Notify event taxonomy + per-user subscriptions (Phase 9 — SUB-01, SUB-02) + -- ============================================================================= + -- notify_event_keys: humanization layer for the event keys that appear in + -- pipeline_steps.config->'route_to_user'->>'event_key'. Admin-managed at + -- /admin/workflow/event-keys. NOT a gate — keys not in this table are + -- still routable; they just render with the raw key as their label. + -- + -- user_event_subscriptions: opt-out matrix per (user, event_key, channel_type). + -- Row absence = enabled (D-15 default-enabled / opt-out). + -- ============================================================================= + + CREATE TABLE IF NOT EXISTS notify_event_keys ( + key TEXT PRIMARY KEY, + display_label TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_notify_event_keys_active + ON notify_event_keys(is_active, sort_order, key); + + CREATE TABLE IF NOT EXISTS user_event_subscriptions ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, + channel_type VARCHAR(20) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, event_key, channel_type) + ); + + CREATE INDEX IF NOT EXISTS idx_user_event_subs_user + ON user_event_subscriptions(user_id); + + COMMENT ON TABLE notify_event_keys IS + 'Humanization layer for event keys used in notify-step route_to_user blocks. Admins curate display_label/description/sort. Not a gate — unknown keys are still routable.'; + COMMENT ON TABLE user_event_subscriptions IS + 'Opt-out matrix. Row absence = enabled. Composite PK enforces one row per (user, event_key, channel_type).'; + + -- Seed an example row so the admin UI is not empty on first run. + INSERT INTO notify_event_keys (key, display_label, description, sort_order, is_active) + VALUES ('ticket_assigned_to_me', 'Ticket assigned to me', 'Fires when an Autotask ticket is assigned to your resource.', 10, true) + ON CONFLICT (key) DO NOTHING; + ``` + + Step C — In `lib/types/pipeline.ts`: + + 1. Add `owner_user_id: string | null;` to the `NotificationChannel` interface, immediately after the `is_active` line. + 2. Append two new exported interfaces at the bottom of the file (after `NotificationChannelInput`): + + ```typescript + // ============================================================================ + // Phase 9 — Event taxonomy + user subscriptions + // ============================================================================ + + export interface NotifyEventKey { + key: string; + display_label: string; + description: string | null; + sort_order: number; + is_active: boolean; + created_at: Date; + updated_at: Date; + } + + export interface UserEventSubscription { + user_id: string; + event_key: string; + channel_type: ChannelType; + enabled: boolean; + updated_at: Date; + } + ``` + + Do NOT touch any other type in this file. Do NOT export a barrel index. + + + + grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && grep -q "CREATE TABLE IF NOT EXISTS notify_event_keys" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "CREATE TABLE IF NOT EXISTS user_event_subscriptions" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "owner_user_id: string | null" lib/types/pipeline.ts && grep -q "export interface NotifyEventKey" lib/types/pipeline.ts && grep -q "export interface UserEventSubscription" lib/types/pipeline.ts && npx tsc --noEmit --pretty 2>&1 | head + + + + - `migrations/085_personal_notification_channels.sql` exists and contains `ALTER TABLE notification_channels` + - File contains `ADD COLUMN IF NOT EXISTS owner_user_id TEXT` and `REFERENCES "user"(id) ON DELETE CASCADE` + - File contains `CREATE INDEX IF NOT EXISTS idx_notification_channels_owner` + - `migrations/086_notify_event_keys_and_subscriptions.sql` exists + - File contains `CREATE TABLE IF NOT EXISTS notify_event_keys` with `key TEXT PRIMARY KEY` + - File contains `CREATE TABLE IF NOT EXISTS user_event_subscriptions` with `PRIMARY KEY (user_id, event_key, channel_type)` + - File contains `INSERT INTO notify_event_keys` ... `ON CONFLICT (key) DO NOTHING` (the seed row) + - `lib/types/pipeline.ts` `NotificationChannel` interface contains the line `owner_user_id: string | null;` + - `lib/types/pipeline.ts` exports `NotifyEventKey` interface with fields `key`, `display_label`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at` + - `lib/types/pipeline.ts` exports `UserEventSubscription` interface with composite-PK fields and `enabled: boolean` + - `npx tsc --noEmit --pretty` reports no new errors + + + + Two new migrations land at the end of the migration sequence (head moves 083 → 086). `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly. + + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → Postgres (via Next API) | All changes here go through schema only; no untrusted input is processed in this plan | +| Future API routes → user table | New columns on `"user"` will be writable only via authenticated `/api/me/*` routes (next plan) | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-01-01 | Tampering | `migrations/084_add_user_theme.sql` | mitigate | `theme TEXT NOT NULL DEFAULT 'system'` enforces the column is always populated; downstream API layer (Plan 02) validates against three-string allowlist before write — schema cannot be bypassed by the application layer | +| T-09-01-02 | Information Disclosure | `notification_channels.owner_user_id` | mitigate | Cascade delete on user removal ensures personal channels (which contain webhook URLs / ntfy topics — secrets) cannot outlive their owner; queries in Plan 02 filter by `owner_user_id = session.user.id OR owner_user_id IS NULL`. Storage at rest stays plaintext (matches existing `notification_channels.config` for global rows — same precedent) | +| T-09-01-03 | Elevation of Privilege | `user_event_subscriptions` PK | mitigate | Composite primary key `(user_id, event_key, channel_type)` makes mass-assignment impossible: no surrogate ID, no nullable user_id. API writes (Plan 02) target `session.user.id` only | +| T-09-01-04 | Denial of Service | `notify_event_keys` | accept | Admin-only CRUD (Plan 05); table is small (one row per event key, expected < 50 rows). No rate-limiting needed — admin role is already trusted | +| T-09-01-05 | Spoofing | `lib/auth.ts theme additionalField` | mitigate | Better Auth signs the session cookie; `session.user.theme` is read from server-side session lookup (cookieCache 5 min). Client cannot tamper with the value — they can only request a write via authenticated `PUT /api/me/theme` (Plan 02) which validates against allowlist | + +No `high` severity threats. ASVS L1 satisfied: V8.3.4 (sensitive data lifecycle — cascade delete), V4.2.1 (mass-assignment prevention via composite PK), V5.1.3 (input validation deferred to Plan 02 API). + + + + +- All three migrations land at sequential numbers 084, 085, 086 (head was 083). +- `lib/auth.ts` `additionalFields` has exactly four entries: `role`, `requires_setup`, `timezone`, `theme`. +- `lib/types/pipeline.ts` compiles and exports the two new interfaces. +- No production code reads from the new tables yet — that lands in Plan 02. +- Migration files use `IF NOT EXISTS` everywhere (idempotent re-runs). +- Backfill semantics match Phase 7.1 precedent (`UPDATE ... WHERE col IS NULL`). + + + +1. `git status` shows three new files in `migrations/` plus modifications to `lib/auth.ts` and `lib/types/pipeline.ts`. +2. `npx tsc --noEmit --pretty` exits 0. +3. `grep -c "additionalFields" lib/auth.ts` returns 1; the block contains a line matching `theme:` with `defaultValue: "system"`. +4. The three migrations are syntactically valid SQL and use `IF NOT EXISTS` on every CREATE / ADD COLUMN. +5. Plan 02 (Wave 2) and Plan 03 (Wave 2) can both import the new types without further schema changes. + + + +After completion, create `.planning/phases/09-user-profile-preferences-new/09-01-SUMMARY.md` documenting: +- Three migration filenames and what each adds +- The exact `theme` additionalField definition added to `lib/auth.ts` +- The two new exported interfaces in `lib/types/pipeline.ts` +- Confirmation that `session.user.theme` is now exposed via Better Auth (verified by checking the additionalFields block) +- Any deviations from the plan (expected: none) + diff --git a/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md new file mode 100644 index 0000000..6c4aadb --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md @@ -0,0 +1,574 @@ +--- +phase: 09-user-profile-preferences-new +plan: 02 +type: execute +wave: 2 +depends_on: [09-01] +files_modified: + - 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 +autonomous: true +requirements: [THEME-02, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, SUB-04] + +must_haves: + truths: + - "Authenticated GET /api/me/theme returns { theme, source } and PUT /api/me/theme accepts { theme: 'light'|'dark'|'system' } and writes session.user.id only" + - "Authenticated GET /api/me/channels returns the calling user's Teams + ntfy personal channels" + - "PUT /api/me/channels/teams accepts { webhook_url } validated as https://*.webhook.office.com OR https://*.logic.azure.com, UPSERTs keyed by (owner_user_id, channel_type='teams'), and issues a best-effort test send returning the test result inline" + - "PUT /api/me/channels/ntfy mints a UUID-prefixed topic on first save (or accepts a custom topic when explicitly supplied), UPSERTs keyed by (owner_user_id, channel_type='ntfy'), and issues a best-effort test send" + - "DELETE /api/me/channels/{type} removes only the calling user's row of that channel type" + - "POST /api/me/channels/{type}/test issues a test send to the calling user's existing channel row" + - "GET /api/me/notification-subscriptions returns { event_keys, channels, matrix } where matrix[event_key][channel_type] = enabled boolean (defaults to true when no row exists)" + - "PUT /api/me/notification-subscriptions accepts { event_key, channel_type, enabled } and UPSERTs a single row keyed by session.user.id" + artifacts: + - path: "app/api/me/theme/route.ts" + provides: "GET + PUT theme endpoints" + exports: ["GET", "PUT"] + - path: "app/api/me/channels/route.ts" + provides: "GET aggregate of user's personal channels" + exports: ["GET"] + - path: "app/api/me/channels/[type]/route.ts" + provides: "PUT (upsert) and DELETE per channel type" + exports: ["PUT", "DELETE"] + - path: "app/api/me/channels/[type]/test/route.ts" + provides: "POST: re-send test message to existing channel" + exports: ["POST"] + - path: "app/api/me/notification-subscriptions/route.ts" + provides: "GET full matrix + PUT single row" + exports: ["GET", "PUT"] + - path: "lib/services/personal-channels.ts" + provides: "shared validation + test-send helpers; sendChannelTest(channel)" + exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY"] + key_links: + - from: "PUT /api/me/channels/teams" + to: "notification_channels" + via: "INSERT ... ON CONFLICT (owner_user_id, channel_type) DO UPDATE — guarded by application-level pre-check, see action" + pattern: "owner_user_id" + - from: "PUT /api/me/channels/ntfy" + to: "lib/services/personal-channels.ts mintNtfyTopic" + via: "topic generated server-side using crypto.randomUUID()" + pattern: "pulse-" + - from: "GET /api/me/notification-subscriptions" + to: "notify_event_keys + user_event_subscriptions" + via: "LEFT JOIN with default-enabled fallback" + pattern: "LEFT JOIN user_event_subscriptions" +--- + + +Build the per-user API surface for Phase 9. All routes mirror the conventions already +established by `/api/me/timezone`: `requireAuth()`, write target is always +`session.user.id`, manual snake_case → camelCase transform, no Zod, explicit validation. + +Three route families: +1. **`/api/me/theme`** (THEME-02) — GET returns `{ theme, source }`, PUT validates against allowlist +2. **`/api/me/channels` family** (CHAN-02..05, CHAN-07) — GET aggregate, PUT/DELETE per type, POST :test +3. **`/api/me/notification-subscriptions`** (SUB-04) — GET full matrix, PUT a single row + +Plus `lib/services/personal-channels.ts` to share Teams URL validation, ntfy topic +minting, and the test-send helper across the routes (and reuse in Plan 03 / Plan 05). + +Purpose: This plan does NOT touch UI, drawer, theme bridge, notify.ts, or admin +surfaces. Those land in Plans 03/04/05. Once these endpoints exist, the mobile profile +page (Plan 04) becomes a pure rendering exercise. + +Output: 5 new route files + 1 new service helper. No DB migrations (those landed in +Plan 01). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/REQUIREMENTS.md +@CLAUDE.md +@app/api/me/timezone/route.ts +@app/api/notification-channels/route.ts +@app/api/notification-channels/[id]/test/route.ts +@lib/services/pipeline-steps/notify.ts +@lib/types/pipeline.ts + + + + +From lib/auth-utils.ts: +```typescript +export async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>; +// On error: caller should return error directly. On success, session.user.id is the write target. +``` + +From lib/services/postgres-client.ts: +```typescript +postgresClient.query(sql: string, params: any[]): Promise<{ rows: T[]; rowCount: number }>; +postgresClient.transaction(fn: (client) => Promise): Promise; +``` + +From lib/types/pipeline.ts (already updated in Plan 01): +```typescript +export type ChannelType = 'teams' | 'telegram' | 'ntfy' | 'webhook'; +export interface NotificationChannel { + id: number; + name: string; + channel_type: ChannelType; + config: Record; + is_active: boolean; + owner_user_id: string | null; + created_at: Date; + updated_at: Date; +} +export interface NotifyEventKey { key: string; display_label: string; description: string | null; sort_order: number; is_active: boolean; created_at: Date; updated_at: Date; } +export interface UserEventSubscription { user_id: string; event_key: string; channel_type: ChannelType; enabled: boolean; updated_at: Date; } +``` + +Reference shape for /api/me/timezone (mirror this convention exactly): +- requireAuth() guard at top +- GET returns `{ value, source: 'user' | 'default' }` +- PUT validates input shape, then validates value against allowlist, then UPDATEs WHERE id = session.user.id +- All errors caught, console.error + 500 NextResponse.json({ error, message }, { status }) + + + + + + + Task 1: GET + PUT /api/me/theme + app/api/me/theme/route.ts + + + - app/api/me/timezone/route.ts (mirror exactly) + - lib/auth-utils.ts (requireAuth signature) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — theme allowlist + default 'system') + + + + Create `app/api/me/theme/route.ts` with: + + 1. Top-of-file comment block matching the `/api/me/timezone` style (THEME-02 reference, "writes session.user.id only", validation note). + 2. Constant: `const ALLOWED_THEMES = new Set(['light', 'dark', 'system'] as const);` + 3. Helper: `function isValidTheme(t: unknown): t is 'light' | 'dark' | 'system'` — returns true iff `typeof t === 'string'` and `ALLOWED_THEMES.has(t)`. + 4. `export async function GET(): Promise`: + - `const { session, error } = await requireAuth(); if (error) return error;` + - Query: `SELECT theme FROM "user" WHERE id = $1` with `session!.user.id` + - Compute `source`: `'user'` when `stored && stored !== 'system'`, else `'default'` (matches the `/api/me/timezone` semantic where `source = 'default'` when value equals fallback). + - Return `NextResponse.json({ theme: stored ?? 'system', source })` + - Wrap in try/catch; on error `console.error('GET /api/me/theme failed:', e)` and return 500. + 5. `export async function PUT(request: NextRequest): Promise`: + - requireAuth guard + - Parse `body = await request.json()` inside try/catch — on parse failure return 400 `{ error: 'Invalid JSON', message: 'Request body must be JSON' }` + - Extract `candidate = body.theme` + - If `!isValidTheme(candidate)` return 400 `{ error: 'Invalid theme', message: "theme must be one of: light, dark, system" }` + - `UPDATE "user" SET theme = $1, "updatedAt" = NOW() WHERE id = $2 RETURNING theme` with `[candidate, session!.user.id]` + - If `rowCount === 0` return 404 `{ error: 'User not found', message: 'No user row matched the session' }` + - Return `NextResponse.json({ theme: result.rows[0].theme })` + - try/catch with `console.error('PUT /api/me/theme failed:', e)` + 500 + + + + test -f app/api/me/theme/route.ts && grep -q "export async function GET" app/api/me/theme/route.ts && grep -q "export async function PUT" app/api/me/theme/route.ts && grep -q "ALLOWED_THEMES" app/api/me/theme/route.ts && grep -q "requireAuth" app/api/me/theme/route.ts && npx tsc --noEmit --pretty 2>&1 | grep "theme/route.ts" | head + + + + - `app/api/me/theme/route.ts` exists + - Contains `import { requireAuth } from '@/lib/auth-utils';` + - Contains `import { postgresClient } from '@/lib/services/postgres-client';` + - Contains `export async function GET(` + - Contains `export async function PUT(` + - Contains the literal string `'light'`, `'dark'`, and `'system'` (the three allowed values) + - Contains an SQL string starting with `UPDATE "user" SET theme = $1` and ending in `WHERE id = $2 RETURNING theme` + - Contains `session!.user.id` (the write target, never from body) + - Does NOT contain a `userId` extraction from `body` or `searchParams` + - `npx tsc --noEmit --pretty` reports no errors in this file + + + + Theme GET/PUT route lives at `/api/me/theme`, gated by `requireAuth()`, writes only session.user.id, validates against the three-string allowlist. Mirrors `/api/me/timezone` shape line-for-line modulo the validation function. + + + + + Task 2: Personal channels service + /api/me/channels routes + 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 + + + - lib/services/pipeline-steps/notify.ts (existing send shapes for teams + ntfy — reuse the same fetch contracts) + - app/api/notification-channels/[id]/test/route.ts (existing global-channel test send pattern) + - app/api/me/timezone/route.ts (per-user route conventions) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-02 singular per-type, D-03 owner_user_id, D-04 mint UUID-prefixed ntfy, D-05 Teams URL validation, D-06 best-effort test on save) + - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Test message body" copy: "Pulse channel verified — you can ignore this message.") + - lib/types/pipeline.ts (NotificationChannel, ChannelType — already updated in Plan 01) + + + + Step A — Create `lib/services/personal-channels.ts` with: + + ```typescript + import { randomUUID } from 'crypto'; + import { NotificationChannel, ChannelType } from '@/lib/types/pipeline'; + + /** Test-send copy used by every personal-channel save and POST :test. */ + export const TEST_MESSAGE_BODY = + 'Pulse channel verified — you can ignore this message.'; + + /** Allowed Teams webhook hosts (CHAN-04 / D-05). */ + const TEAMS_HOST_PATTERNS = [ + /^[a-z0-9-]+\.webhook\.office\.com$/i, + /^[a-z0-9-]+\.logic\.azure\.com$/i, + /^[a-z0-9-]+\.[a-z]+\.logic\.azure\.com$/i, // regional subdomains, e.g. prod-12.eastus.logic.azure.com + ]; + + export function isValidTeamsWebhookUrl(input: unknown): input is string { + if (typeof input !== 'string' || input.length === 0 || input.length > 2048) return false; + try { + const u = new URL(input); + if (u.protocol !== 'https:') return false; + return TEAMS_HOST_PATTERNS.some((re) => re.test(u.hostname)); + } catch { + return false; + } + } + + /** Mint a Pulse-namespaced ntfy topic (CHAN-03 / D-04). */ + export function mintNtfyTopic(): string { + // 8-hex prefix is enough collision-resistance for ntfy public tier. + const id = randomUUID().replace(/-/g, '').slice(0, 8); + return `pulse-${id}`; + } + + export type ChannelTestResult = + | { ok: true } + | { ok: false; status?: number; error: string }; + + /** + * Issue a single best-effort test send to a personal channel. Mirrors the + * existing app/api/notification-channels/[id]/test/route.ts flow but with the + * Phase 9 test-message body. + */ + export async function sendChannelTest(channel: NotificationChannel): Promise { + try { + switch (channel.channel_type) { + case 'teams': { + const url = channel.config.webhook_url; + if (!url) return { ok: false, error: 'Teams channel missing webhook_url' }; + const card = { + type: 'message', + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.4', + body: [ + { type: 'TextBlock', text: 'Pulse channel verified', weight: 'bolder', size: 'medium' }, + { type: 'TextBlock', text: TEST_MESSAGE_BODY, wrap: true }, + ], + }, + }], + }; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(card), + }); + if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) }; + return { ok: true }; + } + case 'ntfy': { + const serverUrl = channel.config.server_url || 'https://ntfy.sh'; + const topic = channel.config.topic; + if (!topic) return { ok: false, error: 'ntfy channel missing topic' }; + const headers: Record = { + 'Content-Type': 'text/plain', + 'Title': 'Pulse channel verified', + }; + if (channel.config.auth_token) headers['Authorization'] = `Bearer ${channel.config.auth_token}`; + const resp = await fetch(`${serverUrl}/${topic}`, { + method: 'POST', + headers, + body: TEST_MESSAGE_BODY, + }); + if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) }; + return { ok: true }; + } + default: + return { ok: false, error: `Unsupported personal channel type: ${channel.channel_type}` }; + } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : 'unknown error' }; + } + } + + /** ChannelType values that personal channels may take in Phase 9 (D-02). */ + export const PERSONAL_CHANNEL_TYPES: ChannelType[] = ['teams', 'ntfy']; + + export function isPersonalChannelType(t: unknown): t is 'teams' | 'ntfy' { + return t === 'teams' || t === 'ntfy'; + } + ``` + + Step B — Create `app/api/me/channels/route.ts`: + + ```typescript + // GET /api/me/channels + // Returns the calling user's personal channels (Teams + ntfy), camelCase. + // Auth: requireAuth(). Reads only owner_user_id = session.user.id. + + import { NextResponse } from 'next/server'; + import { requireAuth } from '@/lib/auth-utils'; + import { postgresClient } from '@/lib/services/postgres-client'; + import { NotificationChannel } from '@/lib/types/pipeline'; + + export async function GET(): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const result = await postgresClient.query( + `SELECT id, name, channel_type, config, is_active, owner_user_id, created_at, updated_at + FROM notification_channels + WHERE owner_user_id = $1 + ORDER BY channel_type ASC`, + [session!.user.id], + ); + // camelCase transform + const channels = result.rows.map((r) => ({ + id: r.id, + name: r.name, + channelType: r.channel_type, + config: r.config, + isActive: r.is_active, + ownerUserId: r.owner_user_id, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + return NextResponse.json({ channels }); + } catch (e) { + console.error('GET /api/me/channels failed:', e); + return NextResponse.json( + { error: 'Failed to read channels', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } + } + ``` + + Step C — Create `app/api/me/channels/[type]/route.ts` with PUT and DELETE: + + PUT semantics: + - `requireAuth()` guard. + - `const { type } = await params; if (!isPersonalChannelType(type)) return 400 'Unsupported channel type'` + - Parse JSON body + - For `type === 'teams'`: + - Extract `webhook_url = body.webhook_url` + - If `!isValidTeamsWebhookUrl(webhook_url)` return 400 with message `"webhook_url must be https://*.webhook.office.com or https://*.logic.azure.com"` + - Build `config = { webhook_url }` + - Build `name = "Personal Teams (" + session!.user.email + ")"` + - For `type === 'ntfy'`: + - `customTopic = typeof body.topic === 'string' ? body.topic : null` + - If `customTopic` provided: validate format (must match `/^[A-Za-z0-9_-]{6,64}$/`); reject with 400 otherwise. + - If no customTopic: `topic = mintNtfyTopic()` + - `config = { server_url: 'https://ntfy.sh', topic }` + - `name = "Personal ntfy (" + session!.user.email + ")"` + - **UPSERT** in a single SQL statement, scoped to the calling user (D-02 / CHAN-02 — API-layer enforcement, not partial unique index): + ```sql + WITH existing AS ( + SELECT id FROM notification_channels + WHERE owner_user_id = $1 AND channel_type = $2 + LIMIT 1 + ), + updated AS ( + UPDATE notification_channels + SET name = $3, config = $4, is_active = true, updated_at = NOW() + WHERE id = (SELECT id FROM existing) + RETURNING * + ), + inserted AS ( + INSERT INTO notification_channels (name, channel_type, config, is_active, owner_user_id) + SELECT $3, $2, $4, true, $1 + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING * + ) + SELECT * FROM updated UNION ALL SELECT * FROM inserted; + ``` + Bind params: `[session!.user.id, type, name, JSON.stringify(config)]`. + - After upsert, fire `await sendChannelTest(row)` (best-effort — do NOT bubble errors). + - Return `NextResponse.json({ channel: , test: })`. + + DELETE semantics: + - `requireAuth()`, validate `type` + - `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id` with `[session!.user.id, type]` + - 404 if no rows; 200 `{ deleted: true, channelType: type }` otherwise. + + Step D — Create `app/api/me/channels/[type]/test/route.ts`: + - `export async function POST(_req: NextRequest, { params }: { params: Promise<{ type: string }> })` + - `requireAuth()`, validate type + - `SELECT * FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2` + - 404 if missing + - `const result = await sendChannelTest(channel)` + - Return `NextResponse.json({ test: result })` (200 even on test failure — mirrors save semantics in Step C) + + + + test -f lib/services/personal-channels.ts && test -f app/api/me/channels/route.ts && test -f app/api/me/channels/[type]/route.ts && test -f app/api/me/channels/[type]/test/route.ts && grep -q "isValidTeamsWebhookUrl" lib/services/personal-channels.ts && grep -q "mintNtfyTopic" lib/services/personal-channels.ts && grep -q "TEST_MESSAGE_BODY" lib/services/personal-channels.ts && grep -q "Pulse channel verified — you can ignore this message" lib/services/personal-channels.ts && grep -q "owner_user_id = \$1" "app/api/me/channels/[type]/route.ts" && npx tsc --noEmit --pretty 2>&1 | head -20 + + + + - `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `mintNtfyTopic`, `sendChannelTest`, `TEST_MESSAGE_BODY`, `isPersonalChannelType`, `PERSONAL_CHANNEL_TYPES` + - `TEST_MESSAGE_BODY` value is exactly `'Pulse channel verified — you can ignore this message.'` + - `mintNtfyTopic()` returns a string starting with `pulse-` and containing 8 hex chars (no UUID dashes) + - `isValidTeamsWebhookUrl` returns `false` for `'http://foo.webhook.office.com/x'`, `'https://evil.com'`, `''`; returns `true` for `'https://yourorg.webhook.office.com/abc'` and `'https://prod-12.eastus.logic.azure.com/foo'` + - `app/api/me/channels/route.ts` `GET` queries `WHERE owner_user_id = $1` with `session!.user.id` + - `app/api/me/channels/[type]/route.ts` `PUT` calls `isValidTeamsWebhookUrl` for the `'teams'` branch and `mintNtfyTopic()` for the `'ntfy'` branch when no custom topic is supplied + - `app/api/me/channels/[type]/route.ts` `PUT` UPSERT SQL includes the literal substring `WHERE owner_user_id = $1 AND channel_type = $2` (the lookup arm of the WITH-CTE) + - `app/api/me/channels/[type]/route.ts` `DELETE` SQL is exactly `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id` + - `app/api/me/channels/[type]/test/route.ts` calls `sendChannelTest(channel)` and returns `{ test: result }` + - No route accepts a `userId`, `user_id`, or `owner_user_id` from request body or query — all writes scope to `session!.user.id` + - `npx tsc --noEmit --pretty` reports no errors in any of these files + + + + The complete `/api/me/channels` family exists, gated by requireAuth, with API-layer UPSERT semantics enforcing one-channel-per-type per user (D-02). Test sends are best-effort and never block save success. The shared service file is reusable from the admin extension in Plan 05. + + + + + Task 3: GET + PUT /api/me/notification-subscriptions (matrix endpoint) + app/api/me/notification-subscriptions/route.ts + + + - app/api/me/timezone/route.ts (per-user route conventions) + - lib/types/pipeline.ts (UserEventSubscription, NotifyEventKey, ChannelType — Plan 01) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-14 matrix shape, D-15 default-enabled, SUB-04) + - lib/services/personal-channels.ts (isPersonalChannelType — Task 2 of this plan) + + + + Create `app/api/me/notification-subscriptions/route.ts`. + + **GET shape:** + + ```jsonc + { + "eventKeys": [ + { "key": "ticket_assigned_to_me", "displayLabel": "Ticket assigned to me", "description": "...", "sortOrder": 10 } + ], + "channelTypes": ["teams", "ntfy"], // only types the user has personal channels configured for + "matrix": { + "ticket_assigned_to_me": { "teams": true, "ntfy": false } + } + } + ``` + + Implementation: + 1. `requireAuth()` guard. + 2. Three queries (one transaction NOT required — read-only): + a. `SELECT key, display_label, description, sort_order FROM notify_event_keys WHERE is_active = true ORDER BY sort_order ASC, key ASC` + b. `SELECT channel_type FROM notification_channels WHERE owner_user_id = $1 AND is_active = true ORDER BY channel_type ASC` with `[session!.user.id]` + c. `SELECT event_key, channel_type, enabled FROM user_event_subscriptions WHERE user_id = $1` with `[session!.user.id]` + 3. Build `channelTypes = result_b.rows.map(r => r.channel_type)`. + 4. Build `matrix: Record>`: + - For every active event key × every configured channelType: default to `true` (D-15). + - Override with stored rows from query (c): `matrix[event_key][channel_type] = enabled`. + 5. camelCase transform of eventKeys (`key`, `displayLabel`, `description`, `sortOrder`). + 6. Return JSON. + + **PUT shape:** + - Body: `{ event_key: string, channel_type: 'teams' | 'ntfy', enabled: boolean }` + - Validation: + - `event_key` must be a non-empty string ≤ 128 chars + - `channel_type` must pass `isPersonalChannelType` (imported from `lib/services/personal-channels`) + - `enabled` must be `boolean` (use `typeof === 'boolean'`) + - UPSERT: + ```sql + INSERT INTO user_event_subscriptions (user_id, event_key, channel_type, enabled, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (user_id, event_key, channel_type) DO UPDATE + SET enabled = EXCLUDED.enabled, updated_at = NOW() + RETURNING user_id, event_key, channel_type, enabled, updated_at; + ``` + Bind: `[session!.user.id, event_key, channel_type, enabled]`. + - Return `NextResponse.json({ subscription: { eventKey, channelType, enabled } })`. + - Standard try/catch + `console.error('PUT /api/me/notification-subscriptions failed:', e)` + 500 fallthrough. + + + + test -f app/api/me/notification-subscriptions/route.ts && grep -q "export async function GET" app/api/me/notification-subscriptions/route.ts && grep -q "export async function PUT" app/api/me/notification-subscriptions/route.ts && grep -q "user_event_subscriptions" app/api/me/notification-subscriptions/route.ts && grep -q "ON CONFLICT (user_id, event_key, channel_type)" app/api/me/notification-subscriptions/route.ts && grep -q "isPersonalChannelType" app/api/me/notification-subscriptions/route.ts && npx tsc --noEmit --pretty 2>&1 | grep -E "(notification-subscriptions|error)" | head + + + + - `app/api/me/notification-subscriptions/route.ts` exists and exports `GET` + `PUT` + - GET issues at minimum the SELECT queries against `notify_event_keys` (filtered `is_active = true`), `notification_channels` (filtered `owner_user_id = session.user.id`), and `user_event_subscriptions` (filtered `user_id = session.user.id`) + - GET response JSON contains a top-level `matrix` object with event_key → channel_type → boolean shape + - GET defaults missing matrix cells to `true` (verifiable: search code for the literal value `true` in the matrix-building loop) + - PUT contains `ON CONFLICT (user_id, event_key, channel_type) DO UPDATE` + - PUT writes `user_id = session!.user.id` (verified: route does NOT extract user_id from body or params) + - PUT validates `channel_type` via `isPersonalChannelType` (imported from `@/lib/services/personal-channels`) + - PUT rejects `enabled` that is not a boolean with HTTP 400 + - `npx tsc --noEmit --pretty` reports no errors in this file + + + + Subscription matrix endpoint exists with the canonical default-enabled fallback (D-15), singular row writes via composite-PK UPSERT. UI in Plan 04 reads the GET shape and writes single rows on Switch toggle. + + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → /api/me/* | Untrusted JSON crosses here; auth via signed Better Auth cookie | +| Server → external webhooks (Teams) | User-supplied URL is dispatched to Teams hosts | +| Server → ntfy.sh | Pulse-minted topic is published to public ntfy.sh | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-02-01 | Spoofing | All `/api/me/*` routes | mitigate | Every route calls `requireAuth()` first; write target is always `session!.user.id` (never from body/query). PUT body extraction explicitly drops any `userId`/`user_id` field | +| T-09-02-02 | Tampering | `PUT /api/me/theme` | mitigate | `ALLOWED_THEMES` allowlist (`'light' \| 'dark' \| 'system'`); rejected with HTTP 400 before any DB write | +| T-09-02-03 | Tampering | `PUT /api/me/notification-subscriptions` | mitigate | `event_key` length-bounded; `channel_type` passes `isPersonalChannelType` allowlist; `enabled` `typeof === 'boolean'`. Composite PK on user_id+event_key+channel_type prevents duplicate insertion attempts | +| T-09-02-04 | Information Disclosure | `GET /api/me/channels` | mitigate | SQL hardcodes `WHERE owner_user_id = session!.user.id` — a user cannot read another user's webhook URL or ntfy topic. Admin path lives in a separate route (Plan 05) | +| T-09-02-05 | Information Disclosure / Logging | Test-send error responses | mitigate | `sendChannelTest` returns `{ status, error }` capped at 200 chars; no full webhook URL is echoed back. `console.error` logs do NOT include `channel.config` (only the channel id and channel_type) — see Action Step C/D | +| T-09-02-06 | SSRF | `PUT /api/me/channels/teams` | mitigate | `isValidTeamsWebhookUrl` enforces `https://` AND hostname matching `*.webhook.office.com` or `*.logic.azure.com`. Cannot point at internal hosts (`localhost`, `127.0.0.1`, `10.x.x.x`) because hostname regex requires the public Teams hosts | +| T-09-02-07 | Spoofing / Topic Squatting | `mintNtfyTopic` | mitigate | Server-side `crypto.randomUUID()` slice (8 hex chars = 32 bits — sufficient unguessability for ntfy public tier; collision probability negligible at expected user count). Custom topic override is permitted but format-restricted to `^[A-Za-z0-9_-]{6,64}$` to prevent injection | +| T-09-02-08 | Repudiation | UPSERT semantics | accept | `notification_channels.updated_at` and `user_event_subscriptions.updated_at` are bumped on every write — sufficient audit for v1. No separate audit log table this phase | +| T-09-02-09 | Denial of Service | Test-send on save | accept | Single best-effort fetch per save; bounded by Node.js fetch default timeout. Save persists even if test fails (D-06). User-driven action so no DoS amplification | + +No `high` severity unmitigated. ASVS L1 satisfied: V4.1.5 (URL allowlist for outbound), V5.1.3 (input validation), V8.1.6 (auth context bound to write target), V12.6.1 (SSRF mitigation via hostname pattern). + + + + +- All five route files exist and export the expected handlers. +- All routes call `requireAuth()` and never extract user_id from body/query. +- `lib/services/personal-channels.ts` exports the four named symbols. +- `npx tsc --noEmit --pretty` exits 0. +- Manual smoke (post-deploy): `curl -X PUT /api/me/theme -d '{"theme":"hacker"}'` returns 400. +- Manual smoke: `curl -X PUT /api/me/channels/teams -d '{"webhook_url":"http://evil.com"}'` returns 400 (protocol + host mismatch). +- Manual smoke: `curl -X PUT /api/me/channels/ntfy -d '{}'` returns 200 with a `pulse-XXXXXXXX`-shaped topic. + + + +1. Five route files written with the correct handler exports. +2. `lib/services/personal-channels.ts` provides Teams URL validation, ntfy minting, and the test-send helper. +3. The Phase 9 frontend (Plan 04) and the notify.ts route_to_user implementation (Plan 03) can both consume `notification_channels` rows shaped by Plan 01 + this plan without further DB or API changes. +4. `npx tsc --noEmit --pretty` exits 0. + + + +After completion, create `.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md` documenting: +- Each route file: path, exported methods, validation rules +- The five symbols exported from `lib/services/personal-channels.ts` +- The matrix shape returned by `GET /api/me/notification-subscriptions` +- Confirmation that no route accepts user_id from request input +- Any deviations from the plan + diff --git a/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md new file mode 100644 index 0000000..0a33173 --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md @@ -0,0 +1,516 @@ +--- +phase: 09-user-profile-preferences-new +plan: 03 +type: execute +wave: 2 +depends_on: [09-01] +files_modified: + - lib/types/pipeline.ts + - lib/services/pipeline-steps/notify-resolvers.ts + - lib/services/pipeline-steps/notify.ts +autonomous: true +requirements: [ROUTE-01, ROUTE-02, ROUTE-03, ROUTE-04, ROUTE-05, ROUTE-06] + +must_haves: + truths: + - "When a notify step config has no route_to_user block, behavior is byte-identical to today (backward compatible)" + - "When route_to_user is present, notify.ts resolves a Pulse user from PipelineContext, checks the user_event_subscriptions matrix, and dispatches via the user's personal channel before falling back" + - "When the user has the (event_key, channel_type) toggle DISABLED, notify.ts records skipped_reason='user_muted' and does NOT fall back to the global channel_id" + - "When the user route can't deliver (no personal channel of the requested type, or HTTP send returns non-2xx, or user not found), notify.ts falls back to the step's channel_id and records output.user_route_fallback = { reason, user_id?, channel_type, error? }" + - "When route_to_user.channel_type is omitted, notify.ts attempts ntfy first, then teams, then global fallback (D-10)" + - "Three resolvers ship in v1: autotask_resource_email, direct_email, pulse_user_id — registered in a Map so adding a resolver is a one-file change" + artifacts: + - path: "lib/types/pipeline.ts" + provides: "RouteToUser, NotifyResolver, ResolvedRecipient, UserRouteFallback types" + contains: "interface RouteToUser" + - path: "lib/services/pipeline-steps/notify-resolvers.ts" + provides: "Resolver registry: autotask_resource_email, direct_email, pulse_user_id" + exports: ["resolveRecipient", "registerResolver", "RESOLVERS"] + - path: "lib/services/pipeline-steps/notify.ts" + provides: "Updated executeNotify with route_to_user branch + fallback semantics" + exports: [] + key_links: + - from: "lib/services/pipeline-steps/notify.ts" + to: "user_event_subscriptions" + via: "SELECT enabled FROM user_event_subscriptions WHERE user_id, event_key, channel_type" + pattern: "user_event_subscriptions" + - from: "lib/services/pipeline-steps/notify.ts" + to: "notification_channels personal lookup" + via: "SELECT WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true" + pattern: "owner_user_id =" + - from: "lib/services/pipeline-steps/notify.ts" + to: "lib/services/pipeline-steps/notify-resolvers.ts" + via: "import { resolveRecipient }" + pattern: "resolveRecipient" +--- + + +Extend the notify pipeline step with optional per-user routing while keeping every +existing pipeline byte-compatible. This plan: + +1. Adds the `route_to_user` shape to `lib/types/pipeline.ts` (ROUTE-01). +2. Creates `lib/services/pipeline-steps/notify-resolvers.ts` with three v1 resolvers and + a `Map` registry (ROUTE-02). +3. Rewrites `executeNotify` in `lib/services/pipeline-steps/notify.ts` to short-circuit + into a per-user branch when `route_to_user` is present, with the exact decision + tree from D-08 / D-10 / D-11 / D-12 (ROUTE-03..06). + +Out of scope: +- Admin filter UI for `user_route_fallback` events (Plan 05, ROUTE-07) +- Profile UI matrix that drives subscriptions (Plan 04, SUB-03) +- Personal channel CRUD endpoints (Plan 02, CHAN-*) + +Output: One new file (`notify-resolvers.ts`), one rewritten file (`notify.ts`), and +type additions in `lib/types/pipeline.ts`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/REQUIREMENTS.md +@CLAUDE.md +@lib/services/pipeline-steps/notify.ts +@lib/types/pipeline.ts +@lib/services/pipeline-engine.ts + + + + +Current executeNotify signature (must keep this signature): +```typescript +async function executeNotify( + step: PipelineStep, + _context: PipelineContext, + _executionId: number, +): Promise; +``` + +Existing helpers in notify.ts (preserve as-is, do NOT inline or remove): +```typescript +async function sendTeams(channel, config, message): Promise; +async function sendTelegram(channel, message): Promise; +async function sendNtfy(channel, config, message): Promise; +async function sendWebhook(channel, config, message): Promise; +``` + +Each helper returns `{ success: true, output: { notified: true, channel: 'teams'|'ntfy'|... } }` +on 2xx and `{ success: false, error: string }` on non-2xx — keep that contract. + +From lib/types/pipeline.ts (already extended in Plan 01): +```typescript +export interface NotificationChannel { + id: number; name: string; channel_type: ChannelType; + config: Record; is_active: boolean; + owner_user_id: string | null; + created_at: Date; updated_at: Date; +} +export interface PipelineContext { [key: string]: any; } +export interface StepExecutorResult { success: boolean; output?: Record; error?: string; waiting?: boolean; } +``` + +Resource table (used by autotask_resource_email resolver): +```sql +-- resources(id INTEGER PRIMARY KEY, email TEXT, ...) +SELECT email FROM resources WHERE id = $1 +``` + +Better Auth user lookup (case-insensitive email match — emails come from Autotask): +```sql +SELECT id FROM "user" WHERE LOWER(email) = LOWER($1) +``` + + + + + + + Task 1: Add route_to_user types and resolver registry + lib/types/pipeline.ts, lib/services/pipeline-steps/notify-resolvers.ts + + + - lib/types/pipeline.ts (current shape, post Plan 01 — types must extend, not replace) + - lib/services/pipeline-steps/notify.ts (current channel-only logic — must remain compatible) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-08 route_to_user shape, D-09 v1 resolvers) + + + + Step A — Append the following block to `lib/types/pipeline.ts` (after the `UserEventSubscription` interface added in Plan 01). Do not modify any existing type. + + ```typescript + // ============================================================================ + // Phase 9 — notify step per-user routing (ROUTE-01..06) + // ============================================================================ + + /** Resolver name; resolvers live in lib/services/pipeline-steps/notify-resolvers.ts. */ + export type ResolverName = 'autotask_resource_email' | 'direct_email' | 'pulse_user_id' | string; + + /** Optional block on a `notify` step's config. When present, the step attempts + * delivery via the resolved Pulse user's personal channel before falling back + * to the step's `channel_id`. */ + export interface RouteToUser { + /** PipelineContext key whose value holds the entity (e.g. 'ticket'). */ + source: string; + /** Field name within context[source] (e.g. 'assignedResourceID'). */ + field: string; + /** Resolver name. Looks up the user from the field value. */ + resolve: ResolverName; + /** Event key checked against user_event_subscriptions for muting (D-12). */ + event_key: string; + /** Optional preferred channel type (D-10). When omitted: ntfy then teams then fallback. */ + channel_type?: 'teams' | 'ntfy'; + } + + /** Output of a resolver. null = no recipient (notify.ts falls through to global). */ + export type ResolvedRecipient = + | { user_id: string } + | { email: string } + | null; + + /** A resolver fn. Pure async; reads from postgres if it needs to. */ + export type NotifyResolver = (fieldValue: unknown) => Promise; + + /** Reasons recorded in execution_step.output_data when fallback occurs (D-11). */ + export type UserRouteFallbackReason = + | 'no_channel' + | 'send_failed' + | 'user_not_found' + | 'no_field_value' + | 'resolver_unknown'; + + export interface UserRouteFallback { + reason: UserRouteFallbackReason; + user_id?: string; + channel_type: string; + error?: string; + } + ``` + + Step B — Create `lib/services/pipeline-steps/notify-resolvers.ts` with: + + - Imports: `postgresClient` from `../postgres-client`, types from `../../types/pipeline`. + - Three resolver functions implementing the `NotifyResolver` signature: + - `directEmail`: returns `{ email: value }` only when `typeof value === 'string'` AND matches `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`. Otherwise `null`. + - `pulseUserId`: queries `SELECT id FROM "user" WHERE id = $1` with the value (when string + non-empty); returns `{ user_id }` or `null`. + - `autotaskResourceEmail`: coerces value to integer (accepts number directly OR string matching `/^\d+$/`); queries `SELECT email FROM resources WHERE id = $1`; returns `{ email }` when row.email is non-empty, else `null`. + - `export const RESOLVERS: Map` initialized with all three: keys `'direct_email'`, `'pulse_user_id'`, `'autotask_resource_email'`. + - `export function registerResolver(name: ResolverName, fn: NotifyResolver): void` that calls `RESOLVERS.set(name, fn)`. + - `export async function resolveRecipient(name: ResolverName, fieldValue: unknown): Promise<{ recipient: ResolvedRecipient; resolverFound: boolean }>`: + - Look up `RESOLVERS.get(name)`. Missing → return `{ recipient: null, resolverFound: false }`. + - Try the resolver in a try/catch — on throw, return `{ recipient: null, resolverFound: true }` (the resolver was found but failed). + + Side-effect-free: file imports postgresClient (lazy-init on first query) and exports a Map. NO worker startup. Safe to import from notify.ts. + + The file must follow the established CLAUDE.md conventions: kebab-case filename, camelCase identifiers, no Zod, no ORM, manual SQL via `postgresClient.query(...)`. + + + + grep -q "interface RouteToUser" lib/types/pipeline.ts && grep -q "type ResolvedRecipient" lib/types/pipeline.ts && grep -q "interface UserRouteFallback" lib/types/pipeline.ts && test -f lib/services/pipeline-steps/notify-resolvers.ts && grep -q "export const RESOLVERS" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "autotask_resource_email" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "direct_email" lib/services/pipeline-steps/notify-resolvers.ts && grep -q "pulse_user_id" lib/services/pipeline-steps/notify-resolvers.ts && npx tsc --noEmit --pretty 2>&1 | head -20 + + + + - `lib/types/pipeline.ts` contains `export interface RouteToUser {` + - `lib/types/pipeline.ts` contains the `event_key` field within `RouteToUser` + - `lib/types/pipeline.ts` contains `export type ResolvedRecipient` + - `lib/types/pipeline.ts` contains `export interface UserRouteFallback {` + - `lib/types/pipeline.ts` contains the literal type union `'no_channel' | 'send_failed' | 'user_not_found' | 'no_field_value' | 'resolver_unknown'` (each value present) + - `lib/services/pipeline-steps/notify-resolvers.ts` exists + - File contains `export const RESOLVERS` + - File contains a `'direct_email'` Map key, a `'pulse_user_id'` Map key, and an `'autotask_resource_email'` Map key + - File contains `export function registerResolver` + - File contains `export async function resolveRecipient` + - `autotask_resource_email` resolver SQL is exactly `SELECT email FROM resources WHERE id = $1` + - `pulse_user_id` resolver SQL is exactly `SELECT id FROM "user" WHERE id = $1` + - File does NOT import or call any worker side-effect (no `setInterval`, `setTimeout` at module scope, no `start()` function calls at top level) + - `npx tsc --noEmit --pretty` reports no errors in either file + + + + Types added to `lib/types/pipeline.ts`. Resolver registry file exists with three v1 resolvers, a registration function, and the resolver dispatcher. Plan Task 2 can now import `resolveRecipient` from this file. + + + + + Task 2: Rewrite executeNotify with route_to_user branch and fallback semantics + lib/services/pipeline-steps/notify.ts + + + - lib/services/pipeline-steps/notify.ts (current implementation — preserve sendTeams/sendTelegram/sendNtfy/sendWebhook helpers verbatim) + - lib/services/pipeline-steps/notify-resolvers.ts (just created in Task 1 — the source of `resolveRecipient`) + - lib/types/pipeline.ts (RouteToUser, UserRouteFallback added in Task 1) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-08 shape, D-10 ntfy-then-teams default, D-11 fallback rules, D-12 mute = skip silently) + - lib/services/pipeline-engine.ts lines 380-410 (how output_data is persisted on the execution_step row — your output object becomes that row's output_data) + + + + Rewrite `lib/services/pipeline-steps/notify.ts` so: + + 1. Imports add: `import { resolveRecipient } from './notify-resolvers';` and types `RouteToUser, UserRouteFallback, ResolvedRecipient` from `../../types/pipeline`. + + 2. Keep the `sendTeams`, `sendTelegram`, `sendNtfy`, `sendWebhook` helpers UNCHANGED at the bottom of the file. + + 3. Replace `executeNotify` with the following decision tree. Use `_context` and `_executionId` (rename to `context` and `executionId` since they're now used). + + **Top of executeNotify:** + + ```typescript + async function executeNotify( + step: PipelineStep, + context: PipelineContext, + _executionId: number, + ): Promise { + const channelId = Number(step.config.channel_id); + if (!channelId || isNaN(channelId)) { + return { success: false, error: 'Missing or invalid channel_id' }; + } + + const route = (step.config.route_to_user as RouteToUser | undefined) ?? null; + const message = step.config.message || ''; + + // BACKWARD COMPATIBLE PATH: no route_to_user → original behavior verbatim. + if (!route) { + return await dispatchToGlobalChannel(channelId, step.config, message); + } + + // USER-ROUTE PATH (ROUTE-01..06) + return await dispatchUserRoute({ step, context, message, channelId, route }); + } + ``` + + 4. Extract the existing global dispatch into a helper called `dispatchToGlobalChannel(channelId, config, message)` that runs the EXACT SQL + switch the current `executeNotify` runs (load the channel by id, dispatch by channel_type, return). This is a pure refactor — the SQL `SELECT * FROM notification_channels WHERE id = $1 AND is_active = true` and the switch on `channel.channel_type` move from `executeNotify` into this helper, returning the same StepExecutorResult. + + 5. Implement `dispatchUserRoute(args: { step, context, message, channelId, route })`: + + **Step 5a — Read field value from context:** + ```typescript + const sourceObj = context[route.source]; + const fieldValue = sourceObj?.[route.field]; + if (fieldValue === undefined || fieldValue === null || fieldValue === '') { + // No field value to resolve — fall back to global with reason + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'no_field_value', channel_type: route.channel_type ?? 'auto' }, + }); + } + ``` + + **Step 5b — Resolve recipient:** + ```typescript + const { recipient, resolverFound } = await resolveRecipient(route.resolve, fieldValue); + if (!resolverFound) { + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'resolver_unknown', channel_type: route.channel_type ?? 'auto', error: `Unknown resolver: ${route.resolve}` }, + }); + } + if (!recipient) { + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'user_not_found', channel_type: route.channel_type ?? 'auto' }, + }); + } + ``` + + **Step 5c — Resolve to Pulse user.id:** + - If `recipient` has `user_id`, use it directly. + - Else (has `email`): `SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)` using `recipient.email`. + - If no user row → fallback with reason `'user_not_found'`. + + **Step 5d — Determine channel-type attempt order (ROUTE-06):** + ```typescript + const attemptOrder: Array<'ntfy' | 'teams'> = + route.channel_type ? [route.channel_type] : ['ntfy', 'teams']; + ``` + + **Step 5e — For each attempted type, check mute then send:** + ```typescript + for (const channelType of attemptOrder) { + // ROUTE-05 mute check — query user_event_subscriptions + const subRes = await postgresClient.query<{ enabled: boolean }>( + `SELECT enabled FROM user_event_subscriptions + WHERE user_id = $1 AND event_key = $2 AND channel_type = $3`, + [userId, route.event_key, channelType], + ); + // Default-enabled (D-15): row absent = enabled = true + const enabled = subRes.rows.length === 0 ? true : subRes.rows[0].enabled; + if (!enabled) { + // ROUTE-05: skip silently, NO fallback + return { + success: true, + output: { + notified: false, + skipped_reason: 'user_muted', + user_id: userId, + event_key: route.event_key, + channel_type: channelType, + }, + }; + } + + // Look up the user's personal channel of this type + const chanRes = await postgresClient.query( + `SELECT * FROM notification_channels + WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true + LIMIT 1`, + [userId, channelType], + ); + if (chanRes.rows.length === 0) { + // No personal channel of this type — try the next type in attemptOrder. + // If this was the last type attempted, the loop falls through to global fallback. + if (channelType === attemptOrder[attemptOrder.length - 1]) { + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'no_channel', user_id: userId, channel_type: channelType }, + }); + } + continue; + } + + // Dispatch to the personal channel using existing helpers + const dispatchResult = + channelType === 'teams' ? await sendTeams(chanRes.rows[0], step.config, message) : + await sendNtfy(chanRes.rows[0], step.config, message); + + if (dispatchResult.success) { + return { + success: true, + output: { + ...(dispatchResult.output ?? {}), + user_route: { user_id: userId, channel_type: channelType, event_key: route.event_key }, + }, + }; + } + + // Send failed — try next type if any, else fall back + if (channelType === attemptOrder[attemptOrder.length - 1]) { + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'send_failed', user_id: userId, channel_type: channelType, error: dispatchResult.error }, + }); + } + } + + // Defensive: should never reach here because the loop always returns or continues + return await fallbackToGlobal({ + channelId, config: step.config, message, + fallback: { reason: 'no_channel', user_id: userId, channel_type: 'auto' }, + }); + ``` + + **Step 5f — Implement `fallbackToGlobal(args)`:** + + ```typescript + async function fallbackToGlobal(args: { + channelId: number; + config: Record; + message: string; + fallback: UserRouteFallback; + }): Promise { + const result = await dispatchToGlobalChannel(args.channelId, args.config, args.message); + // Annotate output with the fallback reason regardless of global success/failure + if (result.success) { + return { + success: true, + output: { + ...(result.output ?? {}), + user_route_fallback: args.fallback, + }, + }; + } + // Global also failed — return error with fallback context attached + return { + success: false, + error: result.error, + output: { user_route_fallback: args.fallback }, + }; + } + ``` + + 6. Keep `registerStepExecutor('notify', executeNotify);` at the bottom (unchanged). + + **Critical correctness notes:** + - The mute path (`enabled === false`) returns `success: true` but `notified: false` and DOES NOT call any send function and DOES NOT call `fallbackToGlobal`. This satisfies ROUTE-05 / D-12 ("muting must actually mute"). + - The no-personal-channel and send-failure paths DO call `fallbackToGlobal` (ROUTE-04 / D-11). + - The output object is what `pipeline-engine.ts updateStepLog` JSON-serializes into `pipeline_execution_steps.output_data` — admins can later filter on `output_data->>'user_route_fallback' IS NOT NULL` for ROUTE-07. + + + + grep -q "route_to_user" lib/services/pipeline-steps/notify.ts && grep -q "resolveRecipient" lib/services/pipeline-steps/notify.ts && grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.ts && grep -q "owner_user_id = " lib/services/pipeline-steps/notify.ts && grep -q "user_route_fallback" lib/services/pipeline-steps/notify.ts && grep -q "skipped_reason" lib/services/pipeline-steps/notify.ts && grep -q "user_muted" lib/services/pipeline-steps/notify.ts && grep -q "dispatchToGlobalChannel\|dispatchUserRoute\|fallbackToGlobal" lib/services/pipeline-steps/notify.ts && grep -q "registerStepExecutor('notify', executeNotify)" lib/services/pipeline-steps/notify.ts && npx tsc --noEmit --pretty 2>&1 | head -20 + + + + - `lib/services/pipeline-steps/notify.ts` still calls `registerStepExecutor('notify', executeNotify)` at the bottom (no behavior break) + - File still defines all four send helpers: `sendTeams`, `sendTelegram`, `sendNtfy`, `sendWebhook` (preserved verbatim) + - File contains `import { resolveRecipient } from './notify-resolvers';` + - File contains the literal substring `route_to_user` (config key) + - File contains a SELECT statement reading `user_event_subscriptions` with the predicate `WHERE user_id = $1 AND event_key = $2 AND channel_type = $3` + - File contains a SELECT statement reading `notification_channels` with the predicate `WHERE owner_user_id = $1 AND channel_type = $2 AND is_active = true` + - File contains the literal string `'user_muted'` (mute disposition) + - File contains the literal string `user_route_fallback` (fallback output key) + - File contains the literal strings `'no_channel'`, `'send_failed'`, `'user_not_found'`, `'no_field_value'`, `'resolver_unknown'` (all five fallback reasons) + - File contains the literal `['ntfy', 'teams']` (default channel-type order — ROUTE-06) + - In the mute branch (`enabled === false`), the function returns `success: true` and the code path does NOT call `dispatchToGlobalChannel` or `fallbackToGlobal` (verified by grep showing the mute return has no fallback call between `if (!enabled)` and the next `}`) + - When `step.config.route_to_user` is undefined/null, `executeNotify` calls only `dispatchToGlobalChannel` — no resolver, no subscription query (backward-compat path) + - `npx tsc --noEmit --pretty` reports no errors in this file + - `npm test -- --run lib/services/pipeline-steps` does not crash on import (resolver registry side-effect-safe) + + + + `notify.ts` honors the `route_to_user` block per the D-08..D-12 decision tree. Pipelines without `route_to_user` continue to work exactly as today. Fallback events are recorded in `output.user_route_fallback` for the admin filter in Plan 05. + + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Pipeline runtime → resolved user | Field value from PipelineContext crosses into a Postgres lookup | +| Pipeline runtime → personal channel send | Personal webhook URL / ntfy topic is dispatched to external host | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-03-01 | Information Disclosure | `dispatchUserRoute` per-user resolver | mitigate | Resolver only returns the SINGLE recipient resolved from the field value. notify.ts never broadcasts to multiple users in a single step. The `LIMIT 1` on the personal channel SELECT enforces single-recipient semantics | +| T-09-03-02 | Tampering / Bypass | mute → fallback path | mitigate | The `if (!enabled)` branch in Task 2 Step 5e returns directly with `success: true` and DOES NOT call `fallbackToGlobal`. Acceptance criteria verifies via grep that no fallback call exists in the mute branch — preserves user opt-out (D-12 / ROUTE-05) | +| T-09-03-03 | Spoofing / Identity | `pulse_user_id` resolver | mitigate | Validates the field value as a non-empty string then performs an existence check (`SELECT id FROM "user" WHERE id = $1`). A malicious context payload claiming to be another user fails the existence check OR routes only to that user's own channel — no cross-user delivery possible | +| T-09-03-04 | Information Disclosure | resolver registry side-effects | mitigate | `notify-resolvers.ts` imports only `postgresClient` and types. No worker side effects (sync-scheduler, analyzer, RMM). Verified by grep that no module-scope `setInterval`/`start()` exists | +| T-09-03-05 | Denial of Service | resolver throws inside notify.ts | mitigate | `resolveRecipient` wraps the resolver call in try/catch and returns `{ recipient: null, resolverFound: true }` on throw. notify.ts treats this as `user_not_found` and falls back to global — no crash, no stuck pipeline | +| T-09-03-06 | Repudiation | fallback event recording | mitigate | Every fallback path writes a `user_route_fallback: { reason, user_id?, channel_type, error? }` object onto `output`, persisted by `pipeline-engine.ts updateStepLog` to `pipeline_execution_steps.output_data`. Plan 05 surfaces this in `/admin/workflow/executions` for review | +| T-09-03-07 | Information Disclosure | error string in `user_route_fallback.error` | mitigate | `dispatchResult.error` from existing `sendTeams`/`sendNtfy` is already truncated to 200 chars (verified by reading current notify.ts). No webhook URL is echoed | + +No `high` severity unmitigated. ASVS L1 satisfied: V8.1.6 (auth context bound to recipient), V12.6.1 (no SSRF amplification — personal channels validated at write-time in Plan 02), V13.1.1 (auditing of routing decisions via output_data). + + + + +- `npx tsc --noEmit --pretty` exits 0 across the three modified files. +- A pipeline with no `route_to_user` produces the same SQL trace and same StepExecutorResult shape it produces today (backward compat). +- A pipeline with `route_to_user.event_key` set, where the user has the toggle off, produces output `{ skipped_reason: 'user_muted', ... }` and never queries `notification_channels` for the personal channel. +- A pipeline with `route_to_user` set but no personal channel of the requested type produces output containing `user_route_fallback.reason === 'no_channel'`. +- A pipeline with `route_to_user.channel_type` omitted attempts ntfy first, then teams, before falling back. + + + +1. Three files modified: `lib/types/pipeline.ts`, `lib/services/pipeline-steps/notify-resolvers.ts` (new), `lib/services/pipeline-steps/notify.ts` (rewritten). +2. `npx tsc --noEmit --pretty` exits 0. +3. All existing tests under `lib/services/` continue to pass: `npm test`. +4. Backward compat: a step with `step.config.channel_id = 7` and no `route_to_user` produces identical behavior to pre-Phase-9. +5. Plans 04 (UI) and 05 (admin filter) can rely on the `output.user_route_fallback` shape stabilized here. + + + +After completion, create `.planning/phases/09-user-profile-preferences-new/09-03-SUMMARY.md` documenting: +- Type additions to `lib/types/pipeline.ts` (RouteToUser, ResolvedRecipient, NotifyResolver, UserRouteFallback, UserRouteFallbackReason) +- The three v1 resolvers in `notify-resolvers.ts` and how to add a fourth +- The decision tree implemented in `executeNotify`: backward-compat path → user-route path → mute → personal-send → fallback +- Confirmation: backward compat verified (a step without `route_to_user` runs the legacy path verbatim) +- The `output` shapes: `user_route` (success), `skipped_reason: 'user_muted'` (mute), `user_route_fallback` (fallback) + diff --git a/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md new file mode 100644 index 0000000..fb594d0 --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md @@ -0,0 +1,637 @@ +--- +phase: 09-user-profile-preferences-new +plan: 04 +type: execute +wave: 3 +depends_on: [09-02] +files_modified: + - app/mobile/profile/page.tsx + - components/mobile/profile/ProfileTimezoneSection.tsx + - components/mobile/profile/ProfileThemeSection.tsx + - components/mobile/profile/ProfileNotificationMatrix.tsx + - components/mobile/profile/ProfileChannelsSection.tsx + - components/mobile/profile/ProfileSectionSkeleton.tsx + - components/mobile/profile/ThemeSessionBridge.tsx + - components/mobile/MoreDrawer.tsx + - components/theme-toggle.tsx + - app/layout.tsx + - package.json +autonomous: true +requirements: [PROF-01, PROF-02, PROF-03, PROF-04, TZ-CHOOSER-01, TZ-CHOOSER-02, THEME-03, THEME-04, SUB-03] + +must_haves: + truths: + - "Tapping 'Profile & preferences' in the More drawer Account section navigates to /mobile/profile" + - "/mobile/profile is gated by requireAuth() server-side and renders four shadcn Cards in order: Timezone, Theme, Notifications, Channels" + - "Timezone Card uses a shadcn Combobox (Command + Popover) populated from Intl.supportedValuesOf('timeZone') plus EXTRA_ALLOWED_TIMEZONES; selecting a value debounces 400ms then PUTs /api/me/timezone and shows a sonner success/error toast" + - "Timezone Card shows a read-only 'Your current time: {time} in {zone}' line below the picker, formatted via useUserTimezone() and rendered with the .num utility class" + - "Theme Card renders three radio rows (Light/Dark/System) with Sun/Moon/Monitor icons; selecting a row immediately calls setTheme() (next-themes) and PUTs /api/me/theme" + - "Notifications Card renders a per-event-key × per-channel-type matrix from GET /api/me/notification-subscriptions, defaulting missing cells to enabled=true; toggle saves are debounced 400ms via PUT and revert on error" + - "Channels Card has a Teams sub-section (URL Input + Save/Clear buttons) and an ntfy sub-section that mints on first save then displays subscribe link + QR code; both show test-send result inline" + - "On session load and after sign-in, ThemeSessionBridge compares session.user.theme to next-themes useTheme() and calls setTheme(session.user.theme) when they differ" + - "ThemeToggle (desktop) writes through to PUT /api/me/theme on every setTheme call" + - "MoreDrawer Account section's user-identity row becomes a Link to /mobile/profile, with a 'Profile & preferences' label, sitting above the Sign-out destructive action" + artifacts: + - path: "app/mobile/profile/page.tsx" + provides: "/mobile/profile page (server-component shell + client orchestration)" + contains: "requireAuth" + - path: "components/mobile/profile/ProfileTimezoneSection.tsx" + provides: "Timezone Card (Combobox + current time)" + - path: "components/mobile/profile/ProfileThemeSection.tsx" + provides: "Theme Card (3-option radio rows + write-through)" + - path: "components/mobile/profile/ProfileNotificationMatrix.tsx" + provides: "Notifications matrix Card with debounced PUT per cell" + - path: "components/mobile/profile/ProfileChannelsSection.tsx" + provides: "Channels Card (Teams + ntfy sub-sections, QR, test result inline)" + - path: "components/mobile/profile/ProfileSectionSkeleton.tsx" + provides: "Generic 3-row pulsing Skeleton for any section" + - path: "components/mobile/profile/ThemeSessionBridge.tsx" + provides: "Client effect comparing session.user.theme to next-themes; calls setTheme on mismatch" + - path: "components/mobile/MoreDrawer.tsx" + provides: "Account section with Profile & preferences link" + contains: "Profile & preferences" + - path: "components/theme-toggle.tsx" + provides: "Session-aware ThemeToggle that writes through to /api/me/theme" + contains: "/api/me/theme" + key_links: + - from: "app/mobile/profile/page.tsx" + to: "/api/me/timezone, /api/me/theme, /api/me/channels, /api/me/notification-subscriptions" + via: "client fetch from each section" + pattern: "fetch.*api/me" + - from: "ThemeSessionBridge" + to: "next-themes useTheme" + via: "setTheme(session.user.theme) on mismatch" + pattern: "setTheme" + - from: "ThemeToggle" + to: "/api/me/theme PUT" + via: "fetch on setTheme callback" + pattern: "method:.*PUT" + - from: "MoreDrawer.tsx Account section" + to: "/mobile/profile" + via: "Link href in identity row + new 'Profile & preferences' row" + pattern: "/mobile/profile" +--- + + +Build the entire Phase 9 mobile profile UI surface plus the theme-bridge plumbing that +makes the server's `theme` value canonical app-wide. This plan has 5 tasks because the +work spans multiple loosely-coupled subsystems (one task per section, one task for +theme bridging, one task for drawer wiring). Despite the count, each task is small +(15–30 min) thanks to the UI-SPEC contract. + +Output: 7 new files in `components/mobile/profile/`, 1 new page, 3 modified files. + +Per UI-SPEC contract `.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md`: +- Page H1: "Profile & Preferences" (text-xl font-semibold) +- Section gap: `space-y-4` +- Each Card: shadcn Card with CardHeader (`px-4 pt-4 pb-0`) + CardContent (`px-4 py-4`) +- Page wrapper: `
` (inherits `max-w-lg mx-auto` from layout) +- All interactive rows ≥ 44px height (`min-h-[44px]` or `py-3`) +- Color tokens only — never hex / raw palette +- Numeric values use `.num` utility (IBM Plex Mono) +- Inline errors `text-xs text-destructive`; toasts via sonner + +Adding `qrcode.react` (~10KB gzip) is the only new dependency. UI-SPEC notes this is +acceptable; reviewed for "no network-call side effects at render time" (it's pure +client-side SVG generation). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md +@.planning/REQUIREMENTS.md +@CLAUDE.md +@components/mobile/MoreDrawer.tsx +@components/theme-toggle.tsx +@components/theme-provider.tsx +@app/layout.tsx +@app/api/me/timezone/route.ts +@lib/hooks/use-user-timezone.ts +@components/ui/card.tsx +@components/ui/command.tsx +@components/ui/popover.tsx +@components/ui/switch.tsx +@components/mobile/EngagementProfileMetricGrid.tsx + + + + +GET /api/me/timezone → { timezone: string, source: 'user' | 'default' } +PUT /api/me/timezone → body { timezone: string } → { timezone: string } + +GET /api/me/theme → { theme: 'light' | 'dark' | 'system', source: 'user' | 'default' } +PUT /api/me/theme → body { theme: 'light'|'dark'|'system' } → { theme } + +GET /api/me/channels → { channels: Array<{ id, name, channelType, config, isActive, ownerUserId, createdAt, updatedAt }> } +PUT /api/me/channels/teams → body { webhook_url } → { channel, test: { ok, status?, error? } } +PUT /api/me/channels/ntfy → body { topic? } → { channel, test } +DELETE /api/me/channels/{type} → { deleted: true, channelType } +POST /api/me/channels/{type}/test → { test: ChannelTestResult } + +GET /api/me/notification-subscriptions → + { + eventKeys: Array<{ key, displayLabel, description, sortOrder }>, + channelTypes: Array<'teams' | 'ntfy'>, + matrix: Record> + } +PUT /api/me/notification-subscriptions → body { event_key, channel_type, enabled } → { subscription } + +useUserTimezone() (existing hook): () => string // IANA timezone + +EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT'] + + + + + + + Task 1: Page shell, MoreDrawer wiring, and skeleton helper + app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx + + + - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md (Page Layout Contract section, MoreDrawer drawer link addition) + - components/mobile/MoreDrawer.tsx (current Account section structure) + - app/mobile/layout.tsx (page wrapper inherits max-w-lg) + - lib/auth-utils.ts (requireAuth signature for server component) + - components/mobile/EngagementProfileSkeleton.tsx (Skeleton precedent if it exists; otherwise mirror Card + 3 Skeleton rows) + + + + Step A — Create `components/mobile/profile/ProfileSectionSkeleton.tsx` (client component): + + - Exports default `ProfileSectionSkeleton` + - Renders a shadcn `Card` with `CardHeader` containing one `Skeleton` (h-5 w-32) and `CardContent` (`px-4 py-4`) containing 3 stacked `Skeleton` rows (each `h-10 w-full`, `space-y-3` between them) + - Uses `Skeleton` from `@/components/ui/skeleton` + + Step B — Create `app/mobile/profile/page.tsx` as a Next.js server component shell: + + ```tsx + // /mobile/profile (PROF-01) — gated by requireAuth(), server-rendered shell + // hosts the four client sections. + + import { redirect } from 'next/navigation'; + import { requireAuth } from '@/lib/auth-utils'; + import { ProfileTimezoneSection } from '@/components/mobile/profile/ProfileTimezoneSection'; + import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection'; + import { ProfileNotificationMatrix } from '@/components/mobile/profile/ProfileNotificationMatrix'; + import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection'; + + export default async function MobileProfilePage() { + const { session, error } = await requireAuth(); + if (error) { + // requireAuth returns a NextResponse 401 on miss; for a page route we redirect to sign-in + redirect('/auth/sign-in'); + } + return ( +
+

Profile & Preferences

+
+ + + + +
+
+ ); + } + ``` + + Note: `requireAuth()` returns a NextResponse on miss — for a Page route we cannot return a Response directly; redirect to `/auth/sign-in` instead. The `session` variable is unused in the shell (each child component reads `useSession()` for what it needs) — that is intentional; the auth check is the gate. + + Step C — Modify `components/mobile/MoreDrawer.tsx` Account section: + + - Add `import { Settings as SettingsIcon } from 'lucide-react';` (alongside existing icons; if `Settings` is already imported keep it). + - Replace the existing Account section's `
` block so the inner structure becomes (in this exact order): + + 1. The current user-identity row, BUT wrapped in a `...inner identity content...`. Preserve the existing avatar circle, name, and email markup verbatim — only the wrapping element changes from a non-clickable `div` to a `Link` (PROF-04: "the existing user-identity row becomes the tappable link"). + + 2. A new row immediately below: `Profile & preferences`. + + 3. The existing Sign-out button (unchanged). + + - Do NOT remove or restructure any other drawer section (Mobile sections / Full site stay exactly as today). + + + + test -f app/mobile/profile/page.tsx && test -f components/mobile/profile/ProfileSectionSkeleton.tsx && grep -q "ProfileTimezoneSection" app/mobile/profile/page.tsx && grep -q "ProfileThemeSection" app/mobile/profile/page.tsx && grep -q "ProfileNotificationMatrix" app/mobile/profile/page.tsx && grep -q "ProfileChannelsSection" app/mobile/profile/page.tsx && grep -q "requireAuth" app/mobile/profile/page.tsx && grep -q '/mobile/profile' components/mobile/MoreDrawer.tsx && grep -q "Profile & preferences\|Profile & preferences" components/mobile/MoreDrawer.tsx && npx tsc --noEmit --pretty 2>&1 | head + + + + - `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, redirects to `/auth/sign-in` on miss + - File renders the four section components in this exact order: ProfileTimezoneSection, ProfileThemeSection, ProfileNotificationMatrix, ProfileChannelsSection + - File contains `

Profile & Preferences

` (UI-SPEC heading copy) + - Page wrapper is `
` and inner div is `
` (UI-SPEC layout) + - `components/mobile/profile/ProfileSectionSkeleton.tsx` exports default; uses Card + Skeleton primitives; renders 3 skeleton rows + - `components/mobile/MoreDrawer.tsx` Account section contains a `Link` with `href="/mobile/profile"` wrapping the identity row + - `components/mobile/MoreDrawer.tsx` Account section contains a new row with the literal text `Profile & preferences` (lowercase 'p' for 'preferences' per UI-SPEC copy table) + - `components/mobile/MoreDrawer.tsx` still contains the Sign-out button using `text-destructive` and `signOut()` (existing destructive action preserved) + - `npx tsc --noEmit --pretty` reports no errors + + + + Page shell exists, drawer routes to it, skeleton helper available for each section's loading state. + + + + + Task 2: ProfileTimezoneSection (Combobox + current time) + components/mobile/profile/ProfileTimezoneSection.tsx + + + - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Timezone Card", Copywriting Contract → Timezone) + - app/api/me/timezone/route.ts (EXTRA_ALLOWED_TIMEZONES list, response shape) + - lib/hooks/use-user-timezone.ts (existing hook to consume) + - components/ui/command.tsx (cmdk Combobox primitives) + - components/ui/popover.tsx (Popover primitives) + - app/styles/brand.css (verify .num utility exists; if not, the executor uses `font-mono` Tailwind class instead — document the choice in the SUMMARY) + + + + Create `components/mobile/profile/ProfileTimezoneSection.tsx` as a `'use client'` component. + + Layout per UI-SPEC: + - shadcn Card; CardHeader with CardTitle "Timezone" (text-xl font-semibold) + - CardContent (px-4 py-4): + - A `Combobox` built from `Popover` + `Command` (cmdk via shadcn). + - Trigger button: full width, displays the currently selected zone (e.g., "America/Chicago"), shows `` icon (lucide). + - Popover content: `No matches.` rendering one `` per timezone. + - The list combines `Intl.supportedValuesOf('timeZone')` (call once via `useMemo`) with the four `EXTRA_ALLOWED_TIMEZONES` (`UTC`, `Etc/UTC`, `GMT`, `Etc/GMT`), de-duplicated; sorted ASC. + - Each CommandItem `value={zone}` and renders the zone string. The selected zone shows a `` (lucide) icon at the right edge. + - Below the trigger: a paragraph "Your current time: {formattedTime} in {selectedZone}". The {formattedTime} span has class `font-mono tabular-nums` (use this if the codebase has no `.num` utility) and is computed via `new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'short', timeZone: selectedZone }).format(new Date())`. The line itself uses `text-xs text-muted-foreground`. + + Behavior: + - On mount: `fetch('/api/me/timezone')` → set `selectedZone = data.timezone`. Render `ProfileSectionSkeleton` while loading. + - On select: optimistically nothing — set a `pending` boolean to true, debounce 400ms via `setTimeout`, then `fetch('/api/me/timezone', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timezone: zone }) })`. + - On 200: `setSelectedZone(data.timezone)`, `toast.success('Timezone updated')` (sonner, copy from UI-SPEC). + - On non-2xx: keep prior selection, render inline `text-xs text-destructive` "Couldn't save. Try again." below the picker, `toast.error('Failed to update timezone')`. + - The "current time" line re-renders whenever `selectedZone` changes; also use `useEffect` with `setInterval(() => forceUpdate(), 60000)` to re-tick the displayed minute (clean up on unmount). + + Imports from project: + - `Card, CardHeader, CardTitle, CardContent` from `@/components/ui/card` + - `Command, CommandEmpty, CommandInput, CommandItem, CommandList` from `@/components/ui/command` + - `Popover, PopoverContent, PopoverTrigger` from `@/components/ui/popover` + - `Button` from `@/components/ui/button` + - `ChevronsUpDown, Check` from `lucide-react` + - `toast` from `sonner` + - `ProfileSectionSkeleton` from `./ProfileSectionSkeleton` + + Do NOT use `useUserTimezone()` here — this Section IS the source of truth that writes the value; reading via the hook would create a stale-loop. The Section calls `/api/me/timezone` directly to read and write. + + + + test -f components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Intl.supportedValuesOf" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "EXTRA_ALLOWED_TIMEZONES\|'UTC'" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "/api/me/timezone" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "method: 'PUT'\|method: \"PUT\"" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Search timezones" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "Your current time" components/mobile/profile/ProfileTimezoneSection.tsx && grep -q "toast.success" components/mobile/profile/ProfileTimezoneSection.tsx && npx tsc --noEmit --pretty 2>&1 | head + + + + - File starts with `'use client';` + - File imports Card primitives from `@/components/ui/card` + - File imports Command primitives from `@/components/ui/command` + - File imports Popover primitives from `@/components/ui/popover` + - File imports `toast` from `sonner` + - File contains `Intl.supportedValuesOf('timeZone')` + - File contains all four EXTRA_ALLOWED_TIMEZONES strings: `'UTC'`, `'Etc/UTC'`, `'GMT'`, `'Etc/GMT'` + - File contains the literal placeholder `Search timezones…` + - File contains the literal label `Your current time:` + - File contains a fetch to `/api/me/timezone` with `method: 'PUT'` (or `"PUT"`) + - File debounces save with `setTimeout` ≥ 350ms (search for `400` or `setTimeout`) + - On success calls `toast.success('Timezone updated')` + - On error calls `toast.error('Failed to update timezone')` AND renders inline `text-xs text-destructive` with copy `Couldn't save. Try again.` + - `npx tsc --noEmit --pretty` reports no errors + + + + Timezone section renders, persists, and shows live current-time. Saves are debounced 400ms; errors don't optimistically update. + + + + + Task 3: ProfileThemeSection + ProfileNotificationMatrix + components/mobile/profile/ProfileThemeSection.tsx, components/mobile/profile/ProfileNotificationMatrix.tsx + + + - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Theme Card", "Section: Notifications Card", Copywriting Contract) + - app/api/me/theme/route.ts (Plan 02) — request/response shape + - app/api/me/notification-subscriptions/route.ts (Plan 02) — matrix shape + - components/ui/switch.tsx + - components/theme-toggle.tsx (existing setTheme pattern) + - components/mobile/profile/ProfileSectionSkeleton.tsx (Task 1 — loading state) + + + + File 1 — `components/mobile/profile/ProfileThemeSection.tsx` (`'use client'`): + + - Card with CardTitle "Theme" + - CardContent renders three rows (NOT a Switch — a 3-position radio group), each `min-h-[44px] flex items-center gap-3 px-2 py-3 rounded-md cursor-pointer hover:bg-accent`. Use `role="radiogroup"` on the wrapping div and `role="radio" aria-checked={selected === value}` on each row. + - Row contents: `` (Sun for Light, Moon for Dark, Monitor for System) + label span (text-sm) + a `` indicator at the right when active. + - Active row uses `text-primary` on the icon and label. + - On mount: read `useTheme()` from `next-themes` for current selection (initial render). Then `fetch('/api/me/theme')` and if `data.theme !== currentTheme` call `setTheme(data.theme)` (server canonical). + - On row click for value V: + - Call `setTheme(V)` immediately (optimistic local render, per UI-SPEC interaction contract: theme is the only optimistic case). + - In parallel, `fetch('/api/me/theme', { method: 'PUT', body: JSON.stringify({ theme: V }) })`. + - On 200: `toast.success('Theme updated')` + - On error: rollback `setTheme(previous)`, `toast.error('Failed to update theme')`. + + File 2 — `components/mobile/profile/ProfileNotificationMatrix.tsx` (`'use client'`): + + - Card with CardTitle "Notifications" and CardDescription "Choose which events trigger a personal notification." (UI-SPEC copy). + - State: `loading`, `error`, `data: { eventKeys, channelTypes, matrix } | null`. + - On mount: fetch `/api/me/notification-subscriptions`. While loading, render ``. + - Empty state — when `data.channelTypes.length === 0`: render `

Configure a Teams or ntfy channel below to enable personal notifications.

` (no skeleton, no matrix). + - Loaded state: + - When `channelTypes.length === 1`: render simple list of rows. Each row: `
{eventKey.displayLabel}
`. + - When `channelTypes.length > 1`: render a header row: `
... per-type column header (text-xs text-center capitalize)
`. Then per event-key row using the same grid: label cell + one Switch per channel type column. + - Switch checked state = `data.matrix[eventKey.key]?.[channelType] ?? true` (default-enabled per D-15). + - On Switch toggle for (event_key, channel_type, newValue): + - Optimistically set local state. + - Debounce 400ms (`setTimeout` per cell — track `pendingTimer` in a ref keyed by cell), then `fetch('/api/me/notification-subscriptions', { method: 'PUT', body: JSON.stringify({ event_key, channel_type, enabled: newValue }) })`. + - On 200: `toast.success('Preference saved')`. + - On error: revert local state, `toast.error("Couldn't save preference")`. +
+ + + test -f components/mobile/profile/ProfileThemeSection.tsx && test -f components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "useTheme\|next-themes" components/mobile/profile/ProfileThemeSection.tsx && grep -q "/api/me/theme" components/mobile/profile/ProfileThemeSection.tsx && grep -q "setTheme" components/mobile/profile/ProfileThemeSection.tsx && grep -q "role=\"radiogroup\"\|role={'radiogroup'}" components/mobile/profile/ProfileThemeSection.tsx && grep -q "/api/me/notification-subscriptions" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Switch" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Configure a Teams or ntfy channel below" components/mobile/profile/ProfileNotificationMatrix.tsx && grep -q "Choose which events trigger" components/mobile/profile/ProfileNotificationMatrix.tsx && npx tsc --noEmit --pretty 2>&1 | head + + + + - `ProfileThemeSection.tsx` starts with `'use client';` and imports `useTheme` from `next-themes` + - File contains three `setTheme` call sites for `'light'`, `'dark'`, `'system'` + - File contains `role="radiogroup"` (or `role={'radiogroup'}`) on the wrapping element + - File contains the literal labels `Light`, `Dark`, and `System` + - File contains a fetch to `/api/me/theme` with `method: 'PUT'` + - File contains `toast.success('Theme updated')` + - File contains `toast.error('Failed to update theme')` + - File rolls back the optimistic `setTheme` on error (search for `setTheme(prev` or equivalent) + - `ProfileNotificationMatrix.tsx` starts with `'use client';` + - File fetches `/api/me/notification-subscriptions` + - File renders `` during loading + - File contains the empty-state copy `Configure a Teams or ntfy channel below to enable personal notifications.` + - File contains the description copy `Choose which events trigger a personal notification.` + - File defaults missing matrix cells to `true` (search for `?? true`) + - File debounces saves with `setTimeout` (≥ 350ms) + - File contains `toast.success('Preference saved')` and `toast.error("Couldn't save preference")` + - `npx tsc --noEmit --pretty` reports no errors + + + + Theme section commits server-side and rolls back on error. Notification matrix renders with default-enabled fallback, debounced PUT per cell. + +
+ + + Task 4: ProfileChannelsSection (Teams + ntfy with QR code) + components/mobile/profile/ProfileChannelsSection.tsx, package.json + + + - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Channels Card" — full sub-section spec, Copywriting Contract → Channels) + - app/api/me/channels/route.ts and [type]/route.ts and [type]/test/route.ts (Plan 02 endpoints) + - lib/services/personal-channels.ts (TEST_MESSAGE_BODY constant — for awareness; not imported into the client) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-04 ntfy mint-on-first-save, D-06 best-effort test) + + + + Step A — Add `qrcode.react` to `package.json` dependencies. Use the latest stable major (`^4.0.0` or current). Run `npm install qrcode.react` semantically by adding the entry; the executor MAY also run the actual install command if the harness allows it. Otherwise, just edit `package.json` and document in SUMMARY that `npm install` is required. + + Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`): + + - Card with CardTitle "Personal Channels" and CardDescription "Receive notifications directly on your devices." (UI-SPEC). + - State: `loading`, `channels: { teams?: Channel; ntfy?: Channel }`, `teamsInput: string`, `teamsTest: TestResult | null`, `ntfyTest: TestResult | null`, `showAdvanced: boolean`, `customTopicInput: string`. + - On mount: `fetch('/api/me/channels')`, then split `data.channels` into `teams` and `ntfy` by `channelType`. + - Layout: two sub-sections separated by ``. + + **Teams sub-section:** + - Label "Microsoft Teams webhook URL" + - `` + - Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern): + - Success: `
Channel verified
` + - Failure: `
Test failed — {test.status ?? test.error ?? 'unknown'}
` + - Buttons row: ` ` (both `min-h-[44px]`). + - Save: `fetch('/api/me/channels/teams', { method: 'PUT', body: JSON.stringify({ webhook_url: teamsInput }) })`. On 200: `toast.success('Channel saved')`, set `teamsTest = data.test`, set `channels.teams = data.channel`. On 4xx (e.g., invalid host): show inline `text-xs text-destructive` with the response's `error`/`message` AND `toast.error('Failed to save channel')`. + - Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`. + + **ntfy sub-section:** + - State A — `channels.ntfy === undefined`: + - Label "Mobile push (ntfy)" + - Description "Pulse will generate a private topic for you." (text-sm text-muted-foreground) + - Button `` (min-h-[44px]) + - `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`. + - State B — `channels.ntfy` exists: + - Label "Mobile push (ntfy)" + - Subscribe link: `{"https://ntfy.sh/" + topic}` + - QR code: `` (from `qrcode.react`). Wrap in `
` for accessibility. + - Helper line: `

Scan with the ntfy app to subscribe.

` + - Test result inline (same pattern as Teams). + - Disclosure: `
Edit advanced` — inside the details, render Label "Custom ntfy topic", Input bound to `customTopicInput`, Button "Save custom topic" calling `PUT /api/me/channels/ntfy` with body `{ topic: customTopicInput }`. + - Bottom buttons row: `` (calls `POST /api/me/channels/ntfy/test`) `` (calls `DELETE /api/me/channels/ntfy`). + + All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`). + + + + test -f components/mobile/profile/ProfileChannelsSection.tsx && grep -q "qrcode.react" package.json && grep -q "/api/me/channels" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Microsoft Teams webhook URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Mobile push (ntfy)" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Enable mobile push" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "QRCodeSVG\|qrcode.react" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Scan with the ntfy app" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Save Teams URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Edit advanced" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx && npx tsc --noEmit --pretty 2>&1 | head -20 + + + + - `package.json` includes `"qrcode.react"` in dependencies + - `components/mobile/profile/ProfileChannelsSection.tsx` starts with `'use client';` + - File imports the QR component from `qrcode.react` (`QRCodeSVG` or default) + - File contains the exact label `Microsoft Teams webhook URL` + - File contains the placeholder `https://yourorg.webhook.office.com/` + - File contains the literal `Mobile push (ntfy)` + - File contains the literal `Enable mobile push` + - File contains the literal `Pulse will generate a private topic for you.` + - File contains the literal `Scan with the ntfy app to subscribe.` + - File contains the literal `Save Teams URL` + - File contains the literal `Clear` (Teams clear button) + - File contains the literal `Edit advanced` (disclosure label) + - File contains the literal `Test now` + - File contains the literal `Remove` + - File contains the literal `Channel verified` (success copy) + - File contains the literal `Test failed` + - File contains the literal toast string `'Channel saved'` + - File contains the literal toast string `'Channel removed'` + - File makes fetch calls to `/api/me/channels`, `/api/me/channels/teams`, `/api/me/channels/ntfy`, `/api/me/channels/ntfy/test` + - File DELETE call uses `method: 'DELETE'` (or `"DELETE"`) + - QR code rendered inside an element with `aria-label` referencing the topic (accessibility) + - `npx tsc --noEmit --pretty` reports no errors after `npm install` adds qrcode.react + + + + Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override, remove). qrcode.react added to dependencies. + + + + + Task 5: Theme session bridge + ThemeToggle write-through + components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx + + + - components/theme-toggle.tsx (current setTheme handlers — three DropdownMenuItems) + - components/theme-provider.tsx + - app/layout.tsx (where ThemeProvider mounts; the bridge sits inside it) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-16, D-17, D-20) + - .planning/REQUIREMENTS.md (THEME-03, THEME-04) + + + + Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`: + + ```tsx + 'use client'; + + /** + * ThemeSessionBridge (THEME-03 / D-16, D-17). + * + * On session load and after sign-in, compares session.user.theme to the + * next-themes useTheme() value and calls setTheme(session.user.theme) if + * different. Server is canonical; this is the bridge that enforces it. + * + * Renders nothing. + */ + + import { useEffect } from 'react'; + import { useTheme } from 'next-themes'; + import { useSession } from '@/lib/auth-client'; + + type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string }; + + export function ThemeSessionBridge() { + const { data: session } = useSession(); + const { theme, setTheme } = useTheme(); + + useEffect(() => { + if (!session?.user) return; + const serverTheme = (session.user as SessionUserWithTheme).theme; + if ( + serverTheme === 'light' || + serverTheme === 'dark' || + serverTheme === 'system' + ) { + if (serverTheme !== theme) { + setTheme(serverTheme); + } + } + }, [session?.user, theme, setTheme]); + + return null; + } + ``` + + Step B — Mount `` inside `app/layout.tsx`: + + - Find the existing `` block. + - Inside the `` children, immediately AFTER `` opens (so it has access to `useSession()`), add ``. The exact placement: as the FIRST child of ``, before the existing `
`. + - Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';` + - Do NOT change any other layout.tsx content. + + Step C — Modify `components/theme-toggle.tsx` (THEME-04): + + - Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper: + ```tsx + const writeTheme = (next: 'light' | 'dark' | 'system') => { + setTheme(next); + // Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway. + fetch('/api/me/theme', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ theme: next }), + }).catch(() => { + // Silent fail on network error — the desktop affordance is best-effort. + // The mobile profile Theme section is the explicit-error UX. + }); + }; + ``` + - Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`. + - Do NOT remove the `useTheme` import or the existing UI structure. + - The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle). + + + + test -f components/mobile/profile/ThemeSessionBridge.tsx && grep -q "useSession\|useTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "session.user" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "setTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "ThemeSessionBridge" app/layout.tsx && grep -q "/api/me/theme" components/theme-toggle.tsx && grep -q "method: 'PUT'\|method: \"PUT\"" components/theme-toggle.tsx && npx tsc --noEmit --pretty 2>&1 | head + + + + - `components/mobile/profile/ThemeSessionBridge.tsx` exists, starts with `'use client';` + - File imports both `useTheme` from `next-themes` and `useSession` from `@/lib/auth-client` + - File contains `setTheme(serverTheme)` inside a `useEffect` + - File guards `serverTheme` against the three-string allowlist before calling `setTheme` + - File returns `null` (renders nothing) + - `app/layout.tsx` imports `ThemeSessionBridge` from `@/components/mobile/profile/ThemeSessionBridge` + - `app/layout.tsx` mounts `` inside `` (so `useSession()` works) + - `components/theme-toggle.tsx` contains the literal `/api/me/theme` reference + - `components/theme-toggle.tsx` issues a fetch with `method: 'PUT'` (or `"PUT"`) + - `components/theme-toggle.tsx` still renders the three `DropdownMenuItem` rows (Light, Dark, System) — visual structure unchanged + - `components/theme-toggle.tsx` still imports `useTheme` from `next-themes` (not removed) + - `npx tsc --noEmit --pretty` reports no errors + + + + Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection. + + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → /api/me/* | Already mitigated by Plan 02; this plan only consumes those endpoints | +| Browser DOM → ntfy.sh subscribe link | User's own minted topic; rendered as anchor + QR — no third-party script | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-04-01 | Information Disclosure | QR code rendering | mitigate | `qrcode.react` is a pure client-side SVG library; no network calls at render time. The QR encodes only the user's own ntfy subscribe URL — no secrets | +| T-09-04-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` rolls back `setTheme(previous)` on PUT error; the `ThemeSessionBridge` re-syncs from server on next session refresh as the canonical source | +| T-09-04-03 | Spoofing | `ThemeSessionBridge` | mitigate | Reads `session.user.theme` from `useSession()` (Better Auth signed cookie). Validates against the three-string allowlist before calling `setTheme` — drops any unexpected value silently | +| T-09-04-04 | Information Disclosure | Inline test-result error display | mitigate | The error string from `/api/me/channels` is already truncated to 200 chars (Plan 02 / `lib/services/personal-channels.ts`). UI renders it `text-xs` and only on the user's own channel form — never on another user's data | +| T-09-04-05 | XSS | Subscribe link `target="_blank"` | mitigate | Anchor uses `rel="noopener noreferrer"`. Topic value flows from server-minted UUID-prefixed string (Plan 02), not user input | +| T-09-04-06 | DoS / Bundle bloat | `qrcode.react` dependency | accept | ~10 KB gzip; loaded only on `/mobile/profile`. Reviewed for "no network-call side effects at render time" per UI-SPEC Registry Safety section | +| T-09-04-07 | CSRF | Fetch calls from client | mitigate | Better Auth uses signed httpOnly session cookies; same-origin fetch is implicitly authenticated. No third-party origin can post on behalf of the user without the cookie | + +No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping + rel=noopener). + + + + +- `npm install` (or equivalent) installs `qrcode.react` cleanly. +- `npx tsc --noEmit --pretty` exits 0. +- `npm run build` (turbopack) builds without errors. +- Manual smoke (post-deploy): + 1. Visit `/mobile/profile` → 4 Cards render in order. + 2. Tap a timezone → toast → reload → still selected. + 3. Tap "Dark" → page switches theme, toast → reload → still dark. + 4. Configure Teams URL → test send works → save → toast. + 5. Enable mobile push → topic minted → QR visible → subscribe link works. + 6. Open the More drawer → Account section shows the identity row as a link AND a "Profile & preferences" row above Sign-out. + 7. Open desktop ThemeToggle → switch theme → reload mobile profile → matches. + + + +1. All 7 new files exist under `components/mobile/profile/` and `app/mobile/profile/`. +2. `MoreDrawer.tsx` Account section routes to `/mobile/profile`. +3. `ThemeSessionBridge` is mounted in `app/layout.tsx` and renders nothing. +4. `ThemeToggle` writes through to `/api/me/theme` on every selection. +5. `package.json` declares `qrcode.react`. +6. `npx tsc --noEmit --pretty` exits 0. +7. The four section Cards each match the UI-SPEC contract (header, content padding, copywriting strings, save model). + + + +After completion, create `.planning/phases/09-user-profile-preferences-new/09-04-SUMMARY.md` documenting: +- The 7 new files (paths and one-line descriptions) +- The MoreDrawer Account section structure (3 rows: identity-link / profile-preferences-link / sign-out) +- The ThemeSessionBridge sync rule (compares session.user.theme to useTheme(), calls setTheme on mismatch, validated against allowlist) +- The ThemeToggle write-through behavior +- The qrcode.react version pinned in package.json +- Whether `npm install` was run during the plan or is required as a follow-up +- Any deviations from the UI-SPEC (expected: none) + diff --git a/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md new file mode 100644 index 0000000..dd727cb --- /dev/null +++ b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md @@ -0,0 +1,393 @@ +--- +phase: 09-user-profile-preferences-new +plan: 05 +type: execute +wave: 3 +depends_on: [09-01, 09-02, 09-03] +files_modified: + - app/admin/workflow/channels/page.tsx + - app/api/notification-channels/route.ts + - app/api/notification-channels/[id]/route.ts + - app/admin/workflow/event-keys/page.tsx + - app/api/admin/notify-event-keys/route.ts + - app/api/admin/notify-event-keys/[key]/route.ts + - app/admin/workflow/pipelines/[id]/page.tsx + - app/api/pipelines/[id]/executions/route.ts +autonomous: true +requirements: [CHAN-06, ROUTE-07] + +must_haves: + truths: + - "Admins (role admin or super-admin) can read AND edit any user's personal notification channel via /admin/workflow/channels — including viewing the webhook_url / topic and toggling is_active and triggering test sends" + - "/admin/workflow/channels has an Owner column that displays 'Global' for owner_user_id IS NULL or the owner's email otherwise, plus a filter widget that toggles between 'All', 'Global only', and 'Personal only'" + - "/admin/workflow/event-keys exists as a real CRUD page for admins (list, create, edit, soft-toggle is_active, change sort_order, delete)" + - "/admin/workflow/pipelines/[id] (recent executions panel) gains a 'Show only fallbacks' filter that filters to executions where any execution_step has output_data->'user_route_fallback' set" + - "All admin routes use requireAdmin() — non-admin users get 403" + - "Non-admin users can never read another user's personal channel via /api/notification-channels (the legacy endpoint hides personal rows for non-admins)" + artifacts: + - path: "app/admin/workflow/channels/page.tsx" + provides: "Channels list with Owner column + filter; admin edit of personal channels" + - path: "app/api/notification-channels/route.ts" + provides: "Updated GET to require auth, scope visibility by role; POST stays admin-only" + - path: "app/api/notification-channels/[id]/route.ts" + provides: "Updated PUT/DELETE/GET to authorize per-row by ownership and role" + - path: "app/admin/workflow/event-keys/page.tsx" + provides: "New admin CRUD page for notify_event_keys" + - path: "app/api/admin/notify-event-keys/route.ts" + provides: "GET list / POST create event keys (admin-only)" + - path: "app/api/admin/notify-event-keys/[key]/route.ts" + provides: "PUT update / DELETE one event key by primary key" + - path: "app/admin/workflow/pipelines/[id]/page.tsx" + provides: "Pipeline detail page with 'Show only fallbacks' filter on recent executions" + - path: "app/api/pipelines/[id]/executions/route.ts" + provides: "Updated executions endpoint to support fallbacks_only=1 filter" + key_links: + - from: "/admin/workflow/channels" + to: "notification_channels with owner_user_id JOIN user.email" + via: "LEFT JOIN \"user\" ON owner_user_id = user.id; renders Owner column" + pattern: "owner_user_id" + - from: "/admin/workflow/event-keys" + to: "notify_event_keys" + via: "POST/PUT/DELETE via /api/admin/notify-event-keys" + pattern: "notify_event_keys" + - from: "/admin/workflow/pipelines/[id]" + to: "pipeline_execution_steps.output_data->'user_route_fallback'" + via: "fallbacks_only=1 filter on /api/pipelines/[id]/executions" + pattern: "user_route_fallback" +--- + + +Land the admin surfaces required by Phase 9: + +1. **CHAN-06: Admin full-edit of personal channels.** `/admin/workflow/channels` gains + an Owner column + filter. Admins can read/edit any user's personal Teams webhook URL + or ntfy topic, toggle is_active, and trigger test sends. Non-admins still cannot + see other users' personal channels via the legacy `/api/notification-channels` API. + +2. **SUB-01 admin CRUD: `/admin/workflow/event-keys`.** A new small admin page + (D-13) for managing the `notify_event_keys` lookup. Backed by two new API routes. + +3. **ROUTE-07: Admin filter for `user_route_fallback` events.** Add a "Show only + fallbacks" filter to the recent-executions panel on + `/admin/workflow/pipelines/[id]`. The requirement text says + `/admin/workflow/executions` but that page is the legacy workflow engine. The + pipeline-engine executions surface today is the per-pipeline detail page — + that is where the filter goes (documented as a deliberate redirection in the + task action below). + +Out of scope: +- Backfilling existing notification_channels rows to set owner_user_id (they stay + global / NULL). +- Notifying users when their personal channel changes (deferred per CONTEXT + "channel-rotation flow" deferred idea). + +Output: 3 modified existing files, 5 new files (2 new pages + 3 new API routes — +including one extra API route for the event-keys collection vs by-key). + +This plan stays small per task by keeping API route handlers minimal (no new +business logic — just CRUD + auth-gating). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/REQUIREMENTS.md +@CLAUDE.md +@app/admin/workflow/channels/page.tsx +@app/api/notification-channels/route.ts +@app/api/notification-channels/[id]/route.ts +@app/admin/workflow/pipelines/[id]/page.tsx +@app/api/pipelines/[id]/executions/route.ts +@lib/auth-utils.ts +@lib/services/personal-channels.ts + + + + +notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at + +notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at + +pipeline_execution_steps.output_data is JSONB. After Plan 03, on user-route fallback the value contains: + output_data->'user_route_fallback' = { reason: string, user_id?: string, channel_type: string, error?: string } + + +requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts + + +- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1) +- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test` +- Uses Card + Switch + DataTable-less list rendering + + +GET /api/pipelines/[id]/executions?limit=N → returns { data: PipelineExecution[] } +After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 → filters via JSONB predicate + + + + + + + Task 1: Owner column + role-scoped reads on /admin/workflow/channels + app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx + + + - app/api/notification-channels/route.ts (current GET / POST — no auth today) + - app/api/notification-channels/[id]/route.ts (current GET / PUT / DELETE — no auth today) + - app/admin/workflow/channels/page.tsx (current rendering structure to extend) + - lib/auth-utils.ts (requireAuth, requireAdmin) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-07 admin full edit, CHAN-06) + + + + Step A — `app/api/notification-channels/route.ts` (GET + POST): + + Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with: + + GET: + - Call `requireAuth()`. On error return error. + - If `(session.user.role === 'admin' || session.user.role === 'super-admin')`: + - Run a JOIN that returns owner email for personal channels: + ```sql + SELECT nc.*, u.email AS owner_email + FROM notification_channels nc + LEFT JOIN "user" u ON u.id = nc.owner_user_id + ORDER BY nc.owner_user_id NULLS FIRST, nc.name + ``` + - Optional `?owner=global|personal|all` filter (default `all`): + - `global` → `WHERE owner_user_id IS NULL` + - `personal` → `WHERE owner_user_id IS NOT NULL` + - Else (non-admin): + - Return only global rows (`WHERE owner_user_id IS NULL`) — non-admins see admin-managed channels for selection in pipelines but NOT other users' personal channels. + - Response shape: `{ data: rows, total: rows.length }` (preserve existing shape). + + POST: + - Call `requireAdmin()`. On error return error. + - Existing validation logic preserved verbatim (validates `channel_type` against `['teams','telegram','ntfy','webhook']`). + - INSERT statement adds `owner_user_id` to the column list, accepting `body.owner_user_id ?? null`. Default behavior: rows created via this admin endpoint are GLOBAL (owner_user_id NULL). + + Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE): + + Each handler: + - `requireAuth()` first. + - Read the row (`SELECT * FROM notification_channels WHERE id = $1`). + - Compute `isOwner = row.owner_user_id === session.user.id`. + - Compute `isAdmin = session.user.role === 'admin' || session.user.role === 'super-admin'`. + - For GET / PUT / DELETE: + - If row is global (`owner_user_id IS NULL`) → require `isAdmin` else 403. + - If row is personal → require `isOwner OR isAdmin` else 403 (CHAN-06: admins have full read+edit). + - Existing UPDATE / DELETE logic preserved verbatim under the gate. + + Step C — `app/admin/workflow/channels/page.tsx`: + + - Page top: add a filter Select (using `@/components/ui/select`) labeled "Show:" with options "All" (default), "Global only", "Personal only". State `ownerFilter: 'all' | 'global' | 'personal'`. Reload list when it changes by passing `?owner=...` to `/api/notification-channels`. + - Add an "Owner" badge to each channel row card. Render BEFORE the existing channel-type Badge: + - When `channel.owner_user_id == null`: `Global` + - Else: `Personal: {channel.owner_email ?? channel.owner_user_id}` (the `owner_email` field comes from the JOIN added in Step A). + - The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit). + - Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `

Personal channels contain user-supplied webhook URLs — handle with care.

` (operational hygiene). +
+ + + grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && grep -q "owner_user_id" app/api/notification-channels/route.ts && grep -q "requireAuth" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_user_id" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_email\|owner_user_id" app/admin/workflow/channels/page.tsx && grep -q "Global only\|owner=global\|Personal only" app/admin/workflow/channels/page.tsx && npx tsc --noEmit --pretty 2>&1 | head + + + + - `app/api/notification-channels/route.ts` GET handler calls `requireAuth()` (it does NOT today) + - GET handler returns only global rows (`WHERE owner_user_id IS NULL`) for non-admin sessions + - GET handler for admin sessions runs `LEFT JOIN "user" u ON u.id = nc.owner_user_id` and selects `u.email AS owner_email` + - GET handler accepts an `owner` query parameter with values `global` / `personal` / `all` + - POST handler calls `requireAdmin()` (it does NOT today) + - `app/api/notification-channels/[id]/route.ts` PUT and DELETE handlers each call `requireAuth()` and authorize via `(isAdmin || isOwner)` predicate against `row.owner_user_id` + - `app/admin/workflow/channels/page.tsx` renders an Owner badge on each channel row that displays `Global` for null owner or `Personal: {email}` otherwise + - `app/admin/workflow/channels/page.tsx` has a Select filter with the three values (`all`, `global`, `personal`) + - Selecting the filter updates the fetch URL with `?owner=` + - `npx tsc --noEmit --pretty` reports no errors + + + + Admin Channels page handles personal-vs-global rows; admins see full data and can edit any row; non-admins (e.g., a pipeline operator without admin role) only see global rows. + +
+ + + Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter + app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts, app/admin/workflow/pipelines/[id]/page.tsx, app/api/pipelines/[id]/executions/route.ts + + + - app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout) + - app/admin/workflow/pipelines/[id]/page.tsx (current recent_executions rendering — add the filter near the top of that section) + - app/api/pipelines/[id]/executions/route.ts (current SELECT — extend with the JSONB filter) + - lib/auth-utils.ts (requireAdmin signature) + - lib/types/pipeline.ts (NotifyEventKey type from Plan 01) + - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-13 lookup table — admin manages, not a gate) + + + + Step A — `app/api/admin/notify-event-keys/route.ts`: + + GET: + - `requireAdmin()` guard. + - `SELECT key, display_label, description, sort_order, is_active, created_at, updated_at FROM notify_event_keys ORDER BY sort_order ASC, key ASC` + - Return `{ data: rows.map(camelCase) }`. + + POST: + - `requireAdmin()` guard. + - Body: `{ key, display_label, description?, sort_order?, is_active? }` + - Validate `key` is non-empty string ≤ 128 chars matching `/^[a-z][a-z0-9_]*$/i` (event key naming). + - Validate `display_label` is non-empty string ≤ 200. + - INSERT and return the new row. Use `ON CONFLICT (key) DO NOTHING` then re-SELECT — return 409 if key already existed. + + Step B — `app/api/admin/notify-event-keys/[key]/route.ts`: + + PUT: + - `requireAdmin()` guard. + - `params.key` is the row to update. + - Body fields: `display_label?`, `description?`, `sort_order?`, `is_active?`. + - `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5 RETURNING *` + - 404 if no row. + + DELETE: + - `requireAdmin()` guard. + - `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key` + - 404 if no row. Return `{ deleted: true, key }`. + + Step C — `app/admin/workflow/event-keys/page.tsx`: + + - `'use client';` page modeled on `/admin/workflow/channels/page.tsx`. + - Header: ArrowLeft back link to `/admin/workflow`, page title "Event Keys". + - Body: a list of Cards or rows displaying each event key with its `display_label`, `description`, `sort_order`, `is_active` Switch, and Edit / Delete buttons. + - "+ New event key" button opens an inline form (Input for `key`, Input for `display_label`, Input for `description`, NumericInput for `sort_order`). + - All writes via `/api/admin/notify-event-keys/...` endpoints. + - Render error toast via `sonner` on non-2xx; success toast on save/delete. + + Step D — `app/api/pipelines/[id]/executions/route.ts`: + + Extend the GET handler to accept `?fallbacks_only=1`. When set, the SELECT becomes: + + ```sql + SELECT pe.* + FROM pipeline_executions pe + WHERE pe.pipeline_id = $1 + AND EXISTS ( + SELECT 1 FROM pipeline_execution_steps pes + WHERE pes.execution_id = pe.id + AND pes.output_data ? 'user_route_fallback' + ) + ORDER BY pe.created_at DESC + LIMIT $2 + ``` + + Without the param, behavior is unchanged. Add `requireAuth()` to the handler if it's not already present (defense in depth — admin page already gates the surface but the API should not be unauthenticated). + + Step E — `app/admin/workflow/pipelines/[id]/page.tsx`: + + - Locate the recent_executions panel (currently rendered from `data.recent_executions`). + - Add a small filter chip / toggle ABOVE the executions list: a Switch labeled "Show only fallbacks" (use `@/components/ui/switch` and a ` + + + test -f app/admin/workflow/event-keys/page.tsx && test -f app/api/admin/notify-event-keys/route.ts && test -f "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts && grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts && grep -q "fallbacks_only\|has_fallback\|user_route_fallback" "app/api/pipelines/[id]/executions/route.ts" && grep -q "Show only fallbacks\|fallbacks_only=1" "app/admin/workflow/pipelines/[id]/page.tsx" && grep -q "has_fallback\|fallback" "app/admin/workflow/pipelines/[id]/page.tsx" && npx tsc --noEmit --pretty 2>&1 | head + + + + - `app/admin/workflow/event-keys/page.tsx` exists, starts with `'use client';` + - File renders a list of event keys and has a "+ New event key" affordance + - File issues writes to `/api/admin/notify-event-keys` (POST, PUT, DELETE on the by-key sub-route) + - `app/api/admin/notify-event-keys/route.ts` exports GET and POST; both call `requireAdmin()` + - GET runs `SELECT ... FROM notify_event_keys ORDER BY sort_order ASC, key ASC` + - POST validates `key` matches `/^[a-z][a-z0-9_]*$/i` + - `app/api/admin/notify-event-keys/[key]/route.ts` exports PUT and DELETE; both call `requireAdmin()` + - PUT uses `UPDATE notify_event_keys SET ... WHERE key = $5` (or equivalent param index) + - `app/api/pipelines/[id]/executions/route.ts` SELECT contains `output_data ? 'user_route_fallback'` (the JSONB containment predicate) + - GET handler accepts `fallbacks_only` query parameter + - GET response per-row contains a `has_fallback` boolean + - `app/admin/workflow/pipelines/[id]/page.tsx` contains a Switch labeled `Show only fallbacks` + - File appends `fallbacks_only=1` to the executions fetch URL when the toggle is ON + - File renders a `fallback` (or equivalent) on rows where `has_fallback === true` + - `npx tsc --noEmit --pretty` reports no errors in any modified file + + + + Admins have a real CRUD page for the event-key lookup (D-13), and the pipeline-detail recent-executions panel surfaces user_route_fallback events with a one-click filter (ROUTE-07). + + + +
+ + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → /admin/* | Admin role required; non-admins must get 403 | +| Browser → /api/notification-channels | Previously unauthenticated — closing the gap | +| Browser → /api/admin/notify-event-keys | New surface; admin-only | +| Browser → /api/pipelines/[id]/executions | New filter param; auth required | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-05-01 | Information Disclosure | `/api/notification-channels` GET | mitigate | Adds `requireAuth()` (was unauthenticated). Non-admin sessions are scoped to global rows only via `WHERE owner_user_id IS NULL`. Admins see all rows. Closes a pre-existing gap | +| T-09-05-02 | Elevation of Privilege | `/api/notification-channels/[id]` PUT/DELETE | mitigate | Per-row authorization: `isAdmin || isOwner`. A non-admin can edit only their own personal channel; the legacy admin path for global channels still requires admin | +| T-09-05-03 | Information Disclosure | Admin Owner-column rendering | accept | Webhook URLs and ntfy topics ARE shown in the admin UI — D-07 explicitly accepts this trade-off (admins can read user secrets) to enable onboarding/offboarding fixes. Mitigation: small inline disclaimer above the personal-channel list | +| T-09-05-04 | Tampering | `/api/admin/notify-event-keys` POST | mitigate | `requireAdmin()` guard + `key` regex `^[a-z][a-z0-9_]*$/i` + length limits. `ON CONFLICT (key) DO NOTHING` prevents accidental overwrite of an existing key — UI must use PUT for updates | +| T-09-05-05 | Elevation of Privilege | `/admin/workflow/event-keys` page | mitigate | Page issues all writes through the gated API routes; even if a non-admin reaches the page URL directly, the API returns 403. Page does not embed admin-only secrets in the rendered HTML beyond what the API would return | +| T-09-05-06 | SQL Injection | executions endpoint `fallbacks_only` filter | mitigate | The `fallbacks_only` query param is converted to a boolean (`searchParams.get('fallbacks_only') === '1'`) and used to choose which parameterized SQL string to execute — never concatenated into the SQL. Pipeline `id` and limit remain parameterized via `$1` / `$2` | +| T-09-05-07 | Information Disclosure / Logging | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/pipelines/[id]`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click) | + +No `high` severity unmitigated. ASVS L1 satisfied: V4.1.1 (per-resource authorization), V4.2.2 (data minimization for non-admins), V5.1.3 (input validation on the event key), V12.1.1 (parameterized SQL). + + + + +- All five admin/API files compile under `npx tsc --noEmit --pretty`. +- Hitting `/api/notification-channels` without a session returns 401. +- Hitting `/api/notification-channels` as a non-admin user returns only global rows. +- Hitting `/api/notification-channels/[id]` PUT for someone else's personal row as a non-admin returns 403. +- Hitting `/api/admin/notify-event-keys` as a non-admin returns 403. +- The recent-executions panel renders a `fallback` badge on rows where `has_fallback === true`, and toggling "Show only fallbacks" filters to those rows. + + + +1. `/admin/workflow/channels` shows Owner column + filter; admins can edit any personal row; non-admins see only global rows. +2. `/admin/workflow/event-keys` exists as a real CRUD page; reachable from `/admin/workflow` (the executor can either add a link there or document the URL in the SUMMARY for a future small wire-up). +3. `/admin/workflow/pipelines/[id]` has a "Show only fallbacks" toggle and renders a fallback badge per row. +4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization. +5. `npx tsc --noEmit --pretty` exits 0. + + + +After completion, create `.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md` documenting: +- The Owner column rendering rule (Global vs Personal: {email}) +- The owner-filter URL parameter values supported by `/api/notification-channels` +- The new event-keys CRUD page URL and the regex used to validate event keys +- The exact JSONB predicate added to the executions query (`output_data ? 'user_route_fallback'`) +- The `has_fallback` per-row flag added to the executions response +- The deliberate redirection of ROUTE-07's `/admin/workflow/executions` filter onto the per-pipeline detail page (with rationale: pipeline_execution_steps table is the source of `user_route_fallback`, and that's where the existing pipeline-engine executions UI lives) +- Whether a link to `/admin/workflow/event-keys` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred) +