docs(planning): sync STATE/ROADMAP + capture untracked phase plans before master merge

- STATE/ROADMAP/config updated to reflect Phase 09.1 execution
- 09-01 plan refreshed (gap-closure detail)
- 09-02..09-05 plans updated during execution
- Add untracked 09-06 plan + 01-01/01-02 PWA scaffolding plans (orphaned from earlier sessions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-14 07:30:51 -04:00
parent 1de6c02c3b
commit 7b644e475f
11 changed files with 1951 additions and 626 deletions

View file

@ -210,7 +210,7 @@ Decimal phases appear between their surrounding integers in numeric order.
4. `/mobile/profile` QR code and subscribe link target `process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'`; help line under custom-topic Input reads "Topic must start with `pulse-me-`"
5. `npx tsc --noEmit --pretty` and `npx vitest run lib/services/pipeline-steps/notify.test.ts` both pass (mute semantics intact)
**Plans**: 1 plan
- [ ] 09.1-01-PLAN.md — Personal-channels regex/prefix/bearer + propagate to notify/approval/digest send paths + ProfileChannelsSection QR & copy
- [x] 09.1-01-PLAN.md — Personal-channels regex/prefix/bearer + propagate to notify/approval/digest send paths + ProfileChannelsSection QR & copy
**UI hint**: no (backend-heavy; one component edit for QR/link target)
## Progress

View file

@ -4,14 +4,14 @@ milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Phase 9 UI-SPEC approved
last_updated: "2026-05-11T01:51:19.856Z"
last_activity: 2026-05-11
last_updated: "2026-05-11T10:38:01.535Z"
last_activity: 2026-05-11 -- Phase 09.1 execution started
progress:
total_phases: 10
total_phases: 11
completed_phases: 10
total_plans: 30
total_plans: 31
completed_plans: 30
percent: 100
percent: 97
---
# Project State
@ -21,14 +21,14 @@ progress:
See: .planning/PROJECT.md (updated 2026-05-03)
**Core value:** A manager can open Pulse on their phone and, in under 30 seconds, see the state of the business and triage tickets — without ever needing to switch to desktop for read-only awareness.
**Current focus:** Phase 09 — user-profile-preferences-new
**Current focus:** Phase 09.1 — ntfy-backend-fix
## Current Position
Phase: 09
Plan: Not started
Status: Executing Phase 09
Last activity: 2026-05-11
Phase: 09.1 (ntfy-backend-fix) — EXECUTING
Plan: 1 of 1
Status: Executing Phase 09.1
Last activity: 2026-05-11 -- Phase 09.1 execution started
Progress: [░░░░░░░░░░] 0%

View file

@ -25,7 +25,8 @@
"text_mode": false,
"research_before_questions": false,
"discuss_mode": "discuss",
"skip_discuss": false
"skip_discuss": false,
"_auto_chain_active": false
},
"hooks": {
"context_warnings": true

View file

@ -0,0 +1,374 @@
---
phase: 01-pwa-scaffolding
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- public/manifest.json
- app/layout.tsx
autonomous: true
requirements:
- PWA-01
- PWA-02
- PWA-03
must_haves:
truths:
- "Visiting /manifest.json returns valid JSON with name 'Pulse', short_name 'Pulse', display 'standalone', start_url '/mobile'"
- "The manifest theme_color matches the Wulf brand blue (#0075AD) and background_color matches the light shell"
- "app/layout.tsx references the manifest so Chrome/Safari pick it up automatically (via metadata.manifest or <link rel='manifest'>)"
- "app/layout.tsx exports a viewport object whose viewportFit is 'cover' so the rendered <meta name='viewport'> contains 'viewport-fit=cover'"
- "Installing Pulse to a phone home screen launches a chromeless app that opens to /mobile"
artifacts:
- path: "public/manifest.json"
provides: "Web App Manifest — name, short_name, display, start_url, theme/background, icons"
contains: '"display": "standalone"'
- path: "app/layout.tsx"
provides: "Root layout exporting metadata (with manifest) and viewport (with viewportFit:'cover')"
contains: "viewportFit"
key_links:
- from: "app/layout.tsx"
to: "public/manifest.json"
via: "metadata.manifest or <link rel='manifest'>"
pattern: "manifest"
- from: "public/manifest.json"
to: "/mobile"
via: "start_url field"
pattern: '"start_url"\s*:\s*"/mobile"'
- from: "app/layout.tsx (viewport export)"
to: "rendered <meta name='viewport'>"
via: "Next.js viewport export → viewport-fit=cover in DOM"
pattern: "viewportFit"
---
<objective>
Add the PWA install surface: a valid Web App Manifest at `/manifest.json`, a manifest reference from the root layout, and a viewport export with `viewportFit: 'cover'` so future shell phases can paint behind the device home indicator.
Purpose: PWA-01, PWA-02, PWA-03 — make Pulse installable to a phone home screen and have the install land on `/mobile` in standalone (chromeless) mode. No service worker, no offline.
Output: `public/manifest.json` (new file) and an updated `app/layout.tsx` that adds the manifest reference and a Next 16 viewport export. Verifiable by `curl http://localhost:3100/manifest.json` and grep on `app/layout.tsx`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
@app/layout.tsx
@app/globals.css
@app/styles/brand.css
<interfaces>
<!-- Key facts the executor needs about the existing codebase. Use these directly — no exploration required. -->
**Existing `app/layout.tsx` shape (what is currently there):**
- Imports `Metadata` from `next` (already imported).
- Exports `const metadata: Metadata = { title, description, icons: { icon: [...], shortcut: '/favicon.png', apple: '/wulff-logo.png' } }`.
- Does NOT currently export `viewport`. Next 16 expects a separate `viewport` export of type `Viewport` from `next`.
- The metadata object already has an `icons` field. Do NOT remove it; the PWA `manifest` field is added alongside `icons`.
**Brand colors (already defined in `app/styles/brand.css`):**
- Wulf primary blue: `#0075AD` (oklch `0.540 0.136 233.3`). This is the `theme_color`.
- Light shell background: white (`#FFFFFF`). This is the `background_color` (the manifest only allows one; the light shell is the standard splash background).
**Existing icon assets (in `/public`):**
- `/public/wulff-logo.png` — square PNG, used today as `metadata.icons.apple` (apple-touch-icon).
- `/public/favicon.png` — square PNG.
- `/public/branding/wulf-mark.png` — Wulf "W" mark, square PNG.
- `/public/branding/wulf-wordmark.png` — Wulf "Pulse" wordmark.
None of these have explicit pixel sizes verified, but they're used today and PWA install tools accept them with `"sizes": "any"`.
**Next.js 16 metadata API for manifest:**
- The recommended way to reference a manifest is `metadata.manifest = '/manifest.json'` in the metadata export. Next emits `<link rel="manifest" href="/manifest.json" />` automatically. This satisfies the spec wording (`<link rel="manifest">`) without hand-rolling the link tag.
- Alternative: hand-roll `<link rel="manifest" href="/manifest.json" />` inside `<head>`. Either approach is acceptable per the spec; prefer `metadata.manifest` because the file already uses the metadata API.
**Next.js 16 viewport API:**
- Import: `import type { Viewport } from 'next'`.
- Export: `export const viewport: Viewport = { ... }` (separate from `metadata`; Next 16 deprecated `metadata.viewport`).
- The `viewportFit` field is camelCase in TS; Next emits `viewport-fit=cover` in the rendered `<meta name="viewport">` tag.
- Reasonable default fields: `width: 'device-width'`, `initialScale: 1`, `viewportFit: 'cover'`. Do NOT add `maximumScale` or `userScalable: false` (accessibility).
**Theme color / dark mode caveat:**
- The manifest only allows one `theme_color`. Use the Wulf blue `#0075AD` so the system UI tint matches the brand in both light and dark modes.
- Optionally also add a viewport `themeColor` array with `media: '(prefers-color-scheme: dark)'` variants in the viewport export. This is a Next.js helper that emits `<meta name="theme-color">` per-scheme. NOT required for PWA-01..03; only add if it falls out naturally.
**Verification commands the executor will use:**
- `curl -sf http://localhost:3100/manifest.json | jq .` (dev server must be running)
- `grep -E "viewportFit|viewport-fit" app/layout.tsx`
- `grep -E 'manifest:|rel="manifest"' app/layout.tsx`
- `npx tsc --noEmit --pretty` (must pass)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create public/manifest.json</name>
<files>public/manifest.json</files>
<read_first>
- docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — exact field requirements)
- .planning/REQUIREMENTS.md (PWA-01)
- app/styles/brand.css (line 28: `--wulf-blue` is `#0075AD` — this is theme_color)
- app/globals.css (lines 47-48: light theme `--background` is white; lines 82-83: dark theme background)
- public/ directory listing — confirm `wulff-logo.png`, `favicon.png`, `branding/wulf-mark.png` exist
</read_first>
<action>
Create `public/manifest.json` (new file) with exactly this JSON shape. Hand-write the file; do not use a generator.
```json
{
"name": "Pulse",
"short_name": "Pulse",
"description": "Wulf Consulting operations console — tickets, RMM, backups, and analytics on the go.",
"start_url": "/mobile",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#0075AD",
"background_color": "#FFFFFF",
"icons": [
{
"src": "/wulff-logo.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/branding/wulf-mark.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
}
]
}
```
Notes on the choices (so a reviewer doesn't have to ask):
- `name` and `short_name` both "Pulse" — matches spec §4 verbatim.
- `start_url: "/mobile"` — spec §4 verbatim. The phone install lands on the mobile shell, not the desktop dashboard.
- `scope: "/"` — allow the standalone window to navigate anywhere in the app without falling out to the browser. (Spec doesn't specify; root scope is the safe default for an installed PSA console.)
- `display: "standalone"` — spec §4 verbatim. Chromeless app surface.
- `orientation: "portrait"` — phone-first per the spec's overall framing (§1, §2). Tablet landscape is explicit out-of-scope (§7).
- `theme_color: "#0075AD"` — Wulf brand blue from `app/styles/brand.css` line 28 (`--wulf-blue`). Matches the `--primary` token in both light and dark modes (oklch values resolve to this brand blue, slightly lifted for dark).
- `background_color: "#FFFFFF"` — light shell background. Manifest only allows one value; the iOS/Android splash uses this. White matches Pulse's default theme on light devices and is acceptable on dark devices (brief flash, not a regression).
- `icons` — three entries reusing existing assets in `/public`. Using `"sizes": "any"` because the assets are not explicitly sized — install tools accept this for PNGs and pick the largest. Do NOT generate new icon PNGs in this task; reuse what's there. (A future polish phase can add density-specific 192/512 icons if install warns.)
Do NOT:
- Add a `serviceworker` field (no SW in v1, spec §4 explicit).
- Add `display_override` or `prefer_related_applications` (not needed; not in spec).
- Add `categories` or `lang` (cosmetic; not in spec scope).
- Reference `next-pwa` or any plugin (forbidden by spec §4 and CLAUDE.md).
- Edit any existing migration, lib/, or component file.
The file must be served directly by Next.js as a static asset — placing it at `public/manifest.json` makes it available at `http://localhost:3100/manifest.json`.
</action>
<verify>
<automated>test -f public/manifest.json &amp;&amp; jq -e '.name == "Pulse" and .short_name == "Pulse" and .display == "standalone" and .start_url == "/mobile" and .theme_color == "#0075AD" and .background_color == "#FFFFFF" and (.icons | length) >= 1' public/manifest.json</automated>
</verify>
<acceptance_criteria>
- File `public/manifest.json` exists.
- `jq -r .name public/manifest.json` outputs `Pulse`.
- `jq -r .short_name public/manifest.json` outputs `Pulse`.
- `jq -r .display public/manifest.json` outputs `standalone`.
- `jq -r .start_url public/manifest.json` outputs `/mobile`.
- `jq -r .theme_color public/manifest.json` outputs `#0075AD`.
- `jq -r .background_color public/manifest.json` outputs `#FFFFFF`.
- `jq -e '.icons | length >= 1' public/manifest.json` exits 0.
- `jq -e '.icons[0].src' public/manifest.json` outputs a path beginning with `/` (e.g., `/wulff-logo.png`).
- File is valid JSON: `jq empty public/manifest.json` exits 0.
- No `serviceworker` field present: `jq -e '.serviceworker == null' public/manifest.json` exits 0.
- When dev server is running on port 3100: `curl -sf http://localhost:3100/manifest.json` exits 0 and the body equals the file contents.
</acceptance_criteria>
<done>
`public/manifest.json` exists, is valid JSON, contains the spec-mandated fields with the values above, references at least one icon from `/public`, and is reachable at `http://localhost:3100/manifest.json` when the dev server is running.
</done>
</task>
<task type="auto">
<name>Task 2: Add manifest reference and viewport export to app/layout.tsx</name>
<files>app/layout.tsx</files>
<read_first>
- app/layout.tsx (current file — already exports `metadata: Metadata`, no `viewport` export yet)
- docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — viewport-fit=cover wording)
- .planning/REQUIREMENTS.md (PWA-02, PWA-03)
- public/manifest.json (created in Task 1 — must exist before this task ships)
</read_first>
<action>
Edit `app/layout.tsx` (do NOT create a new file). Two changes, both at the top of the file alongside the existing `metadata` export. The body of `RootLayout` is unchanged.
**Change 1 — add `manifest: '/manifest.json'` to the existing `metadata` object.**
The current export looks like:
```ts
export const metadata: Metadata = {
title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
icons: {
icon: [
{ url: "/favicon.png", sizes: "any" },
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
],
shortcut: "/favicon.png",
apple: "/wulff-logo.png",
},
};
```
Add a `manifest` field alongside `icons`. The result should be:
```ts
export const metadata: Metadata = {
title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.png", sizes: "any" },
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
],
shortcut: "/favicon.png",
apple: "/wulff-logo.png",
},
};
```
Next.js 16 emits `<link rel="manifest" href="/manifest.json" />` automatically from this field — this satisfies the spec wording (`<link rel="manifest">` from §4) without hand-rolling the tag.
**Change 2 — add a `Viewport` import and a separate `viewport` export.**
Update the `next` type import on line 1. The current import is:
```ts
import type { Metadata } from "next";
```
Change it to:
```ts
import type { Metadata, Viewport } from "next";
```
Then, immediately after the `metadata` export (and before `export default function RootLayout(...)`), add:
```ts
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
],
};
```
Notes on the choices:
- `viewportFit: 'cover'` — the only field PWA-03 strictly requires. Emits `viewport-fit=cover` in the rendered `<meta name="viewport">` tag. With this set, Phase 2's safe-area-inset utilities can paint behind the home indicator.
- `width: 'device-width'` and `initialScale: 1` — standard mobile viewport defaults; they were absent before and Next 16 would warn without them. Adding them here removes the warning and makes the viewport explicit.
- `themeColor` — paired light/dark values for the system browser chrome (status bar tint). Light = white (matches manifest `background_color`); dark = `#0A0A0A` (close to the existing `--background` oklch `0.145 0 0` in `app/globals.css` line 83). This is OPTIONAL for PWA-03 (the manifest's `theme_color` already covers the install chrome), but it's a one-line improvement that ships better dark-mode rendering and costs nothing. Keep it; remove if it ever conflicts with a future per-page override.
- Do NOT add `maximumScale`, `userScalable: false`, or `minimumScale` — accessibility regression.
Do NOT:
- Touch the `RootLayout` function body.
- Touch the `ThemeProvider`, `AppNavigation`, `CommandPalette`, `TaglineFooter`, `Toaster`, or `AuthProvider` imports.
- Add any `<head>` JSX (no hand-rolled `<link rel="manifest">` tag — let Next emit it from `metadata.manifest`).
- Touch the `IBM_Plex_Sans` / `IBM_Plex_Mono` font setup.
- Add `'use client'` — root layout is a server component.
</action>
<verify>
<automated>grep -q 'manifest: "/manifest.json"' app/layout.tsx &amp;&amp; grep -q 'viewportFit: "cover"' app/layout.tsx &amp;&amp; grep -q 'import type { Metadata, Viewport } from "next"' app/layout.tsx &amp;&amp; grep -q 'export const viewport: Viewport' app/layout.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | tee /tmp/tsc-out &amp;&amp; ! grep -E "app/layout\\.tsx.*error" /tmp/tsc-out</automated>
</verify>
<acceptance_criteria>
- `grep -E '^import type \{ Metadata, Viewport \} from "next"' app/layout.tsx` matches one line (or `Metadata` and `Viewport` both appear in a single named-import line from `next`).
- `grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsx` matches one line inside the `metadata` object.
- `grep -E '^export const viewport: Viewport = \{' app/layout.tsx` matches exactly one line.
- `grep -E 'viewportFit:\s*"cover"' app/layout.tsx` matches one line inside the `viewport` export.
- `grep -E 'width:\s*"device-width"' app/layout.tsx` matches one line.
- `grep -E 'initialScale:\s*1' app/layout.tsx` matches one line.
- The `metadata.icons` object is unchanged (still contains `apple: "/wulff-logo.png"`): `grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsx` matches.
- The `RootLayout` default export is unchanged: `grep -E 'export default function RootLayout' app/layout.tsx` matches.
- No `'use client'` pragma added: `! grep -E "^'use client'" app/layout.tsx`.
- Type check passes: `npx tsc --noEmit --pretty` exits 0 (or, if other files have unrelated pre-existing errors, no error rows mention `app/layout.tsx`).
- When dev server is running: viewing http://localhost:3100/ source contains `viewport-fit=cover` (e.g. `curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'` exits 0). Optional manual check; not strictly required for the automated gate.
</acceptance_criteria>
<done>
`app/layout.tsx` exports both `metadata` (now with `manifest: "/manifest.json"`) and `viewport` (with `viewportFit: "cover"`, `width: "device-width"`, `initialScale: 1`, and themeColor light/dark pair). Type check passes. The `RootLayout` body is unchanged. PWA-02 (manifest reference) and PWA-03 (viewport-fit=cover) are satisfied.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser ↔ static asset (/manifest.json) | Public client read of a manifest. No auth, no input. |
| Browser ↔ rendered HTML head | Public client read of `<meta name="viewport">` and `<link rel="manifest">`. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Information Disclosure | public/manifest.json | accept | Manifest is intended to be world-readable per W3C Web App Manifest spec. Contains only public app branding (name, theme color, icon paths) — no secrets, no user data, no endpoints. |
| T-01-02 | Tampering | app/layout.tsx viewport export | accept | Server-rendered; no user input flows into the viewport meta. No injection vector. |
| T-01-03 | Denial of Service | manifest fetch | accept | Static file served by Next.js; same risk profile as `/favicon.png`. No new attack surface. |
**Summary:** No new threat surface introduced. `manifest.json` is public per W3C spec; viewport meta is a public client hint; no auth, data, or endpoints are introduced. ASVS-L1 baseline preserved.
</threat_model>
<verification>
With dev server running (`npm run dev` → port 3100), all of the following must pass:
```bash
# Manifest is reachable and well-formed
curl -sf http://localhost:3100/manifest.json | jq -e '.name == "Pulse" and .display == "standalone" and .start_url == "/mobile"'
# Manifest is referenced from root layout (Next emits the link tag automatically)
curl -s http://localhost:3100/ | grep -E 'rel="manifest"'
# Viewport meta includes viewport-fit=cover
curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'
# Type check passes
npx tsc --noEmit --pretty
# No service worker file shipped (negative check — must be absent)
test ! -f public/sw.js && test ! -f public/service-worker.js
# next-pwa is not in dependencies
! grep -E '"next-pwa"' package.json
```
</verification>
<success_criteria>
- `public/manifest.json` exists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme_color "#0075AD", background_color "#FFFFFF", and at least one icon (PWA-01).
- `app/layout.tsx` references the manifest via `metadata.manifest = "/manifest.json"`, which makes Next.js emit `<link rel="manifest" href="/manifest.json" />` in the rendered HTML head (PWA-02).
- `app/layout.tsx` exports `viewport: Viewport` with `viewportFit: "cover"` so the rendered `<meta name="viewport">` tag contains `viewport-fit=cover` (PWA-03).
- `npx tsc --noEmit --pretty` passes.
- No service worker file or `next-pwa` dependency introduced.
</success_criteria>
<output>
After completion, create `.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md` documenting:
- Files created/modified (paths and one-line descriptions)
- The exact `theme_color` and `background_color` values chosen (and why — Wulf brand blue + light shell background)
- The viewport export shape (so Phase 2 knows it can rely on `viewport-fit=cover` being present)
- Verification results (manifest curl, viewport grep, tsc result)
- Any deviations from the plan and rationale
</output>

View file

@ -0,0 +1,286 @@
---
phase: 01-pwa-scaffolding
plan: 02
type: execute
wave: 1
depends_on: []
gap_closure: true
files_modified:
- app/styles/brand.css
autonomous: true
requirements:
- PWA-04
must_haves:
truths:
- "A shared @utility named pt-safe is defined in app/styles/brand.css that applies padding-top: env(safe-area-inset-top)"
- "A shared @utility named pb-safe is defined in app/styles/brand.css that applies padding-bottom: env(safe-area-inset-bottom)"
- "Phase 2's sticky header can opt into safe-area-inset-top padding by adding the pt-safe class"
- "Phase 2's fixed bottom nav can opt into safe-area-inset-bottom padding by adding the pb-safe class"
- "The Tailwind 4 build accepts the new @utility blocks (no CSS syntax errors, npm run build succeeds)"
artifacts:
- path: "app/styles/brand.css"
provides: "Two new @utility blocks (pt-safe, pb-safe) sitting alongside the existing num/metric/surface/rule/chrome/tagline utilities"
contains: "@utility pt-safe"
key_links:
- from: "app/styles/brand.css (@utility pt-safe)"
to: "rendered CSS class .pt-safe"
via: "Tailwind 4 @utility block — Tailwind compiles @utility name { ... } into a class .name { ... }"
pattern: "@utility pt-safe"
- from: "app/styles/brand.css (@utility pb-safe)"
to: "rendered CSS class .pb-safe"
via: "Tailwind 4 @utility block"
pattern: "@utility pb-safe"
- from: "app/globals.css"
to: "app/styles/brand.css"
via: "@import './styles/brand.css' on line 125 (already wired — no change required)"
pattern: '@import "./styles/brand.css"'
---
<objective>
Close the PWA-04 gap from Phase 01 verification by adding shared safe-area `@utility` blocks to `app/styles/brand.css`. Phase 2's sticky header and fixed bottom nav need to opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding so content paints correctly under the iOS home indicator and Android gesture bar when `viewport-fit=cover` is in effect (already shipped by 01-01).
Purpose: PWA-04 — make a safe-area utility available so any sticky top/bottom bar can opt in. ROADMAP Phase 1 SC #3 requires the utility to be **available in Phase 1**; Phase 2's contract (SHELL-05, SHELL-06) only mandates **consumption**. This plan restores the broken phase boundary identified by `01-VERIFICATION.md`.
Output: `app/styles/brand.css` updated with two new `@utility` blocks (`pt-safe`, `pb-safe`) appended to the existing utility section. No other files touched. Verifiable by `grep -E '@utility (pt-safe|pb-safe)' app/styles/brand.css` and `npm run build`.
Why `app/styles/brand.css` (not `app/globals.css`):
- All named project utilities (`num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`, `has-mark-watermark`) already live there.
- `brand.css` is already imported into `globals.css` (line 125) — no extra wiring needed.
- Keeps utilities co-located so Phase 2 has a single file to scan when looking for project helpers.
- `globals.css` is reserved for Tailwind imports, `@theme inline` token mapping, and `:root` / `.dark` variable definitions — adding utility classes there would muddy that separation.
Note on traceability: this plan claims `PWA-04` in its `requirements` frontmatter, restoring the orphaned-requirement state flagged by `01-VERIFICATION.md`. The executor's SUMMARY (`01-02-SUMMARY.md`) should explicitly call out that PWA-04 is now satisfied, closing the requirements traceability table.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-pwa-scaffolding/01-VERIFICATION.md
@.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
@app/styles/brand.css
@app/globals.css
<interfaces>
<!-- Key facts the executor needs about the existing codebase. Use these directly — no exploration required. -->
**Tailwind 4 `@utility` syntax (already in use in this project):**
- This project is Tailwind 4 with NO `tailwind.config.*` file. Custom utilities are declared inline in CSS using the `@utility` at-rule.
- Pattern: `@utility name { /* CSS declarations */ }` — Tailwind compiles this to a class `.name { ... }` that participates in the variant system (`hover:name`, `md:name`, etc.).
- See existing examples in `app/styles/brand.css` lines 70-140 (e.g., `@utility num { ... }`, `@utility metric-label { ... }`, `@utility surface-brand { ... }`).
- Each `@utility` block holds plain CSS property declarations. No `@apply` is required for simple `padding-*` cases.
**`env()` CSS environment variables for safe areas:**
- `env(safe-area-inset-top)` — top safe-area inset (e.g., iPhone notch / Dynamic Island area).
- `env(safe-area-inset-bottom)` — bottom safe-area inset (e.g., iPhone home indicator area).
- Browser-side CSS feature; no JavaScript involvement. Falls back to `0` on browsers/devices without safe-area insets.
- Requires `<meta name="viewport" content="...viewport-fit=cover">` to take non-zero values. **This is already shipped by 01-01** (`viewportFit: "cover"` in `app/layout.tsx`).
**Existing `app/styles/brand.css` structure (line numbers from current file):**
- Lines 1-21: file header / brand documentation comment.
- Lines 23-52: `:root` overrides (`--wulf-blue`, etc.) for the light theme.
- Lines 54-62: `.dark` overrides.
- Lines 64-68: `/* === Utility classes === */` section header comment.
- Lines 70-140: existing `@utility` blocks — `num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`.
- Lines 142-147: `/* === Wolf-mark watermark === */` section header comment.
- Lines 149-152: `@utility has-mark-watermark`.
- Lines 154-170: `.mark-watermark` plain rule + `.dark .mark-watermark` override.
- **Insertion point for new utilities:** after the `tagline` utility (line 140) and **before** the watermark section header (line 142). This keeps utilities grouped before the watermark block, which has its own thematic header.
**`app/globals.css` import wiring (already in place — DO NOT change):**
- Line 125: `@import "./styles/brand.css";` — pulls `brand.css` into the global stylesheet at the end. Anything added to `brand.css` is automatically available app-wide. No additional wiring needed.
**Spec wording (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §5/§6):**
- The mobile shell's sticky top header must respect `env(safe-area-inset-top)`.
- The fixed bottom nav must respect `env(safe-area-inset-bottom)` (often combined with the bottom-nav height).
- The spec accepts either a named utility or Tailwind 4 arbitrary values (`pt-[env(safe-area-inset-top)]`).
**Why ship a named utility (not arbitrary values):**
- Phase 2 will use these classes in 2+ places (header, bottom nav, drawer footer, possibly modals). A named utility is one source of truth — if the iOS rules ever change (e.g., add `max(env(safe-area-inset-top), 0.5rem)`), it's a one-line edit instead of a multi-file find-and-replace.
- `pt-safe` / `pb-safe` reads more clearly in JSX class lists than `pt-[env(safe-area-inset-top)]`.
- ROADMAP Phase 1 SC #3 explicitly mentions "shared utility class" as one acceptable form — picking that form removes ambiguity for Phase 2.
**Verification commands the executor will use:**
- `grep -E '@utility pt-safe' app/styles/brand.css`
- `grep -E '@utility pb-safe' app/styles/brand.css`
- `grep -E 'env\(safe-area-inset-top\)' app/styles/brand.css`
- `grep -E 'env\(safe-area-inset-bottom\)' app/styles/brand.css`
- `npm run build` (CSS @utility blocks must parse — broken syntax fails the Tailwind compile step in Next.js)
- `npx tsc --noEmit --pretty` (sanity check; CSS doesn't affect TS but pre-existing baseline must hold)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Append pt-safe and pb-safe @utility blocks to app/styles/brand.css</name>
<files>app/styles/brand.css</files>
<read_first>
- app/styles/brand.css (the entire file — confirm line numbers above match current state; the exact insertion point is between the existing `tagline` utility and the watermark section header)
- app/globals.css lines 1-5 and 125 (confirm `brand.css` is still imported; no change needed)
- .planning/phases/01-pwa-scaffolding/01-VERIFICATION.md (the gap source — frontmatter `gaps[0].missing`)
- .planning/REQUIREMENTS.md line 16 (PWA-04 wording)
</read_first>
<action>
Edit `app/styles/brand.css`. Append two new `@utility` blocks **after** the existing `@utility tagline { ... }` block (which ends around line 140) and **before** the `/* === Wolf-mark watermark === */` section header comment (around line 142). Do NOT touch any other part of the file.
Insert exactly this block (including the leading section comment and the two `@utility` definitions):
```css
/* === Safe-area insets =================================================
*
* Opt-in padding helpers for sticky top / fixed bottom bars on devices
* with notches, dynamic islands, or gesture home indicators. Pair with
* the viewport-fit=cover viewport meta (set in app/layout.tsx) — without
* that, env(safe-area-inset-*) resolves to 0 and these utilities are
* no-ops, which is the desired fallback on non-PWA / non-mobile contexts.
*
* Usage:
* <header class="sticky top-0 pt-safe ..."> // header clears notch
* <nav class="fixed bottom-0 pb-safe ..."> // bottom bar clears home bar
*
* Closes PWA-04 (REQUIREMENTS.md) and ROADMAP Phase 1 SC #3.
* ==================================================================== */
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
```
Notes on the choices (so a reviewer doesn't have to ask):
- **Two utilities only (`pt-safe`, `pb-safe`).** The spec only requires top + bottom safe-area handling — that's what notches and home indicators occupy. Left/right insets (`safe-area-inset-left`, `safe-area-inset-right`) only matter for landscape mode on devices with notches, and the manifest pins orientation to `portrait` (per 01-01-SUMMARY.md). Adding `pl-safe` / `pr-safe` now would be speculative; Phase 2 can add them in 30 seconds if a real consumer appears.
- **No additive variants like `pt-safe-4`.** ROADMAP Phase 1 SC #3 only requires the **base** utility be available. Combinations like "safe-area inset PLUS 1rem" can be expressed at consume time with `pt-safe pt-4` or arbitrary values. Don't pre-build helpers that nothing yet calls.
- **Plain `env(safe-area-inset-top)` (not `max(env(safe-area-inset-top), 0px)`).** The CSS env value already returns `0` when no inset is reported — wrapping it in `max(..., 0)` is a no-op and adds noise. Wrap it later if a real device misbehaves.
- **Insertion location: alongside the other `@utility` blocks in `brand.css`.** Already justified in `<objective>`: keeps named utilities co-located, requires no new import wiring, fits the project's "Wulf design helpers" theme (these are layout primitives that go with the rest of the brand kit).
Do NOT:
- Edit `app/globals.css` — the existing `@import "./styles/brand.css"` (line 125) is already correct.
- Add a new file — these are utilities, not a new module.
- Add `pl-safe`, `pr-safe`, or stacked variants — out of scope for PWA-04.
- Modify any existing `@utility` block (`num`, `metric-label`, `tagline`, etc.) — they are unrelated.
- Modify the `:root` / `.dark` token sections at the top of `brand.css` — these are CSS custom properties, not utilities.
- Touch the `.mark-watermark` plain rule or the watermark section header — they live below the insertion point and are unrelated.
- Add a `tailwind.config.*` file — this is a Tailwind 4 project with no config file, by design (CLAUDE.md `Conventions to follow`).
After editing, the file should be ~12-13 lines longer than before (1 comment block + 2 `@utility` definitions = ~13 lines including blank lines). The diff should show **only** additions, no deletions.
</action>
<verify>
<automated>grep -q '@utility pt-safe' app/styles/brand.css &amp;&amp; grep -q '@utility pb-safe' app/styles/brand.css &amp;&amp; grep -q 'padding-top: env(safe-area-inset-top)' app/styles/brand.css &amp;&amp; grep -q 'padding-bottom: env(safe-area-inset-bottom)' app/styles/brand.css &amp;&amp; npx tsc --noEmit --pretty &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `grep -E '@utility pt-safe' app/styles/brand.css` matches exactly one line.
- `grep -E '@utility pb-safe' app/styles/brand.css` matches exactly one line.
- `grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css` matches exactly one line.
- `grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css` matches exactly one line.
- The existing `@utility tagline` block is still present and unchanged: `grep -E '@utility tagline' app/styles/brand.css` matches.
- The existing `@utility num` block is still present: `grep -E '@utility num \{' app/styles/brand.css` matches.
- The existing `.mark-watermark` rule is still present: `grep -E '\.mark-watermark \{' app/styles/brand.css` matches.
- The `:root` `--wulf-blue` token is still present: `grep -E '\-\-wulf-blue:' app/styles/brand.css` matches.
- No `tailwind.config` file was created: `test ! -f tailwind.config.ts && test ! -f tailwind.config.js && test ! -f tailwind.config.mjs`.
- `app/globals.css` is unchanged from baseline: `grep -E '@import "\./styles/brand\.css";' app/globals.css` matches (the existing import is intact).
- No `pl-safe` / `pr-safe` utilities were added (out of scope): `! grep -E '@utility (pl-safe|pr-safe)' app/styles/brand.css`.
- No service worker file shipped: `test ! -f public/sw.js && test ! -f public/service-worker.js`.
- `next-pwa` is not in dependencies: `! grep '"next-pwa"' package.json`.
- Type check passes: `npx tsc --noEmit --pretty` exits 0 (CSS changes don't affect TS, but the pre-existing baseline is preserved).
- Production build succeeds: `npm run build` exits 0. This is the load-bearing gate — Tailwind 4 will fail the build if the `@utility` syntax is malformed, so a green build confirms the new utilities compile and are emitted into the production CSS bundle.
</acceptance_criteria>
<done>
`app/styles/brand.css` contains two new `@utility` blocks — `pt-safe` (sets `padding-top: env(safe-area-inset-top)`) and `pb-safe` (sets `padding-bottom: env(safe-area-inset-bottom)`) — placed between the existing `@utility tagline` block and the `/* === Wolf-mark watermark === */` section header. The block is preceded by a section comment that documents the intent, usage, and the PWA-04 / SC #3 requirement IDs being closed. No other file is modified. `npm run build` and `npx tsc --noEmit --pretty` both exit 0. PWA-04 is satisfied; the orphaned-requirement state from `01-VERIFICATION.md` is closed.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser ↔ static CSS bundle | Public client read of compiled Tailwind CSS containing `.pt-safe` / `.pb-safe` rules. No auth, no input, no data flow. |
## STRIDE Threat Register
No trust boundaries crossed. CSS utilities are public client-side styles; `env(safe-area-inset-*)` is a CSS environment variable resolved by the browser based on the device viewport. There is no user input, no data flow, no auth surface, no new endpoint. ASVS-L1 baseline preserved — no new attack surface introduced beyond the (already-public) Tailwind CSS bundle.
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01.02-01 | Information Disclosure | Compiled CSS bundle (`.pt-safe`, `.pb-safe` class rules) | accept | CSS class rules are world-readable by design — same risk profile as every other Tailwind utility class. Contains zero secrets, zero user data, zero endpoints. |
</threat_model>
<verification>
With dev server NOT required (these checks are static-file and build-time):
```bash
# New utilities exist in brand.css
grep -E '@utility pt-safe' app/styles/brand.css
grep -E '@utility pb-safe' app/styles/brand.css
# They reference the correct CSS env() variables
grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css
grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css
# Existing utilities and brand tokens are untouched
grep -E '@utility tagline' app/styles/brand.css
grep -E '@utility num \{' app/styles/brand.css
grep -E '\-\-wulf-blue:' app/styles/brand.css
# globals.css import wiring is unchanged
grep -E '@import "\./styles/brand\.css";' app/globals.css
# Out-of-scope items NOT added
! grep -E '@utility (pl-safe|pr-safe)' app/styles/brand.css
test ! -f tailwind.config.ts && test ! -f tailwind.config.js && test ! -f tailwind.config.mjs
test ! -f public/sw.js
! grep '"next-pwa"' package.json
# Type check (sanity, baseline preserved)
npx tsc --noEmit --pretty
# Production build (load-bearing — Tailwind 4 fails the build on malformed @utility syntax)
npm run build
```
With dev server running on port 3100, an OPTIONAL spot check (not required for the automated gate; useful for human verification on a real iPhone):
```bash
# Confirm the compiled .pt-safe / .pb-safe classes are present in the served CSS bundle
curl -s http://localhost:3100/ | grep -oE '/_next/static/css/[^"]+\.css' | head -1 | xargs -I {} curl -s "http://localhost:3100{}" | grep -E '\.pt-safe|\.pb-safe'
```
</verification>
<success_criteria>
- `app/styles/brand.css` defines `@utility pt-safe { padding-top: env(safe-area-inset-top); }` (PWA-04, SC #3).
- `app/styles/brand.css` defines `@utility pb-safe { padding-bottom: env(safe-area-inset-bottom); }` (PWA-04, SC #3).
- The new utilities are placed in the existing utilities section of `brand.css` (between `@utility tagline` and the watermark section), preceded by a section comment that documents intent and references PWA-04.
- All pre-existing `@utility` blocks (`num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`, `has-mark-watermark`) and the `.mark-watermark` plain rule remain unchanged.
- `app/globals.css` is unchanged (the existing `@import "./styles/brand.css"` on line 125 already pulls the new utilities into the global stylesheet).
- `npm run build` exits 0 — confirms Tailwind 4 accepts the new `@utility` syntax and compiles `.pt-safe` / `.pb-safe` into the production CSS bundle.
- `npx tsc --noEmit --pretty` exits 0 (baseline preserved; CSS edits don't affect TS).
- No `tailwind.config.*` file is created (Tailwind 4 + CLAUDE.md convention).
- No service worker, no `next-pwa`, no new dependencies introduced.
- PWA-04 is satisfied; the orphaned-requirement state from `01-VERIFICATION.md` is closed (SUMMARY explicitly claims PWA-04).
</success_criteria>
<output>
After completion, create `.planning/phases/01-pwa-scaffolding/01-02-SUMMARY.md` documenting:
- File modified (`app/styles/brand.css`) with the exact diff (one section comment block + two `@utility` definitions appended).
- The two utility names (`pt-safe`, `pb-safe`) and the CSS they emit (`padding-top: env(safe-area-inset-top)`, `padding-bottom: env(safe-area-inset-bottom)`).
- Why `brand.css` was chosen over `globals.css` (utilities co-located with other named project utilities; already imported into globals; no extra wiring required).
- Why only `pt-safe` / `pb-safe` (not `pl-safe` / `pr-safe` / additive variants) — orientation is portrait-locked, spec only mandates top/bottom; left/right and additive variants are speculative until a consumer asks.
- Verification results (the grep commands and `npm run build` exit code from `<verification>`).
- **Explicit Requirements Satisfied entry: `PWA-04`** — call this out by name so the orphaned-requirement state from `01-VERIFICATION.md` is visibly closed in the SUMMARY trail. This makes the next verification pass green on the requirements coverage table.
- Pointer to Phase 2: the new utilities are ready for `app/mobile/layout.tsx` (sticky header → `pt-safe`, fixed bottom nav → `pb-safe`).
- Threat surface scan: confirm "no new threat surface" (CSS utility addition only, no auth/input/data flow).
</output>

View file

@ -18,6 +18,7 @@ must_haves:
- "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)"
- "notification_channels has a partial unique index on (owner_user_id, channel_type) WHERE owner_user_id IS NOT NULL — defense-in-depth for the API-layer UPSERT in Plan 02"
- "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:
@ -25,7 +26,7 @@ must_haves:
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"
provides: "owner_user_id column + partial unique index 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"
@ -50,7 +51,7 @@ must_haves:
<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)
2. `owner_user_id` column on `notification_channels` plus a partial unique index that enforces "one personal Teams + one personal ntfy per user" at the database level (CHAN-01, defense-in-depth for D-02)
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
@ -112,6 +113,26 @@ ALTER TABLE "user"
UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL;
COMMENT ON COLUMN "user".timezone IS '...';
```
From migrations/012_create_auth_tables.sql (Better Auth `"user"` table column casing — IMPORTANT, read this carefully):
```sql
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
image TEXT,
role TEXT DEFAULT 'user',
banned BOOLEAN DEFAULT FALSE,
banned_reason TEXT,
ban_expires TIMESTAMP,
requires_setup BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
The Better Auth `"user"` table uses **snake_case unquoted columns** (`updated_at`, `created_at`, `email_verified`, `requires_setup`). This contradicts a stale precedent in `app/api/me/timezone/route.ts` which writes `"updatedAt"` (a quoted camelCase identifier that does NOT exist in this schema). Plan 02 corrects that error — Plan 01 itself does not write to `updated_at` (the migration relies on the column DEFAULT NOW()).
</interfaces>
</context>
@ -123,7 +144,7 @@ COMMENT ON COLUMN "user".timezone IS '...';
<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)
- migrations/012_create_auth_tables.sql (confirm "user" table is the Better Auth table and that its audit columns are snake_case unquoted: `updated_at`, NOT `"updatedAt"`)
- 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>
@ -191,13 +212,13 @@ COMMENT ON COLUMN "user".timezone IS '...';
</task>
<task type="auto" tdd="false">
<name>Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables</name>
<name>Task 2: Add owner_user_id + partial unique index 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)
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-02 singular per-type, D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled)
</read_first>
<action>
@ -212,10 +233,12 @@ COMMENT ON COLUMN "user".timezone IS '...';
-- - 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.
--
-- Defense-in-depth: a partial unique index enforces one-personal-channel-per-
-- type-per-user at the DB layer (D-02 / CHAN-02). The API-layer WITH-CTE UPSERT
-- in Plan 02 still has a small race window for concurrent saves; this index
-- closes that gap. The WHERE clause keeps existing global rows (NULL owner)
-- exempt — multiple global rows of the same type can still coexist.
-- =============================================================================
ALTER TABLE notification_channels
@ -226,6 +249,11 @@ COMMENT ON COLUMN "user".timezone IS '...';
ON notification_channels(owner_user_id, channel_type)
WHERE owner_user_id IS NOT NULL;
-- One personal channel per (owner, type). Globals (NULL owner) unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq
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.';
```
@ -314,13 +342,14 @@ COMMENT ON COLUMN "user".timezone IS '...';
</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>
<automated>grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql &amp;&amp; grep -q "notification_channels_owner_user_id_channel_type_uniq" 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`
- File contains `CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq` with the predicate `WHERE owner_user_id IS NOT NULL` (defense-in-depth for the API-layer UPSERT race window — medium 10 from plan checker)
- `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)`
@ -332,7 +361,7 @@ COMMENT ON COLUMN "user".timezone IS '...';
</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.
Two new migrations land at the end of the migration sequence (head moves 083 → 086). The partial unique index closes the API-layer UPSERT race window. `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly.
</done>
</task>
@ -354,8 +383,9 @@ COMMENT ON COLUMN "user".timezone IS '...';
| 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-04 | Denial of Service | `notify_event_keys` | accept | Admin-only CRUD (Plan 06); 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 |
| T-09-01-06 | Race Condition / Duplicate Insertion | `notification_channels` personal UPSERT | mitigate | Partial unique index `notification_channels_owner_user_id_channel_type_uniq` enforces one-row-per-(owner,type) at the DB layer, closing the small race window in the API-layer WITH-CTE UPSERT (Plan 02). Globals (NULL owner) remain exempt |
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).
@ -368,6 +398,7 @@ No `high` severity threats. ASVS L1 satisfied: V8.3.4 (sensitive data lifecycle
- 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`).
- Partial unique index uses `IF NOT EXISTS` and the matching `WHERE owner_user_id IS NOT NULL` predicate so re-running the migration on an already-migrated database is a no-op.
</verification>
<success_criteria>
@ -384,5 +415,7 @@ After completion, create `.planning/phases/09-user-profile-preferences-new/09-01
- 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)
- Confirmation the partial unique index `notification_channels_owner_user_id_channel_type_uniq` exists with the correct WHERE predicate
- Any deviations from the plan (expected: none)
</output>
</output>

View file

@ -17,9 +17,10 @@ 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"
- "All UPDATE statements against the Better Auth \"user\" table use the snake_case unquoted column name `updated_at` (NOT the quoted camelCase identifier `\"updatedAt\"`) — see read_first for migration 012"
- "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"
- "PUT /api/me/channels/ntfy mints a UUID-prefixed topic on first save (or accepts a custom topic when explicitly supplied; format ^[A-Za-z0-9_-]{6,64}$ — invalid topics return 400 with a JSON `{ error, message }` body the UI can render inline), 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)"
@ -42,11 +43,11 @@ must_haves:
exports: ["GET", "PUT"]
- path: "lib/services/personal-channels.ts"
provides: "shared validation + test-send helpers; sendChannelTest(channel)"
exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY"]
exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY", "isPersonalChannelType", "isValidNtfyTopic", "PERSONAL_CHANNEL_TYPES"]
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"
via: "INSERT ... WITH-CTE UPSERT scoped to (owner_user_id, channel_type) — partial unique index in Plan 01 closes the race window"
pattern: "owner_user_id"
- from: "PUT /api/me/channels/ntfy"
to: "lib/services/personal-channels.ts mintNtfyTopic"
@ -63,17 +64,24 @@ Build the per-user API surface for Phase 9. All routes mirror the conventions al
established by `/api/me/timezone`: `requireAuth()`, write target is always
`session.user.id`, manual snake_case → camelCase transform, no Zod, explicit validation.
**IMPORTANT — column casing:** the Better Auth `"user"` table from
`migrations/012_create_auth_tables.sql` uses snake_case unquoted columns
(`updated_at`, `created_at`). The existing `app/api/me/timezone/route.ts` writes
`"updatedAt"` (quoted camelCase) which does NOT match the schema — it is a stale
precedent. This plan writes `updated_at` (matches the schema and the working
precedents in `app/api/settings/profile/route.ts` and `lib/bootstrap.ts`).
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).
minting, and the test-send helper across the routes (and reuse in Plan 03 / Plan 06).
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.
surfaces. Those land in Plans 03/04/05/06. Once these endpoints exist, the mobile
profile page (Plans 04 + 05) becomes a pure rendering exercise.
Output: 5 new route files + 1 new service helper. No DB migrations (those landed in
Plan 01).
@ -89,6 +97,8 @@ Plan 01).
@.planning/REQUIREMENTS.md
@CLAUDE.md
@app/api/me/timezone/route.ts
@app/api/settings/profile/route.ts
@migrations/012_create_auth_tables.sql
@app/api/notification-channels/route.ts
@app/api/notification-channels/[id]/test/route.ts
@lib/services/pipeline-steps/notify.ts
@ -126,7 +136,20 @@ export interface NotifyEventKey { key: string; display_label: string; descriptio
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):
Better Auth `"user"` schema (migration 012 — verified):
```sql
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
...
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
Columns are snake_case unquoted. UPDATE statements MUST use `updated_at`, NOT `"updatedAt"`.
Reference shape for /api/me/timezone (mirror this convention exactly EXCEPT for the column-casing bug — see read_first):
- 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
@ -141,7 +164,9 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
<files>app/api/me/theme/route.ts</files>
<read_first>
- app/api/me/timezone/route.ts (mirror exactly)
- app/api/me/timezone/route.ts (mirror exactly EXCEPT replace `"updatedAt"` with `updated_at` — the timezone route's quoted camelCase identifier is a stale bug; see migration 012)
- app/api/settings/profile/route.ts (working precedent for `updated_at = NOW()` against the `"user"` table)
- migrations/012_create_auth_tables.sql (proves `updated_at` is the actual schema column)
- 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>
@ -163,14 +188,14 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
- 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]`
- `UPDATE "user" SET theme = $1, updated_at = NOW() WHERE id = $2 RETURNING theme` with `[candidate, session!.user.id]` — note `updated_at` is unquoted snake_case (matches migration 012 schema).
- 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>
<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; grep -q "updated_at = NOW()" app/api/me/theme/route.ts &amp;&amp; ! grep -q '"updatedAt"' app/api/me/theme/route.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | grep "theme/route.ts" | head</automated>
</verify>
<acceptance_criteria>
@ -181,13 +206,15 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
- 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`
- The UPDATE statement uses the literal substring `updated_at = NOW()` (unquoted snake_case — matches migration 012)
- The file does NOT contain the literal `"updatedAt"` (the stale precedent from `timezone/route.ts` is intentionally NOT carried forward)
- 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.
Theme GET/PUT route lives at `/api/me/theme`, gated by `requireAuth()`, writes only session.user.id, validates against the three-string allowlist, and uses the correct `updated_at` schema column. Mirrors `/api/me/timezone` shape line-for-line modulo the validation function and the column-casing fix.
</done>
</task>
@ -198,7 +225,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
<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)
- app/api/me/timezone/route.ts (per-user route conventions — but use `updated_at` not `"updatedAt"`)
- .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)
@ -233,6 +260,13 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
}
}
/** ntfy topic format guard (CHAN-03 / D-04). Used when a user supplies a
* custom topic via "Edit advanced". Default flow mints via mintNtfyTopic. */
const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{6,64}$/;
export function isValidNtfyTopic(input: unknown): input is string {
return typeof input === 'string' && NTFY_TOPIC_RE.test(input);
}
/** 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.
@ -361,20 +395,20 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
PUT semantics:
- `requireAuth()` guard.
- `const { type } = await params; if (!isPersonalChannelType(type)) return 400 'Unsupported channel type'`
- `const { type } = await params; if (!isPersonalChannelType(type)) return 400 { error: 'Unsupported channel type', message: ...}`
- 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"`
- If `!isValidTeamsWebhookUrl(webhook_url)` return 400 `{ error: 'Invalid webhook URL', 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 `customTopic` provided: validate via `isValidNtfyTopic(customTopic)`. On failure return 400 `{ error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }` — the UI in Plan 05 renders the `message` inline below the custom-topic Input.
- 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):
- **UPSERT** in a single SQL statement, scoped to the calling user (D-02 / CHAN-02 — the partial unique index added in Plan 01 closes the race window):
```sql
WITH existing AS (
SELECT id FROM notification_channels
@ -396,6 +430,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
SELECT * FROM updated UNION ALL SELECT * FROM inserted;
```
Bind params: `[session!.user.id, type, name, JSON.stringify(config)]`.
If a concurrent request races and the partial unique index `notification_channels_owner_user_id_channel_type_uniq` (Plan 01) fires a `23505 unique_violation`, catch in the surrounding try/catch and return 409 `{ error: 'Conflict', message: 'Channel already exists for this user; retry the save' }`. The UI is debounced so this should not normally occur, but the index is the safety net.
- After upsert, fire `await sendChannelTest(row)` (best-effort — do NOT bubble errors).
- Return `NextResponse.json({ channel: <camelCase row>, test: <ChannelTestResult> })`.
@ -414,16 +449,18 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
</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>
<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 "isValidNtfyTopic" 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`
- `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `isValidNtfyTopic`, `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)
- `isValidNtfyTopic('foo-bar')` returns `false` (5 chars, below min 6); `isValidNtfyTopic('valid_topic_1')` returns `true`; `isValidNtfyTopic('with spaces')` returns `false`
- `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` ntfy branch with a custom topic calls `isValidNtfyTopic`; on failure returns HTTP 400 with a JSON body containing both `error` and `message` keys (the UI in Plan 05 renders `message` inline)
- `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 }`
@ -432,7 +469,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
</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.
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 06. Custom ntfy topic 400s carry a renderable `message`.
</done>
</task>
@ -534,14 +571,15 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
| 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-02 | Tampering | `PUT /api/me/theme` | mitigate | `ALLOWED_THEMES` allowlist (`'light' \| 'dark' \| 'system'`); rejected with HTTP 400 before any DB write. UPDATE SQL uses the actual schema column `updated_at` (not the stale `"updatedAt"` from the timezone-route precedent) |
| 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-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 06) |
| 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-07 | Spoofing / Topic Squatting | `mintNtfyTopic` + custom topic override | 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 goes through `isValidNtfyTopic` (`^[A-Za-z0-9_-]{6,64}$`) — the 400 response carries a renderable `message` so the UI can show inline error |
| 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 |
| T-09-02-10 | Race Condition | Concurrent personal-channel saves | mitigate | API-layer WITH-CTE UPSERT has a small race window; the partial unique index added in Plan 01 (`notification_channels_owner_user_id_channel_type_uniq`) raises `23505` on the loser — caught and returned as 409 |
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).
@ -550,25 +588,30 @@ No `high` severity unmitigated. ASVS L1 satisfied: V4.1.5 (URL allowlist for out
<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.
- `lib/services/personal-channels.ts` exports the seven 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.
- Manual smoke: `curl -X PUT /api/me/channels/ntfy -d '{"topic":"bad space"}'` returns 400 with a body containing `message: "topic must match ^[A-Za-z0-9_-]{6,64}$"`.
</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.
2. `lib/services/personal-channels.ts` provides Teams URL validation, ntfy minting + format guard, and the test-send helper.
3. The Phase 9 frontend (Plans 04 + 05) 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.
5. The theme route writes `updated_at` (matching migration 012), not the stale `"updatedAt"` from the timezone-route precedent.
</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 seven 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
- Confirmation that the theme route uses `updated_at` (NOT `"updatedAt"`)
- The 400-response shape for invalid custom ntfy topics
- Any deviations from the plan
</output>
</output>

View file

@ -8,6 +8,7 @@ files_modified:
- lib/types/pipeline.ts
- lib/services/pipeline-steps/notify-resolvers.ts
- lib/services/pipeline-steps/notify.ts
- lib/services/pipeline-steps/notify.test.ts
autonomous: true
requirements: [ROUTE-01, ROUTE-02, ROUTE-03, ROUTE-04, ROUTE-05, ROUTE-06]
@ -15,10 +16,11 @@ 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 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 (verified by an actual unit test, not just grep)"
- "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"
- "Field-path resolution is single-level (context[source][field]); dotted paths like 'company.id' are NOT supported in v1 — admins must surface needed values at top-level in the pipeline context"
artifacts:
- path: "lib/types/pipeline.ts"
provides: "RouteToUser, NotifyResolver, ResolvedRecipient, UserRouteFallback types"
@ -29,6 +31,9 @@ must_haves:
- path: "lib/services/pipeline-steps/notify.ts"
provides: "Updated executeNotify with route_to_user branch + fallback semantics"
exports: []
- path: "lib/services/pipeline-steps/notify.test.ts"
provides: "Vitest unit test asserting muted user does NOT fall back to global channel"
exports: []
key_links:
- from: "lib/services/pipeline-steps/notify.ts"
to: "user_event_subscriptions"
@ -42,6 +47,10 @@ must_haves:
to: "lib/services/pipeline-steps/notify-resolvers.ts"
via: "import { resolveRecipient }"
pattern: "resolveRecipient"
- from: "lib/services/pipeline-steps/notify.test.ts"
to: "lib/services/pipeline-steps/notify.ts"
via: "asserts mute path returns success without dispatchToGlobalChannel"
pattern: "muted user must not fall back"
---
<objective>
@ -54,14 +63,27 @@ existing pipeline byte-compatible. This plan:
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).
4. Adds a vitest unit test at `lib/services/pipeline-steps/notify.test.ts` that
asserts muting actually mutes — i.e. when `user_event_subscriptions.enabled = false`
for the resolved user/event/channel, the step returns `success: true` with
`notified: false`, does NOT call `dispatchToGlobalChannel`, and does NOT include
`user_route_fallback` in the output. Grep is non-discriminating because both names
appear elsewhere in the file by design — only a behavioral test can prove this.
Out of scope:
- Admin filter UI for `user_route_fallback` events (Plan 05, ROUTE-07)
- Admin filter UI for `user_route_fallback` events (Plan 06, 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`.
**Field-path resolution scope (v1, deferred-idea):** The resolver receives
`context[route.source]?.[route.field]` — a single-level lookup. Dotted paths
(e.g. `"company.id"`) are NOT walked in v1. Admins who need a nested value must
surface it at the top of the pipeline context (e.g., via a transform step before
the notify step). This is documented as a known limitation and a candidate
deferred-idea for a later phase.
Output: One new file (`notify-resolvers.ts`), one rewritten file (`notify.ts`),
type additions in `lib/types/pipeline.ts`, and one new test file (`notify.test.ts`).
</objective>
<execution_context>
@ -76,6 +98,7 @@ type additions in `lib/types/pipeline.ts`.
@lib/services/pipeline-steps/notify.ts
@lib/types/pipeline.ts
@lib/services/pipeline-engine.ts
@vitest.config.ts
<interfaces>
<!-- The current notify.ts contract — preserve everything not touched here. -->
@ -122,6 +145,12 @@ Better Auth user lookup (case-insensitive email match — emails come from Autot
```sql
SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
```
Vitest config (vitest.config.ts):
```typescript
test: { environment: 'node', include: ['lib/**/*.test.ts'], globals: false }
```
So a test at `lib/services/pipeline-steps/notify.test.ts` is auto-included.
</interfaces>
</context>
@ -150,11 +179,15 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
/** 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`. */
* to the step's `channel_id`.
*
* v1 limitation: `field` is a single-level key; dotted paths like 'company.id'
* are NOT walked. Admins must surface needed values at top level of the source
* object (e.g., via an upstream transform step). */
export interface RouteToUser {
/** PipelineContext key whose value holds the entity (e.g. 'ticket'). */
source: string;
/** Field name within context[source] (e.g. 'assignedResourceID'). */
/** Single-level field name within context[source] (e.g. 'assignedResourceID'). */
field: string;
/** Resolver name. Looks up the user from the field value. */
resolve: ResolverName;
@ -217,6 +250,7 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
- `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/types/pipeline.ts` `RouteToUser.field` JSDoc mentions the single-level lookup limitation
- `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
@ -284,8 +318,10 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
5. Implement `dispatchUserRoute(args: { step, context, message, channelId, route })`:
**Step 5a — Read field value from context:**
**Step 5a — Read field value from context (single-level lookup, v1 limitation):**
```typescript
// v1: single-level only. Dotted paths like 'company.id' are NOT walked.
// Admins must surface nested values at top level via an upstream transform.
const sourceObj = context[route.source];
const fieldValue = sourceObj?.[route.field];
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
@ -431,14 +467,15 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
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").
**Critical correctness notes (verified by Task 3 unit test):**
- 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") — Task 3 asserts this behaviorally.
- 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.
- `dispatchUserRoute` and `dispatchToGlobalChannel` and `fallbackToGlobal` MUST be exported (or hoisted in a way Task 3 can spy on). The simplest pattern: keep them as module-scope `async function` declarations and export an `_INTERNALS` object: `export const _INTERNALS = { dispatchToGlobalChannel, dispatchUserRoute, fallbackToGlobal };` — this matches the codebase convention noted in `analyzer/link-discovery.ts` where `_INTERNALS` exposes test seams. Task 3's test imports `_INTERNALS` (or uses `vi.mock` to intercept the postgresClient calls) to drive the assertion.
</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>
<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; grep -q "_INTERNALS\|export.*dispatchUserRoute\|export.*executeNotify" lib/services/pipeline-steps/notify.ts &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</automated>
</verify>
<acceptance_criteria>
@ -452,14 +489,163 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
- 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 `}`)
- File exports a test seam (either `_INTERNALS` object or named exports) so Task 3's vitest test can drive `dispatchUserRoute` directly
- 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.
`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 06. Test seam exposed for Task 3.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: Vitest unit test — muted user must NOT fall back to global channel</name>
<files>lib/services/pipeline-steps/notify.test.ts</files>
<read_first>
- lib/services/pipeline-steps/notify.ts (post-Task-2 — the test seam exposed via `_INTERNALS` or direct named export)
- vitest.config.ts (confirms `lib/**/*.test.ts` is auto-included)
- lib/services/analyzer/link-discovery.test.ts (vitest mocking precedent in this codebase — same `vi.mock` + spy pattern; see how it intercepts postgresClient)
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-12 muting must actually mute, no fallback)
</read_first>
<action>
Create `lib/services/pipeline-steps/notify.test.ts`. The test must demonstrate
behaviorally that the mute branch returns `success: true` with `notified: false`,
does NOT contact the global channel, and does NOT include `user_route_fallback`
in the output.
Use vitest's `vi.mock('../postgres-client', () => ({...}))` to stub
`postgresClient.query`. The mock returns:
- For the `user_event_subscriptions` SELECT (matched by the SQL substring
`user_event_subscriptions`): `{ rows: [{ enabled: false }], rowCount: 1 }`.
- For any `notification_channels` SELECT (substring `notification_channels`):
the test asserts THIS query is NEVER called (track call count).
- For any `"user"` SELECT (only used by the email-resolver path): not needed
in this fixture because the test uses `pulse_user_id` resolver and a
valid user_id field value.
Stub `notify-resolvers.ts` resolveRecipient via `vi.mock` to return
`{ recipient: { user_id: 'test-user-id' }, resolverFound: true }` so we don't
need a real DB. Alternative: keep the real resolver and stub only the
`pulseUserId` SQL — either is acceptable. Pick the simpler one (mocking
`resolveRecipient` directly).
Test layout (vitest):
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock postgresClient BEFORE importing the module under test.
const queryMock = vi.fn();
vi.mock('../postgres-client', () => ({
postgresClient: {
query: (...args: unknown[]) => queryMock(...args),
},
}));
// Mock the resolver registry to short-circuit user resolution.
vi.mock('./notify-resolvers', () => ({
resolveRecipient: vi.fn(async () => ({
recipient: { user_id: 'test-user-id' },
resolverFound: true,
})),
RESOLVERS: new Map(),
registerResolver: vi.fn(),
}));
// Mock the global fetch so a stray send call would be observable AND
// would not actually hit the network.
const fetchMock = vi.fn(async () => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
import { _INTERNALS } from './notify';
describe('notify.ts route_to_user', () => {
beforeEach(() => {
queryMock.mockReset();
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('muted user must not fall back to global channel', async () => {
// Arrange: user has the (event_key, channel_type) toggle DISABLED.
queryMock.mockImplementation((sql: string) => {
if (sql.includes('user_event_subscriptions')) {
return Promise.resolve({ rows: [{ enabled: false }], rowCount: 1 });
}
// Any other SQL is a failure — the mute branch must short-circuit.
throw new Error(`Unexpected SQL after mute: ${sql.slice(0, 80)}`);
});
const step = {
id: 1,
step_type: 'notify' as const,
name: 'test',
config: {
channel_id: 99,
message: 'hi',
route_to_user: {
source: 'ticket',
field: 'assignedResourceID',
resolve: 'pulse_user_id',
event_key: 'ticket_assigned_to_me',
channel_type: 'ntfy',
},
},
};
const context = { ticket: { assignedResourceID: 'test-user-id' } };
// Act
const result = await _INTERNALS.dispatchUserRoute({
step: step as any,
context,
message: 'hi',
channelId: 99,
route: step.config.route_to_user as any,
});
// Assert: success + notified=false + skipped_reason=user_muted, NO fallback
expect(result.success).toBe(true);
expect(result.output?.notified).toBe(false);
expect(result.output?.skipped_reason).toBe('user_muted');
expect(result.output?.user_route_fallback).toBeUndefined();
// The notification_channels SELECT must NEVER have been issued
const calls = queryMock.mock.calls.map((c) => String(c[0]));
expect(calls.some((sql) => sql.includes('notification_channels'))).toBe(false);
// No send call must have occurred
expect(fetchMock).not.toHaveBeenCalled();
});
});
```
If `_INTERNALS` is not the chosen test seam in Task 2, adapt the import
accordingly (e.g., named export of `dispatchUserRoute`). The test name
`'muted user must not fall back'` (substring) MUST appear verbatim — the
plan checker pinned this as the discriminating signal.
</action>
<verify>
<automated>test -f lib/services/pipeline-steps/notify.test.ts &amp;&amp; grep -q "muted user must not fall back" lib/services/pipeline-steps/notify.test.ts &amp;&amp; grep -q "vi.mock" lib/services/pipeline-steps/notify.test.ts &amp;&amp; grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.test.ts &amp;&amp; grep -q "user_route_fallback" lib/services/pipeline-steps/notify.test.ts &amp;&amp; npx vitest run lib/services/pipeline-steps/notify.test.ts 2>&amp;1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `lib/services/pipeline-steps/notify.test.ts` exists
- File imports `vi`, `describe`, `it`, `expect` from `vitest`
- File contains a `vi.mock('../postgres-client', ...)` call
- File contains a test name (it/test description) that includes the substring `muted user must not fall back`
- The test asserts `result.success === true` AND `result.output.skipped_reason === 'user_muted'` AND `result.output.user_route_fallback === undefined`
- The test asserts (via spy/mock-call inspection) that NO query containing the substring `notification_channels` was issued during the mute path
- The test asserts (via fetch mock) that NO outbound HTTP request was made
- `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits with code 0 (test passes)
- `npx tsc --noEmit --pretty` reports no errors in this file
</acceptance_criteria>
<done>
Behavioral guarantee for D-12 / ROUTE-05 is now enforced by an automated test, not just grep. Future regressions that re-introduce a `fallbackToGlobal` call in the mute branch will fail this test on every CI run / `npm test`.
</done>
</task>
@ -479,31 +665,35 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
| 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-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`. Task 3's vitest unit test (`muted user must not fall back`) is the behavioral guarantee — grep is non-discriminating because both names appear elsewhere in the file by design. CI will fail any regression |
| 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-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 06 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 |
| T-09-03-08 | Information Disclosure | dotted-path field traversal | accept | v1 supports only single-level `context[source][field]` lookup. Admins must surface nested values via an upstream transform — documented as a deferred-idea limitation. No risk of accidental traversal into adjacent fields because the lookup is exact-match |
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.
- `npx tsc --noEmit --pretty` exits 0 across the four modified files.
- `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits 0 — the mute test passes.
- 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.
- Dotted field paths (e.g., `field: "company.id"`) are NOT walked — admins must surface needed values at top level.
</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).
1. Four files modified: `lib/types/pipeline.ts`, `lib/services/pipeline-steps/notify-resolvers.ts` (new), `lib/services/pipeline-steps/notify.ts` (rewritten), `lib/services/pipeline-steps/notify.test.ts` (new test).
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.
3. `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits 0.
4. All existing tests under `lib/services/` continue to pass: `npm test`.
5. Backward compat: a step with `step.config.channel_id = 7` and no `route_to_user` produces identical behavior to pre-Phase-9.
6. Plans 04 (UI), 05 (channels UI), and 06 (admin filter) can rely on the `output.user_route_fallback` shape stabilized here.
</success_criteria>
<output>
@ -513,4 +703,7 @@ After completion, create `.planning/phases/09-user-profile-preferences-new/09-03
- 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)
- The new vitest test file and the discriminating assertion (`muted user must not fall back`)
- The v1 single-level field-path limitation (deferred-idea: dotted/nested path traversal)
</output>
</output>

View file

@ -9,63 +9,44 @@ files_modified:
- 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/profile/ProfileChannelsSectionPlaceholder.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]
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, TZ-CHOOSER-01, TZ-CHOOSER-02, THEME-03, 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"
- "/mobile/profile is gated server-side: when requireAuth() returns an error (NextResponse) OR session is null, the page calls redirect('/auth/sign-in') — it does NOT return the NextResponse from a Page route"
- "/mobile/profile renders four shadcn Cards in order: Timezone, Theme, Notifications, Channels (the Channels card is a placeholder rendered by ProfileChannelsSectionPlaceholder.tsx in this plan and replaced with the real ProfileChannelsSection in Plan 05)"
- "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"
- "Timezone Card shows a read-only 'Your current time: {time} in {zone}' line below the picker, formatted via Intl.DateTimeFormat with the selected zone"
- "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"
contains: "redirect('/auth/sign-in')"
- 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/profile/ProfileChannelsSectionPlaceholder.tsx"
provides: "Stub Channels card so the page compiles in Wave 3 before Plan 05 ships the real component"
- 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"
to: "/api/me/timezone, /api/me/theme, /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"
@ -73,13 +54,19 @@ must_haves:
---
<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.
Build the Phase 9 mobile profile UI page shell, three of the four section Cards
(Timezone, Theme, Notifications), and the drawer wiring. The fourth Card
(Channels) is rendered here as a tiny placeholder component so the page compiles
and ships incrementally — Plan 05 replaces the placeholder with the real
ProfileChannelsSection and lands the ThemeSessionBridge + ThemeToggle
write-through.
Output: 7 new files in `components/mobile/profile/`, 1 new page, 3 modified files.
This split (was a single 5-task plan; the plan checker flagged it as too large
at 11 modified files) keeps each plan within the 4-task / 58-file ceiling.
This plan: 3 tasks, 7 modified files. Plan 05: 2 tasks, 5 modified files.
Output: 6 new files in `components/mobile/profile/` + `app/mobile/profile/`,
1 modified file (`MoreDrawer.tsx`).
Per UI-SPEC contract `.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md`:
- Page H1: "Profile & Preferences" (text-xl font-semibold)
@ -88,12 +75,15 @@ Per UI-SPEC contract `.planning/phases/09-user-profile-preferences-new/09-UI-SPE
- 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).
CRITICAL — Page-level redirect (HIGH 5 from plan checker):
`requireAuth()` from `lib/auth-utils.ts` returns `{ session, error: NextResponse | null }`.
The `error` value is a NextResponse intended for API ROUTES, NOT page routes.
A Next.js Page server component cannot return a NextResponse — it must call
`redirect()` from `next/navigation`. So the executor MUST NOT return `error`
from the Page; instead, when `error` is truthy or `session` is null, call
`redirect('/auth/sign-in')`.
</objective>
<execution_context>
@ -107,16 +97,14 @@ client-side SVG generation).
@.planning/REQUIREMENTS.md
@CLAUDE.md
@components/mobile/MoreDrawer.tsx
@components/theme-toggle.tsx
@components/theme-provider.tsx
@app/layout.tsx
@app/mobile/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
@lib/auth-utils.ts
<interfaces>
<!-- Endpoint shapes (from Plan 02) the executor consumes here. -->
@ -127,12 +115,6 @@ 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 }>,
@ -141,24 +123,27 @@ GET /api/me/notification-subscriptions →
}
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']
requireAuth signature (from lib/auth-utils.ts):
```typescript
async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>
// Page rule: NEVER return `error` from a server component. Use redirect('/auth/sign-in').
```
</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>
<name>Task 1: Page shell, MoreDrawer wiring, skeleton helper, channels placeholder</name>
<files>app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.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)
- lib/auth-utils.ts (requireAuth signature — `error` is NextResponse, NOT a redirect)
</read_first>
<action>
@ -168,7 +153,42 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
- 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:
Step B — Create `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` (client component, named export `ProfileChannelsSection`):
```tsx
'use client';
/**
* Placeholder for ProfileChannelsSection — wired in Plan 04 so the page
* compiles. Plan 05 replaces this file with the real component (Teams +
* ntfy + QR code). The named export `ProfileChannelsSection` matches the
* import in app/mobile/profile/page.tsx so the swap is a one-file change.
*/
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
export function ProfileChannelsSection() {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle>Personal Channels</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
<p className="text-sm text-muted-foreground">
Coming soon — channel configuration ships in Plan 05.
</p>
</CardContent>
</Card>
);
}
```
Note: the placeholder filename is `ProfileChannelsSectionPlaceholder.tsx` but
the named export is `ProfileChannelsSection`. Plan 05 deletes this file and
creates the real `ProfileChannelsSection.tsx` exporting the same name. The
page `import` line therefore needs ZERO changes when Plan 05 lands.
Step C — Create `app/mobile/profile/page.tsx` as a Next.js server component shell:
```tsx
// /mobile/profile (PROF-01) — gated by requireAuth(), server-rendered shell
@ -179,12 +199,14 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
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';
import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSectionPlaceholder';
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
// CRITICAL: requireAuth() returns NextResponse on miss — that shape is for
// API routes only. A Page route MUST call redirect() instead. Returning
// `error` from here would render a JSON 401 body as the page, which is wrong.
if (error || !session) {
redirect('/auth/sign-in');
}
return (
@ -201,9 +223,11 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
}
```
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.
Plan 05 changes the import line from `ProfileChannelsSectionPlaceholder` to
`ProfileChannelsSection` (real component) — that is the only edit to this
file in Wave 4.
Step C — Modify `components/mobile/MoreDrawer.tsx` Account section:
Step D — 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):
@ -218,15 +242,19 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
</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>
<automated>test -f app/mobile/profile/page.tsx &amp;&amp; test -f components/mobile/profile/ProfileSectionSkeleton.tsx &amp;&amp; test -f components/mobile/profile/ProfileChannelsSectionPlaceholder.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 -qE "redirect\(['\"]/auth/sign-in['\"]\)" app/mobile/profile/page.tsx &amp;&amp; ! grep -qE "return error" 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
- `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, and calls `redirect('/auth/sign-in')` on miss
- The redirect call regex `redirect\(['"]/auth/sign-in['"]\)` matches in the file (single OR double quotes)
- The file does NOT contain `return error` (the NextResponse from requireAuth must NOT be returned from a Page route)
- 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)
- File imports `ProfileChannelsSection` from `@/components/mobile/profile/ProfileChannelsSectionPlaceholder` (Plan 05 will swap the source path; the import name stays the same)
- `components/mobile/profile/ProfileSectionSkeleton.tsx` exports default; uses Card + Skeleton primitives; renders 3 skeleton rows
- `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` exports a NAMED function `ProfileChannelsSection` (not default) and renders a placeholder Card titled "Personal Channels"
- `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)
@ -234,7 +262,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
</acceptance_criteria>
<done>
Page shell exists, drawer routes to it, skeleton helper available for each section's loading state.
Page shell exists, drawer routes to it, skeleton helper available, placeholder Channels card keeps the build green. Plan 05 drops in the real ProfileChannelsSection by renaming the source file.
</done>
</task>
@ -248,7 +276,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
- 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)
- app/styles/brand.css (verify .num utility exists; if not, the executor uses `font-mono tabular-nums` Tailwind classes — document the choice in the SUMMARY)
</read_first>
<action>
@ -318,7 +346,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
- 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/theme-toggle.tsx (existing setTheme pattern; the write-through PUT lands in Plan 05)
- components/mobile/profile/ProfileSectionSkeleton.tsx (Task 1 — loading state)
</read_first>
@ -382,198 +410,6 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
</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>
@ -583,55 +419,48 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
| 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 |
| Page route auth gate | Server component must redirect, NOT return a NextResponse |
## 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 |
| T-09-04-01 | Spoofing / Bypass | `app/mobile/profile/page.tsx` auth gate | mitigate | Page calls `requireAuth()` then `redirect('/auth/sign-in')` when `error || !session`. The acceptance criteria assert via grep that `redirect('/auth/sign-in')` is present AND `return error` is absent — closing the HIGH-5 gap from the plan checker (returning a NextResponse from a Page would render JSON-as-HTML) |
| T-09-04-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` rolls back `setTheme(previous)` on PUT error; the server-canonical reconciliation lives in Plan 05's `ThemeSessionBridge` |
| T-09-04-03 | Information Disclosure | matrix endpoint default-enabled | mitigate | When `data.channelTypes.length === 0`, the Notifications card renders an empty-state message and does NOT issue PUTs (no spurious toggles for non-existent channels) |
| T-09-04-04 | CSRF | Fetch calls from client sections | 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).
No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping), V8.1.6 (auth gate enforced server-side).
</threat_model>
<verification>
- `npm install` (or equivalent) installs `qrcode.react` cleanly.
- `npx tsc --noEmit --pretty` exits 0.
- Visiting `/mobile/profile` while signed-out redirects to `/auth/sign-in` (server-side `redirect`, NOT a JSON 401 body).
- Visiting `/mobile/profile` while signed-in renders four Cards in order with the placeholder Channels Card visible.
- `npm run build` (turbopack) builds without errors.
- Manual smoke (post-deploy):
1. Visit `/mobile/profile` → 4 Cards render in order.
1. Visit `/mobile/profile` → 4 Cards render in order (Channels card shows "Coming soon").
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.
4. Open the More drawer → Account section shows the identity row as a link AND a "Profile & preferences" row above Sign-out.
</verification>
<success_criteria>
1. All 7 new files exist under `components/mobile/profile/` and `app/mobile/profile/`.
1. All 5 new files exist under `components/mobile/profile/` and `app/mobile/profile/` (Section components × 3, Skeleton, ChannelsPlaceholder).
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).
3. The Page route gates via `redirect('/auth/sign-in')`, not by returning `requireAuth()`'s NextResponse.
4. `npx tsc --noEmit --pretty` exits 0.
5. The three section Cards (Timezone, Theme, Notifications) match the UI-SPEC contract.
6. The Channels Card placeholder ships so the page compiles; Plan 05 swaps in the real component.
</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 5 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
- Confirmation the Page uses `redirect('/auth/sign-in')` and does NOT `return error`
- The Channels Card placeholder import path (so Plan 05 knows what to swap)
- Any deviations from the UI-SPEC (expected: none)
</output>
</output>

View file

@ -2,90 +2,71 @@
phase: 09-user-profile-preferences-new
plan: 05
type: execute
wave: 3
depends_on: [09-01, 09-02, 09-03]
wave: 4
depends_on: [09-04]
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
- components/mobile/profile/ProfileChannelsSection.tsx
- components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx
- components/mobile/profile/ThemeSessionBridge.tsx
- components/theme-toggle.tsx
- app/layout.tsx
- app/mobile/profile/page.tsx
- package.json
- package-lock.json
autonomous: true
requirements: [CHAN-06, ROUTE-07]
requirements: [CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, THEME-04]
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)"
- "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"
- "Custom ntfy topic Input shows a `text-xs text-destructive` inline error rendering the response message string when PUT /api/me/channels/ntfy returns 400 (mirrors the Teams URL inline-error pattern)"
- "qrcode.react is INSTALLED into node_modules (npm install runs during the task — package.json edit alone is insufficient because npm ci in CI/Docker would break)"
- "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"
- "app/mobile/profile/page.tsx imports ProfileChannelsSection from `./ProfileChannelsSection` (not the Placeholder); the Placeholder file is removed"
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"
- path: "components/mobile/profile/ProfileChannelsSection.tsx"
provides: "Channels Card (Teams + ntfy sub-sections, QR, test result inline, custom-topic inline-error)"
- path: "components/mobile/profile/ThemeSessionBridge.tsx"
provides: "Client effect comparing session.user.theme to next-themes; calls setTheme on mismatch"
- path: "components/theme-toggle.tsx"
provides: "Session-aware ThemeToggle that writes through to /api/me/theme"
contains: "/api/me/theme"
- path: "app/layout.tsx"
provides: "ThemeSessionBridge mounted inside AuthProvider"
- path: "package.json"
provides: "qrcode.react dependency declared"
contains: "qrcode.react"
- path: "package-lock.json"
provides: "qrcode.react resolved + locked"
contains: "qrcode.react"
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"
- from: "ProfileChannelsSection"
to: "/api/me/channels, /api/me/channels/teams, /api/me/channels/ntfy, /api/me/channels/ntfy/test"
via: "client fetch with PUT/DELETE/POST"
pattern: "fetch.*api/me/channels"
- 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"
---
<objective>
Land the admin surfaces required by Phase 9:
Land the second half of the Phase 9 mobile UI: the real ProfileChannelsSection
(Teams + ntfy with QR code), the ThemeSessionBridge that makes the server's
`theme` value canonical app-wide, and the ThemeToggle write-through.
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.
This plan was split out of the original 5-task Plan 04 (the plan checker flagged
that as too-large at 11 modified files). Splitting keeps each plan within the
4-task / 58-file ceiling without dropping any locked decision.
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).
Adding `qrcode.react` (~10KB gzip) is the only new dependency. The install MUST
run during the task (not just a package.json edit) — otherwise `npm ci` in
CI/Docker would break on a missing lockfile entry.
</objective>
<execution_context>
@ -95,243 +76,299 @@ business logic — just CRUD + auth-gating).
<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
@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
@components/theme-toggle.tsx
@components/theme-provider.tsx
@app/layout.tsx
@app/mobile/profile/page.tsx
@components/ui/card.tsx
@components/ui/input.tsx
@components/ui/separator.tsx
@components/ui/button.tsx
@lib/services/personal-channels.ts
<interfaces>
<!-- From Plan 01 schema (already landed): -->
<!-- Endpoint shapes (from Plan 02). -->
notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at
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? } }
- 400 on invalid host: { error: 'Invalid webhook URL', message: 'webhook_url must be ...' }
PUT /api/me/channels/ntfy → body { topic? } → { channel, test }
- 400 on invalid custom topic: { error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }
DELETE /api/me/channels/{type} → { deleted: true, channelType }
POST /api/me/channels/{type}/test → { test: ChannelTestResult }
notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at
PUT /api/me/theme → body { theme: 'light'|'dark'|'system' } → { theme }
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
<!-- Better Auth client session shape (from @/lib/auth-client useSession): -->
session.user = { id, email, name, role, theme?: 'light'|'dark'|'system', timezone?, ... }
</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>
<name>Task 1: ProfileChannelsSection (Teams + ntfy with QR code) + qrcode.react install</name>
<files>components/mobile/profile/ProfileChannelsSection.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx, app/mobile/profile/page.tsx, package.json, package-lock.json</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)
- .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 — NOTE: ntfy custom-topic 400 returns `{ error, message }` body)
- lib/services/personal-channels.ts (TEST_MESSAGE_BODY, isValidNtfyTopic — 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)
- components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx (Plan 04 — this gets deleted)
- app/mobile/profile/page.tsx (Plan 04 — change one import line)
</read_first>
<action>
Step A — `app/api/notification-channels/route.ts` (GET + POST):
Step A — Run `npm install qrcode.react` from the repo root (NOT just edit
package.json). The install must update both `package.json` AND
`package-lock.json`; otherwise `npm ci` in Docker / CI will fail because the
lockfile won't have an entry for the new dep. Acceptance below verifies
`node_modules/qrcode.react/package.json` exists. Pin to `^4.0.0` (or the
current latest stable major).
Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with:
Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`):
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).
- 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`, `teamsError: string | null` (inline 400 message), `ntfyTest: TestResult | null`, `ntfyError: string | null`, `customTopicInput: string`, `customTopicError: string | null`.
- 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" />`.
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).
**Teams sub-section:**
- Label "Microsoft Teams webhook URL"
- `<Input placeholder="https://yourorg.webhook.office.com/..." value={teamsInput} onChange={...} />`
- Below input: render `teamsError` (if present) as `<p className="text-xs text-destructive">{teamsError}</p>` (UI-SPEC inline-error pattern).
- Below that: if `teamsTest` is set, render an inline test result row:
- 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 (`saveTeams`):
```tsx
setTeamsError(null);
const resp = await fetch('/api/me/channels/teams', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook_url: teamsInput }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
setTeamsError(body.message || body.error || `Save failed (${resp.status})`);
toast.error('Failed to save channel');
return;
}
setChannels((c) => ({ ...c, teams: body.channel }));
setTeamsTest(body.test);
toast.success('Channel saved');
```
- Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`.
Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE):
**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:
- Label "Custom ntfy topic"
- `<Input value={customTopicInput} onChange={(e) => { setCustomTopicInput(e.target.value); setCustomTopicError(null); }} />`
- **Inline error rendering for the custom-topic 400 (HIGH 7 from plan checker):**
```tsx
{customTopicError && (
<p className="text-xs text-destructive">{customTopicError}</p>
)}
```
- Button "Save custom topic" calling:
```tsx
const resp = await fetch('/api/me/channels/ntfy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: customTopicInput }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
setCustomTopicError(body.message || body.error || `Save failed (${resp.status})`);
toast.error('Failed to save channel');
return;
}
setChannels((c) => ({ ...c, ntfy: body.channel }));
setNtfyTest(body.test);
setCustomTopicError(null);
toast.success('Channel saved');
```
- 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`).
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.
All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
Step C — `app/admin/workflow/channels/page.tsx`:
Step C — Delete `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx`. The new real `ProfileChannelsSection.tsx` exports the same named symbol so downstream imports stay valid after the swap.
- 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).
Step D — Edit `app/mobile/profile/page.tsx` to change ONLY the import line:
```diff
- import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSectionPlaceholder';
+ import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection';
```
Do NOT modify any other line in `page.tsx`.
</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>
<automated>test -f components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; ! test -f components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx &amp;&amp; grep -q "qrcode.react" package.json &amp;&amp; grep -q "qrcode.react" package-lock.json &amp;&amp; test -f node_modules/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 "customTopicError" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx &amp;&amp; grep -q "ProfileChannelsSection.tsx" app/mobile/profile/page.tsx &amp;&amp; ! grep -q "ProfileChannelsSectionPlaceholder" app/mobile/profile/page.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head -20</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=`
- `npm install qrcode.react` was run during the task — `node_modules/qrcode.react/package.json` exists (MEDIUM 11 from plan checker)
- `package.json` includes `"qrcode.react"` in dependencies pinned to `^4.0.0` (or current major)
- `package-lock.json` contains `qrcode.react` entries (verified by `grep -q "qrcode.react" package-lock.json`)
- `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` no longer exists (deleted)
- `components/mobile/profile/ProfileChannelsSection.tsx` exists, starts with `'use client';`, exports a NAMED function `ProfileChannelsSection` matching the import in page.tsx
- 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"`)
- **HIGH 7:** File renders an inline `text-xs text-destructive` error element next to the custom-topic Input, displaying the response body's `message` / `error` string when PUT /api/me/channels/ntfy returns a non-2xx (mirrors the Teams URL inline-error pattern)
- File contains a state variable named `customTopicError` (or equivalent) populated from the 400 response body
- File renders an analogous inline error for the Teams URL save (`teamsError` state from the 400 response body)
- QR code rendered inside an element with `aria-label` referencing the topic (accessibility)
- `app/mobile/profile/page.tsx` imports `ProfileChannelsSection` from `@/components/mobile/profile/ProfileChannelsSection` (NOT `Placeholder`)
- `npx tsc --noEmit --pretty` reports no errors
- LOW 14: Manual smoke step recorded in SUMMARY: navigate to `/mobile/profile` after sign-in, confirm no console errors during initial render
</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.
Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override with inline-error, remove). qrcode.react is fully installed (package.json + lockfile + node_modules). The placeholder is gone and the page imports the real component.
</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>
<name>Task 2: Theme session bridge + ThemeToggle write-through</name>
<files>components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx</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)
- 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-04)
</read_first>
<action>
Step A — `app/api/admin/notify-event-keys/route.ts`:
Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`:
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) }`.
```tsx
'use client';
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.
/**
* 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.
*/
Step B — `app/api/admin/notify-event-keys/[key]/route.ts`:
import { useEffect } from 'react';
import { useTheme } from 'next-themes';
import { useSession } from '@/lib/auth-client';
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.
type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string };
DELETE:
- `requireAdmin()` guard.
- `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`
- 404 if no row. Return `{ deleted: true, key }`.
export function ThemeSessionBridge() {
const { data: session } = useSession();
const { theme, setTheme } = useTheme();
Step C — `app/admin/workflow/event-keys/page.tsx`:
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]);
- `'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
return null;
}
```
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 B — Mount `<ThemeSessionBridge />` inside `app/layout.tsx`:
Step E — `app/admin/workflow/pipelines/[id]/page.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.
- 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.
Step C — Modify `components/theme-toggle.tsx` (THEME-04):
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.
- 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 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>
<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>
- `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
- `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)
- LOW 14: Manual smoke step recorded in SUMMARY: navigate to `/mobile/profile` after sign-in, confirm no console errors AND that `<ThemeSessionBridge />` does not throw on first render
- `npx tsc --noEmit --pretty` reports no errors
</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).
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>
@ -343,51 +380,57 @@ After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 →
| 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 |
| 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-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) |
| T-09-05-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-05-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` (Plan 04) rolls back `setTheme(previous)` on PUT error; `ThemeSessionBridge` (this plan) re-syncs from server on next session refresh as the canonical source |
| T-09-05-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-05-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-05-05 | Information Disclosure | Inline custom-topic 400 message | mitigate | The `message` from the Plan 02 400 response is rendered verbatim inside `text-xs text-destructive`. React auto-escapes string children, and the message is server-controlled (e.g., "topic must match ^[A-Za-z0-9_-]{6,64}$"), not user-controlled. Mirrors the existing Teams URL inline-error pattern |
| T-09-05-06 | 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-05-07 | 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. Lockfile entry ensures reproducible installs (CI/Docker safe) |
| T-09-05-08 | 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: 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).
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), V14.2.3 (dependency lockfile reproducibility).
</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.
- `npm install` (re-runs idempotently) yields `node_modules/qrcode.react/package.json`.
- `npx tsc --noEmit --pretty` exits 0.
- `npm run build` (turbopack) builds without errors.
- Manual smoke (post-deploy):
1. Configure Teams URL with bad host → inline `text-xs text-destructive` shows the server's `message`.
2. Configure Teams URL with valid `https://*.webhook.office.com` → test send works → save → toast.
3. Enable mobile push → topic minted → QR visible → subscribe link works.
4. Open "Edit advanced" → enter `bad space` topic → 400 → inline error shows under the Input.
5. Open desktop ThemeToggle → switch theme → reload mobile profile → matches.
6. LOW 14: navigate to /mobile/profile after sign-in, no console errors.
</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.
1. `ProfileChannelsSection.tsx` exists; the placeholder file is deleted; `app/mobile/profile/page.tsx` imports the real component.
2. `qrcode.react` is installed (node_modules + package.json + package-lock.json all consistent).
3. `ThemeSessionBridge` is mounted in `app/layout.tsx` and renders nothing.
4. `ThemeToggle` writes through to `/api/me/theme` on every selection.
5. The custom ntfy topic Input renders an inline error `text-xs text-destructive` on 400, mirroring the Teams URL pattern.
6. `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)
- The 2 new files (ProfileChannelsSection, ThemeSessionBridge) and the 3 modified files (theme-toggle, layout, page.tsx)
- The qrcode.react version pinned in package.json (and confirmation `package-lock.json` was regenerated)
- The ThemeSessionBridge sync rule (compares session.user.theme to useTheme(), calls setTheme on mismatch, validated against allowlist)
- The ThemeToggle write-through behavior
- The custom-topic inline-error UX (HIGH 7 fix)
- The placeholder swap (`ProfileChannelsSectionPlaceholder.tsx` deleted, page.tsx import updated)
- LOW 14: confirmation of manual smoke (navigate to /mobile/profile after sign-in, no console errors)
- Any deviations from the UI-SPEC (expected: none)
</output>
</output>

View file

@ -0,0 +1,523 @@
---
phase: 09-user-profile-preferences-new
plan: 06
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/executions/page.tsx
- app/api/admin/pipeline-executions/route.ts
autonomous: true
requirements: [CHAN-06, SUB-01, 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/executions exists as a NEW admin page (not the legacy workflow_executions surface) that lists pipeline-engine executions across all pipelines with a 'Show only fallbacks' filter that filters to executions where any pipeline_execution_steps row has output_data ? 'user_route_fallback'"
- "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)"
- "POST /api/notification-channels still accepts all four channel_type values (teams, telegram, ntfy, webhook) — the existing global-channel allowlist is unchanged"
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/executions/page.tsx"
provides: "NEW admin page listing pipeline-engine executions with 'Show only fallbacks' filter (ROUTE-07)"
- path: "app/api/admin/pipeline-executions/route.ts"
provides: "GET list across all pipelines with optional fallbacks_only 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/executions"
to: "pipeline_executions + pipeline_execution_steps.output_data->'user_route_fallback'"
via: "GET /api/admin/pipeline-executions?fallbacks_only=1"
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: New `/admin/workflow/executions` page with the fallback filter.**
The plan checker's HIGH-3 issue is resolved here by building the filter on a
NEW route at `/admin/workflow/executions` (not the legacy workflow-engine
surface, which is `/admin/workflow/executions` in the legacy `workflow_executions`
table — that table doesn't carry pipeline-engine fallback data anyway). This
plan creates a fresh admin page and a fresh API route over the pipeline-engine
`pipeline_executions` + `pipeline_execution_steps` tables, listing executions
across ALL pipelines with the `user_route_fallback` filter. CONTEXT.md D-11
specifically calls out `/admin/workflow/executions` as the surface for this
filter — building it here honors the locked decision verbatim.
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 (3 new pages + 2 new API routes — the
pipeline-executions API route is also new because we're scoping over all pipelines).
This plan stays small per task by keeping API route handlers minimal (no new
business logic — just CRUD + auth-gating) and keeping the SQL parameterized.
</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
<!-- New executions API shape (this plan creates it): -->
GET /api/admin/pipeline-executions?limit=N&fallbacks_only=1&pipeline_id=N → { data: Array<execution + has_fallback boolean> }
</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 — the POST handler MUST continue to accept all four `channel_type` values: `teams`, `telegram`, `ntfy`, `webhook` (LOW 12 from plan checker — explicit acceptance criterion).
- 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; grep -q "teams\|telegram\|ntfy\|webhook" app/api/notification-channels/route.ts &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)
- **LOW 12 (explicit acceptance):** POST handler still accepts all four `channel_type` values: `'teams'`, `'telegram'`, `'ntfy'`, `'webhook'` (the global-channel allowlist is unchanged from 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. POST allowlist for `channel_type` remains all four values.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: /admin/workflow/event-keys CRUD page + API</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</files>
<read_first>
- app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout)
- 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.
</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; 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)
- `npx tsc --noEmit --pretty` reports no errors
</acceptance_criteria>
<done>
Admins have a real CRUD page for the event-key lookup (D-13) — D-13 surface lives at the URL CONTEXT.md specifies.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: NEW /admin/workflow/executions page + API for ROUTE-07 fallback filter</name>
<files>app/admin/workflow/executions/page.tsx, app/api/admin/pipeline-executions/route.ts</files>
<read_first>
- app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft + container)
- app/admin/workflow/pipelines/[id]/page.tsx (current per-pipeline executions panel — borrow rendering ideas; DO NOT modify this file in this plan)
- app/api/pipelines/[id]/executions/route.ts (per-pipeline executions endpoint — borrow JSONB predicate idea; DO NOT modify in this plan)
- lib/types/pipeline.ts (PipelineExecution type if exported; otherwise inline)
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-11 ROUTE-07 surface = `/admin/workflow/executions`, locked decision)
- .planning/REQUIREMENTS.md (ROUTE-07 — verbatim text says `/admin/workflow/executions`)
</read_first>
<action>
**HIGH 3 (plan checker) resolution:** CONTEXT.md and REQUIREMENTS.md both name
`/admin/workflow/executions` as the surface. The legacy `workflow_executions`
table doesn't carry pipeline-engine `user_route_fallback` data. Build a NEW
page at `/admin/workflow/executions` over the pipeline-engine tables — that
is where the data lives and the URL the user locked in. We are NOT touching
the legacy `workflow_executions` table or any existing admin route.
Step A — Create `app/api/admin/pipeline-executions/route.ts`:
```typescript
// GET /api/admin/pipeline-executions?limit=50&fallbacks_only=1&pipeline_id=NN
//
// Lists pipeline-engine executions across ALL pipelines (or one pipeline
// when pipeline_id is provided). Joins a `has_fallback` boolean computed
// from pipeline_execution_steps.output_data ? 'user_route_fallback'.
// ROUTE-07 / D-11 — admin filter for user_route_fallback events.
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(req: NextRequest): Promise<NextResponse> {
const { error } = await requireAdmin();
if (error) return error;
const { searchParams } = new URL(req.url);
const fallbacksOnly = searchParams.get('fallbacks_only') === '1';
const pipelineIdRaw = searchParams.get('pipeline_id');
const pipelineId = pipelineIdRaw && /^\d+$/.test(pipelineIdRaw)
? Number(pipelineIdRaw) : null;
const limitRaw = searchParams.get('limit');
const limit = limitRaw && /^\d+$/.test(limitRaw)
? Math.min(Number(limitRaw), 500) : 100;
// HIGH 4 (plan checker) — choose ONE of two complete parameterized SQL
// strings; never use a SELECT-list alias inside its own WHERE clause
// (PostgreSQL rejects that).
try {
const params: any[] = [];
let sql: string;
if (fallbacksOnly && pipelineId !== null) {
params.push(pipelineId, limit);
sql = `
SELECT pe.*, true AS has_fallback
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
`;
} else if (fallbacksOnly) {
params.push(limit);
sql = `
SELECT pe.*, true AS has_fallback
FROM pipeline_executions pe
WHERE 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 $1
`;
} else if (pipelineId !== null) {
params.push(pipelineId, limit);
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
ORDER BY pe.created_at DESC
LIMIT $2
`;
} else {
params.push(limit);
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
ORDER BY pe.created_at DESC
LIMIT $1
`;
}
const result = await postgresClient.query<any>(sql, params);
return NextResponse.json({ data: result.rows });
} catch (e) {
console.error('GET /api/admin/pipeline-executions failed:', e);
return NextResponse.json(
{ error: 'Failed to read executions', message: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}
```
Critical: NEVER concatenate a column alias (`has_fallback`) into a WHERE
clause on the same SELECT. PostgreSQL rejects `WHERE has_fallback` because
aliases aren't visible in WHERE — only in ORDER BY, GROUP BY, and the outer
layer of a subquery. The pseudo-SQL in the original plan had this bug
(`${fallbacks_only ? 'AND has_fallback' : ''}`); the four parameterized
strings above replace it with the EXISTS predicate inlined into WHERE.
Step B — Create `app/admin/workflow/executions/page.tsx` (`'use client'`):
- Header: ArrowLeft back link to `/admin/workflow`, page title "Pipeline Executions".
- Above the list: a row with `<Switch />` labeled "Show only fallbacks" (use `@/components/ui/switch` and `<Label>`). State `fallbacksOnly: boolean`.
- Optional pipeline filter: a `<Select>` listing existing pipelines (fetch `/api/pipelines` if a list endpoint exists; otherwise leave the field as a free-text "Pipeline ID" Input). Default = `all`.
- List: each row renders `id`, `pipeline_id`, `status`, `started_at`, `duration_ms`, and a `<Badge variant="outline">fallback</Badge>` when `has_fallback === true`.
- On toggle change OR pipeline-filter change: re-fetch `/api/admin/pipeline-executions?fallbacks_only=1&pipeline_id=...&limit=100` and re-render.
- Empty state: `<p className="text-sm text-muted-foreground">No executions match the current filter.</p>`
- Loading state: render a small Skeleton.
- Each row clickable: `<Link href={"/admin/workflow/pipelines/" + pipeline_id}>` so the admin can drill into the per-pipeline detail page (which already exists).
</action>
<verify>
<automated>test -f app/admin/workflow/executions/page.tsx &amp;&amp; test -f app/api/admin/pipeline-executions/route.ts &amp;&amp; grep -q "requireAdmin" app/api/admin/pipeline-executions/route.ts &amp;&amp; grep -q "user_route_fallback" app/api/admin/pipeline-executions/route.ts &amp;&amp; grep -q "fallbacks_only" app/api/admin/pipeline-executions/route.ts &amp;&amp; grep -q "has_fallback" app/api/admin/pipeline-executions/route.ts &amp;&amp; ! grep -q "AND has_fallback" app/api/admin/pipeline-executions/route.ts &amp;&amp; grep -q "Show only fallbacks" app/admin/workflow/executions/page.tsx &amp;&amp; grep -q "fallbacks_only=1\|fallbacks_only=" app/admin/workflow/executions/page.tsx &amp;&amp; grep -q "fallback" app/admin/workflow/executions/page.tsx &amp;&amp; npx tsc --noEmit --pretty 2>&amp;1 | head</automated>
</verify>
<acceptance_criteria>
- `app/admin/workflow/executions/page.tsx` exists at the URL CONTEXT.md / REQUIREMENTS.md locks (`/admin/workflow/executions`)
- File starts with `'use client';`
- File 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`
- `app/api/admin/pipeline-executions/route.ts` exports `GET` and calls `requireAdmin()`
- The route handler accepts `fallbacks_only`, `pipeline_id`, and `limit` query parameters
- The route handler implements **four** complete parameterized SQL strings (selected by the boolean flags); the `has_fallback` SELECT-list alias is NEVER referenced inside the same SELECT's WHERE clause (HIGH 4 from plan checker — verified by `! grep -q "AND has_fallback" route.ts`)
- Each SQL string contains the JSONB containment predicate `output_data ? 'user_route_fallback'`
- Each SQL string is parameterized: `pipeline_id` is `$1` or `$2`; `limit` always last; pipeline_id passes a `/^\d+$/` regex coercion before binding
- Per-row response contains `has_fallback: boolean` (either as constant `true` in the fallbacks-only branches or as an EXISTS subquery in the unfiltered branches)
- This plan does NOT modify `app/admin/workflow/pipelines/[id]/page.tsx` or `app/api/pipelines/[id]/executions/route.ts` (the new admin surface is fully fresh — preserves the locked URL without disturbing existing pipeline-detail surfaces)
- `npx tsc --noEmit --pretty` reports no errors
</acceptance_criteria>
<done>
The ROUTE-07 fallback filter exists at the locked URL `/admin/workflow/executions`, sourced from the pipeline-engine tables (the only tables that carry `user_route_fallback`). The SQL avoids the alias-in-WHERE bug by selecting one of four complete parameterized strings.
</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/admin/pipeline-executions | New surface; admin-only |
## STRIDE Threat Register (ASVS L1)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-06-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-06-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-06-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-06-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-06-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-06-06 | SQL Injection | executions endpoint | mitigate | All user input is parameterized: `pipeline_id` validated against `/^\d+$/` before binding to `$1`, `limit` validated against `/^\d+$/` and capped at 500. `fallbacks_only` is a boolean (`searchParams.get('fallbacks_only') === '1'`) used to choose which of FOUR complete SQL strings to execute — never concatenated. The earlier alias-in-WHERE bug (HIGH 4) is fixed by inlining the EXISTS predicate into WHERE in the fallbacks-only branches |
| T-09-06-07 | Information Disclosure | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/executions`, gated by `requireAdmin()`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click via the existing pipeline-detail page) |
| T-09-06-08 | Tampering | LOW 12 channel_type allowlist | mitigate | POST handler retains the existing four-value `channel_type` allowlist (`teams`, `telegram`, `ntfy`, `webhook`); acceptance criteria explicitly verify it is unchanged |
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 — validated by absence of alias-in-WHERE).
</threat_model>
<verification>
- All seven 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.
- Hitting `/api/admin/pipeline-executions` as a non-admin returns 403.
- Hitting `/api/admin/pipeline-executions?fallbacks_only=1` as an admin returns ONLY rows where some step output_data contains `user_route_fallback`.
- Hitting `/api/admin/pipeline-executions` as an admin returns ALL recent rows, each with `has_fallback: boolean`.
- LOW 12: POSTing `{ name: 'test', channel_type: 'webhook', config: {} }` to `/api/notification-channels` as admin still creates a webhook-type global channel (no behavior change).
- The new admin executions page renders at `/admin/workflow/executions` and the toggle filter works.
</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/executions` exists as a NEW admin page over the pipeline-engine tables, with a "Show only fallbacks" toggle and per-row fallback badge — at the URL CONTEXT.md / REQUIREMENTS.md lock.
4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization.
5. The pipeline-executions SQL handler uses **four** complete parameterized strings (no alias-in-WHERE bug).
6. POST `/api/notification-channels` channel_type allowlist is unchanged — all four values still accepted.
7. `npx tsc --noEmit --pretty` exits 0.
</success_criteria>
<output>
After completion, create `.planning/phases/09-user-profile-preferences-new/09-06-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 used by the new pipeline-executions endpoint (`output_data ? 'user_route_fallback'`)
- The `has_fallback` per-row flag on the executions response
- Confirmation: the ROUTE-07 filter lives at the LOCKED URL `/admin/workflow/executions` — no silent rerouting
- Confirmation: the executions route uses FOUR complete parameterized SQL strings; alias-in-WHERE bug is gone
- Confirmation: POST `/api/notification-channels` channel_type allowlist (`teams`, `telegram`, `ntfy`, `webhook`) is unchanged
- Whether a link to `/admin/workflow/event-keys` and `/admin/workflow/executions` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred)
</output>