diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 7b98aa2..ab9f0d4 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -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
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 58b6161..1c34678 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -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%
diff --git a/.planning/config.json b/.planning/config.json
index 040e7b2..eb3a86b 100644
--- a/.planning/config.json
+++ b/.planning/config.json
@@ -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
diff --git a/.planning/phases/01-pwa-scaffolding/01-01-PLAN.md b/.planning/phases/01-pwa-scaffolding/01-01-PLAN.md
new file mode 100644
index 0000000..6a48d35
--- /dev/null
+++ b/.planning/phases/01-pwa-scaffolding/01-01-PLAN.md
@@ -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 )"
+ - "app/layout.tsx exports a viewport object whose viewportFit is 'cover' so the rendered 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 "
+ 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 "
+ via: "Next.js viewport export → viewport-fit=cover in DOM"
+ pattern: "viewportFit"
+---
+
+
+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`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+**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 `` automatically. This satisfies the spec wording (``) without hand-rolling the link tag.
+- Alternative: hand-roll `` inside ``. 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 `` 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 `` 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)
+
+
+
+
+
+
+ Task 1: Create public/manifest.json
+ public/manifest.json
+
+ - 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
+
+
+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`.
+
+
+ test -f public/manifest.json && 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
+
+
+ - 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.
+
+
+ `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.
+
+
+
+
+ Task 2: Add manifest reference and viewport export to app/layout.tsx
+ app/layout.tsx
+
+ - 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)
+
+
+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 `` automatically from this field — this satisfies the spec wording (`` 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 `` 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 `` JSX (no hand-rolled `` 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.
+
+
+ grep -q 'manifest: "/manifest.json"' app/layout.tsx && grep -q 'viewportFit: "cover"' app/layout.tsx && grep -q 'import type { Metadata, Viewport } from "next"' app/layout.tsx && grep -q 'export const viewport: Viewport' app/layout.tsx && npx tsc --noEmit --pretty 2>&1 | tee /tmp/tsc-out && ! grep -E "app/layout\\.tsx.*error" /tmp/tsc-out
+
+
+ - `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.
+
+
+ `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.
+
+
+
+
+
+
+## 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 `` and ``. |
+
+## 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.
+
+
+
+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
+```
+
+
+
+- `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 `` in the rendered HTML head (PWA-02).
+- `app/layout.tsx` exports `viewport: Viewport` with `viewportFit: "cover"` so the rendered `` tag contains `viewport-fit=cover` (PWA-03).
+- `npx tsc --noEmit --pretty` passes.
+- No service worker file or `next-pwa` dependency introduced.
+
+
+
diff --git a/.planning/phases/01-pwa-scaffolding/01-02-PLAN.md b/.planning/phases/01-pwa-scaffolding/01-02-PLAN.md
new file mode 100644
index 0000000..7c2dab8
--- /dev/null
+++ b/.planning/phases/01-pwa-scaffolding/01-02-PLAN.md
@@ -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"'
+---
+
+
+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.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+**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 `` 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)
+
+
+
+
+
+
+ Task 1: Append pt-safe and pb-safe @utility blocks to app/styles/brand.css
+ app/styles/brand.css
+
+ - 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)
+
+
+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 clears notch
+ *
+
+ grep -q '@utility pt-safe' app/styles/brand.css && grep -q '@utility pb-safe' app/styles/brand.css && grep -q 'padding-top: env(safe-area-inset-top)' app/styles/brand.css && grep -q 'padding-bottom: env(safe-area-inset-bottom)' app/styles/brand.css && npx tsc --noEmit --pretty && npm run build
+
+
+ - `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.
+
+
+ `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.
+
+
+
+
+
+
+## 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. |
+
+
+
+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'
+```
+
+
+
+- `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).
+
+
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
index 707157b..f25f917 100644
--- a/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
+++ b/.planning/phases/09-user-profile-preferences-new/09-01-PLAN.md
@@ -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:
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()).
@@ -123,7 +144,7 @@ COMMENT ON COLUMN "user".timezone IS '...';
- 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)
@@ -191,13 +212,13 @@ COMMENT ON COLUMN "user".timezone IS '...';
- Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables
+ Task 2: Add owner_user_id + partial unique index to notification_channels + create event-keys / subscriptions tablesmigrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts
- migrations/033_create_pipeline_engine_tables.sql (current notification_channels schema)
- lib/types/pipeline.ts (current NotificationChannel interface — must extend, not replace)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled)
+ - .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)
@@ -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 '...';
- grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && grep -q "CREATE TABLE IF NOT EXISTS notify_event_keys" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "CREATE TABLE IF NOT EXISTS user_event_subscriptions" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "owner_user_id: string | null" lib/types/pipeline.ts && grep -q "export interface NotifyEventKey" lib/types/pipeline.ts && grep -q "export interface UserEventSubscription" lib/types/pipeline.ts && npx tsc --noEmit --pretty 2>&1 | head
+ grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && grep -q "notification_channels_owner_user_id_channel_type_uniq" migrations/085_personal_notification_channels.sql && grep -q "CREATE TABLE IF NOT EXISTS notify_event_keys" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "CREATE TABLE IF NOT EXISTS user_event_subscriptions" migrations/086_notify_event_keys_and_subscriptions.sql && grep -q "owner_user_id: string | null" lib/types/pipeline.ts && grep -q "export interface NotifyEventKey" lib/types/pipeline.ts && grep -q "export interface UserEventSubscription" lib/types/pipeline.ts && npx tsc --noEmit --pretty 2>&1 | head
- `migrations/085_personal_notification_channels.sql` exists and contains `ALTER TABLE notification_channels`
- File contains `ADD COLUMN IF NOT EXISTS owner_user_id TEXT` and `REFERENCES "user"(id) ON DELETE CASCADE`
- File contains `CREATE INDEX IF NOT EXISTS idx_notification_channels_owner`
+ - 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 '...';
- 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.
@@ -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.
@@ -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)
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
index 6c4aadb..e658c57 100644
--- a/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
+++ b/.planning/phases/09-user-profile-preferences-new/09-02-PLAN.md
@@ -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):
app/api/me/theme/route.ts
- - 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')
@@ -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
- test -f app/api/me/theme/route.ts && grep -q "export async function GET" app/api/me/theme/route.ts && grep -q "export async function PUT" app/api/me/theme/route.ts && grep -q "ALLOWED_THEMES" app/api/me/theme/route.ts && grep -q "requireAuth" app/api/me/theme/route.ts && npx tsc --noEmit --pretty 2>&1 | grep "theme/route.ts" | head
+ test -f app/api/me/theme/route.ts && grep -q "export async function GET" app/api/me/theme/route.ts && grep -q "export async function PUT" app/api/me/theme/route.ts && grep -q "ALLOWED_THEMES" app/api/me/theme/route.ts && grep -q "requireAuth" app/api/me/theme/route.ts && grep -q "updated_at = NOW()" app/api/me/theme/route.ts && ! grep -q '"updatedAt"' app/api/me/theme/route.ts && npx tsc --noEmit --pretty 2>&1 | grep "theme/route.ts" | head
@@ -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
- 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.
@@ -198,7 +225,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
- 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: , test: })`.
@@ -414,16 +449,18 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
- test -f lib/services/personal-channels.ts && test -f app/api/me/channels/route.ts && test -f app/api/me/channels/[type]/route.ts && test -f app/api/me/channels/[type]/test/route.ts && grep -q "isValidTeamsWebhookUrl" lib/services/personal-channels.ts && grep -q "mintNtfyTopic" lib/services/personal-channels.ts && grep -q "TEST_MESSAGE_BODY" lib/services/personal-channels.ts && grep -q "Pulse channel verified — you can ignore this message" lib/services/personal-channels.ts && grep -q "owner_user_id = \$1" "app/api/me/channels/[type]/route.ts" && npx tsc --noEmit --pretty 2>&1 | head -20
+ test -f lib/services/personal-channels.ts && test -f app/api/me/channels/route.ts && test -f app/api/me/channels/[type]/route.ts && test -f app/api/me/channels/[type]/test/route.ts && grep -q "isValidTeamsWebhookUrl" lib/services/personal-channels.ts && grep -q "isValidNtfyTopic" lib/services/personal-channels.ts && grep -q "mintNtfyTopic" lib/services/personal-channels.ts && grep -q "TEST_MESSAGE_BODY" lib/services/personal-channels.ts && grep -q "Pulse channel verified — you can ignore this message" lib/services/personal-channels.ts && grep -q "owner_user_id = \$1" "app/api/me/channels/[type]/route.ts" && npx tsc --noEmit --pretty 2>&1 | head -20
- - `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `mintNtfyTopic`, `sendChannelTest`, `TEST_MESSAGE_BODY`, `isPersonalChannelType`, `PERSONAL_CHANNEL_TYPES`
+ - `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):
- 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`.
@@ -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
- 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}$"`.
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.
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
index 0a33173..4399eaf 100644
--- a/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
+++ b/.planning/phases/09-user-profile-preferences-new/09-03-PLAN.md
@@ -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"
---
@@ -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`).
@@ -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
@@ -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.
@@ -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.
- grep -q "route_to_user" lib/services/pipeline-steps/notify.ts && grep -q "resolveRecipient" lib/services/pipeline-steps/notify.ts && grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.ts && grep -q "owner_user_id = " lib/services/pipeline-steps/notify.ts && grep -q "user_route_fallback" lib/services/pipeline-steps/notify.ts && grep -q "skipped_reason" lib/services/pipeline-steps/notify.ts && grep -q "user_muted" lib/services/pipeline-steps/notify.ts && grep -q "dispatchToGlobalChannel\|dispatchUserRoute\|fallbackToGlobal" lib/services/pipeline-steps/notify.ts && grep -q "registerStepExecutor('notify', executeNotify)" lib/services/pipeline-steps/notify.ts && npx tsc --noEmit --pretty 2>&1 | head -20
+ grep -q "route_to_user" lib/services/pipeline-steps/notify.ts && grep -q "resolveRecipient" lib/services/pipeline-steps/notify.ts && grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.ts && grep -q "owner_user_id = " lib/services/pipeline-steps/notify.ts && grep -q "user_route_fallback" lib/services/pipeline-steps/notify.ts && grep -q "skipped_reason" lib/services/pipeline-steps/notify.ts && grep -q "user_muted" lib/services/pipeline-steps/notify.ts && grep -q "dispatchToGlobalChannel\|dispatchUserRoute\|fallbackToGlobal" lib/services/pipeline-steps/notify.ts && grep -q "registerStepExecutor('notify', executeNotify)" lib/services/pipeline-steps/notify.ts && grep -q "_INTERNALS\|export.*dispatchUserRoute\|export.*executeNotify" lib/services/pipeline-steps/notify.ts && npx tsc --noEmit --pretty 2>&1 | head -20
@@ -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)
- `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.
+
+
+
+
+ Task 3: Vitest unit test — muted user must NOT fall back to global channel
+ lib/services/pipeline-steps/notify.test.ts
+
+
+ - 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)
+
+
+
+ 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.
+
+
+
+ test -f lib/services/pipeline-steps/notify.test.ts && grep -q "muted user must not fall back" lib/services/pipeline-steps/notify.test.ts && grep -q "vi.mock" lib/services/pipeline-steps/notify.test.ts && grep -q "user_event_subscriptions" lib/services/pipeline-steps/notify.test.ts && grep -q "user_route_fallback" lib/services/pipeline-steps/notify.test.ts && npx vitest run lib/services/pipeline-steps/notify.test.ts 2>&1 | tail -30
+
+
+
+ - `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
+
+
+
+ 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`.
@@ -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).
-- `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.
-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.
diff --git a/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
index fb594d0..ec69814 100644
--- a/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
+++ b/.planning/phases/09-user-profile-preferences-new/09-04-PLAN.md
@@ -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:
---
-Build the entire Phase 9 mobile profile UI surface plus the theme-bridge plumbing that
-makes the server's `theme` value canonical app-wide. This plan has 5 tasks because the
-work spans multiple loosely-coupled subsystems (one task per section, one task for
-theme bridging, one task for drawer wiring). Despite the count, each task is small
-(15–30 min) thanks to the UI-SPEC contract.
+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 / 5–8-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: `` (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')`.
@@ -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
@@ -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').
+```
- Task 1: Page shell, MoreDrawer wiring, and skeleton helper
- app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx
+ Task 1: Page shell, MoreDrawer wiring, skeleton helper, channels placeholder
+ app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx, components/mobile/MoreDrawer.tsx
- .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md (Page Layout Contract section, MoreDrawer drawer link addition)
- components/mobile/MoreDrawer.tsx (current Account section structure)
- app/mobile/layout.tsx (page wrapper inherits max-w-lg)
- - lib/auth-utils.ts (requireAuth signature for server component)
- - components/mobile/EngagementProfileSkeleton.tsx (Skeleton precedent if it exists; otherwise mirror Card + 3 Skeleton rows)
+ - lib/auth-utils.ts (requireAuth signature — `error` is NextResponse, NOT a redirect)
@@ -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 (
+
+
+ Personal Channels
+
+
+
+ Coming soon — channel configuration ships in Plan 05.
+
+
+
+ );
+ }
+ ```
+
+ 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 `
` 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
- test -f app/mobile/profile/page.tsx && test -f components/mobile/profile/ProfileSectionSkeleton.tsx && grep -q "ProfileTimezoneSection" app/mobile/profile/page.tsx && grep -q "ProfileThemeSection" app/mobile/profile/page.tsx && grep -q "ProfileNotificationMatrix" app/mobile/profile/page.tsx && grep -q "ProfileChannelsSection" app/mobile/profile/page.tsx && grep -q "requireAuth" app/mobile/profile/page.tsx && grep -q '/mobile/profile' components/mobile/MoreDrawer.tsx && grep -q "Profile & preferences\|Profile & preferences" components/mobile/MoreDrawer.tsx && npx tsc --noEmit --pretty 2>&1 | head
+ test -f app/mobile/profile/page.tsx && test -f components/mobile/profile/ProfileSectionSkeleton.tsx && test -f components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx && grep -q "ProfileTimezoneSection" app/mobile/profile/page.tsx && grep -q "ProfileThemeSection" app/mobile/profile/page.tsx && grep -q "ProfileNotificationMatrix" app/mobile/profile/page.tsx && grep -q "ProfileChannelsSection" app/mobile/profile/page.tsx && grep -q "requireAuth" app/mobile/profile/page.tsx && grep -qE "redirect\(['\"]/auth/sign-in['\"]\)" app/mobile/profile/page.tsx && ! grep -qE "return error" app/mobile/profile/page.tsx && grep -q '/mobile/profile' components/mobile/MoreDrawer.tsx && grep -q "Profile & preferences\|Profile & preferences" components/mobile/MoreDrawer.tsx && npx tsc --noEmit --pretty 2>&1 | head
- - `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, redirects to `/auth/sign-in` on miss
+ - `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 `
Profile & Preferences
` (UI-SPEC heading copy)
- Page wrapper is `` and inner div is `
` (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
- 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.
@@ -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)
@@ -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)
@@ -382,198 +410,6 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
-
- Task 4: ProfileChannelsSection (Teams + ntfy with QR code)
- components/mobile/profile/ProfileChannelsSection.tsx, package.json
-
-
- - .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Channels Card" — full sub-section spec, Copywriting Contract → Channels)
- - app/api/me/channels/route.ts and [type]/route.ts and [type]/test/route.ts (Plan 02 endpoints)
- - lib/services/personal-channels.ts (TEST_MESSAGE_BODY constant — for awareness; not imported into the client)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-04 ntfy mint-on-first-save, D-06 best-effort test)
-
-
-
- Step A — Add `qrcode.react` to `package.json` dependencies. Use the latest stable major (`^4.0.0` or current). Run `npm install qrcode.react` semantically by adding the entry; the executor MAY also run the actual install command if the harness allows it. Otherwise, just edit `package.json` and document in SUMMARY that `npm install` is required.
-
- Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`):
-
- - Card with CardTitle "Personal Channels" and CardDescription "Receive notifications directly on your devices." (UI-SPEC).
- - State: `loading`, `channels: { teams?: Channel; ntfy?: Channel }`, `teamsInput: string`, `teamsTest: TestResult | null`, `ntfyTest: TestResult | null`, `showAdvanced: boolean`, `customTopicInput: string`.
- - On mount: `fetch('/api/me/channels')`, then split `data.channels` into `teams` and `ntfy` by `channelType`.
- - Layout: two sub-sections separated by ``.
-
- **Teams sub-section:**
- - Label "Microsoft Teams webhook URL"
- - ``
- - Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern):
- - Success: `
Channel verified
`
- - Failure: `
Test failed — {test.status ?? test.error ?? 'unknown'}
`
- - Buttons row: ` ` (both `min-h-[44px]`).
- - Save: `fetch('/api/me/channels/teams', { method: 'PUT', body: JSON.stringify({ webhook_url: teamsInput }) })`. On 200: `toast.success('Channel saved')`, set `teamsTest = data.test`, set `channels.teams = data.channel`. On 4xx (e.g., invalid host): show inline `text-xs text-destructive` with the response's `error`/`message` AND `toast.error('Failed to save channel')`.
- - Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`.
-
- **ntfy sub-section:**
- - State A — `channels.ntfy === undefined`:
- - Label "Mobile push (ntfy)"
- - Description "Pulse will generate a private topic for you." (text-sm text-muted-foreground)
- - Button `` (min-h-[44px])
- - `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`.
- - State B — `channels.ntfy` exists:
- - Label "Mobile push (ntfy)"
- - Subscribe link: `{"https://ntfy.sh/" + topic}`
- - QR code: `` (from `qrcode.react`). Wrap in `
` for accessibility.
- - Helper line: `
Scan with the ntfy app to subscribe.
`
- - Test result inline (same pattern as Teams).
- - Disclosure: `Edit advanced` — inside the details, render Label "Custom ntfy topic", Input bound to `customTopicInput`, Button "Save custom topic" calling `PUT /api/me/channels/ntfy` with body `{ topic: customTopicInput }`.
- - Bottom buttons row: `` (calls `POST /api/me/channels/ntfy/test`) `` (calls `DELETE /api/me/channels/ntfy`).
-
- All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
-
-
-
- test -f components/mobile/profile/ProfileChannelsSection.tsx && grep -q "qrcode.react" package.json && grep -q "/api/me/channels" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Microsoft Teams webhook URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Mobile push (ntfy)" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Enable mobile push" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "QRCodeSVG\|qrcode.react" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Scan with the ntfy app" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Save Teams URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Edit advanced" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx && npx tsc --noEmit --pretty 2>&1 | head -20
-
-
-
- - `package.json` includes `"qrcode.react"` in dependencies
- - `components/mobile/profile/ProfileChannelsSection.tsx` starts with `'use client';`
- - File imports the QR component from `qrcode.react` (`QRCodeSVG` or default)
- - File contains the exact label `Microsoft Teams webhook URL`
- - File contains the placeholder `https://yourorg.webhook.office.com/`
- - File contains the literal `Mobile push (ntfy)`
- - File contains the literal `Enable mobile push`
- - File contains the literal `Pulse will generate a private topic for you.`
- - File contains the literal `Scan with the ntfy app to subscribe.`
- - File contains the literal `Save Teams URL`
- - File contains the literal `Clear` (Teams clear button)
- - File contains the literal `Edit advanced` (disclosure label)
- - File contains the literal `Test now`
- - File contains the literal `Remove`
- - File contains the literal `Channel verified` (success copy)
- - File contains the literal `Test failed`
- - File contains the literal toast string `'Channel saved'`
- - File contains the literal toast string `'Channel removed'`
- - File makes fetch calls to `/api/me/channels`, `/api/me/channels/teams`, `/api/me/channels/ntfy`, `/api/me/channels/ntfy/test`
- - File DELETE call uses `method: 'DELETE'` (or `"DELETE"`)
- - QR code rendered inside an element with `aria-label` referencing the topic (accessibility)
- - `npx tsc --noEmit --pretty` reports no errors after `npm install` adds qrcode.react
-
-
-
- Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override, remove). qrcode.react added to dependencies.
-
-
-
-
- Task 5: Theme session bridge + ThemeToggle write-through
- components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx
-
-
- - components/theme-toggle.tsx (current setTheme handlers — three DropdownMenuItems)
- - components/theme-provider.tsx
- - app/layout.tsx (where ThemeProvider mounts; the bridge sits inside it)
- - .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-16, D-17, D-20)
- - .planning/REQUIREMENTS.md (THEME-03, THEME-04)
-
-
-
- Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`:
-
- ```tsx
- 'use client';
-
- /**
- * ThemeSessionBridge (THEME-03 / D-16, D-17).
- *
- * On session load and after sign-in, compares session.user.theme to the
- * next-themes useTheme() value and calls setTheme(session.user.theme) if
- * different. Server is canonical; this is the bridge that enforces it.
- *
- * Renders nothing.
- */
-
- import { useEffect } from 'react';
- import { useTheme } from 'next-themes';
- import { useSession } from '@/lib/auth-client';
-
- type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string };
-
- export function ThemeSessionBridge() {
- const { data: session } = useSession();
- const { theme, setTheme } = useTheme();
-
- useEffect(() => {
- if (!session?.user) return;
- const serverTheme = (session.user as SessionUserWithTheme).theme;
- if (
- serverTheme === 'light' ||
- serverTheme === 'dark' ||
- serverTheme === 'system'
- ) {
- if (serverTheme !== theme) {
- setTheme(serverTheme);
- }
- }
- }, [session?.user, theme, setTheme]);
-
- return null;
- }
- ```
-
- Step B — Mount `` inside `app/layout.tsx`:
-
- - Find the existing `` block.
- - Inside the `` children, immediately AFTER `` opens (so it has access to `useSession()`), add ``. The exact placement: as the FIRST child of ``, before the existing `
`.
- - Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
- - Do NOT change any other layout.tsx content.
-
- Step C — Modify `components/theme-toggle.tsx` (THEME-04):
-
- - Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper:
- ```tsx
- const writeTheme = (next: 'light' | 'dark' | 'system') => {
- setTheme(next);
- // Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway.
- fetch('/api/me/theme', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ theme: next }),
- }).catch(() => {
- // Silent fail on network error — the desktop affordance is best-effort.
- // The mobile profile Theme section is the explicit-error UX.
- });
- };
- ```
- - Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`.
- - Do NOT remove the `useTheme` import or the existing UI structure.
- - The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle).
-
-
-
- test -f components/mobile/profile/ThemeSessionBridge.tsx && grep -q "useSession\|useTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "session.user" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "setTheme" components/mobile/profile/ThemeSessionBridge.tsx && grep -q "ThemeSessionBridge" app/layout.tsx && grep -q "/api/me/theme" components/theme-toggle.tsx && grep -q "method: 'PUT'\|method: \"PUT\"" components/theme-toggle.tsx && npx tsc --noEmit --pretty 2>&1 | head
-
-
-
- - `components/mobile/profile/ThemeSessionBridge.tsx` exists, starts with `'use client';`
- - File imports both `useTheme` from `next-themes` and `useSession` from `@/lib/auth-client`
- - File contains `setTheme(serverTheme)` inside a `useEffect`
- - File guards `serverTheme` against the three-string allowlist before calling `setTheme`
- - File returns `null` (renders nothing)
- - `app/layout.tsx` imports `ThemeSessionBridge` from `@/components/mobile/profile/ThemeSessionBridge`
- - `app/layout.tsx` mounts `` inside `` (so `useSession()` works)
- - `components/theme-toggle.tsx` contains the literal `/api/me/theme` reference
- - `components/theme-toggle.tsx` issues a fetch with `method: 'PUT'` (or `"PUT"`)
- - `components/theme-toggle.tsx` still renders the three `DropdownMenuItem` rows (Light, Dark, System) — visual structure unchanged
- - `components/theme-toggle.tsx` still imports `useTheme` from `next-themes` (not removed)
- - `npx tsc --noEmit --pretty` reports no errors
-
-
-
- Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
-
-
-
@@ -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).
-- `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.
-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.
+
diff --git a/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
index dd727cb..a0bea0c 100644
--- a/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
+++ b/.planning/phases/09-user-profile-preferences-new/09-05-PLAN.md
@@ -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"
---
-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 / 5–8-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.
@@ -95,243 +76,299 @@ business logic — just CRUD + auth-gating).
@.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
-
+
-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 }
-
-
-requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
-
-
-- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1)
-- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test`
-- Uses Card + Switch + DataTable-less list rendering
-
-
-GET /api/pipelines/[id]/executions?limit=N → returns { data: PipelineExecution[] }
-After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 → filters via JSONB predicate
+
+session.user = { id, email, name, role, theme?: 'light'|'dark'|'system', timezone?, ... }
- Task 1: Owner column + role-scoped reads on /admin/workflow/channels
- app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx
+ Task 1: ProfileChannelsSection (Teams + ntfy with QR code) + qrcode.react install
+ components/mobile/profile/ProfileChannelsSection.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx, app/mobile/profile/page.tsx, package.json, package-lock.json
- - 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)
- 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 ``.
- 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"
+ - ``
+ - Below input: render `teamsError` (if present) as `
{teamsError}
` (UI-SPEC inline-error pattern).
+ - Below that: if `teamsTest` is set, render an inline test result row:
+ - Success: `
Channel verified
`
+ - Failure: `
Test failed — {test.status ?? test.error ?? 'unknown'}
`
+ - Buttons row: ` ` (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 `` (min-h-[44px])
+ - `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`.
+ - State B — `channels.ntfy` exists:
+ - Label "Mobile push (ntfy)"
+ - Subscribe link: `{"https://ntfy.sh/" + topic}`
+ - QR code: `` (from `qrcode.react`). Wrap in `
` for accessibility.
+ - Helper line: `
Scan with the ntfy app to subscribe.
`
+ - Test result inline (same pattern as Teams).
+ - Disclosure: `Edit advanced` — inside the details:
+ - Label "Custom ntfy topic"
+ - ` { setCustomTopicInput(e.target.value); setCustomTopicError(null); }} />`
+ - **Inline error rendering for the custom-topic 400 (HIGH 7 from plan checker):**
+ ```tsx
+ {customTopicError && (
+
{customTopicError}
+ )}
+ ```
+ - 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: `` (calls `POST /api/me/channels/ntfy/test`) `` (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`: `Global`
- - Else: `Personal: {channel.owner_email ?? channel.owner_user_id}` (the `owner_email` field comes from the JOIN added in Step A).
- - The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit).
- - Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `
Personal channels contain user-supplied webhook URLs — handle with care.
` (operational hygiene).
+ 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`.
- grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && grep -q "owner_user_id" app/api/notification-channels/route.ts && grep -q "requireAuth" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_user_id" "app/api/notification-channels/[id]/route.ts" && grep -q "owner_email\|owner_user_id" app/admin/workflow/channels/page.tsx && grep -q "Global only\|owner=global\|Personal only" app/admin/workflow/channels/page.tsx && npx tsc --noEmit --pretty 2>&1 | head
+ test -f components/mobile/profile/ProfileChannelsSection.tsx && ! test -f components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx && grep -q "qrcode.react" package.json && grep -q "qrcode.react" package-lock.json && test -f node_modules/qrcode.react/package.json && grep -q "/api/me/channels" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Microsoft Teams webhook URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Mobile push (ntfy)" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Enable mobile push" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "QRCodeSVG\|qrcode.react" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Scan with the ntfy app" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Save Teams URL" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "Edit advanced" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "customTopicError" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "method: 'DELETE'\|method: \"DELETE\"" components/mobile/profile/ProfileChannelsSection.tsx && grep -q "ProfileChannelsSection.tsx" app/mobile/profile/page.tsx && ! grep -q "ProfileChannelsSectionPlaceholder" app/mobile/profile/page.tsx && npx tsc --noEmit --pretty 2>&1 | head -20
- - `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
- 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.
- Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter
- app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts, app/admin/workflow/pipelines/[id]/page.tsx, app/api/pipelines/[id]/executions/route.ts
+ Task 2: Theme session bridge + ThemeToggle write-through
+ components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx
- - 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)
- 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 `` inside `app/layout.tsx`:
- Step E — `app/admin/workflow/pipelines/[id]/page.tsx`:
+ - Find the existing `` block.
+ - Inside the `` children, immediately AFTER `` opens (so it has access to `useSession()`), add ``. The exact placement: as the FIRST child of ``, before the existing `
`.
+ - Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
+ - Do NOT change any other layout.tsx content.
- - 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 `