docs(09): create phase plan

This commit is contained in:
lorentz 2026-05-09 22:54:02 -04:00
parent 480c115296
commit e5031e5613
6 changed files with 2515 additions and 2 deletions

View file

@ -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 37 (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*

View file

@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Existing types that this plan extends. -->
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<string, any>;
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 '...';
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Add theme column to user + Better Auth additionalField</name>
<files>migrations/084_add_user_theme.sql, lib/auth.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -q "ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'" migrations/084_add_user_theme.sql &amp;&amp; grep -q 'theme:' lib/auth.ts &amp;&amp; grep -q "defaultValue: \"system\"" lib/auth.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | grep -E "(auth.ts|error)" | head</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>
Migration file written with the exact ALTER + backfill + COMMENT structure of 083. `lib/auth.ts` exposes `theme` as a fourth additionalField. TypeScript compiles cleanly.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables</name>
<files>migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql &amp;&amp; grep -q "CREATE TABLE IF NOT EXISTS notify_event_keys" migrations/086_notify_event_keys_and_subscriptions.sql &amp;&amp; grep -q "CREATE TABLE IF NOT EXISTS user_event_subscriptions" migrations/086_notify_event_keys_and_subscriptions.sql &amp;&amp; grep -q "owner_user_id: string | null" lib/types/pipeline.ts &amp;&amp; grep -q "export interface NotifyEventKey" lib/types/pipeline.ts &amp;&amp; grep -q "export interface UserEventSubscription" lib/types/pipeline.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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).
</threat_model>
<verification>
- 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`).
</verification>
<success_criteria>
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.
</success_criteria>
<output>
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)
</output>

View file

@ -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"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Shapes the executor needs without exploring the codebase. -->
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<T>(sql: string, params: any[]): Promise<{ rows: T[]; rowCount: number }>;
postgresClient.transaction(fn: (client) => Promise<R>): Promise<R>;
```
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<string, any>;
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 })
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: GET + PUT /api/me/theme</name>
<files>app/api/me/theme/route.ts</files>
<read_first>
- 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')
</read_first>
<action>
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<NextResponse>`:
- `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<NextResponse>`:
- 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
</action>
<verify>
<automated>test -f app/api/me/theme/route.ts &amp;&amp; grep -q "export async function GET" app/api/me/theme/route.ts &amp;&amp; grep -q "export async function PUT" app/api/me/theme/route.ts &amp;&amp; grep -q "ALLOWED_THEMES" app/api/me/theme/route.ts &amp;&amp; grep -q "requireAuth" app/api/me/theme/route.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | grep "theme/route.ts" | head</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Personal channels service + /api/me/channels routes</name>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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<ChannelTestResult> {
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<string, string> = {
'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<NextResponse> {
const { session, error } = await requireAuth();
if (error) return error;
try {
const result = await postgresClient.query<NotificationChannel>(
`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: <camelCase row>, test: <ChannelTestResult> })`.
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)
</action>
<verify>
<automated>test -f lib/services/personal-channels.ts &amp;&amp; test -f app/api/me/channels/route.ts &amp;&amp; test -f app/api/me/channels/[type]/route.ts &amp;&amp; test -f app/api/me/channels/[type]/test/route.ts &amp;&amp; grep -q "isValidTeamsWebhookUrl" lib/services/personal-channels.ts &amp;&amp; grep -q "mintNtfyTopic" lib/services/personal-channels.ts &amp;&amp; grep -q "TEST_MESSAGE_BODY" lib/services/personal-channels.ts &amp;&amp; grep -q "Pulse channel verified — you can ignore this message" lib/services/personal-channels.ts &amp;&amp; grep -q "owner_user_id = \$1" "app/api/me/channels/[type]/route.ts" &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: GET + PUT /api/me/notification-subscriptions (matrix endpoint)</name>
<files>app/api/me/notification-subscriptions/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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<string, Record<string, boolean>>`:
- 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.
</action>
<verify>
<automated>test -f app/api/me/notification-subscriptions/route.ts &amp;&amp; grep -q "export async function GET" app/api/me/notification-subscriptions/route.ts &amp;&amp; grep -q "export async function PUT" app/api/me/notification-subscriptions/route.ts &amp;&amp; grep -q "user_event_subscriptions" app/api/me/notification-subscriptions/route.ts &amp;&amp; grep -q "ON CONFLICT (user_id, event_key, channel_type)" app/api/me/notification-subscriptions/route.ts &amp;&amp; grep -q "isPersonalChannelType" app/api/me/notification-subscriptions/route.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | grep -E "(notification-subscriptions|error)" | head</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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).
</threat_model>
<verification>
- 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.
</verification>
<success_criteria>
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.
</success_criteria>
<output>
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
</output>

View file

@ -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"
---
<objective>
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<string, Resolver>` 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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- The current notify.ts contract — preserve everything not touched here. -->
Current executeNotify signature (must keep this signature):
```typescript
async function executeNotify(
step: PipelineStep,
_context: PipelineContext,
_executionId: number,
): Promise<StepExecutorResult>;
```
Existing helpers in notify.ts (preserve as-is, do NOT inline or remove):
```typescript
async function sendTeams(channel, config, message): Promise<StepExecutorResult>;
async function sendTelegram(channel, message): Promise<StepExecutorResult>;
async function sendNtfy(channel, config, message): Promise<StepExecutorResult>;
async function sendWebhook(channel, config, message): Promise<StepExecutorResult>;
```
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<string, any>; 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<string, any>; 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)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Add route_to_user types and resolver registry</name>
<files>lib/types/pipeline.ts, lib/services/pipeline-steps/notify-resolvers.ts</files>
<read_first>
- 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)
</read_first>
<action>
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<ResolvedRecipient>;
/** 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<string, NotifyResolver>` 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<T>(...)`.
</action>
<verify>
<automated>grep -q "interface RouteToUser" lib/types/pipeline.ts &amp;&amp; grep -q "type ResolvedRecipient" lib/types/pipeline.ts &amp;&amp; grep -q "interface UserRouteFallback" lib/types/pipeline.ts &amp;&amp; test -f lib/services/pipeline-steps/notify-resolvers.ts &amp;&amp; grep -q "export const RESOLVERS" lib/services/pipeline-steps/notify-resolvers.ts &amp;&amp; grep -q "autotask_resource_email" lib/services/pipeline-steps/notify-resolvers.ts &amp;&amp; grep -q "direct_email" lib/services/pipeline-steps/notify-resolvers.ts &amp;&amp; grep -q "pulse_user_id" lib/services/pipeline-steps/notify-resolvers.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Rewrite executeNotify with route_to_user branch and fallback semantics</name>
<files>lib/services/pipeline-steps/notify.ts</files>
<read_first>
- 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)
</read_first>
<action>
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<StepExecutorResult> {
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<NotificationChannel>(
`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<string, any>;
message: string;
fallback: UserRouteFallback;
}): Promise<StepExecutorResult> {
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.
</action>
<verify>
<automated>grep -q "route_to_user" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "resolveRecipient" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "owner_user_id = " lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "user_route_fallback" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "skipped_reason" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "user_muted" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "dispatchToGlobalChannel\|dispatchUserRoute\|fallbackToGlobal" lib/services/pipeline-steps/notify.ts &amp;&amp; grep -q "registerStepExecutor('notify', executeNotify)" lib/services/pipeline-steps/notify.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>
`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.
</done>
</task>
</tasks>
<threat_model>
## 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).
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
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.
</success_criteria>
<output>
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)
</output>

View file

@ -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"
---
<objective>
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
(1530 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: `<main className="px-4 pb-safe">` (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).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Endpoint shapes (from Plan 02) the executor consumes here. -->
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<string, Record<string, boolean>>
}
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']
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Page shell, MoreDrawer wiring, and skeleton helper</name>
<files>app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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 (
<main className="px-4 pb-safe">
<h1 className="text-xl font-semibold pt-4 pb-2">Profile &amp; Preferences</h1>
<div className="space-y-4">
<ProfileTimezoneSection />
<ProfileThemeSection />
<ProfileNotificationMatrix />
<ProfileChannelsSection />
</div>
</main>
);
}
```
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 `<div className="rounded-2xl border overflow-hidden">` block so the inner structure becomes (in this exact order):
1. The current user-identity row, BUT wrapped in a `<SheetClose asChild><Link href="/mobile/profile" className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors border-b">...inner identity content...</Link></SheetClose>`. 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: `<SheetClose asChild><Link href="/mobile/profile" className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors border-b"><SettingsIcon className="w-4 h-4 text-muted-foreground shrink-0" /><span className="text-sm flex-1">Profile &amp; preferences</span></Link></SheetClose>`.
3. The existing Sign-out button (unchanged).
- Do NOT remove or restructure any other drawer section (Mobile sections / Full site stay exactly as today).
</action>
<verify>
<automated>test -f app/mobile/profile/page.tsx &amp;&amp; test -f components/mobile/profile/ProfileSectionSkeleton.tsx &amp;&amp; grep -q "ProfileTimezoneSection" app/mobile/profile/page.tsx &amp;&amp; grep -q "ProfileThemeSection" app/mobile/profile/page.tsx &amp;&amp; grep -q "ProfileNotificationMatrix" app/mobile/profile/page.tsx &amp;&amp; grep -q "ProfileChannelsSection" app/mobile/profile/page.tsx &amp;&amp; grep -q "requireAuth" app/mobile/profile/page.tsx &amp;&amp; grep -q '/mobile/profile' components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Profile &amp; preferences\|Profile & preferences" components/mobile/MoreDrawer.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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 `<h1 className="text-xl font-semibold pt-4 pb-2">Profile &amp; Preferences</h1>` (UI-SPEC heading copy)
- Page wrapper is `<main className="px-4 pb-safe">` and inner div is `<div className="space-y-4">` (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
</acceptance_criteria>
<done>
Page shell exists, drawer routes to it, skeleton helper available for each section's loading state.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: ProfileTimezoneSection (Combobox + current time)</name>
<files>components/mobile/profile/ProfileTimezoneSection.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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 `<ChevronsUpDown />` icon (lucide).
- Popover content: `<Command><CommandInput placeholder="Search timezones…" /><CommandEmpty>No matches.</CommandEmpty><CommandList>` rendering one `<CommandItem>` 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 `<Check />` (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.
</action>
<verify>
<automated>test -f components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "Intl.supportedValuesOf" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "EXTRA_ALLOWED_TIMEZONES\|'UTC'" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "/api/me/timezone" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "method: 'PUT'\|method: \"PUT\"" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "Search timezones" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "Your current time" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; grep -q "toast.success" components/mobile/profile/ProfileTimezoneSection.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
Timezone section renders, persists, and shows live current-time. Saves are debounced 400ms; errors don't optimistically update.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: ProfileThemeSection + ProfileNotificationMatrix</name>
<files>components/mobile/profile/ProfileThemeSection.tsx, components/mobile/profile/ProfileNotificationMatrix.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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: `<Icon className="w-4 h-4" />` (Sun for Light, Moon for Dark, Monitor for System) + label span (text-sm) + a `<Check />` 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 `<ProfileSectionSkeleton />`.
- Empty state — when `data.channelTypes.length === 0`: render `<p className="text-sm text-muted-foreground">Configure a Teams or ntfy channel below to enable personal notifications.</p>` (no skeleton, no matrix).
- Loaded state:
- When `channelTypes.length === 1`: render simple list of rows. Each row: `<div className="flex items-center justify-between min-h-[44px] py-3 gap-3"><span className="text-sm flex-1">{eventKey.displayLabel}</span><Switch ... /></div>`.
- When `channelTypes.length > 1`: render a header row: `<div className="grid" style={{ gridTemplateColumns: 'minmax(0,1fr) ' + 'repeat(' + channelTypes.length + ', 4rem)' }}><span /> ... per-type column header (text-xs text-center capitalize) </div>`. 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")`.
</action>
<verify>
<automated>test -f components/mobile/profile/ProfileThemeSection.tsx &amp;&amp; test -f components/mobile/profile/ProfileNotificationMatrix.tsx &amp;&amp; grep -q "useTheme\|next-themes" components/mobile/profile/ProfileThemeSection.tsx &amp;&amp; grep -q "/api/me/theme" components/mobile/profile/ProfileThemeSection.tsx &amp;&amp; grep -q "setTheme" components/mobile/profile/ProfileThemeSection.tsx &amp;&amp; grep -q "role=\"radiogroup\"\|role={'radiogroup'}" components/mobile/profile/ProfileThemeSection.tsx &amp;&amp; grep -q "/api/me/notification-subscriptions" components/mobile/profile/ProfileNotificationMatrix.tsx &amp;&amp; grep -q "Switch" components/mobile/profile/ProfileNotificationMatrix.tsx &amp;&amp; grep -q "Configure a Teams or ntfy channel below" components/mobile/profile/ProfileNotificationMatrix.tsx &amp;&amp; grep -q "Choose which events trigger" components/mobile/profile/ProfileNotificationMatrix.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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 `<ProfileSectionSkeleton />` 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
</acceptance_criteria>
<done>
Theme section commits server-side and rolls back on error. Notification matrix renders with default-enabled fallback, debounced PUT per cell.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 4: ProfileChannelsSection (Teams + ntfy with QR code)</name>
<files>components/mobile/profile/ProfileChannelsSection.tsx, package.json</files>
<read_first>
- .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)
</read_first>
<action>
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 `<Separator className="my-4" />`.
**Teams sub-section:**
- Label "Microsoft Teams webhook URL"
- `<Input placeholder="https://yourorg.webhook.office.com/..." value={teamsInput} onChange={...} />`
- Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern):
- Success: `<div className="flex items-center gap-2 text-xs"><CheckCircle className="w-3.5 h-3.5 text-green-600" /><span className="text-green-600">Channel verified</span></div>`
- Failure: `<div className="flex items-center gap-2 text-xs"><XCircle className="w-3.5 h-3.5 text-destructive" /><span className="text-destructive">Test failed — {test.status ?? test.error ?? 'unknown'}</span></div>`
- Buttons row: `<Button onClick={save}>Save Teams URL</Button> <Button variant="ghost" onClick={clear} className="text-destructive">Clear</Button>` (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 `<Button className="w-full" onClick={enableNtfy}>Enable mobile push</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: `<a href={"https://ntfy.sh/" + topic} target="_blank" rel="noopener noreferrer" className="text-primary text-sm underline">{"https://ntfy.sh/" + topic}</a>`
- QR code: `<QRCodeSVG value={"https://ntfy.sh/" + topic} size={200} />` (from `qrcode.react`). Wrap in `<div role="img" aria-label={"Subscribe to " + topic + " on ntfy"}>` for accessibility.
- Helper line: `<p className="text-xs text-muted-foreground">Scan with the ntfy app to subscribe.</p>`
- Test result inline (same pattern as Teams).
- Disclosure: `<details><summary className="text-sm text-primary cursor-pointer">Edit advanced</summary>` — 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: `<Button onClick={testNow}>Test now</Button>` (calls `POST /api/me/channels/ntfy/test`) `<Button variant="ghost" onClick={removeNtfy} className="text-destructive">Remove</Button>` (calls `DELETE /api/me/channels/ntfy`).
All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
</action>
<verify>
<automated>test -f components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "qrcode.react" package.json &amp;&amp; grep -q "/api/me/channels" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Microsoft Teams webhook URL" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Mobile push (ntfy)" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Enable mobile push" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "QRCodeSVG\|qrcode.react" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Scan with the ntfy app" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Save Teams URL" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "Edit advanced" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 5: Theme session bridge + ThemeToggle write-through</name>
<files>components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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 `<ThemeSessionBridge />` inside `app/layout.tsx`:
- Find the existing `<ThemeProvider>` block.
- Inside the `<ThemeProvider>` children, immediately AFTER `<AuthProvider>` opens (so it has access to `useSession()`), add `<ThemeSessionBridge />`. The exact placement: as the FIRST child of `<AuthProvider>`, before the existing `<div className="min-h-screen ...">`.
- 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).
</action>
<verify>
<automated>test -f components/mobile/profile/ThemeSessionBridge.tsx &amp;&amp; grep -q "useSession\|useTheme" components/mobile/profile/ThemeSessionBridge.tsx &amp;&amp; grep -q "session.user" components/mobile/profile/ThemeSessionBridge.tsx &amp;&amp; grep -q "setTheme" components/mobile/profile/ThemeSessionBridge.tsx &amp;&amp; grep -q "ThemeSessionBridge" app/layout.tsx &amp;&amp; grep -q "/api/me/theme" components/theme-toggle.tsx &amp;&amp; grep -q "method: 'PUT'\|method: \"PUT\"" components/theme-toggle.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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 `<ThemeSessionBridge />` inside `<AuthProvider>` (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
</acceptance_criteria>
<done>
Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
</done>
</task>
</tasks>
<threat_model>
## 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).
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
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).
</success_criteria>
<output>
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)
</output>

View file

@ -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"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 01 schema (already landed): -->
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 }
<!-- Auth helpers (existing): -->
requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
<!-- Existing admin Channels page state (today; you'll extend, not replace): -->
- 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
<!-- Existing executions endpoint shape (legacy /api/workflow/executions vs pipeline-engine): -->
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
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Owner column + role-scoped reads on /admin/workflow/channels</name>
<files>app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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`: `<Badge variant="secondary">Global</Badge>`
- Else: `<Badge>Personal: {channel.owner_email ?? channel.owner_user_id}</Badge>` (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'`: `<p className="text-xs text-muted-foreground">Personal channels contain user-supplied webhook URLs — handle with care.</p>` (operational hygiene).
</action>
<verify>
<automated>grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts &amp;&amp; grep -q "owner_user_id" app/api/notification-channels/route.ts &amp;&amp; grep -q "requireAuth" "app/api/notification-channels/[id]/route.ts" &amp;&amp; grep -q "owner_user_id" "app/api/notification-channels/[id]/route.ts" &amp;&amp; grep -q "owner_email\|owner_user_id" app/admin/workflow/channels/page.tsx &amp;&amp; grep -q "Global only\|owner=global\|Personal only" app/admin/workflow/channels/page.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter</name>
<files>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</files>
<read_first>
- 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)
</read_first>
<action>
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 `<Label>`). When ON, append `?fallbacks_only=1` to the executions fetch URL and refresh.
- For each execution row, when the executions response contains a `has_fallback: true` flag (added by Step D's SELECT — extend it to also return `EXISTS(...) AS has_fallback` so the UI can badge per row), render a `<Badge variant="outline">fallback</Badge>` next to the existing status icon.
Note for Step D: To keep the per-row badge cheap, extend the SELECT (always, not only with the filter) to:
```sql
SELECT pe.*,
EXISTS (
SELECT 1 FROM pipeline_execution_steps pes
WHERE pes.execution_id = pe.id
AND pes.output_data ? 'user_route_fallback'
) AS has_fallback
FROM pipeline_executions pe
WHERE pe.pipeline_id = $1
${fallbacks_only ? 'AND has_fallback' : ''} -- composed as parameter; never raw concat
ORDER BY pe.created_at DESC
LIMIT $2
```
The `has_fallback` filter clause must be conditionally included via a TS branch, not string concatenation of a user value. The query is parameterized; only the predicate-presence is conditional.
</action>
<verify>
<automated>test -f app/admin/workflow/event-keys/page.tsx &amp;&amp; test -f app/api/admin/notify-event-keys/route.ts &amp;&amp; test -f "app/api/admin/notify-event-keys/[key]/route.ts" &amp;&amp; grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts &amp;&amp; grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" &amp;&amp; grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts &amp;&amp; grep -q "fallbacks_only\|has_fallback\|user_route_fallback" "app/api/pipelines/[id]/executions/route.ts" &amp;&amp; grep -q "Show only fallbacks\|fallbacks_only=1" "app/admin/workflow/pipelines/[id]/page.tsx" &amp;&amp; grep -q "has_fallback\|fallback" "app/admin/workflow/pipelines/[id]/page.tsx" &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `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 `<Badge>fallback</Badge>` (or equivalent) on rows where `has_fallback === true`
- `npx tsc --noEmit --pretty` reports no errors in any modified file
</acceptance_criteria>
<done>
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).
</done>
</task>
</tasks>
<threat_model>
## 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).
</threat_model>
<verification>
- 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.
</verification>
<success_criteria>
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.
</success_criteria>
<output>
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)
</output>