docs(planning): sync STATE/ROADMAP + capture untracked phase plans before master merge
- STATE/ROADMAP/config updated to reflect Phase 09.1 execution - 09-01 plan refreshed (gap-closure detail) - 09-02..09-05 plans updated during execution - Add untracked 09-06 plan + 01-01/01-02 PWA scaffolding plans (orphaned from earlier sessions) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1de6c02c3b
commit
7b644e475f
11 changed files with 1951 additions and 626 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
374
.planning/phases/01-pwa-scaffolding/01-01-PLAN.md
Normal file
374
.planning/phases/01-pwa-scaffolding/01-01-PLAN.md
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
---
|
||||
phase: 01-pwa-scaffolding
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- public/manifest.json
|
||||
- app/layout.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PWA-01
|
||||
- PWA-02
|
||||
- PWA-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Visiting /manifest.json returns valid JSON with name 'Pulse', short_name 'Pulse', display 'standalone', start_url '/mobile'"
|
||||
- "The manifest theme_color matches the Wulf brand blue (#0075AD) and background_color matches the light shell"
|
||||
- "app/layout.tsx references the manifest so Chrome/Safari pick it up automatically (via metadata.manifest or <link rel='manifest'>)"
|
||||
- "app/layout.tsx exports a viewport object whose viewportFit is 'cover' so the rendered <meta name='viewport'> contains 'viewport-fit=cover'"
|
||||
- "Installing Pulse to a phone home screen launches a chromeless app that opens to /mobile"
|
||||
artifacts:
|
||||
- path: "public/manifest.json"
|
||||
provides: "Web App Manifest — name, short_name, display, start_url, theme/background, icons"
|
||||
contains: '"display": "standalone"'
|
||||
- path: "app/layout.tsx"
|
||||
provides: "Root layout exporting metadata (with manifest) and viewport (with viewportFit:'cover')"
|
||||
contains: "viewportFit"
|
||||
key_links:
|
||||
- from: "app/layout.tsx"
|
||||
to: "public/manifest.json"
|
||||
via: "metadata.manifest or <link rel='manifest'>"
|
||||
pattern: "manifest"
|
||||
- from: "public/manifest.json"
|
||||
to: "/mobile"
|
||||
via: "start_url field"
|
||||
pattern: '"start_url"\s*:\s*"/mobile"'
|
||||
- from: "app/layout.tsx (viewport export)"
|
||||
to: "rendered <meta name='viewport'>"
|
||||
via: "Next.js viewport export → viewport-fit=cover in DOM"
|
||||
pattern: "viewportFit"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the PWA install surface: a valid Web App Manifest at `/manifest.json`, a manifest reference from the root layout, and a viewport export with `viewportFit: 'cover'` so future shell phases can paint behind the device home indicator.
|
||||
|
||||
Purpose: PWA-01, PWA-02, PWA-03 — make Pulse installable to a phone home screen and have the install land on `/mobile` in standalone (chromeless) mode. No service worker, no offline.
|
||||
|
||||
Output: `public/manifest.json` (new file) and an updated `app/layout.tsx` that adds the manifest reference and a Next 16 viewport export. Verifiable by `curl http://localhost:3100/manifest.json` and grep on `app/layout.tsx`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
|
||||
@CLAUDE.md
|
||||
@app/layout.tsx
|
||||
@app/globals.css
|
||||
@app/styles/brand.css
|
||||
|
||||
<interfaces>
|
||||
<!-- Key facts the executor needs about the existing codebase. Use these directly — no exploration required. -->
|
||||
|
||||
**Existing `app/layout.tsx` shape (what is currently there):**
|
||||
- Imports `Metadata` from `next` (already imported).
|
||||
- Exports `const metadata: Metadata = { title, description, icons: { icon: [...], shortcut: '/favicon.png', apple: '/wulff-logo.png' } }`.
|
||||
- Does NOT currently export `viewport`. Next 16 expects a separate `viewport` export of type `Viewport` from `next`.
|
||||
- The metadata object already has an `icons` field. Do NOT remove it; the PWA `manifest` field is added alongside `icons`.
|
||||
|
||||
**Brand colors (already defined in `app/styles/brand.css`):**
|
||||
- Wulf primary blue: `#0075AD` (oklch `0.540 0.136 233.3`). This is the `theme_color`.
|
||||
- Light shell background: white (`#FFFFFF`). This is the `background_color` (the manifest only allows one; the light shell is the standard splash background).
|
||||
|
||||
**Existing icon assets (in `/public`):**
|
||||
- `/public/wulff-logo.png` — square PNG, used today as `metadata.icons.apple` (apple-touch-icon).
|
||||
- `/public/favicon.png` — square PNG.
|
||||
- `/public/branding/wulf-mark.png` — Wulf "W" mark, square PNG.
|
||||
- `/public/branding/wulf-wordmark.png` — Wulf "Pulse" wordmark.
|
||||
None of these have explicit pixel sizes verified, but they're used today and PWA install tools accept them with `"sizes": "any"`.
|
||||
|
||||
**Next.js 16 metadata API for manifest:**
|
||||
- The recommended way to reference a manifest is `metadata.manifest = '/manifest.json'` in the metadata export. Next emits `<link rel="manifest" href="/manifest.json" />` automatically. This satisfies the spec wording (`<link rel="manifest">`) without hand-rolling the link tag.
|
||||
- Alternative: hand-roll `<link rel="manifest" href="/manifest.json" />` inside `<head>`. Either approach is acceptable per the spec; prefer `metadata.manifest` because the file already uses the metadata API.
|
||||
|
||||
**Next.js 16 viewport API:**
|
||||
- Import: `import type { Viewport } from 'next'`.
|
||||
- Export: `export const viewport: Viewport = { ... }` (separate from `metadata`; Next 16 deprecated `metadata.viewport`).
|
||||
- The `viewportFit` field is camelCase in TS; Next emits `viewport-fit=cover` in the rendered `<meta name="viewport">` tag.
|
||||
- Reasonable default fields: `width: 'device-width'`, `initialScale: 1`, `viewportFit: 'cover'`. Do NOT add `maximumScale` or `userScalable: false` (accessibility).
|
||||
|
||||
**Theme color / dark mode caveat:**
|
||||
- The manifest only allows one `theme_color`. Use the Wulf blue `#0075AD` so the system UI tint matches the brand in both light and dark modes.
|
||||
- Optionally also add a viewport `themeColor` array with `media: '(prefers-color-scheme: dark)'` variants in the viewport export. This is a Next.js helper that emits `<meta name="theme-color">` per-scheme. NOT required for PWA-01..03; only add if it falls out naturally.
|
||||
|
||||
**Verification commands the executor will use:**
|
||||
- `curl -sf http://localhost:3100/manifest.json | jq .` (dev server must be running)
|
||||
- `grep -E "viewportFit|viewport-fit" app/layout.tsx`
|
||||
- `grep -E 'manifest:|rel="manifest"' app/layout.tsx`
|
||||
- `npx tsc --noEmit --pretty` (must pass)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create public/manifest.json</name>
|
||||
<files>public/manifest.json</files>
|
||||
<read_first>
|
||||
- docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — exact field requirements)
|
||||
- .planning/REQUIREMENTS.md (PWA-01)
|
||||
- app/styles/brand.css (line 28: `--wulf-blue` is `#0075AD` — this is theme_color)
|
||||
- app/globals.css (lines 47-48: light theme `--background` is white; lines 82-83: dark theme background)
|
||||
- public/ directory listing — confirm `wulff-logo.png`, `favicon.png`, `branding/wulf-mark.png` exist
|
||||
</read_first>
|
||||
<action>
|
||||
Create `public/manifest.json` (new file) with exactly this JSON shape. Hand-write the file; do not use a generator.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Pulse",
|
||||
"short_name": "Pulse",
|
||||
"description": "Wulf Consulting operations console — tickets, RMM, backups, and analytics on the go.",
|
||||
"start_url": "/mobile",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#0075AD",
|
||||
"background_color": "#FFFFFF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/wulff-logo.png",
|
||||
"sizes": "any",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/branding/wulf-mark.png",
|
||||
"sizes": "any",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "any",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the choices (so a reviewer doesn't have to ask):
|
||||
- `name` and `short_name` both "Pulse" — matches spec §4 verbatim.
|
||||
- `start_url: "/mobile"` — spec §4 verbatim. The phone install lands on the mobile shell, not the desktop dashboard.
|
||||
- `scope: "/"` — allow the standalone window to navigate anywhere in the app without falling out to the browser. (Spec doesn't specify; root scope is the safe default for an installed PSA console.)
|
||||
- `display: "standalone"` — spec §4 verbatim. Chromeless app surface.
|
||||
- `orientation: "portrait"` — phone-first per the spec's overall framing (§1, §2). Tablet landscape is explicit out-of-scope (§7).
|
||||
- `theme_color: "#0075AD"` — Wulf brand blue from `app/styles/brand.css` line 28 (`--wulf-blue`). Matches the `--primary` token in both light and dark modes (oklch values resolve to this brand blue, slightly lifted for dark).
|
||||
- `background_color: "#FFFFFF"` — light shell background. Manifest only allows one value; the iOS/Android splash uses this. White matches Pulse's default theme on light devices and is acceptable on dark devices (brief flash, not a regression).
|
||||
- `icons` — three entries reusing existing assets in `/public`. Using `"sizes": "any"` because the assets are not explicitly sized — install tools accept this for PNGs and pick the largest. Do NOT generate new icon PNGs in this task; reuse what's there. (A future polish phase can add density-specific 192/512 icons if install warns.)
|
||||
|
||||
Do NOT:
|
||||
- Add a `serviceworker` field (no SW in v1, spec §4 explicit).
|
||||
- Add `display_override` or `prefer_related_applications` (not needed; not in spec).
|
||||
- Add `categories` or `lang` (cosmetic; not in spec scope).
|
||||
- Reference `next-pwa` or any plugin (forbidden by spec §4 and CLAUDE.md).
|
||||
- Edit any existing migration, lib/, or component file.
|
||||
|
||||
The file must be served directly by Next.js as a static asset — placing it at `public/manifest.json` makes it available at `http://localhost:3100/manifest.json`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f public/manifest.json && jq -e '.name == "Pulse" and .short_name == "Pulse" and .display == "standalone" and .start_url == "/mobile" and .theme_color == "#0075AD" and .background_color == "#FFFFFF" and (.icons | length) >= 1' public/manifest.json</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `public/manifest.json` exists.
|
||||
- `jq -r .name public/manifest.json` outputs `Pulse`.
|
||||
- `jq -r .short_name public/manifest.json` outputs `Pulse`.
|
||||
- `jq -r .display public/manifest.json` outputs `standalone`.
|
||||
- `jq -r .start_url public/manifest.json` outputs `/mobile`.
|
||||
- `jq -r .theme_color public/manifest.json` outputs `#0075AD`.
|
||||
- `jq -r .background_color public/manifest.json` outputs `#FFFFFF`.
|
||||
- `jq -e '.icons | length >= 1' public/manifest.json` exits 0.
|
||||
- `jq -e '.icons[0].src' public/manifest.json` outputs a path beginning with `/` (e.g., `/wulff-logo.png`).
|
||||
- File is valid JSON: `jq empty public/manifest.json` exits 0.
|
||||
- No `serviceworker` field present: `jq -e '.serviceworker == null' public/manifest.json` exits 0.
|
||||
- When dev server is running on port 3100: `curl -sf http://localhost:3100/manifest.json` exits 0 and the body equals the file contents.
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`public/manifest.json` exists, is valid JSON, contains the spec-mandated fields with the values above, references at least one icon from `/public`, and is reachable at `http://localhost:3100/manifest.json` when the dev server is running.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add manifest reference and viewport export to app/layout.tsx</name>
|
||||
<files>app/layout.tsx</files>
|
||||
<read_first>
|
||||
- app/layout.tsx (current file — already exports `metadata: Metadata`, no `viewport` export yet)
|
||||
- docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — viewport-fit=cover wording)
|
||||
- .planning/REQUIREMENTS.md (PWA-02, PWA-03)
|
||||
- public/manifest.json (created in Task 1 — must exist before this task ships)
|
||||
</read_first>
|
||||
<action>
|
||||
Edit `app/layout.tsx` (do NOT create a new file). Two changes, both at the top of the file alongside the existing `metadata` export. The body of `RootLayout` is unchanged.
|
||||
|
||||
**Change 1 — add `manifest: '/manifest.json'` to the existing `metadata` object.**
|
||||
|
||||
The current export looks like:
|
||||
|
||||
```ts
|
||||
export const metadata: Metadata = {
|
||||
title: "Pulse · Operations console",
|
||||
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", sizes: "any" },
|
||||
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
|
||||
],
|
||||
shortcut: "/favicon.png",
|
||||
apple: "/wulff-logo.png",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Add a `manifest` field alongside `icons`. The result should be:
|
||||
|
||||
```ts
|
||||
export const metadata: Metadata = {
|
||||
title: "Pulse · Operations console",
|
||||
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", sizes: "any" },
|
||||
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
|
||||
],
|
||||
shortcut: "/favicon.png",
|
||||
apple: "/wulff-logo.png",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Next.js 16 emits `<link rel="manifest" href="/manifest.json" />` automatically from this field — this satisfies the spec wording (`<link rel="manifest">` from §4) without hand-rolling the tag.
|
||||
|
||||
**Change 2 — add a `Viewport` import and a separate `viewport` export.**
|
||||
|
||||
Update the `next` type import on line 1. The current import is:
|
||||
|
||||
```ts
|
||||
import type { Metadata } from "next";
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```ts
|
||||
import type { Metadata, Viewport } from "next";
|
||||
```
|
||||
|
||||
Then, immediately after the `metadata` export (and before `export default function RootLayout(...)`), add:
|
||||
|
||||
```ts
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Notes on the choices:
|
||||
- `viewportFit: 'cover'` — the only field PWA-03 strictly requires. Emits `viewport-fit=cover` in the rendered `<meta name="viewport">` tag. With this set, Phase 2's safe-area-inset utilities can paint behind the home indicator.
|
||||
- `width: 'device-width'` and `initialScale: 1` — standard mobile viewport defaults; they were absent before and Next 16 would warn without them. Adding them here removes the warning and makes the viewport explicit.
|
||||
- `themeColor` — paired light/dark values for the system browser chrome (status bar tint). Light = white (matches manifest `background_color`); dark = `#0A0A0A` (close to the existing `--background` oklch `0.145 0 0` in `app/globals.css` line 83). This is OPTIONAL for PWA-03 (the manifest's `theme_color` already covers the install chrome), but it's a one-line improvement that ships better dark-mode rendering and costs nothing. Keep it; remove if it ever conflicts with a future per-page override.
|
||||
- Do NOT add `maximumScale`, `userScalable: false`, or `minimumScale` — accessibility regression.
|
||||
|
||||
Do NOT:
|
||||
- Touch the `RootLayout` function body.
|
||||
- Touch the `ThemeProvider`, `AppNavigation`, `CommandPalette`, `TaglineFooter`, `Toaster`, or `AuthProvider` imports.
|
||||
- Add any `<head>` JSX (no hand-rolled `<link rel="manifest">` tag — let Next emit it from `metadata.manifest`).
|
||||
- Touch the `IBM_Plex_Sans` / `IBM_Plex_Mono` font setup.
|
||||
- Add `'use client'` — root layout is a server component.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q 'manifest: "/manifest.json"' app/layout.tsx && 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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -E '^import type \{ Metadata, Viewport \} from "next"' app/layout.tsx` matches one line (or `Metadata` and `Viewport` both appear in a single named-import line from `next`).
|
||||
- `grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsx` matches one line inside the `metadata` object.
|
||||
- `grep -E '^export const viewport: Viewport = \{' app/layout.tsx` matches exactly one line.
|
||||
- `grep -E 'viewportFit:\s*"cover"' app/layout.tsx` matches one line inside the `viewport` export.
|
||||
- `grep -E 'width:\s*"device-width"' app/layout.tsx` matches one line.
|
||||
- `grep -E 'initialScale:\s*1' app/layout.tsx` matches one line.
|
||||
- The `metadata.icons` object is unchanged (still contains `apple: "/wulff-logo.png"`): `grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsx` matches.
|
||||
- The `RootLayout` default export is unchanged: `grep -E 'export default function RootLayout' app/layout.tsx` matches.
|
||||
- No `'use client'` pragma added: `! grep -E "^'use client'" app/layout.tsx`.
|
||||
- Type check passes: `npx tsc --noEmit --pretty` exits 0 (or, if other files have unrelated pre-existing errors, no error rows mention `app/layout.tsx`).
|
||||
- When dev server is running: viewing http://localhost:3100/ source contains `viewport-fit=cover` (e.g. `curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'` exits 0). Optional manual check; not strictly required for the automated gate.
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`app/layout.tsx` exports both `metadata` (now with `manifest: "/manifest.json"`) and `viewport` (with `viewportFit: "cover"`, `width: "device-width"`, `initialScale: 1`, and themeColor light/dark pair). Type check passes. The `RootLayout` body is unchanged. PWA-02 (manifest reference) and PWA-03 (viewport-fit=cover) are satisfied.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser ↔ static asset (/manifest.json) | Public client read of a manifest. No auth, no input. |
|
||||
| Browser ↔ rendered HTML head | Public client read of `<meta name="viewport">` and `<link rel="manifest">`. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01-01 | Information Disclosure | public/manifest.json | accept | Manifest is intended to be world-readable per W3C Web App Manifest spec. Contains only public app branding (name, theme color, icon paths) — no secrets, no user data, no endpoints. |
|
||||
| T-01-02 | Tampering | app/layout.tsx viewport export | accept | Server-rendered; no user input flows into the viewport meta. No injection vector. |
|
||||
| T-01-03 | Denial of Service | manifest fetch | accept | Static file served by Next.js; same risk profile as `/favicon.png`. No new attack surface. |
|
||||
|
||||
**Summary:** No new threat surface introduced. `manifest.json` is public per W3C spec; viewport meta is a public client hint; no auth, data, or endpoints are introduced. ASVS-L1 baseline preserved.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
With dev server running (`npm run dev` → port 3100), all of the following must pass:
|
||||
|
||||
```bash
|
||||
# Manifest is reachable and well-formed
|
||||
curl -sf http://localhost:3100/manifest.json | jq -e '.name == "Pulse" and .display == "standalone" and .start_url == "/mobile"'
|
||||
|
||||
# Manifest is referenced from root layout (Next emits the link tag automatically)
|
||||
curl -s http://localhost:3100/ | grep -E 'rel="manifest"'
|
||||
|
||||
# Viewport meta includes viewport-fit=cover
|
||||
curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'
|
||||
|
||||
# Type check passes
|
||||
npx tsc --noEmit --pretty
|
||||
|
||||
# No service worker file shipped (negative check — must be absent)
|
||||
test ! -f public/sw.js && test ! -f public/service-worker.js
|
||||
|
||||
# next-pwa is not in dependencies
|
||||
! grep -E '"next-pwa"' package.json
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `public/manifest.json` exists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme_color "#0075AD", background_color "#FFFFFF", and at least one icon (PWA-01).
|
||||
- `app/layout.tsx` references the manifest via `metadata.manifest = "/manifest.json"`, which makes Next.js emit `<link rel="manifest" href="/manifest.json" />` in the rendered HTML head (PWA-02).
|
||||
- `app/layout.tsx` exports `viewport: Viewport` with `viewportFit: "cover"` so the rendered `<meta name="viewport">` tag contains `viewport-fit=cover` (PWA-03).
|
||||
- `npx tsc --noEmit --pretty` passes.
|
||||
- No service worker file or `next-pwa` dependency introduced.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md` documenting:
|
||||
- Files created/modified (paths and one-line descriptions)
|
||||
- The exact `theme_color` and `background_color` values chosen (and why — Wulf brand blue + light shell background)
|
||||
- The viewport export shape (so Phase 2 knows it can rely on `viewport-fit=cover` being present)
|
||||
- Verification results (manifest curl, viewport grep, tsc result)
|
||||
- Any deviations from the plan and rationale
|
||||
</output>
|
||||
286
.planning/phases/01-pwa-scaffolding/01-02-PLAN.md
Normal file
286
.planning/phases/01-pwa-scaffolding/01-02-PLAN.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
---
|
||||
phase: 01-pwa-scaffolding
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
gap_closure: true
|
||||
files_modified:
|
||||
- app/styles/brand.css
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PWA-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A shared @utility named pt-safe is defined in app/styles/brand.css that applies padding-top: env(safe-area-inset-top)"
|
||||
- "A shared @utility named pb-safe is defined in app/styles/brand.css that applies padding-bottom: env(safe-area-inset-bottom)"
|
||||
- "Phase 2's sticky header can opt into safe-area-inset-top padding by adding the pt-safe class"
|
||||
- "Phase 2's fixed bottom nav can opt into safe-area-inset-bottom padding by adding the pb-safe class"
|
||||
- "The Tailwind 4 build accepts the new @utility blocks (no CSS syntax errors, npm run build succeeds)"
|
||||
artifacts:
|
||||
- path: "app/styles/brand.css"
|
||||
provides: "Two new @utility blocks (pt-safe, pb-safe) sitting alongside the existing num/metric/surface/rule/chrome/tagline utilities"
|
||||
contains: "@utility pt-safe"
|
||||
key_links:
|
||||
- from: "app/styles/brand.css (@utility pt-safe)"
|
||||
to: "rendered CSS class .pt-safe"
|
||||
via: "Tailwind 4 @utility block — Tailwind compiles @utility name { ... } into a class .name { ... }"
|
||||
pattern: "@utility pt-safe"
|
||||
- from: "app/styles/brand.css (@utility pb-safe)"
|
||||
to: "rendered CSS class .pb-safe"
|
||||
via: "Tailwind 4 @utility block"
|
||||
pattern: "@utility pb-safe"
|
||||
- from: "app/globals.css"
|
||||
to: "app/styles/brand.css"
|
||||
via: "@import './styles/brand.css' on line 125 (already wired — no change required)"
|
||||
pattern: '@import "./styles/brand.css"'
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the PWA-04 gap from Phase 01 verification by adding shared safe-area `@utility` blocks to `app/styles/brand.css`. Phase 2's sticky header and fixed bottom nav need to opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding so content paints correctly under the iOS home indicator and Android gesture bar when `viewport-fit=cover` is in effect (already shipped by 01-01).
|
||||
|
||||
Purpose: PWA-04 — make a safe-area utility available so any sticky top/bottom bar can opt in. ROADMAP Phase 1 SC #3 requires the utility to be **available in Phase 1**; Phase 2's contract (SHELL-05, SHELL-06) only mandates **consumption**. This plan restores the broken phase boundary identified by `01-VERIFICATION.md`.
|
||||
|
||||
Output: `app/styles/brand.css` updated with two new `@utility` blocks (`pt-safe`, `pb-safe`) appended to the existing utility section. No other files touched. Verifiable by `grep -E '@utility (pt-safe|pb-safe)' app/styles/brand.css` and `npm run build`.
|
||||
|
||||
Why `app/styles/brand.css` (not `app/globals.css`):
|
||||
- All named project utilities (`num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`, `has-mark-watermark`) already live there.
|
||||
- `brand.css` is already imported into `globals.css` (line 125) — no extra wiring needed.
|
||||
- Keeps utilities co-located so Phase 2 has a single file to scan when looking for project helpers.
|
||||
- `globals.css` is reserved for Tailwind imports, `@theme inline` token mapping, and `:root` / `.dark` variable definitions — adding utility classes there would muddy that separation.
|
||||
|
||||
Note on traceability: this plan claims `PWA-04` in its `requirements` frontmatter, restoring the orphaned-requirement state flagged by `01-VERIFICATION.md`. The executor's SUMMARY (`01-02-SUMMARY.md`) should explicitly call out that PWA-04 is now satisfied, closing the requirements traceability table.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/01-pwa-scaffolding/01-VERIFICATION.md
|
||||
@.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md
|
||||
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
|
||||
@CLAUDE.md
|
||||
@app/styles/brand.css
|
||||
@app/globals.css
|
||||
|
||||
<interfaces>
|
||||
<!-- Key facts the executor needs about the existing codebase. Use these directly — no exploration required. -->
|
||||
|
||||
**Tailwind 4 `@utility` syntax (already in use in this project):**
|
||||
- This project is Tailwind 4 with NO `tailwind.config.*` file. Custom utilities are declared inline in CSS using the `@utility` at-rule.
|
||||
- Pattern: `@utility name { /* CSS declarations */ }` — Tailwind compiles this to a class `.name { ... }` that participates in the variant system (`hover:name`, `md:name`, etc.).
|
||||
- See existing examples in `app/styles/brand.css` lines 70-140 (e.g., `@utility num { ... }`, `@utility metric-label { ... }`, `@utility surface-brand { ... }`).
|
||||
- Each `@utility` block holds plain CSS property declarations. No `@apply` is required for simple `padding-*` cases.
|
||||
|
||||
**`env()` CSS environment variables for safe areas:**
|
||||
- `env(safe-area-inset-top)` — top safe-area inset (e.g., iPhone notch / Dynamic Island area).
|
||||
- `env(safe-area-inset-bottom)` — bottom safe-area inset (e.g., iPhone home indicator area).
|
||||
- Browser-side CSS feature; no JavaScript involvement. Falls back to `0` on browsers/devices without safe-area insets.
|
||||
- Requires `<meta name="viewport" content="...viewport-fit=cover">` to take non-zero values. **This is already shipped by 01-01** (`viewportFit: "cover"` in `app/layout.tsx`).
|
||||
|
||||
**Existing `app/styles/brand.css` structure (line numbers from current file):**
|
||||
- Lines 1-21: file header / brand documentation comment.
|
||||
- Lines 23-52: `:root` overrides (`--wulf-blue`, etc.) for the light theme.
|
||||
- Lines 54-62: `.dark` overrides.
|
||||
- Lines 64-68: `/* === Utility classes === */` section header comment.
|
||||
- Lines 70-140: existing `@utility` blocks — `num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`.
|
||||
- Lines 142-147: `/* === Wolf-mark watermark === */` section header comment.
|
||||
- Lines 149-152: `@utility has-mark-watermark`.
|
||||
- Lines 154-170: `.mark-watermark` plain rule + `.dark .mark-watermark` override.
|
||||
- **Insertion point for new utilities:** after the `tagline` utility (line 140) and **before** the watermark section header (line 142). This keeps utilities grouped before the watermark block, which has its own thematic header.
|
||||
|
||||
**`app/globals.css` import wiring (already in place — DO NOT change):**
|
||||
- Line 125: `@import "./styles/brand.css";` — pulls `brand.css` into the global stylesheet at the end. Anything added to `brand.css` is automatically available app-wide. No additional wiring needed.
|
||||
|
||||
**Spec wording (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §5/§6):**
|
||||
- The mobile shell's sticky top header must respect `env(safe-area-inset-top)`.
|
||||
- The fixed bottom nav must respect `env(safe-area-inset-bottom)` (often combined with the bottom-nav height).
|
||||
- The spec accepts either a named utility or Tailwind 4 arbitrary values (`pt-[env(safe-area-inset-top)]`).
|
||||
|
||||
**Why ship a named utility (not arbitrary values):**
|
||||
- Phase 2 will use these classes in 2+ places (header, bottom nav, drawer footer, possibly modals). A named utility is one source of truth — if the iOS rules ever change (e.g., add `max(env(safe-area-inset-top), 0.5rem)`), it's a one-line edit instead of a multi-file find-and-replace.
|
||||
- `pt-safe` / `pb-safe` reads more clearly in JSX class lists than `pt-[env(safe-area-inset-top)]`.
|
||||
- ROADMAP Phase 1 SC #3 explicitly mentions "shared utility class" as one acceptable form — picking that form removes ambiguity for Phase 2.
|
||||
|
||||
**Verification commands the executor will use:**
|
||||
- `grep -E '@utility pt-safe' app/styles/brand.css`
|
||||
- `grep -E '@utility pb-safe' app/styles/brand.css`
|
||||
- `grep -E 'env\(safe-area-inset-top\)' app/styles/brand.css`
|
||||
- `grep -E 'env\(safe-area-inset-bottom\)' app/styles/brand.css`
|
||||
- `npm run build` (CSS @utility blocks must parse — broken syntax fails the Tailwind compile step in Next.js)
|
||||
- `npx tsc --noEmit --pretty` (sanity check; CSS doesn't affect TS but pre-existing baseline must hold)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Append pt-safe and pb-safe @utility blocks to app/styles/brand.css</name>
|
||||
<files>app/styles/brand.css</files>
|
||||
<read_first>
|
||||
- app/styles/brand.css (the entire file — confirm line numbers above match current state; the exact insertion point is between the existing `tagline` utility and the watermark section header)
|
||||
- app/globals.css lines 1-5 and 125 (confirm `brand.css` is still imported; no change needed)
|
||||
- .planning/phases/01-pwa-scaffolding/01-VERIFICATION.md (the gap source — frontmatter `gaps[0].missing`)
|
||||
- .planning/REQUIREMENTS.md line 16 (PWA-04 wording)
|
||||
</read_first>
|
||||
<action>
|
||||
Edit `app/styles/brand.css`. Append two new `@utility` blocks **after** the existing `@utility tagline { ... }` block (which ends around line 140) and **before** the `/* === Wolf-mark watermark === */` section header comment (around line 142). Do NOT touch any other part of the file.
|
||||
|
||||
Insert exactly this block (including the leading section comment and the two `@utility` definitions):
|
||||
|
||||
```css
|
||||
/* === Safe-area insets =================================================
|
||||
*
|
||||
* Opt-in padding helpers for sticky top / fixed bottom bars on devices
|
||||
* with notches, dynamic islands, or gesture home indicators. Pair with
|
||||
* the viewport-fit=cover viewport meta (set in app/layout.tsx) — without
|
||||
* that, env(safe-area-inset-*) resolves to 0 and these utilities are
|
||||
* no-ops, which is the desired fallback on non-PWA / non-mobile contexts.
|
||||
*
|
||||
* Usage:
|
||||
* <header class="sticky top-0 pt-safe ..."> // header clears notch
|
||||
* <nav class="fixed bottom-0 pb-safe ..."> // bottom bar clears home bar
|
||||
*
|
||||
* Closes PWA-04 (REQUIREMENTS.md) and ROADMAP Phase 1 SC #3.
|
||||
* ==================================================================== */
|
||||
|
||||
@utility pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
@utility pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the choices (so a reviewer doesn't have to ask):
|
||||
- **Two utilities only (`pt-safe`, `pb-safe`).** The spec only requires top + bottom safe-area handling — that's what notches and home indicators occupy. Left/right insets (`safe-area-inset-left`, `safe-area-inset-right`) only matter for landscape mode on devices with notches, and the manifest pins orientation to `portrait` (per 01-01-SUMMARY.md). Adding `pl-safe` / `pr-safe` now would be speculative; Phase 2 can add them in 30 seconds if a real consumer appears.
|
||||
- **No additive variants like `pt-safe-4`.** ROADMAP Phase 1 SC #3 only requires the **base** utility be available. Combinations like "safe-area inset PLUS 1rem" can be expressed at consume time with `pt-safe pt-4` or arbitrary values. Don't pre-build helpers that nothing yet calls.
|
||||
- **Plain `env(safe-area-inset-top)` (not `max(env(safe-area-inset-top), 0px)`).** The CSS env value already returns `0` when no inset is reported — wrapping it in `max(..., 0)` is a no-op and adds noise. Wrap it later if a real device misbehaves.
|
||||
- **Insertion location: alongside the other `@utility` blocks in `brand.css`.** Already justified in `<objective>`: keeps named utilities co-located, requires no new import wiring, fits the project's "Wulf design helpers" theme (these are layout primitives that go with the rest of the brand kit).
|
||||
|
||||
Do NOT:
|
||||
- Edit `app/globals.css` — the existing `@import "./styles/brand.css"` (line 125) is already correct.
|
||||
- Add a new file — these are utilities, not a new module.
|
||||
- Add `pl-safe`, `pr-safe`, or stacked variants — out of scope for PWA-04.
|
||||
- Modify any existing `@utility` block (`num`, `metric-label`, `tagline`, etc.) — they are unrelated.
|
||||
- Modify the `:root` / `.dark` token sections at the top of `brand.css` — these are CSS custom properties, not utilities.
|
||||
- Touch the `.mark-watermark` plain rule or the watermark section header — they live below the insertion point and are unrelated.
|
||||
- Add a `tailwind.config.*` file — this is a Tailwind 4 project with no config file, by design (CLAUDE.md `Conventions to follow`).
|
||||
|
||||
After editing, the file should be ~12-13 lines longer than before (1 comment block + 2 `@utility` definitions = ~13 lines including blank lines). The diff should show **only** additions, no deletions.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q '@utility pt-safe' app/styles/brand.css && 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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -E '@utility pt-safe' app/styles/brand.css` matches exactly one line.
|
||||
- `grep -E '@utility pb-safe' app/styles/brand.css` matches exactly one line.
|
||||
- `grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css` matches exactly one line.
|
||||
- `grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css` matches exactly one line.
|
||||
- The existing `@utility tagline` block is still present and unchanged: `grep -E '@utility tagline' app/styles/brand.css` matches.
|
||||
- The existing `@utility num` block is still present: `grep -E '@utility num \{' app/styles/brand.css` matches.
|
||||
- The existing `.mark-watermark` rule is still present: `grep -E '\.mark-watermark \{' app/styles/brand.css` matches.
|
||||
- The `:root` `--wulf-blue` token is still present: `grep -E '\-\-wulf-blue:' app/styles/brand.css` matches.
|
||||
- No `tailwind.config` file was created: `test ! -f tailwind.config.ts && test ! -f tailwind.config.js && test ! -f tailwind.config.mjs`.
|
||||
- `app/globals.css` is unchanged from baseline: `grep -E '@import "\./styles/brand\.css";' app/globals.css` matches (the existing import is intact).
|
||||
- No `pl-safe` / `pr-safe` utilities were added (out of scope): `! grep -E '@utility (pl-safe|pr-safe)' app/styles/brand.css`.
|
||||
- No service worker file shipped: `test ! -f public/sw.js && test ! -f public/service-worker.js`.
|
||||
- `next-pwa` is not in dependencies: `! grep '"next-pwa"' package.json`.
|
||||
- Type check passes: `npx tsc --noEmit --pretty` exits 0 (CSS changes don't affect TS, but the pre-existing baseline is preserved).
|
||||
- Production build succeeds: `npm run build` exits 0. This is the load-bearing gate — Tailwind 4 will fail the build if the `@utility` syntax is malformed, so a green build confirms the new utilities compile and are emitted into the production CSS bundle.
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
`app/styles/brand.css` contains two new `@utility` blocks — `pt-safe` (sets `padding-top: env(safe-area-inset-top)`) and `pb-safe` (sets `padding-bottom: env(safe-area-inset-bottom)`) — placed between the existing `@utility tagline` block and the `/* === Wolf-mark watermark === */` section header. The block is preceded by a section comment that documents the intent, usage, and the PWA-04 / SC #3 requirement IDs being closed. No other file is modified. `npm run build` and `npx tsc --noEmit --pretty` both exit 0. PWA-04 is satisfied; the orphaned-requirement state from `01-VERIFICATION.md` is closed.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser ↔ static CSS bundle | Public client read of compiled Tailwind CSS containing `.pt-safe` / `.pb-safe` rules. No auth, no input, no data flow. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
No trust boundaries crossed. CSS utilities are public client-side styles; `env(safe-area-inset-*)` is a CSS environment variable resolved by the browser based on the device viewport. There is no user input, no data flow, no auth surface, no new endpoint. ASVS-L1 baseline preserved — no new attack surface introduced beyond the (already-public) Tailwind CSS bundle.
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01.02-01 | Information Disclosure | Compiled CSS bundle (`.pt-safe`, `.pb-safe` class rules) | accept | CSS class rules are world-readable by design — same risk profile as every other Tailwind utility class. Contains zero secrets, zero user data, zero endpoints. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
With dev server NOT required (these checks are static-file and build-time):
|
||||
|
||||
```bash
|
||||
# New utilities exist in brand.css
|
||||
grep -E '@utility pt-safe' app/styles/brand.css
|
||||
grep -E '@utility pb-safe' app/styles/brand.css
|
||||
|
||||
# They reference the correct CSS env() variables
|
||||
grep -E 'padding-top:\s*env\(safe-area-inset-top\)' app/styles/brand.css
|
||||
grep -E 'padding-bottom:\s*env\(safe-area-inset-bottom\)' app/styles/brand.css
|
||||
|
||||
# Existing utilities and brand tokens are untouched
|
||||
grep -E '@utility tagline' app/styles/brand.css
|
||||
grep -E '@utility num \{' app/styles/brand.css
|
||||
grep -E '\-\-wulf-blue:' app/styles/brand.css
|
||||
|
||||
# globals.css import wiring is unchanged
|
||||
grep -E '@import "\./styles/brand\.css";' app/globals.css
|
||||
|
||||
# Out-of-scope items NOT added
|
||||
! grep -E '@utility (pl-safe|pr-safe)' app/styles/brand.css
|
||||
test ! -f tailwind.config.ts && test ! -f tailwind.config.js && test ! -f tailwind.config.mjs
|
||||
test ! -f public/sw.js
|
||||
! grep '"next-pwa"' package.json
|
||||
|
||||
# Type check (sanity, baseline preserved)
|
||||
npx tsc --noEmit --pretty
|
||||
|
||||
# Production build (load-bearing — Tailwind 4 fails the build on malformed @utility syntax)
|
||||
npm run build
|
||||
```
|
||||
|
||||
With dev server running on port 3100, an OPTIONAL spot check (not required for the automated gate; useful for human verification on a real iPhone):
|
||||
|
||||
```bash
|
||||
# Confirm the compiled .pt-safe / .pb-safe classes are present in the served CSS bundle
|
||||
curl -s http://localhost:3100/ | grep -oE '/_next/static/css/[^"]+\.css' | head -1 | xargs -I {} curl -s "http://localhost:3100{}" | grep -E '\.pt-safe|\.pb-safe'
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `app/styles/brand.css` defines `@utility pt-safe { padding-top: env(safe-area-inset-top); }` (PWA-04, SC #3).
|
||||
- `app/styles/brand.css` defines `@utility pb-safe { padding-bottom: env(safe-area-inset-bottom); }` (PWA-04, SC #3).
|
||||
- The new utilities are placed in the existing utilities section of `brand.css` (between `@utility tagline` and the watermark section), preceded by a section comment that documents intent and references PWA-04.
|
||||
- All pre-existing `@utility` blocks (`num`, `num-lg`, `num-xl`, `metric-label`, `surface-brand`, `surface-brand-ink`, `rule-brand`, `text-chrome`, `border-chrome`, `tagline`, `has-mark-watermark`) and the `.mark-watermark` plain rule remain unchanged.
|
||||
- `app/globals.css` is unchanged (the existing `@import "./styles/brand.css"` on line 125 already pulls the new utilities into the global stylesheet).
|
||||
- `npm run build` exits 0 — confirms Tailwind 4 accepts the new `@utility` syntax and compiles `.pt-safe` / `.pb-safe` into the production CSS bundle.
|
||||
- `npx tsc --noEmit --pretty` exits 0 (baseline preserved; CSS edits don't affect TS).
|
||||
- No `tailwind.config.*` file is created (Tailwind 4 + CLAUDE.md convention).
|
||||
- No service worker, no `next-pwa`, no new dependencies introduced.
|
||||
- PWA-04 is satisfied; the orphaned-requirement state from `01-VERIFICATION.md` is closed (SUMMARY explicitly claims PWA-04).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-pwa-scaffolding/01-02-SUMMARY.md` documenting:
|
||||
- File modified (`app/styles/brand.css`) with the exact diff (one section comment block + two `@utility` definitions appended).
|
||||
- The two utility names (`pt-safe`, `pb-safe`) and the CSS they emit (`padding-top: env(safe-area-inset-top)`, `padding-bottom: env(safe-area-inset-bottom)`).
|
||||
- Why `brand.css` was chosen over `globals.css` (utilities co-located with other named project utilities; already imported into globals; no extra wiring required).
|
||||
- Why only `pt-safe` / `pb-safe` (not `pl-safe` / `pr-safe` / additive variants) — orientation is portrait-locked, spec only mandates top/bottom; left/right and additive variants are speculative until a consumer asks.
|
||||
- Verification results (the grep commands and `npm run build` exit code from `<verification>`).
|
||||
- **Explicit Requirements Satisfied entry: `PWA-04`** — call this out by name so the orphaned-requirement state from `01-VERIFICATION.md` is visibly closed in the SUMMARY trail. This makes the next verification pass green on the requirements coverage table.
|
||||
- Pointer to Phase 2: the new utilities are ready for `app/mobile/layout.tsx` (sticky header → `pt-safe`, fixed bottom nav → `pb-safe`).
|
||||
- Threat surface scan: confirm "no new threat surface" (CSS utility addition only, no auth/input/data flow).
|
||||
</output>
|
||||
|
|
@ -18,6 +18,7 @@ must_haves:
|
|||
- "user table has theme TEXT column with default 'system' and existing rows backfilled to 'system'"
|
||||
- "session.user.theme is exposed via Better Auth additionalFields the same way session.user.timezone is"
|
||||
- "notification_channels has owner_user_id TEXT REFERENCES user(id) ON DELETE CASCADE column; existing rows have owner_user_id IS NULL (global)"
|
||||
- "notification_channels has a partial unique index on (owner_user_id, channel_type) WHERE owner_user_id IS NOT NULL — defense-in-depth for the API-layer UPSERT in Plan 02"
|
||||
- "notify_event_keys table exists with admin-CRUD-able rows (key, display_label, description, sort_order, is_active)"
|
||||
- "user_event_subscriptions table exists with composite PK (user_id, event_key, channel_type)"
|
||||
artifacts:
|
||||
|
|
@ -25,7 +26,7 @@ must_haves:
|
|||
provides: "theme column on user with backfill"
|
||||
contains: 'ALTER TABLE "user"'
|
||||
- path: "migrations/085_personal_notification_channels.sql"
|
||||
provides: "owner_user_id column on notification_channels"
|
||||
provides: "owner_user_id column + partial unique index on notification_channels"
|
||||
contains: "ALTER TABLE notification_channels"
|
||||
- path: "migrations/086_notify_event_keys_and_subscriptions.sql"
|
||||
provides: "two new tables for event taxonomy and per-user subscriptions"
|
||||
|
|
@ -50,7 +51,7 @@ must_haves:
|
|||
<objective>
|
||||
Land the foundational schema and types for Phase 9. This plan adds:
|
||||
1. `theme` column on `"user"` (Better Auth) + Better Auth `additionalFields` exposure (THEME-01, THEME-05)
|
||||
2. `owner_user_id` column on `notification_channels` (CHAN-01)
|
||||
2. `owner_user_id` column on `notification_channels` plus a partial unique index that enforces "one personal Teams + one personal ntfy per user" at the database level (CHAN-01, defense-in-depth for D-02)
|
||||
3. New tables `notify_event_keys` and `user_event_subscriptions` (SUB-01, SUB-02)
|
||||
4. TypeScript type updates in `lib/types/pipeline.ts` so downstream code compiles against the new shapes
|
||||
|
||||
|
|
@ -112,6 +113,26 @@ ALTER TABLE "user"
|
|||
UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL;
|
||||
COMMENT ON COLUMN "user".timezone IS '...';
|
||||
```
|
||||
|
||||
From migrations/012_create_auth_tables.sql (Better Auth `"user"` table column casing — IMPORTANT, read this carefully):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
image TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
banned BOOLEAN DEFAULT FALSE,
|
||||
banned_reason TEXT,
|
||||
ban_expires TIMESTAMP,
|
||||
requires_setup BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
The Better Auth `"user"` table uses **snake_case unquoted columns** (`updated_at`, `created_at`, `email_verified`, `requires_setup`). This contradicts a stale precedent in `app/api/me/timezone/route.ts` which writes `"updatedAt"` (a quoted camelCase identifier that does NOT exist in this schema). Plan 02 corrects that error — Plan 01 itself does not write to `updated_at` (the migration relies on the column DEFAULT NOW()).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
|
|
@ -123,7 +144,7 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
|
||||
<read_first>
|
||||
- migrations/083_add_user_timezone.sql (mirror this exact structure)
|
||||
- migrations/012_create_auth_tables.sql (confirm "user" table is the Better Auth table and it uses snake_case-style quoted identifier)
|
||||
- migrations/012_create_auth_tables.sql (confirm "user" table is the Better Auth table and that its audit columns are snake_case unquoted: `updated_at`, NOT `"updatedAt"`)
|
||||
- lib/auth.ts (current additionalFields block)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — column shape and default)
|
||||
</read_first>
|
||||
|
|
@ -191,13 +212,13 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Add owner_user_id to notification_channels + create event-keys / subscriptions tables</name>
|
||||
<name>Task 2: Add owner_user_id + partial unique index to notification_channels + create event-keys / subscriptions tables</name>
|
||||
<files>migrations/085_personal_notification_channels.sql, migrations/086_notify_event_keys_and_subscriptions.sql, lib/types/pipeline.ts</files>
|
||||
|
||||
<read_first>
|
||||
- migrations/033_create_pipeline_engine_tables.sql (current notification_channels schema)
|
||||
- lib/types/pipeline.ts (current NotificationChannel interface — must extend, not replace)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-02 singular per-type, D-03 owner_user_id, D-13 event keys, D-14 subscriptions, D-15 default-enabled)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
|
|
@ -212,10 +233,12 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
-- - Personal (owner_user_id = "user".id) — created via /api/me/channels
|
||||
--
|
||||
-- Cascade delete: removing a Better Auth user removes their personal channels.
|
||||
-- Multi-channel-per-type-per-user is forbidden by API-layer UPSERT logic
|
||||
-- keyed by (owner_user_id, channel_type) — D-02 / CHAN-02. We deliberately
|
||||
-- do NOT add a partial unique index so existing global rows (NULL owner) and
|
||||
-- multiple users can coexist.
|
||||
--
|
||||
-- Defense-in-depth: a partial unique index enforces one-personal-channel-per-
|
||||
-- type-per-user at the DB layer (D-02 / CHAN-02). The API-layer WITH-CTE UPSERT
|
||||
-- in Plan 02 still has a small race window for concurrent saves; this index
|
||||
-- closes that gap. The WHERE clause keeps existing global rows (NULL owner)
|
||||
-- exempt — multiple global rows of the same type can still coexist.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE notification_channels
|
||||
|
|
@ -226,6 +249,11 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
ON notification_channels(owner_user_id, channel_type)
|
||||
WHERE owner_user_id IS NOT NULL;
|
||||
|
||||
-- One personal channel per (owner, type). Globals (NULL owner) unaffected.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq
|
||||
ON notification_channels (owner_user_id, channel_type)
|
||||
WHERE owner_user_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN notification_channels.owner_user_id IS
|
||||
'Personal channel owner. NULL = global channel (admin-managed). NOT NULL = personal channel reachable only by the owner and admins.';
|
||||
```
|
||||
|
|
@ -314,13 +342,14 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -q "ADD COLUMN IF NOT EXISTS owner_user_id" migrations/085_personal_notification_channels.sql && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `migrations/085_personal_notification_channels.sql` exists and contains `ALTER TABLE notification_channels`
|
||||
- File contains `ADD COLUMN IF NOT EXISTS owner_user_id TEXT` and `REFERENCES "user"(id) ON DELETE CASCADE`
|
||||
- File contains `CREATE INDEX IF NOT EXISTS idx_notification_channels_owner`
|
||||
- File contains `CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_owner_user_id_channel_type_uniq` with the predicate `WHERE owner_user_id IS NOT NULL` (defense-in-depth for the API-layer UPSERT race window — medium 10 from plan checker)
|
||||
- `migrations/086_notify_event_keys_and_subscriptions.sql` exists
|
||||
- File contains `CREATE TABLE IF NOT EXISTS notify_event_keys` with `key TEXT PRIMARY KEY`
|
||||
- File contains `CREATE TABLE IF NOT EXISTS user_event_subscriptions` with `PRIMARY KEY (user_id, event_key, channel_type)`
|
||||
|
|
@ -332,7 +361,7 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Two new migrations land at the end of the migration sequence (head moves 083 → 086). `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly.
|
||||
Two new migrations land at the end of the migration sequence (head moves 083 → 086). The partial unique index closes the API-layer UPSERT race window. `lib/types/pipeline.ts` types match the new schema. TypeScript compiles cleanly.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -354,8 +383,9 @@ COMMENT ON COLUMN "user".timezone IS '...';
|
|||
| T-09-01-01 | Tampering | `migrations/084_add_user_theme.sql` | mitigate | `theme TEXT NOT NULL DEFAULT 'system'` enforces the column is always populated; downstream API layer (Plan 02) validates against three-string allowlist before write — schema cannot be bypassed by the application layer |
|
||||
| T-09-01-02 | Information Disclosure | `notification_channels.owner_user_id` | mitigate | Cascade delete on user removal ensures personal channels (which contain webhook URLs / ntfy topics — secrets) cannot outlive their owner; queries in Plan 02 filter by `owner_user_id = session.user.id OR owner_user_id IS NULL`. Storage at rest stays plaintext (matches existing `notification_channels.config` for global rows — same precedent) |
|
||||
| T-09-01-03 | Elevation of Privilege | `user_event_subscriptions` PK | mitigate | Composite primary key `(user_id, event_key, channel_type)` makes mass-assignment impossible: no surrogate ID, no nullable user_id. API writes (Plan 02) target `session.user.id` only |
|
||||
| T-09-01-04 | Denial of Service | `notify_event_keys` | accept | Admin-only CRUD (Plan 05); table is small (one row per event key, expected < 50 rows). No rate-limiting needed — admin role is already trusted |
|
||||
| T-09-01-04 | Denial of Service | `notify_event_keys` | accept | Admin-only CRUD (Plan 06); table is small (one row per event key, expected < 50 rows). No rate-limiting needed — admin role is already trusted |
|
||||
| T-09-01-05 | Spoofing | `lib/auth.ts theme additionalField` | mitigate | Better Auth signs the session cookie; `session.user.theme` is read from server-side session lookup (cookieCache 5 min). Client cannot tamper with the value — they can only request a write via authenticated `PUT /api/me/theme` (Plan 02) which validates against allowlist |
|
||||
| T-09-01-06 | Race Condition / Duplicate Insertion | `notification_channels` personal UPSERT | mitigate | Partial unique index `notification_channels_owner_user_id_channel_type_uniq` enforces one-row-per-(owner,type) at the DB layer, closing the small race window in the API-layer WITH-CTE UPSERT (Plan 02). Globals (NULL owner) remain exempt |
|
||||
|
||||
No `high` severity threats. ASVS L1 satisfied: V8.3.4 (sensitive data lifecycle — cascade delete), V4.2.1 (mass-assignment prevention via composite PK), V5.1.3 (input validation deferred to Plan 02 API).
|
||||
|
||||
|
|
@ -368,6 +398,7 @@ No `high` severity threats. ASVS L1 satisfied: V8.3.4 (sensitive data lifecycle
|
|||
- No production code reads from the new tables yet — that lands in Plan 02.
|
||||
- Migration files use `IF NOT EXISTS` everywhere (idempotent re-runs).
|
||||
- Backfill semantics match Phase 7.1 precedent (`UPDATE ... WHERE col IS NULL`).
|
||||
- Partial unique index uses `IF NOT EXISTS` and the matching `WHERE owner_user_id IS NOT NULL` predicate so re-running the migration on an already-migrated database is a no-op.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
|
|
@ -384,5 +415,7 @@ After completion, create `.planning/phases/09-user-profile-preferences-new/09-01
|
|||
- The exact `theme` additionalField definition added to `lib/auth.ts`
|
||||
- The two new exported interfaces in `lib/types/pipeline.ts`
|
||||
- Confirmation that `session.user.theme` is now exposed via Better Auth (verified by checking the additionalFields block)
|
||||
- Confirmation the partial unique index `notification_channels_owner_user_id_channel_type_uniq` exists with the correct WHERE predicate
|
||||
- Any deviations from the plan (expected: none)
|
||||
</output>
|
||||
</output>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ requirements: [THEME-02, CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, SUB-04]
|
|||
must_haves:
|
||||
truths:
|
||||
- "Authenticated GET /api/me/theme returns { theme, source } and PUT /api/me/theme accepts { theme: 'light'|'dark'|'system' } and writes session.user.id only"
|
||||
- "All UPDATE statements against the Better Auth \"user\" table use the snake_case unquoted column name `updated_at` (NOT the quoted camelCase identifier `\"updatedAt\"`) — see read_first for migration 012"
|
||||
- "Authenticated GET /api/me/channels returns the calling user's Teams + ntfy personal channels"
|
||||
- "PUT /api/me/channels/teams accepts { webhook_url } validated as https://*.webhook.office.com OR https://*.logic.azure.com, UPSERTs keyed by (owner_user_id, channel_type='teams'), and issues a best-effort test send returning the test result inline"
|
||||
- "PUT /api/me/channels/ntfy mints a UUID-prefixed topic on first save (or accepts a custom topic when explicitly supplied), UPSERTs keyed by (owner_user_id, channel_type='ntfy'), and issues a best-effort test send"
|
||||
- "PUT /api/me/channels/ntfy mints a UUID-prefixed topic on first save (or accepts a custom topic when explicitly supplied; format ^[A-Za-z0-9_-]{6,64}$ — invalid topics return 400 with a JSON `{ error, message }` body the UI can render inline), UPSERTs keyed by (owner_user_id, channel_type='ntfy'), and issues a best-effort test send"
|
||||
- "DELETE /api/me/channels/{type} removes only the calling user's row of that channel type"
|
||||
- "POST /api/me/channels/{type}/test issues a test send to the calling user's existing channel row"
|
||||
- "GET /api/me/notification-subscriptions returns { event_keys, channels, matrix } where matrix[event_key][channel_type] = enabled boolean (defaults to true when no row exists)"
|
||||
|
|
@ -42,11 +43,11 @@ must_haves:
|
|||
exports: ["GET", "PUT"]
|
||||
- path: "lib/services/personal-channels.ts"
|
||||
provides: "shared validation + test-send helpers; sendChannelTest(channel)"
|
||||
exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY"]
|
||||
exports: ["isValidTeamsWebhookUrl", "mintNtfyTopic", "sendChannelTest", "TEST_MESSAGE_BODY", "isPersonalChannelType", "isValidNtfyTopic", "PERSONAL_CHANNEL_TYPES"]
|
||||
key_links:
|
||||
- from: "PUT /api/me/channels/teams"
|
||||
to: "notification_channels"
|
||||
via: "INSERT ... ON CONFLICT (owner_user_id, channel_type) DO UPDATE — guarded by application-level pre-check, see action"
|
||||
via: "INSERT ... WITH-CTE UPSERT scoped to (owner_user_id, channel_type) — partial unique index in Plan 01 closes the race window"
|
||||
pattern: "owner_user_id"
|
||||
- from: "PUT /api/me/channels/ntfy"
|
||||
to: "lib/services/personal-channels.ts mintNtfyTopic"
|
||||
|
|
@ -63,17 +64,24 @@ Build the per-user API surface for Phase 9. All routes mirror the conventions al
|
|||
established by `/api/me/timezone`: `requireAuth()`, write target is always
|
||||
`session.user.id`, manual snake_case → camelCase transform, no Zod, explicit validation.
|
||||
|
||||
**IMPORTANT — column casing:** the Better Auth `"user"` table from
|
||||
`migrations/012_create_auth_tables.sql` uses snake_case unquoted columns
|
||||
(`updated_at`, `created_at`). The existing `app/api/me/timezone/route.ts` writes
|
||||
`"updatedAt"` (quoted camelCase) which does NOT match the schema — it is a stale
|
||||
precedent. This plan writes `updated_at` (matches the schema and the working
|
||||
precedents in `app/api/settings/profile/route.ts` and `lib/bootstrap.ts`).
|
||||
|
||||
Three route families:
|
||||
1. **`/api/me/theme`** (THEME-02) — GET returns `{ theme, source }`, PUT validates against allowlist
|
||||
2. **`/api/me/channels` family** (CHAN-02..05, CHAN-07) — GET aggregate, PUT/DELETE per type, POST :test
|
||||
3. **`/api/me/notification-subscriptions`** (SUB-04) — GET full matrix, PUT a single row
|
||||
|
||||
Plus `lib/services/personal-channels.ts` to share Teams URL validation, ntfy topic
|
||||
minting, and the test-send helper across the routes (and reuse in Plan 03 / Plan 05).
|
||||
minting, and the test-send helper across the routes (and reuse in Plan 03 / Plan 06).
|
||||
|
||||
Purpose: This plan does NOT touch UI, drawer, theme bridge, notify.ts, or admin
|
||||
surfaces. Those land in Plans 03/04/05. Once these endpoints exist, the mobile profile
|
||||
page (Plan 04) becomes a pure rendering exercise.
|
||||
surfaces. Those land in Plans 03/04/05/06. Once these endpoints exist, the mobile
|
||||
profile page (Plans 04 + 05) becomes a pure rendering exercise.
|
||||
|
||||
Output: 5 new route files + 1 new service helper. No DB migrations (those landed in
|
||||
Plan 01).
|
||||
|
|
@ -89,6 +97,8 @@ Plan 01).
|
|||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@app/api/me/timezone/route.ts
|
||||
@app/api/settings/profile/route.ts
|
||||
@migrations/012_create_auth_tables.sql
|
||||
@app/api/notification-channels/route.ts
|
||||
@app/api/notification-channels/[id]/test/route.ts
|
||||
@lib/services/pipeline-steps/notify.ts
|
||||
|
|
@ -126,7 +136,20 @@ export interface NotifyEventKey { key: string; display_label: string; descriptio
|
|||
export interface UserEventSubscription { user_id: string; event_key: string; channel_type: ChannelType; enabled: boolean; updated_at: Date; }
|
||||
```
|
||||
|
||||
Reference shape for /api/me/timezone (mirror this convention exactly):
|
||||
Better Auth `"user"` schema (migration 012 — verified):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
...
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
Columns are snake_case unquoted. UPDATE statements MUST use `updated_at`, NOT `"updatedAt"`.
|
||||
|
||||
Reference shape for /api/me/timezone (mirror this convention exactly EXCEPT for the column-casing bug — see read_first):
|
||||
- requireAuth() guard at top
|
||||
- GET returns `{ value, source: 'user' | 'default' }`
|
||||
- PUT validates input shape, then validates value against allowlist, then UPDATEs WHERE id = session.user.id
|
||||
|
|
@ -141,7 +164,9 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
<files>app/api/me/theme/route.ts</files>
|
||||
|
||||
<read_first>
|
||||
- app/api/me/timezone/route.ts (mirror exactly)
|
||||
- app/api/me/timezone/route.ts (mirror exactly EXCEPT replace `"updatedAt"` with `updated_at` — the timezone route's quoted camelCase identifier is a stale bug; see migration 012)
|
||||
- app/api/settings/profile/route.ts (working precedent for `updated_at = NOW()` against the `"user"` table)
|
||||
- migrations/012_create_auth_tables.sql (proves `updated_at` is the actual schema column)
|
||||
- lib/auth-utils.ts (requireAuth signature)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-18, D-19 — theme allowlist + default 'system')
|
||||
</read_first>
|
||||
|
|
@ -163,14 +188,14 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
- Parse `body = await request.json()` inside try/catch — on parse failure return 400 `{ error: 'Invalid JSON', message: 'Request body must be JSON' }`
|
||||
- Extract `candidate = body.theme`
|
||||
- If `!isValidTheme(candidate)` return 400 `{ error: 'Invalid theme', message: "theme must be one of: light, dark, system" }`
|
||||
- `UPDATE "user" SET theme = $1, "updatedAt" = NOW() WHERE id = $2 RETURNING theme` with `[candidate, session!.user.id]`
|
||||
- `UPDATE "user" SET theme = $1, updated_at = NOW() WHERE id = $2 RETURNING theme` with `[candidate, session!.user.id]` — note `updated_at` is unquoted snake_case (matches migration 012 schema).
|
||||
- If `rowCount === 0` return 404 `{ error: 'User not found', message: 'No user row matched the session' }`
|
||||
- Return `NextResponse.json({ theme: result.rows[0].theme })`
|
||||
- try/catch with `console.error('PUT /api/me/theme failed:', e)` + 500
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f app/api/me/theme/route.ts && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
|
@ -181,13 +206,15 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
- Contains `export async function PUT(`
|
||||
- Contains the literal string `'light'`, `'dark'`, and `'system'` (the three allowed values)
|
||||
- Contains an SQL string starting with `UPDATE "user" SET theme = $1` and ending in `WHERE id = $2 RETURNING theme`
|
||||
- The UPDATE statement uses the literal substring `updated_at = NOW()` (unquoted snake_case — matches migration 012)
|
||||
- The file does NOT contain the literal `"updatedAt"` (the stale precedent from `timezone/route.ts` is intentionally NOT carried forward)
|
||||
- Contains `session!.user.id` (the write target, never from body)
|
||||
- Does NOT contain a `userId` extraction from `body` or `searchParams`
|
||||
- `npx tsc --noEmit --pretty` reports no errors in this file
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Theme GET/PUT route lives at `/api/me/theme`, gated by `requireAuth()`, writes only session.user.id, validates against the three-string allowlist. Mirrors `/api/me/timezone` shape line-for-line modulo the validation function.
|
||||
Theme GET/PUT route lives at `/api/me/theme`, gated by `requireAuth()`, writes only session.user.id, validates against the three-string allowlist, and uses the correct `updated_at` schema column. Mirrors `/api/me/timezone` shape line-for-line modulo the validation function and the column-casing fix.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -198,7 +225,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
<read_first>
|
||||
- lib/services/pipeline-steps/notify.ts (existing send shapes for teams + ntfy — reuse the same fetch contracts)
|
||||
- app/api/notification-channels/[id]/test/route.ts (existing global-channel test send pattern)
|
||||
- app/api/me/timezone/route.ts (per-user route conventions)
|
||||
- app/api/me/timezone/route.ts (per-user route conventions — but use `updated_at` not `"updatedAt"`)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-02 singular per-type, D-03 owner_user_id, D-04 mint UUID-prefixed ntfy, D-05 Teams URL validation, D-06 best-effort test on save)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Test message body" copy: "Pulse channel verified — you can ignore this message.")
|
||||
- lib/types/pipeline.ts (NotificationChannel, ChannelType — already updated in Plan 01)
|
||||
|
|
@ -233,6 +260,13 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
}
|
||||
}
|
||||
|
||||
/** ntfy topic format guard (CHAN-03 / D-04). Used when a user supplies a
|
||||
* custom topic via "Edit advanced". Default flow mints via mintNtfyTopic. */
|
||||
const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{6,64}$/;
|
||||
export function isValidNtfyTopic(input: unknown): input is string {
|
||||
return typeof input === 'string' && NTFY_TOPIC_RE.test(input);
|
||||
}
|
||||
|
||||
/** Mint a Pulse-namespaced ntfy topic (CHAN-03 / D-04). */
|
||||
export function mintNtfyTopic(): string {
|
||||
// 8-hex prefix is enough collision-resistance for ntfy public tier.
|
||||
|
|
@ -361,20 +395,20 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
|
||||
PUT semantics:
|
||||
- `requireAuth()` guard.
|
||||
- `const { type } = await params; if (!isPersonalChannelType(type)) return 400 'Unsupported channel type'`
|
||||
- `const { type } = await params; if (!isPersonalChannelType(type)) return 400 { error: 'Unsupported channel type', message: ...}`
|
||||
- Parse JSON body
|
||||
- For `type === 'teams'`:
|
||||
- Extract `webhook_url = body.webhook_url`
|
||||
- If `!isValidTeamsWebhookUrl(webhook_url)` return 400 with message `"webhook_url must be https://*.webhook.office.com or https://*.logic.azure.com"`
|
||||
- If `!isValidTeamsWebhookUrl(webhook_url)` return 400 `{ error: 'Invalid webhook URL', message: 'webhook_url must be https://*.webhook.office.com or https://*.logic.azure.com' }`
|
||||
- Build `config = { webhook_url }`
|
||||
- Build `name = "Personal Teams (" + session!.user.email + ")"`
|
||||
- For `type === 'ntfy'`:
|
||||
- `customTopic = typeof body.topic === 'string' ? body.topic : null`
|
||||
- If `customTopic` provided: validate format (must match `/^[A-Za-z0-9_-]{6,64}$/`); reject with 400 otherwise.
|
||||
- If `customTopic` provided: validate via `isValidNtfyTopic(customTopic)`. On failure return 400 `{ error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }` — the UI in Plan 05 renders the `message` inline below the custom-topic Input.
|
||||
- If no customTopic: `topic = mintNtfyTopic()`
|
||||
- `config = { server_url: 'https://ntfy.sh', topic }`
|
||||
- `name = "Personal ntfy (" + session!.user.email + ")"`
|
||||
- **UPSERT** in a single SQL statement, scoped to the calling user (D-02 / CHAN-02 — API-layer enforcement, not partial unique index):
|
||||
- **UPSERT** in a single SQL statement, scoped to the calling user (D-02 / CHAN-02 — the partial unique index added in Plan 01 closes the race window):
|
||||
```sql
|
||||
WITH existing AS (
|
||||
SELECT id FROM notification_channels
|
||||
|
|
@ -396,6 +430,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
SELECT * FROM updated UNION ALL SELECT * FROM inserted;
|
||||
```
|
||||
Bind params: `[session!.user.id, type, name, JSON.stringify(config)]`.
|
||||
If a concurrent request races and the partial unique index `notification_channels_owner_user_id_channel_type_uniq` (Plan 01) fires a `23505 unique_violation`, catch in the surrounding try/catch and return 409 `{ error: 'Conflict', message: 'Channel already exists for this user; retry the save' }`. The UI is debounced so this should not normally occur, but the index is the safety net.
|
||||
- After upsert, fire `await sendChannelTest(row)` (best-effort — do NOT bubble errors).
|
||||
- Return `NextResponse.json({ channel: <camelCase row>, test: <ChannelTestResult> })`.
|
||||
|
||||
|
|
@ -414,16 +449,18 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f lib/services/personal-channels.ts && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `mintNtfyTopic`, `sendChannelTest`, `TEST_MESSAGE_BODY`, `isPersonalChannelType`, `PERSONAL_CHANNEL_TYPES`
|
||||
- `lib/services/personal-channels.ts` exports `isValidTeamsWebhookUrl`, `isValidNtfyTopic`, `mintNtfyTopic`, `sendChannelTest`, `TEST_MESSAGE_BODY`, `isPersonalChannelType`, `PERSONAL_CHANNEL_TYPES`
|
||||
- `TEST_MESSAGE_BODY` value is exactly `'Pulse channel verified — you can ignore this message.'`
|
||||
- `mintNtfyTopic()` returns a string starting with `pulse-` and containing 8 hex chars (no UUID dashes)
|
||||
- `isValidNtfyTopic('foo-bar')` returns `false` (5 chars, below min 6); `isValidNtfyTopic('valid_topic_1')` returns `true`; `isValidNtfyTopic('with spaces')` returns `false`
|
||||
- `isValidTeamsWebhookUrl` returns `false` for `'http://foo.webhook.office.com/x'`, `'https://evil.com'`, `''`; returns `true` for `'https://yourorg.webhook.office.com/abc'` and `'https://prod-12.eastus.logic.azure.com/foo'`
|
||||
- `app/api/me/channels/route.ts` `GET` queries `WHERE owner_user_id = $1` with `session!.user.id`
|
||||
- `app/api/me/channels/[type]/route.ts` `PUT` calls `isValidTeamsWebhookUrl` for the `'teams'` branch and `mintNtfyTopic()` for the `'ntfy'` branch when no custom topic is supplied
|
||||
- `app/api/me/channels/[type]/route.ts` `PUT` ntfy branch with a custom topic calls `isValidNtfyTopic`; on failure returns HTTP 400 with a JSON body containing both `error` and `message` keys (the UI in Plan 05 renders `message` inline)
|
||||
- `app/api/me/channels/[type]/route.ts` `PUT` UPSERT SQL includes the literal substring `WHERE owner_user_id = $1 AND channel_type = $2` (the lookup arm of the WITH-CTE)
|
||||
- `app/api/me/channels/[type]/route.ts` `DELETE` SQL is exactly `DELETE FROM notification_channels WHERE owner_user_id = $1 AND channel_type = $2 RETURNING id`
|
||||
- `app/api/me/channels/[type]/test/route.ts` calls `sendChannelTest(channel)` and returns `{ test: result }`
|
||||
|
|
@ -432,7 +469,7 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
The complete `/api/me/channels` family exists, gated by requireAuth, with API-layer UPSERT semantics enforcing one-channel-per-type per user (D-02). Test sends are best-effort and never block save success. The shared service file is reusable from the admin extension in Plan 05.
|
||||
The complete `/api/me/channels` family exists, gated by requireAuth, with API-layer UPSERT semantics enforcing one-channel-per-type per user (D-02). Test sends are best-effort and never block save success. The shared service file is reusable from the admin extension in Plan 06. Custom ntfy topic 400s carry a renderable `message`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -534,14 +571,15 @@ Reference shape for /api/me/timezone (mirror this convention exactly):
|
|||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-09-02-01 | Spoofing | All `/api/me/*` routes | mitigate | Every route calls `requireAuth()` first; write target is always `session!.user.id` (never from body/query). PUT body extraction explicitly drops any `userId`/`user_id` field |
|
||||
| T-09-02-02 | Tampering | `PUT /api/me/theme` | mitigate | `ALLOWED_THEMES` allowlist (`'light' \| 'dark' \| 'system'`); rejected with HTTP 400 before any DB write |
|
||||
| T-09-02-02 | Tampering | `PUT /api/me/theme` | mitigate | `ALLOWED_THEMES` allowlist (`'light' \| 'dark' \| 'system'`); rejected with HTTP 400 before any DB write. UPDATE SQL uses the actual schema column `updated_at` (not the stale `"updatedAt"` from the timezone-route precedent) |
|
||||
| T-09-02-03 | Tampering | `PUT /api/me/notification-subscriptions` | mitigate | `event_key` length-bounded; `channel_type` passes `isPersonalChannelType` allowlist; `enabled` `typeof === 'boolean'`. Composite PK on user_id+event_key+channel_type prevents duplicate insertion attempts |
|
||||
| T-09-02-04 | Information Disclosure | `GET /api/me/channels` | mitigate | SQL hardcodes `WHERE owner_user_id = session!.user.id` — a user cannot read another user's webhook URL or ntfy topic. Admin path lives in a separate route (Plan 05) |
|
||||
| T-09-02-04 | Information Disclosure | `GET /api/me/channels` | mitigate | SQL hardcodes `WHERE owner_user_id = session!.user.id` — a user cannot read another user's webhook URL or ntfy topic. Admin path lives in a separate route (Plan 06) |
|
||||
| T-09-02-05 | Information Disclosure / Logging | Test-send error responses | mitigate | `sendChannelTest` returns `{ status, error }` capped at 200 chars; no full webhook URL is echoed back. `console.error` logs do NOT include `channel.config` (only the channel id and channel_type) — see Action Step C/D |
|
||||
| T-09-02-06 | SSRF | `PUT /api/me/channels/teams` | mitigate | `isValidTeamsWebhookUrl` enforces `https://` AND hostname matching `*.webhook.office.com` or `*.logic.azure.com`. Cannot point at internal hosts (`localhost`, `127.0.0.1`, `10.x.x.x`) because hostname regex requires the public Teams hosts |
|
||||
| T-09-02-07 | Spoofing / Topic Squatting | `mintNtfyTopic` | mitigate | Server-side `crypto.randomUUID()` slice (8 hex chars = 32 bits — sufficient unguessability for ntfy public tier; collision probability negligible at expected user count). Custom topic override is permitted but format-restricted to `^[A-Za-z0-9_-]{6,64}$` to prevent injection |
|
||||
| T-09-02-07 | Spoofing / Topic Squatting | `mintNtfyTopic` + custom topic override | mitigate | Server-side `crypto.randomUUID()` slice (8 hex chars = 32 bits — sufficient unguessability for ntfy public tier; collision probability negligible at expected user count). Custom topic override goes through `isValidNtfyTopic` (`^[A-Za-z0-9_-]{6,64}$`) — the 400 response carries a renderable `message` so the UI can show inline error |
|
||||
| T-09-02-08 | Repudiation | UPSERT semantics | accept | `notification_channels.updated_at` and `user_event_subscriptions.updated_at` are bumped on every write — sufficient audit for v1. No separate audit log table this phase |
|
||||
| T-09-02-09 | Denial of Service | Test-send on save | accept | Single best-effort fetch per save; bounded by Node.js fetch default timeout. Save persists even if test fails (D-06). User-driven action so no DoS amplification |
|
||||
| T-09-02-10 | Race Condition | Concurrent personal-channel saves | mitigate | API-layer WITH-CTE UPSERT has a small race window; the partial unique index added in Plan 01 (`notification_channels_owner_user_id_channel_type_uniq`) raises `23505` on the loser — caught and returned as 409 |
|
||||
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V4.1.5 (URL allowlist for outbound), V5.1.3 (input validation), V8.1.6 (auth context bound to write target), V12.6.1 (SSRF mitigation via hostname pattern).
|
||||
|
||||
|
|
@ -550,25 +588,30 @@ No `high` severity unmitigated. ASVS L1 satisfied: V4.1.5 (URL allowlist for out
|
|||
<verification>
|
||||
- All five route files exist and export the expected handlers.
|
||||
- All routes call `requireAuth()` and never extract user_id from body/query.
|
||||
- `lib/services/personal-channels.ts` exports the four named symbols.
|
||||
- `lib/services/personal-channels.ts` exports the seven named symbols.
|
||||
- `npx tsc --noEmit --pretty` exits 0.
|
||||
- Manual smoke (post-deploy): `curl -X PUT /api/me/theme -d '{"theme":"hacker"}'` returns 400.
|
||||
- Manual smoke: `curl -X PUT /api/me/channels/teams -d '{"webhook_url":"http://evil.com"}'` returns 400 (protocol + host mismatch).
|
||||
- Manual smoke: `curl -X PUT /api/me/channels/ntfy -d '{}'` returns 200 with a `pulse-XXXXXXXX`-shaped topic.
|
||||
- Manual smoke: `curl -X PUT /api/me/channels/ntfy -d '{"topic":"bad space"}'` returns 400 with a body containing `message: "topic must match ^[A-Za-z0-9_-]{6,64}$"`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Five route files written with the correct handler exports.
|
||||
2. `lib/services/personal-channels.ts` provides Teams URL validation, ntfy minting, and the test-send helper.
|
||||
3. The Phase 9 frontend (Plan 04) and the notify.ts route_to_user implementation (Plan 03) can both consume `notification_channels` rows shaped by Plan 01 + this plan without further DB or API changes.
|
||||
2. `lib/services/personal-channels.ts` provides Teams URL validation, ntfy minting + format guard, and the test-send helper.
|
||||
3. The Phase 9 frontend (Plans 04 + 05) and the notify.ts route_to_user implementation (Plan 03) can both consume `notification_channels` rows shaped by Plan 01 + this plan without further DB or API changes.
|
||||
4. `npx tsc --noEmit --pretty` exits 0.
|
||||
5. The theme route writes `updated_at` (matching migration 012), not the stale `"updatedAt"` from the timezone-route precedent.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md` documenting:
|
||||
- Each route file: path, exported methods, validation rules
|
||||
- The five symbols exported from `lib/services/personal-channels.ts`
|
||||
- The seven symbols exported from `lib/services/personal-channels.ts`
|
||||
- The matrix shape returned by `GET /api/me/notification-subscriptions`
|
||||
- Confirmation that no route accepts user_id from request input
|
||||
- Confirmation that the theme route uses `updated_at` (NOT `"updatedAt"`)
|
||||
- The 400-response shape for invalid custom ntfy topics
|
||||
- Any deviations from the plan
|
||||
</output>
|
||||
</output>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ files_modified:
|
|||
- lib/types/pipeline.ts
|
||||
- lib/services/pipeline-steps/notify-resolvers.ts
|
||||
- lib/services/pipeline-steps/notify.ts
|
||||
- lib/services/pipeline-steps/notify.test.ts
|
||||
autonomous: true
|
||||
requirements: [ROUTE-01, ROUTE-02, ROUTE-03, ROUTE-04, ROUTE-05, ROUTE-06]
|
||||
|
||||
|
|
@ -15,10 +16,11 @@ must_haves:
|
|||
truths:
|
||||
- "When a notify step config has no route_to_user block, behavior is byte-identical to today (backward compatible)"
|
||||
- "When route_to_user is present, notify.ts resolves a Pulse user from PipelineContext, checks the user_event_subscriptions matrix, and dispatches via the user's personal channel before falling back"
|
||||
- "When the user has the (event_key, channel_type) toggle DISABLED, notify.ts records skipped_reason='user_muted' and does NOT fall back to the global channel_id"
|
||||
- "When the user has the (event_key, channel_type) toggle DISABLED, notify.ts records skipped_reason='user_muted' and does NOT fall back to the global channel_id (verified by an actual unit test, not just grep)"
|
||||
- "When the user route can't deliver (no personal channel of the requested type, or HTTP send returns non-2xx, or user not found), notify.ts falls back to the step's channel_id and records output.user_route_fallback = { reason, user_id?, channel_type, error? }"
|
||||
- "When route_to_user.channel_type is omitted, notify.ts attempts ntfy first, then teams, then global fallback (D-10)"
|
||||
- "Three resolvers ship in v1: autotask_resource_email, direct_email, pulse_user_id — registered in a Map so adding a resolver is a one-file change"
|
||||
- "Field-path resolution is single-level (context[source][field]); dotted paths like 'company.id' are NOT supported in v1 — admins must surface needed values at top-level in the pipeline context"
|
||||
artifacts:
|
||||
- path: "lib/types/pipeline.ts"
|
||||
provides: "RouteToUser, NotifyResolver, ResolvedRecipient, UserRouteFallback types"
|
||||
|
|
@ -29,6 +31,9 @@ must_haves:
|
|||
- path: "lib/services/pipeline-steps/notify.ts"
|
||||
provides: "Updated executeNotify with route_to_user branch + fallback semantics"
|
||||
exports: []
|
||||
- path: "lib/services/pipeline-steps/notify.test.ts"
|
||||
provides: "Vitest unit test asserting muted user does NOT fall back to global channel"
|
||||
exports: []
|
||||
key_links:
|
||||
- from: "lib/services/pipeline-steps/notify.ts"
|
||||
to: "user_event_subscriptions"
|
||||
|
|
@ -42,6 +47,10 @@ must_haves:
|
|||
to: "lib/services/pipeline-steps/notify-resolvers.ts"
|
||||
via: "import { resolveRecipient }"
|
||||
pattern: "resolveRecipient"
|
||||
- from: "lib/services/pipeline-steps/notify.test.ts"
|
||||
to: "lib/services/pipeline-steps/notify.ts"
|
||||
via: "asserts mute path returns success without dispatchToGlobalChannel"
|
||||
pattern: "muted user must not fall back"
|
||||
---
|
||||
|
||||
<objective>
|
||||
|
|
@ -54,14 +63,27 @@ existing pipeline byte-compatible. This plan:
|
|||
3. Rewrites `executeNotify` in `lib/services/pipeline-steps/notify.ts` to short-circuit
|
||||
into a per-user branch when `route_to_user` is present, with the exact decision
|
||||
tree from D-08 / D-10 / D-11 / D-12 (ROUTE-03..06).
|
||||
4. Adds a vitest unit test at `lib/services/pipeline-steps/notify.test.ts` that
|
||||
asserts muting actually mutes — i.e. when `user_event_subscriptions.enabled = false`
|
||||
for the resolved user/event/channel, the step returns `success: true` with
|
||||
`notified: false`, does NOT call `dispatchToGlobalChannel`, and does NOT include
|
||||
`user_route_fallback` in the output. Grep is non-discriminating because both names
|
||||
appear elsewhere in the file by design — only a behavioral test can prove this.
|
||||
|
||||
Out of scope:
|
||||
- Admin filter UI for `user_route_fallback` events (Plan 05, ROUTE-07)
|
||||
- Admin filter UI for `user_route_fallback` events (Plan 06, ROUTE-07)
|
||||
- Profile UI matrix that drives subscriptions (Plan 04, SUB-03)
|
||||
- Personal channel CRUD endpoints (Plan 02, CHAN-*)
|
||||
|
||||
Output: One new file (`notify-resolvers.ts`), one rewritten file (`notify.ts`), and
|
||||
type additions in `lib/types/pipeline.ts`.
|
||||
**Field-path resolution scope (v1, deferred-idea):** The resolver receives
|
||||
`context[route.source]?.[route.field]` — a single-level lookup. Dotted paths
|
||||
(e.g. `"company.id"`) are NOT walked in v1. Admins who need a nested value must
|
||||
surface it at the top of the pipeline context (e.g., via a transform step before
|
||||
the notify step). This is documented as a known limitation and a candidate
|
||||
deferred-idea for a later phase.
|
||||
|
||||
Output: One new file (`notify-resolvers.ts`), one rewritten file (`notify.ts`),
|
||||
type additions in `lib/types/pipeline.ts`, and one new test file (`notify.test.ts`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -76,6 +98,7 @@ type additions in `lib/types/pipeline.ts`.
|
|||
@lib/services/pipeline-steps/notify.ts
|
||||
@lib/types/pipeline.ts
|
||||
@lib/services/pipeline-engine.ts
|
||||
@vitest.config.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- The current notify.ts contract — preserve everything not touched here. -->
|
||||
|
|
@ -122,6 +145,12 @@ Better Auth user lookup (case-insensitive email match — emails come from Autot
|
|||
```sql
|
||||
SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
||||
```
|
||||
|
||||
Vitest config (vitest.config.ts):
|
||||
```typescript
|
||||
test: { environment: 'node', include: ['lib/**/*.test.ts'], globals: false }
|
||||
```
|
||||
So a test at `lib/services/pipeline-steps/notify.test.ts` is auto-included.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
|
|
@ -150,11 +179,15 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
|
||||
/** Optional block on a `notify` step's config. When present, the step attempts
|
||||
* delivery via the resolved Pulse user's personal channel before falling back
|
||||
* to the step's `channel_id`. */
|
||||
* to the step's `channel_id`.
|
||||
*
|
||||
* v1 limitation: `field` is a single-level key; dotted paths like 'company.id'
|
||||
* are NOT walked. Admins must surface needed values at top level of the source
|
||||
* object (e.g., via an upstream transform step). */
|
||||
export interface RouteToUser {
|
||||
/** PipelineContext key whose value holds the entity (e.g. 'ticket'). */
|
||||
source: string;
|
||||
/** Field name within context[source] (e.g. 'assignedResourceID'). */
|
||||
/** Single-level field name within context[source] (e.g. 'assignedResourceID'). */
|
||||
field: string;
|
||||
/** Resolver name. Looks up the user from the field value. */
|
||||
resolve: ResolverName;
|
||||
|
|
@ -217,6 +250,7 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
- `lib/types/pipeline.ts` contains `export type ResolvedRecipient`
|
||||
- `lib/types/pipeline.ts` contains `export interface UserRouteFallback {`
|
||||
- `lib/types/pipeline.ts` contains the literal type union `'no_channel' | 'send_failed' | 'user_not_found' | 'no_field_value' | 'resolver_unknown'` (each value present)
|
||||
- `lib/types/pipeline.ts` `RouteToUser.field` JSDoc mentions the single-level lookup limitation
|
||||
- `lib/services/pipeline-steps/notify-resolvers.ts` exists
|
||||
- File contains `export const RESOLVERS`
|
||||
- File contains a `'direct_email'` Map key, a `'pulse_user_id'` Map key, and an `'autotask_resource_email'` Map key
|
||||
|
|
@ -284,8 +318,10 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
|
||||
5. Implement `dispatchUserRoute(args: { step, context, message, channelId, route })`:
|
||||
|
||||
**Step 5a — Read field value from context:**
|
||||
**Step 5a — Read field value from context (single-level lookup, v1 limitation):**
|
||||
```typescript
|
||||
// v1: single-level only. Dotted paths like 'company.id' are NOT walked.
|
||||
// Admins must surface nested values at top level via an upstream transform.
|
||||
const sourceObj = context[route.source];
|
||||
const fieldValue = sourceObj?.[route.field];
|
||||
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
|
||||
|
|
@ -431,14 +467,15 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
|
||||
6. Keep `registerStepExecutor('notify', executeNotify);` at the bottom (unchanged).
|
||||
|
||||
**Critical correctness notes:**
|
||||
- The mute path (`enabled === false`) returns `success: true` but `notified: false` and DOES NOT call any send function and DOES NOT call `fallbackToGlobal`. This satisfies ROUTE-05 / D-12 ("muting must actually mute").
|
||||
**Critical correctness notes (verified by Task 3 unit test):**
|
||||
- The mute path (`enabled === false`) returns `success: true` but `notified: false` and DOES NOT call any send function and DOES NOT call `fallbackToGlobal`. This satisfies ROUTE-05 / D-12 ("muting must actually mute") — Task 3 asserts this behaviorally.
|
||||
- The no-personal-channel and send-failure paths DO call `fallbackToGlobal` (ROUTE-04 / D-11).
|
||||
- The output object is what `pipeline-engine.ts updateStepLog` JSON-serializes into `pipeline_execution_steps.output_data` — admins can later filter on `output_data->>'user_route_fallback' IS NOT NULL` for ROUTE-07.
|
||||
- `dispatchUserRoute` and `dispatchToGlobalChannel` and `fallbackToGlobal` MUST be exported (or hoisted in a way Task 3 can spy on). The simplest pattern: keep them as module-scope `async function` declarations and export an `_INTERNALS` object: `export const _INTERNALS = { dispatchToGlobalChannel, dispatchUserRoute, fallbackToGlobal };` — this matches the codebase convention noted in `analyzer/link-discovery.ts` where `_INTERNALS` exposes test seams. Task 3's test imports `_INTERNALS` (or uses `vi.mock` to intercept the postgresClient calls) to drive the assertion.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -q "route_to_user" lib/services/pipeline-steps/notify.ts && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
|
@ -452,14 +489,163 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
- File contains the literal string `user_route_fallback` (fallback output key)
|
||||
- File contains the literal strings `'no_channel'`, `'send_failed'`, `'user_not_found'`, `'no_field_value'`, `'resolver_unknown'` (all five fallback reasons)
|
||||
- File contains the literal `['ntfy', 'teams']` (default channel-type order — ROUTE-06)
|
||||
- In the mute branch (`enabled === false`), the function returns `success: true` and the code path does NOT call `dispatchToGlobalChannel` or `fallbackToGlobal` (verified by grep showing the mute return has no fallback call between `if (!enabled)` and the next `}`)
|
||||
- File exports a test seam (either `_INTERNALS` object or named exports) so Task 3's vitest test can drive `dispatchUserRoute` directly
|
||||
- When `step.config.route_to_user` is undefined/null, `executeNotify` calls only `dispatchToGlobalChannel` — no resolver, no subscription query (backward-compat path)
|
||||
- `npx tsc --noEmit --pretty` reports no errors in this file
|
||||
- `npm test -- --run lib/services/pipeline-steps` does not crash on import (resolver registry side-effect-safe)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
`notify.ts` honors the `route_to_user` block per the D-08..D-12 decision tree. Pipelines without `route_to_user` continue to work exactly as today. Fallback events are recorded in `output.user_route_fallback` for the admin filter in Plan 05.
|
||||
`notify.ts` honors the `route_to_user` block per the D-08..D-12 decision tree. Pipelines without `route_to_user` continue to work exactly as today. Fallback events are recorded in `output.user_route_fallback` for the admin filter in Plan 06. Test seam exposed for Task 3.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: Vitest unit test — muted user must NOT fall back to global channel</name>
|
||||
<files>lib/services/pipeline-steps/notify.test.ts</files>
|
||||
|
||||
<read_first>
|
||||
- lib/services/pipeline-steps/notify.ts (post-Task-2 — the test seam exposed via `_INTERNALS` or direct named export)
|
||||
- vitest.config.ts (confirms `lib/**/*.test.ts` is auto-included)
|
||||
- lib/services/analyzer/link-discovery.test.ts (vitest mocking precedent in this codebase — same `vi.mock` + spy pattern; see how it intercepts postgresClient)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-12 muting must actually mute, no fallback)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Create `lib/services/pipeline-steps/notify.test.ts`. The test must demonstrate
|
||||
behaviorally that the mute branch returns `success: true` with `notified: false`,
|
||||
does NOT contact the global channel, and does NOT include `user_route_fallback`
|
||||
in the output.
|
||||
|
||||
Use vitest's `vi.mock('../postgres-client', () => ({...}))` to stub
|
||||
`postgresClient.query`. The mock returns:
|
||||
- For the `user_event_subscriptions` SELECT (matched by the SQL substring
|
||||
`user_event_subscriptions`): `{ rows: [{ enabled: false }], rowCount: 1 }`.
|
||||
- For any `notification_channels` SELECT (substring `notification_channels`):
|
||||
the test asserts THIS query is NEVER called (track call count).
|
||||
- For any `"user"` SELECT (only used by the email-resolver path): not needed
|
||||
in this fixture because the test uses `pulse_user_id` resolver and a
|
||||
valid user_id field value.
|
||||
|
||||
Stub `notify-resolvers.ts` resolveRecipient via `vi.mock` to return
|
||||
`{ recipient: { user_id: 'test-user-id' }, resolverFound: true }` so we don't
|
||||
need a real DB. Alternative: keep the real resolver and stub only the
|
||||
`pulseUserId` SQL — either is acceptable. Pick the simpler one (mocking
|
||||
`resolveRecipient` directly).
|
||||
|
||||
Test layout (vitest):
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient BEFORE importing the module under test.
|
||||
const queryMock = vi.fn();
|
||||
vi.mock('../postgres-client', () => ({
|
||||
postgresClient: {
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the resolver registry to short-circuit user resolution.
|
||||
vi.mock('./notify-resolvers', () => ({
|
||||
resolveRecipient: vi.fn(async () => ({
|
||||
recipient: { user_id: 'test-user-id' },
|
||||
resolverFound: true,
|
||||
})),
|
||||
RESOLVERS: new Map(),
|
||||
registerResolver: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the global fetch so a stray send call would be observable AND
|
||||
// would not actually hit the network.
|
||||
const fetchMock = vi.fn(async () => new Response('ok', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
import { _INTERNALS } from './notify';
|
||||
|
||||
describe('notify.ts route_to_user', () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
it('muted user must not fall back to global channel', async () => {
|
||||
// Arrange: user has the (event_key, channel_type) toggle DISABLED.
|
||||
queryMock.mockImplementation((sql: string) => {
|
||||
if (sql.includes('user_event_subscriptions')) {
|
||||
return Promise.resolve({ rows: [{ enabled: false }], rowCount: 1 });
|
||||
}
|
||||
// Any other SQL is a failure — the mute branch must short-circuit.
|
||||
throw new Error(`Unexpected SQL after mute: ${sql.slice(0, 80)}`);
|
||||
});
|
||||
|
||||
const step = {
|
||||
id: 1,
|
||||
step_type: 'notify' as const,
|
||||
name: 'test',
|
||||
config: {
|
||||
channel_id: 99,
|
||||
message: 'hi',
|
||||
route_to_user: {
|
||||
source: 'ticket',
|
||||
field: 'assignedResourceID',
|
||||
resolve: 'pulse_user_id',
|
||||
event_key: 'ticket_assigned_to_me',
|
||||
channel_type: 'ntfy',
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { ticket: { assignedResourceID: 'test-user-id' } };
|
||||
|
||||
// Act
|
||||
const result = await _INTERNALS.dispatchUserRoute({
|
||||
step: step as any,
|
||||
context,
|
||||
message: 'hi',
|
||||
channelId: 99,
|
||||
route: step.config.route_to_user as any,
|
||||
});
|
||||
|
||||
// Assert: success + notified=false + skipped_reason=user_muted, NO fallback
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output?.notified).toBe(false);
|
||||
expect(result.output?.skipped_reason).toBe('user_muted');
|
||||
expect(result.output?.user_route_fallback).toBeUndefined();
|
||||
|
||||
// The notification_channels SELECT must NEVER have been issued
|
||||
const calls = queryMock.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((sql) => sql.includes('notification_channels'))).toBe(false);
|
||||
|
||||
// No send call must have occurred
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
If `_INTERNALS` is not the chosen test seam in Task 2, adapt the import
|
||||
accordingly (e.g., named export of `dispatchUserRoute`). The test name
|
||||
`'muted user must not fall back'` (substring) MUST appear verbatim — the
|
||||
plan checker pinned this as the discriminating signal.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f lib/services/pipeline-steps/notify.test.ts && 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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `lib/services/pipeline-steps/notify.test.ts` exists
|
||||
- File imports `vi`, `describe`, `it`, `expect` from `vitest`
|
||||
- File contains a `vi.mock('../postgres-client', ...)` call
|
||||
- File contains a test name (it/test description) that includes the substring `muted user must not fall back`
|
||||
- The test asserts `result.success === true` AND `result.output.skipped_reason === 'user_muted'` AND `result.output.user_route_fallback === undefined`
|
||||
- The test asserts (via spy/mock-call inspection) that NO query containing the substring `notification_channels` was issued during the mute path
|
||||
- The test asserts (via fetch mock) that NO outbound HTTP request was made
|
||||
- `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits with code 0 (test passes)
|
||||
- `npx tsc --noEmit --pretty` reports no errors in this file
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Behavioral guarantee for D-12 / ROUTE-05 is now enforced by an automated test, not just grep. Future regressions that re-introduce a `fallbackToGlobal` call in the mute branch will fail this test on every CI run / `npm test`.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -479,31 +665,35 @@ SELECT id FROM "user" WHERE LOWER(email) = LOWER($1)
|
|||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-09-03-01 | Information Disclosure | `dispatchUserRoute` per-user resolver | mitigate | Resolver only returns the SINGLE recipient resolved from the field value. notify.ts never broadcasts to multiple users in a single step. The `LIMIT 1` on the personal channel SELECT enforces single-recipient semantics |
|
||||
| T-09-03-02 | Tampering / Bypass | mute → fallback path | mitigate | The `if (!enabled)` branch in Task 2 Step 5e returns directly with `success: true` and DOES NOT call `fallbackToGlobal`. Acceptance criteria verifies via grep that no fallback call exists in the mute branch — preserves user opt-out (D-12 / ROUTE-05) |
|
||||
| T-09-03-02 | Tampering / Bypass | mute → fallback path | mitigate | The `if (!enabled)` branch in Task 2 Step 5e returns directly with `success: true` and DOES NOT call `fallbackToGlobal`. Task 3's vitest unit test (`muted user must not fall back`) is the behavioral guarantee — grep is non-discriminating because both names appear elsewhere in the file by design. CI will fail any regression |
|
||||
| T-09-03-03 | Spoofing / Identity | `pulse_user_id` resolver | mitigate | Validates the field value as a non-empty string then performs an existence check (`SELECT id FROM "user" WHERE id = $1`). A malicious context payload claiming to be another user fails the existence check OR routes only to that user's own channel — no cross-user delivery possible |
|
||||
| T-09-03-04 | Information Disclosure | resolver registry side-effects | mitigate | `notify-resolvers.ts` imports only `postgresClient` and types. No worker side effects (sync-scheduler, analyzer, RMM). Verified by grep that no module-scope `setInterval`/`start()` exists |
|
||||
| T-09-03-05 | Denial of Service | resolver throws inside notify.ts | mitigate | `resolveRecipient` wraps the resolver call in try/catch and returns `{ recipient: null, resolverFound: true }` on throw. notify.ts treats this as `user_not_found` and falls back to global — no crash, no stuck pipeline |
|
||||
| T-09-03-06 | Repudiation | fallback event recording | mitigate | Every fallback path writes a `user_route_fallback: { reason, user_id?, channel_type, error? }` object onto `output`, persisted by `pipeline-engine.ts updateStepLog` to `pipeline_execution_steps.output_data`. Plan 05 surfaces this in `/admin/workflow/executions` for review |
|
||||
| T-09-03-06 | Repudiation | fallback event recording | mitigate | Every fallback path writes a `user_route_fallback: { reason, user_id?, channel_type, error? }` object onto `output`, persisted by `pipeline-engine.ts updateStepLog` to `pipeline_execution_steps.output_data`. Plan 06 surfaces this in `/admin/workflow/executions` for review |
|
||||
| T-09-03-07 | Information Disclosure | error string in `user_route_fallback.error` | mitigate | `dispatchResult.error` from existing `sendTeams`/`sendNtfy` is already truncated to 200 chars (verified by reading current notify.ts). No webhook URL is echoed |
|
||||
| T-09-03-08 | Information Disclosure | dotted-path field traversal | accept | v1 supports only single-level `context[source][field]` lookup. Admins must surface nested values via an upstream transform — documented as a deferred-idea limitation. No risk of accidental traversal into adjacent fields because the lookup is exact-match |
|
||||
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V8.1.6 (auth context bound to recipient), V12.6.1 (no SSRF amplification — personal channels validated at write-time in Plan 02), V13.1.1 (auditing of routing decisions via output_data).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty` exits 0 across the three modified files.
|
||||
- `npx tsc --noEmit --pretty` exits 0 across the four modified files.
|
||||
- `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits 0 — the mute test passes.
|
||||
- A pipeline with no `route_to_user` produces the same SQL trace and same StepExecutorResult shape it produces today (backward compat).
|
||||
- A pipeline with `route_to_user.event_key` set, where the user has the toggle off, produces output `{ skipped_reason: 'user_muted', ... }` and never queries `notification_channels` for the personal channel.
|
||||
- A pipeline with `route_to_user` set but no personal channel of the requested type produces output containing `user_route_fallback.reason === 'no_channel'`.
|
||||
- A pipeline with `route_to_user.channel_type` omitted attempts ntfy first, then teams, before falling back.
|
||||
- Dotted field paths (e.g., `field: "company.id"`) are NOT walked — admins must surface needed values at top level.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Three files modified: `lib/types/pipeline.ts`, `lib/services/pipeline-steps/notify-resolvers.ts` (new), `lib/services/pipeline-steps/notify.ts` (rewritten).
|
||||
1. Four files modified: `lib/types/pipeline.ts`, `lib/services/pipeline-steps/notify-resolvers.ts` (new), `lib/services/pipeline-steps/notify.ts` (rewritten), `lib/services/pipeline-steps/notify.test.ts` (new test).
|
||||
2. `npx tsc --noEmit --pretty` exits 0.
|
||||
3. All existing tests under `lib/services/` continue to pass: `npm test`.
|
||||
4. Backward compat: a step with `step.config.channel_id = 7` and no `route_to_user` produces identical behavior to pre-Phase-9.
|
||||
5. Plans 04 (UI) and 05 (admin filter) can rely on the `output.user_route_fallback` shape stabilized here.
|
||||
3. `npx vitest run lib/services/pipeline-steps/notify.test.ts` exits 0.
|
||||
4. All existing tests under `lib/services/` continue to pass: `npm test`.
|
||||
5. Backward compat: a step with `step.config.channel_id = 7` and no `route_to_user` produces identical behavior to pre-Phase-9.
|
||||
6. Plans 04 (UI), 05 (channels UI), and 06 (admin filter) can rely on the `output.user_route_fallback` shape stabilized here.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
|
@ -513,4 +703,7 @@ After completion, create `.planning/phases/09-user-profile-preferences-new/09-03
|
|||
- The decision tree implemented in `executeNotify`: backward-compat path → user-route path → mute → personal-send → fallback
|
||||
- Confirmation: backward compat verified (a step without `route_to_user` runs the legacy path verbatim)
|
||||
- The `output` shapes: `user_route` (success), `skipped_reason: 'user_muted'` (mute), `user_route_fallback` (fallback)
|
||||
- The new vitest test file and the discriminating assertion (`muted user must not fall back`)
|
||||
- The v1 single-level field-path limitation (deferred-idea: dotted/nested path traversal)
|
||||
</output>
|
||||
</output>
|
||||
|
|
|
|||
|
|
@ -9,63 +9,44 @@ files_modified:
|
|||
- components/mobile/profile/ProfileTimezoneSection.tsx
|
||||
- components/mobile/profile/ProfileThemeSection.tsx
|
||||
- components/mobile/profile/ProfileNotificationMatrix.tsx
|
||||
- components/mobile/profile/ProfileChannelsSection.tsx
|
||||
- components/mobile/profile/ProfileSectionSkeleton.tsx
|
||||
- components/mobile/profile/ThemeSessionBridge.tsx
|
||||
- components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx
|
||||
- components/mobile/MoreDrawer.tsx
|
||||
- components/theme-toggle.tsx
|
||||
- app/layout.tsx
|
||||
- package.json
|
||||
autonomous: true
|
||||
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, TZ-CHOOSER-01, TZ-CHOOSER-02, THEME-03, THEME-04, SUB-03]
|
||||
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, TZ-CHOOSER-01, TZ-CHOOSER-02, THEME-03, SUB-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Tapping 'Profile & preferences' in the More drawer Account section navigates to /mobile/profile"
|
||||
- "/mobile/profile is gated by requireAuth() server-side and renders four shadcn Cards in order: Timezone, Theme, Notifications, Channels"
|
||||
- "/mobile/profile is gated server-side: when requireAuth() returns an error (NextResponse) OR session is null, the page calls redirect('/auth/sign-in') — it does NOT return the NextResponse from a Page route"
|
||||
- "/mobile/profile renders four shadcn Cards in order: Timezone, Theme, Notifications, Channels (the Channels card is a placeholder rendered by ProfileChannelsSectionPlaceholder.tsx in this plan and replaced with the real ProfileChannelsSection in Plan 05)"
|
||||
- "Timezone Card uses a shadcn Combobox (Command + Popover) populated from Intl.supportedValuesOf('timeZone') plus EXTRA_ALLOWED_TIMEZONES; selecting a value debounces 400ms then PUTs /api/me/timezone and shows a sonner success/error toast"
|
||||
- "Timezone Card shows a read-only 'Your current time: {time} in {zone}' line below the picker, formatted via useUserTimezone() and rendered with the .num utility class"
|
||||
- "Timezone Card shows a read-only 'Your current time: {time} in {zone}' line below the picker, formatted via Intl.DateTimeFormat with the selected zone"
|
||||
- "Theme Card renders three radio rows (Light/Dark/System) with Sun/Moon/Monitor icons; selecting a row immediately calls setTheme() (next-themes) and PUTs /api/me/theme"
|
||||
- "Notifications Card renders a per-event-key × per-channel-type matrix from GET /api/me/notification-subscriptions, defaulting missing cells to enabled=true; toggle saves are debounced 400ms via PUT and revert on error"
|
||||
- "Channels Card has a Teams sub-section (URL Input + Save/Clear buttons) and an ntfy sub-section that mints on first save then displays subscribe link + QR code; both show test-send result inline"
|
||||
- "On session load and after sign-in, ThemeSessionBridge compares session.user.theme to next-themes useTheme() and calls setTheme(session.user.theme) when they differ"
|
||||
- "ThemeToggle (desktop) writes through to PUT /api/me/theme on every setTheme call"
|
||||
- "MoreDrawer Account section's user-identity row becomes a Link to /mobile/profile, with a 'Profile & preferences' label, sitting above the Sign-out destructive action"
|
||||
artifacts:
|
||||
- path: "app/mobile/profile/page.tsx"
|
||||
provides: "/mobile/profile page (server-component shell + client orchestration)"
|
||||
contains: "requireAuth"
|
||||
contains: "redirect('/auth/sign-in')"
|
||||
- path: "components/mobile/profile/ProfileTimezoneSection.tsx"
|
||||
provides: "Timezone Card (Combobox + current time)"
|
||||
- path: "components/mobile/profile/ProfileThemeSection.tsx"
|
||||
provides: "Theme Card (3-option radio rows + write-through)"
|
||||
- path: "components/mobile/profile/ProfileNotificationMatrix.tsx"
|
||||
provides: "Notifications matrix Card with debounced PUT per cell"
|
||||
- path: "components/mobile/profile/ProfileChannelsSection.tsx"
|
||||
provides: "Channels Card (Teams + ntfy sub-sections, QR, test result inline)"
|
||||
- path: "components/mobile/profile/ProfileSectionSkeleton.tsx"
|
||||
provides: "Generic 3-row pulsing Skeleton for any section"
|
||||
- path: "components/mobile/profile/ThemeSessionBridge.tsx"
|
||||
provides: "Client effect comparing session.user.theme to next-themes; calls setTheme on mismatch"
|
||||
- path: "components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx"
|
||||
provides: "Stub Channels card so the page compiles in Wave 3 before Plan 05 ships the real component"
|
||||
- path: "components/mobile/MoreDrawer.tsx"
|
||||
provides: "Account section with Profile & preferences link"
|
||||
contains: "Profile & preferences"
|
||||
- path: "components/theme-toggle.tsx"
|
||||
provides: "Session-aware ThemeToggle that writes through to /api/me/theme"
|
||||
contains: "/api/me/theme"
|
||||
key_links:
|
||||
- from: "app/mobile/profile/page.tsx"
|
||||
to: "/api/me/timezone, /api/me/theme, /api/me/channels, /api/me/notification-subscriptions"
|
||||
to: "/api/me/timezone, /api/me/theme, /api/me/notification-subscriptions"
|
||||
via: "client fetch from each section"
|
||||
pattern: "fetch.*api/me"
|
||||
- from: "ThemeSessionBridge"
|
||||
to: "next-themes useTheme"
|
||||
via: "setTheme(session.user.theme) on mismatch"
|
||||
pattern: "setTheme"
|
||||
- from: "ThemeToggle"
|
||||
to: "/api/me/theme PUT"
|
||||
via: "fetch on setTheme callback"
|
||||
pattern: "method:.*PUT"
|
||||
- from: "MoreDrawer.tsx Account section"
|
||||
to: "/mobile/profile"
|
||||
via: "Link href in identity row + new 'Profile & preferences' row"
|
||||
|
|
@ -73,13 +54,19 @@ must_haves:
|
|||
---
|
||||
|
||||
<objective>
|
||||
Build the entire Phase 9 mobile profile UI surface plus the theme-bridge plumbing that
|
||||
makes the server's `theme` value canonical app-wide. This plan has 5 tasks because the
|
||||
work spans multiple loosely-coupled subsystems (one task per section, one task for
|
||||
theme bridging, one task for drawer wiring). Despite the count, each task is small
|
||||
(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: `<main className="px-4 pb-safe">` (inherits `max-w-lg mx-auto` from layout)
|
||||
- All interactive rows ≥ 44px height (`min-h-[44px]` or `py-3`)
|
||||
- Color tokens only — never hex / raw palette
|
||||
- Numeric values use `.num` utility (IBM Plex Mono)
|
||||
- Inline errors `text-xs text-destructive`; toasts via sonner
|
||||
|
||||
Adding `qrcode.react` (~10KB gzip) is the only new dependency. UI-SPEC notes this is
|
||||
acceptable; reviewed for "no network-call side effects at render time" (it's pure
|
||||
client-side SVG generation).
|
||||
CRITICAL — Page-level redirect (HIGH 5 from plan checker):
|
||||
`requireAuth()` from `lib/auth-utils.ts` returns `{ session, error: NextResponse | null }`.
|
||||
The `error` value is a NextResponse intended for API ROUTES, NOT page routes.
|
||||
A Next.js Page server component cannot return a NextResponse — it must call
|
||||
`redirect()` from `next/navigation`. So the executor MUST NOT return `error`
|
||||
from the Page; instead, when `error` is truthy or `session` is null, call
|
||||
`redirect('/auth/sign-in')`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -107,16 +97,14 @@ client-side SVG generation).
|
|||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@components/mobile/MoreDrawer.tsx
|
||||
@components/theme-toggle.tsx
|
||||
@components/theme-provider.tsx
|
||||
@app/layout.tsx
|
||||
@app/mobile/layout.tsx
|
||||
@app/api/me/timezone/route.ts
|
||||
@lib/hooks/use-user-timezone.ts
|
||||
@components/ui/card.tsx
|
||||
@components/ui/command.tsx
|
||||
@components/ui/popover.tsx
|
||||
@components/ui/switch.tsx
|
||||
@components/mobile/EngagementProfileMetricGrid.tsx
|
||||
@lib/auth-utils.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Endpoint shapes (from Plan 02) the executor consumes here. -->
|
||||
|
|
@ -127,12 +115,6 @@ PUT /api/me/timezone → body { timezone: string } → { timezone: string }
|
|||
GET /api/me/theme → { theme: 'light' | 'dark' | 'system', source: 'user' | 'default' }
|
||||
PUT /api/me/theme → body { theme: 'light'|'dark'|'system' } → { theme }
|
||||
|
||||
GET /api/me/channels → { channels: Array<{ id, name, channelType, config, isActive, ownerUserId, createdAt, updatedAt }> }
|
||||
PUT /api/me/channels/teams → body { webhook_url } → { channel, test: { ok, status?, error? } }
|
||||
PUT /api/me/channels/ntfy → body { topic? } → { channel, test }
|
||||
DELETE /api/me/channels/{type} → { deleted: true, channelType }
|
||||
POST /api/me/channels/{type}/test → { test: ChannelTestResult }
|
||||
|
||||
GET /api/me/notification-subscriptions →
|
||||
{
|
||||
eventKeys: Array<{ key, displayLabel, description, sortOrder }>,
|
||||
|
|
@ -141,24 +123,27 @@ GET /api/me/notification-subscriptions →
|
|||
}
|
||||
PUT /api/me/notification-subscriptions → body { event_key, channel_type, enabled } → { subscription }
|
||||
|
||||
useUserTimezone() (existing hook): () => string // IANA timezone
|
||||
|
||||
EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
|
||||
|
||||
requireAuth signature (from lib/auth-utils.ts):
|
||||
```typescript
|
||||
async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>
|
||||
// Page rule: NEVER return `error` from a server component. Use redirect('/auth/sign-in').
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Page shell, MoreDrawer wiring, and skeleton helper</name>
|
||||
<files>app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/MoreDrawer.tsx</files>
|
||||
<name>Task 1: Page shell, MoreDrawer wiring, skeleton helper, channels placeholder</name>
|
||||
<files>app/mobile/profile/page.tsx, components/mobile/profile/ProfileSectionSkeleton.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx, components/mobile/MoreDrawer.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md (Page Layout Contract section, MoreDrawer drawer link addition)
|
||||
- components/mobile/MoreDrawer.tsx (current Account section structure)
|
||||
- app/mobile/layout.tsx (page wrapper inherits max-w-lg)
|
||||
- lib/auth-utils.ts (requireAuth signature for server component)
|
||||
- components/mobile/EngagementProfileSkeleton.tsx (Skeleton precedent if it exists; otherwise mirror Card + 3 Skeleton rows)
|
||||
- lib/auth-utils.ts (requireAuth signature — `error` is NextResponse, NOT a redirect)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
|
|
@ -168,7 +153,42 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
- Renders a shadcn `Card` with `CardHeader` containing one `Skeleton` (h-5 w-32) and `CardContent` (`px-4 py-4`) containing 3 stacked `Skeleton` rows (each `h-10 w-full`, `space-y-3` between them)
|
||||
- Uses `Skeleton` from `@/components/ui/skeleton`
|
||||
|
||||
Step B — Create `app/mobile/profile/page.tsx` as a Next.js server component shell:
|
||||
Step B — Create `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` (client component, named export `ProfileChannelsSection`):
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Placeholder for ProfileChannelsSection — wired in Plan 04 so the page
|
||||
* compiles. Plan 05 replaces this file with the real component (Teams +
|
||||
* ntfy + QR code). The named export `ProfileChannelsSection` matches the
|
||||
* import in app/mobile/profile/page.tsx so the swap is a one-file change.
|
||||
*/
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
export function ProfileChannelsSection() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0">
|
||||
<CardTitle>Personal Channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Coming soon — channel configuration ships in Plan 05.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: the placeholder filename is `ProfileChannelsSectionPlaceholder.tsx` but
|
||||
the named export is `ProfileChannelsSection`. Plan 05 deletes this file and
|
||||
creates the real `ProfileChannelsSection.tsx` exporting the same name. The
|
||||
page `import` line therefore needs ZERO changes when Plan 05 lands.
|
||||
|
||||
Step C — Create `app/mobile/profile/page.tsx` as a Next.js server component shell:
|
||||
|
||||
```tsx
|
||||
// /mobile/profile (PROF-01) — gated by requireAuth(), server-rendered shell
|
||||
|
|
@ -179,12 +199,14 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
import { ProfileTimezoneSection } from '@/components/mobile/profile/ProfileTimezoneSection';
|
||||
import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection';
|
||||
import { ProfileNotificationMatrix } from '@/components/mobile/profile/ProfileNotificationMatrix';
|
||||
import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection';
|
||||
import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSectionPlaceholder';
|
||||
|
||||
export default async function MobileProfilePage() {
|
||||
const { session, error } = await requireAuth();
|
||||
if (error) {
|
||||
// requireAuth returns a NextResponse 401 on miss; for a page route we redirect to sign-in
|
||||
// CRITICAL: requireAuth() returns NextResponse on miss — that shape is for
|
||||
// API routes only. A Page route MUST call redirect() instead. Returning
|
||||
// `error` from here would render a JSON 401 body as the page, which is wrong.
|
||||
if (error || !session) {
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
return (
|
||||
|
|
@ -201,9 +223,11 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
}
|
||||
```
|
||||
|
||||
Note: `requireAuth()` returns a NextResponse on miss — for a Page route we cannot return a Response directly; redirect to `/auth/sign-in` instead. The `session` variable is unused in the shell (each child component reads `useSession()` for what it needs) — that is intentional; the auth check is the gate.
|
||||
Plan 05 changes the import line from `ProfileChannelsSectionPlaceholder` to
|
||||
`ProfileChannelsSection` (real component) — that is the only edit to this
|
||||
file in Wave 4.
|
||||
|
||||
Step C — Modify `components/mobile/MoreDrawer.tsx` Account section:
|
||||
Step D — Modify `components/mobile/MoreDrawer.tsx` Account section:
|
||||
|
||||
- Add `import { Settings as SettingsIcon } from 'lucide-react';` (alongside existing icons; if `Settings` is already imported keep it).
|
||||
- Replace the existing Account section's `<div className="rounded-2xl border overflow-hidden">` block so the inner structure becomes (in this exact order):
|
||||
|
|
@ -218,15 +242,19 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f app/mobile/profile/page.tsx && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, redirects to `/auth/sign-in` on miss
|
||||
- `app/mobile/profile/page.tsx` exists, calls `requireAuth()`, and calls `redirect('/auth/sign-in')` on miss
|
||||
- The redirect call regex `redirect\(['"]/auth/sign-in['"]\)` matches in the file (single OR double quotes)
|
||||
- The file does NOT contain `return error` (the NextResponse from requireAuth must NOT be returned from a Page route)
|
||||
- File renders the four section components in this exact order: ProfileTimezoneSection, ProfileThemeSection, ProfileNotificationMatrix, ProfileChannelsSection
|
||||
- File contains `<h1 className="text-xl font-semibold pt-4 pb-2">Profile & Preferences</h1>` (UI-SPEC heading copy)
|
||||
- Page wrapper is `<main className="px-4 pb-safe">` and inner div is `<div className="space-y-4">` (UI-SPEC layout)
|
||||
- File imports `ProfileChannelsSection` from `@/components/mobile/profile/ProfileChannelsSectionPlaceholder` (Plan 05 will swap the source path; the import name stays the same)
|
||||
- `components/mobile/profile/ProfileSectionSkeleton.tsx` exports default; uses Card + Skeleton primitives; renders 3 skeleton rows
|
||||
- `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` exports a NAMED function `ProfileChannelsSection` (not default) and renders a placeholder Card titled "Personal Channels"
|
||||
- `components/mobile/MoreDrawer.tsx` Account section contains a `Link` with `href="/mobile/profile"` wrapping the identity row
|
||||
- `components/mobile/MoreDrawer.tsx` Account section contains a new row with the literal text `Profile & preferences` (lowercase 'p' for 'preferences' per UI-SPEC copy table)
|
||||
- `components/mobile/MoreDrawer.tsx` still contains the Sign-out button using `text-destructive` and `signOut()` (existing destructive action preserved)
|
||||
|
|
@ -234,7 +262,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Page shell exists, drawer routes to it, skeleton helper available for each section's loading state.
|
||||
Page shell exists, drawer routes to it, skeleton helper available, placeholder Channels card keeps the build green. Plan 05 drops in the real ProfileChannelsSection by renaming the source file.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -248,7 +276,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
- lib/hooks/use-user-timezone.ts (existing hook to consume)
|
||||
- components/ui/command.tsx (cmdk Combobox primitives)
|
||||
- components/ui/popover.tsx (Popover primitives)
|
||||
- app/styles/brand.css (verify .num utility exists; if not, the executor uses `font-mono` Tailwind class instead — document the choice in the SUMMARY)
|
||||
- app/styles/brand.css (verify .num utility exists; if not, the executor uses `font-mono tabular-nums` Tailwind classes — document the choice in the SUMMARY)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
|
|
@ -318,7 +346,7 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
- app/api/me/theme/route.ts (Plan 02) — request/response shape
|
||||
- app/api/me/notification-subscriptions/route.ts (Plan 02) — matrix shape
|
||||
- components/ui/switch.tsx
|
||||
- components/theme-toggle.tsx (existing setTheme pattern)
|
||||
- components/theme-toggle.tsx (existing setTheme pattern; the write-through PUT lands in Plan 05)
|
||||
- components/mobile/profile/ProfileSectionSkeleton.tsx (Task 1 — loading state)
|
||||
</read_first>
|
||||
|
||||
|
|
@ -382,198 +410,6 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 4: ProfileChannelsSection (Teams + ntfy with QR code)</name>
|
||||
<files>components/mobile/profile/ProfileChannelsSection.tsx, package.json</files>
|
||||
|
||||
<read_first>
|
||||
- .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Channels Card" — full sub-section spec, Copywriting Contract → Channels)
|
||||
- app/api/me/channels/route.ts and [type]/route.ts and [type]/test/route.ts (Plan 02 endpoints)
|
||||
- lib/services/personal-channels.ts (TEST_MESSAGE_BODY constant — for awareness; not imported into the client)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-04 ntfy mint-on-first-save, D-06 best-effort test)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — Add `qrcode.react` to `package.json` dependencies. Use the latest stable major (`^4.0.0` or current). Run `npm install qrcode.react` semantically by adding the entry; the executor MAY also run the actual install command if the harness allows it. Otherwise, just edit `package.json` and document in SUMMARY that `npm install` is required.
|
||||
|
||||
Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`):
|
||||
|
||||
- Card with CardTitle "Personal Channels" and CardDescription "Receive notifications directly on your devices." (UI-SPEC).
|
||||
- State: `loading`, `channels: { teams?: Channel; ntfy?: Channel }`, `teamsInput: string`, `teamsTest: TestResult | null`, `ntfyTest: TestResult | null`, `showAdvanced: boolean`, `customTopicInput: string`.
|
||||
- On mount: `fetch('/api/me/channels')`, then split `data.channels` into `teams` and `ntfy` by `channelType`.
|
||||
- Layout: two sub-sections separated by `<Separator className="my-4" />`.
|
||||
|
||||
**Teams sub-section:**
|
||||
- Label "Microsoft Teams webhook URL"
|
||||
- `<Input placeholder="https://yourorg.webhook.office.com/..." value={teamsInput} onChange={...} />`
|
||||
- Below input: if `teamsTest` is set, render an inline test result row (UI-SPEC pattern):
|
||||
- Success: `<div className="flex items-center gap-2 text-xs"><CheckCircle className="w-3.5 h-3.5 text-green-600" /><span className="text-green-600">Channel verified</span></div>`
|
||||
- Failure: `<div className="flex items-center gap-2 text-xs"><XCircle className="w-3.5 h-3.5 text-destructive" /><span className="text-destructive">Test failed — {test.status ?? test.error ?? 'unknown'}</span></div>`
|
||||
- Buttons row: `<Button onClick={save}>Save Teams URL</Button> <Button variant="ghost" onClick={clear} className="text-destructive">Clear</Button>` (both `min-h-[44px]`).
|
||||
- Save: `fetch('/api/me/channels/teams', { method: 'PUT', body: JSON.stringify({ webhook_url: teamsInput }) })`. On 200: `toast.success('Channel saved')`, set `teamsTest = data.test`, set `channels.teams = data.channel`. On 4xx (e.g., invalid host): show inline `text-xs text-destructive` with the response's `error`/`message` AND `toast.error('Failed to save channel')`.
|
||||
- Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`.
|
||||
|
||||
**ntfy sub-section:**
|
||||
- State A — `channels.ntfy === undefined`:
|
||||
- Label "Mobile push (ntfy)"
|
||||
- Description "Pulse will generate a private topic for you." (text-sm text-muted-foreground)
|
||||
- Button `<Button className="w-full" onClick={enableNtfy}>Enable mobile push</Button>` (min-h-[44px])
|
||||
- `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`.
|
||||
- State B — `channels.ntfy` exists:
|
||||
- Label "Mobile push (ntfy)"
|
||||
- Subscribe link: `<a href={"https://ntfy.sh/" + topic} target="_blank" rel="noopener noreferrer" className="text-primary text-sm underline">{"https://ntfy.sh/" + topic}</a>`
|
||||
- QR code: `<QRCodeSVG value={"https://ntfy.sh/" + topic} size={200} />` (from `qrcode.react`). Wrap in `<div role="img" aria-label={"Subscribe to " + topic + " on ntfy"}>` for accessibility.
|
||||
- Helper line: `<p className="text-xs text-muted-foreground">Scan with the ntfy app to subscribe.</p>`
|
||||
- Test result inline (same pattern as Teams).
|
||||
- Disclosure: `<details><summary className="text-sm text-primary cursor-pointer">Edit advanced</summary>` — inside the details, render Label "Custom ntfy topic", Input bound to `customTopicInput`, Button "Save custom topic" calling `PUT /api/me/channels/ntfy` with body `{ topic: customTopicInput }`.
|
||||
- Bottom buttons row: `<Button onClick={testNow}>Test now</Button>` (calls `POST /api/me/channels/ntfy/test`) `<Button variant="ghost" onClick={removeNtfy} className="text-destructive">Remove</Button>` (calls `DELETE /api/me/channels/ntfy`).
|
||||
|
||||
All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f components/mobile/profile/ProfileChannelsSection.tsx && 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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `package.json` includes `"qrcode.react"` in dependencies
|
||||
- `components/mobile/profile/ProfileChannelsSection.tsx` starts with `'use client';`
|
||||
- File imports the QR component from `qrcode.react` (`QRCodeSVG` or default)
|
||||
- File contains the exact label `Microsoft Teams webhook URL`
|
||||
- File contains the placeholder `https://yourorg.webhook.office.com/`
|
||||
- File contains the literal `Mobile push (ntfy)`
|
||||
- File contains the literal `Enable mobile push`
|
||||
- File contains the literal `Pulse will generate a private topic for you.`
|
||||
- File contains the literal `Scan with the ntfy app to subscribe.`
|
||||
- File contains the literal `Save Teams URL`
|
||||
- File contains the literal `Clear` (Teams clear button)
|
||||
- File contains the literal `Edit advanced` (disclosure label)
|
||||
- File contains the literal `Test now`
|
||||
- File contains the literal `Remove`
|
||||
- File contains the literal `Channel verified` (success copy)
|
||||
- File contains the literal `Test failed`
|
||||
- File contains the literal toast string `'Channel saved'`
|
||||
- File contains the literal toast string `'Channel removed'`
|
||||
- File makes fetch calls to `/api/me/channels`, `/api/me/channels/teams`, `/api/me/channels/ntfy`, `/api/me/channels/ntfy/test`
|
||||
- File DELETE call uses `method: 'DELETE'` (or `"DELETE"`)
|
||||
- QR code rendered inside an element with `aria-label` referencing the topic (accessibility)
|
||||
- `npx tsc --noEmit --pretty` reports no errors after `npm install` adds qrcode.react
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override, remove). qrcode.react added to dependencies.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 5: Theme session bridge + ThemeToggle write-through</name>
|
||||
<files>components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- components/theme-toggle.tsx (current setTheme handlers — three DropdownMenuItems)
|
||||
- components/theme-provider.tsx
|
||||
- app/layout.tsx (where ThemeProvider mounts; the bridge sits inside it)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-16, D-17, D-20)
|
||||
- .planning/REQUIREMENTS.md (THEME-03, THEME-04)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ThemeSessionBridge (THEME-03 / D-16, D-17).
|
||||
*
|
||||
* On session load and after sign-in, compares session.user.theme to the
|
||||
* next-themes useTheme() value and calls setTheme(session.user.theme) if
|
||||
* different. Server is canonical; this is the bridge that enforces it.
|
||||
*
|
||||
* Renders nothing.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string };
|
||||
|
||||
export function ThemeSessionBridge() {
|
||||
const { data: session } = useSession();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user) return;
|
||||
const serverTheme = (session.user as SessionUserWithTheme).theme;
|
||||
if (
|
||||
serverTheme === 'light' ||
|
||||
serverTheme === 'dark' ||
|
||||
serverTheme === 'system'
|
||||
) {
|
||||
if (serverTheme !== theme) {
|
||||
setTheme(serverTheme);
|
||||
}
|
||||
}
|
||||
}, [session?.user, theme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Step B — Mount `<ThemeSessionBridge />` inside `app/layout.tsx`:
|
||||
|
||||
- Find the existing `<ThemeProvider>` block.
|
||||
- Inside the `<ThemeProvider>` children, immediately AFTER `<AuthProvider>` opens (so it has access to `useSession()`), add `<ThemeSessionBridge />`. The exact placement: as the FIRST child of `<AuthProvider>`, before the existing `<div className="min-h-screen ...">`.
|
||||
- Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
|
||||
- Do NOT change any other layout.tsx content.
|
||||
|
||||
Step C — Modify `components/theme-toggle.tsx` (THEME-04):
|
||||
|
||||
- Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper:
|
||||
```tsx
|
||||
const writeTheme = (next: 'light' | 'dark' | 'system') => {
|
||||
setTheme(next);
|
||||
// Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway.
|
||||
fetch('/api/me/theme', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: next }),
|
||||
}).catch(() => {
|
||||
// Silent fail on network error — the desktop affordance is best-effort.
|
||||
// The mobile profile Theme section is the explicit-error UX.
|
||||
});
|
||||
};
|
||||
```
|
||||
- Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`.
|
||||
- Do NOT remove the `useTheme` import or the existing UI structure.
|
||||
- The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f components/mobile/profile/ThemeSessionBridge.tsx && 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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `components/mobile/profile/ThemeSessionBridge.tsx` exists, starts with `'use client';`
|
||||
- File imports both `useTheme` from `next-themes` and `useSession` from `@/lib/auth-client`
|
||||
- File contains `setTheme(serverTheme)` inside a `useEffect`
|
||||
- File guards `serverTheme` against the three-string allowlist before calling `setTheme`
|
||||
- File returns `null` (renders nothing)
|
||||
- `app/layout.tsx` imports `ThemeSessionBridge` from `@/components/mobile/profile/ThemeSessionBridge`
|
||||
- `app/layout.tsx` mounts `<ThemeSessionBridge />` inside `<AuthProvider>` (so `useSession()` works)
|
||||
- `components/theme-toggle.tsx` contains the literal `/api/me/theme` reference
|
||||
- `components/theme-toggle.tsx` issues a fetch with `method: 'PUT'` (or `"PUT"`)
|
||||
- `components/theme-toggle.tsx` still renders the three `DropdownMenuItem` rows (Light, Dark, System) — visual structure unchanged
|
||||
- `components/theme-toggle.tsx` still imports `useTheme` from `next-themes` (not removed)
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
|
@ -583,55 +419,48 @@ EXTRA_ALLOWED_TIMEZONES from app/api/me/timezone/route.ts: ['UTC', 'Etc/UTC', 'G
|
|||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /api/me/* | Already mitigated by Plan 02; this plan only consumes those endpoints |
|
||||
| Browser DOM → ntfy.sh subscribe link | User's own minted topic; rendered as anchor + QR — no third-party script |
|
||||
| Page route auth gate | Server component must redirect, NOT return a NextResponse |
|
||||
|
||||
## STRIDE Threat Register (ASVS L1)
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-09-04-01 | Information Disclosure | QR code rendering | mitigate | `qrcode.react` is a pure client-side SVG library; no network calls at render time. The QR encodes only the user's own ntfy subscribe URL — no secrets |
|
||||
| T-09-04-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` rolls back `setTheme(previous)` on PUT error; the `ThemeSessionBridge` re-syncs from server on next session refresh as the canonical source |
|
||||
| T-09-04-03 | Spoofing | `ThemeSessionBridge` | mitigate | Reads `session.user.theme` from `useSession()` (Better Auth signed cookie). Validates against the three-string allowlist before calling `setTheme` — drops any unexpected value silently |
|
||||
| T-09-04-04 | Information Disclosure | Inline test-result error display | mitigate | The error string from `/api/me/channels` is already truncated to 200 chars (Plan 02 / `lib/services/personal-channels.ts`). UI renders it `text-xs` and only on the user's own channel form — never on another user's data |
|
||||
| T-09-04-05 | XSS | Subscribe link `target="_blank"` | mitigate | Anchor uses `rel="noopener noreferrer"`. Topic value flows from server-minted UUID-prefixed string (Plan 02), not user input |
|
||||
| T-09-04-06 | DoS / Bundle bloat | `qrcode.react` dependency | accept | ~10 KB gzip; loaded only on `/mobile/profile`. Reviewed for "no network-call side effects at render time" per UI-SPEC Registry Safety section |
|
||||
| T-09-04-07 | CSRF | Fetch calls from client | mitigate | Better Auth uses signed httpOnly session cookies; same-origin fetch is implicitly authenticated. No third-party origin can post on behalf of the user without the cookie |
|
||||
| T-09-04-01 | Spoofing / Bypass | `app/mobile/profile/page.tsx` auth gate | mitigate | Page calls `requireAuth()` then `redirect('/auth/sign-in')` when `error || !session`. The acceptance criteria assert via grep that `redirect('/auth/sign-in')` is present AND `return error` is absent — closing the HIGH-5 gap from the plan checker (returning a NextResponse from a Page would render JSON-as-HTML) |
|
||||
| T-09-04-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` rolls back `setTheme(previous)` on PUT error; the server-canonical reconciliation lives in Plan 05's `ThemeSessionBridge` |
|
||||
| T-09-04-03 | Information Disclosure | matrix endpoint default-enabled | mitigate | When `data.channelTypes.length === 0`, the Notifications card renders an empty-state message and does NOT issue PUTs (no spurious toggles for non-existent channels) |
|
||||
| T-09-04-04 | CSRF | Fetch calls from client sections | mitigate | Better Auth uses signed httpOnly session cookies; same-origin fetch is implicitly authenticated. No third-party origin can post on behalf of the user without the cookie |
|
||||
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping + rel=noopener).
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping), V8.1.6 (auth gate enforced server-side).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm install` (or equivalent) installs `qrcode.react` cleanly.
|
||||
- `npx tsc --noEmit --pretty` exits 0.
|
||||
- Visiting `/mobile/profile` while signed-out redirects to `/auth/sign-in` (server-side `redirect`, NOT a JSON 401 body).
|
||||
- Visiting `/mobile/profile` while signed-in renders four Cards in order with the placeholder Channels Card visible.
|
||||
- `npm run build` (turbopack) builds without errors.
|
||||
- Manual smoke (post-deploy):
|
||||
1. Visit `/mobile/profile` → 4 Cards render in order.
|
||||
1. Visit `/mobile/profile` → 4 Cards render in order (Channels card shows "Coming soon").
|
||||
2. Tap a timezone → toast → reload → still selected.
|
||||
3. Tap "Dark" → page switches theme, toast → reload → still dark.
|
||||
4. Configure Teams URL → test send works → save → toast.
|
||||
5. Enable mobile push → topic minted → QR visible → subscribe link works.
|
||||
6. Open the More drawer → Account section shows the identity row as a link AND a "Profile & preferences" row above Sign-out.
|
||||
7. Open desktop ThemeToggle → switch theme → reload mobile profile → matches.
|
||||
4. Open the More drawer → Account section shows the identity row as a link AND a "Profile & preferences" row above Sign-out.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. All 7 new files exist under `components/mobile/profile/` and `app/mobile/profile/`.
|
||||
1. All 5 new files exist under `components/mobile/profile/` and `app/mobile/profile/` (Section components × 3, Skeleton, ChannelsPlaceholder).
|
||||
2. `MoreDrawer.tsx` Account section routes to `/mobile/profile`.
|
||||
3. `ThemeSessionBridge` is mounted in `app/layout.tsx` and renders nothing.
|
||||
4. `ThemeToggle` writes through to `/api/me/theme` on every selection.
|
||||
5. `package.json` declares `qrcode.react`.
|
||||
6. `npx tsc --noEmit --pretty` exits 0.
|
||||
7. The four section Cards each match the UI-SPEC contract (header, content padding, copywriting strings, save model).
|
||||
3. The Page route gates via `redirect('/auth/sign-in')`, not by returning `requireAuth()`'s NextResponse.
|
||||
4. `npx tsc --noEmit --pretty` exits 0.
|
||||
5. The three section Cards (Timezone, Theme, Notifications) match the UI-SPEC contract.
|
||||
6. The Channels Card placeholder ships so the page compiles; Plan 05 swaps in the real component.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-04-SUMMARY.md` documenting:
|
||||
- The 7 new files (paths and one-line descriptions)
|
||||
- The 5 new files (paths and one-line descriptions)
|
||||
- The MoreDrawer Account section structure (3 rows: identity-link / profile-preferences-link / sign-out)
|
||||
- The ThemeSessionBridge sync rule (compares session.user.theme to useTheme(), calls setTheme on mismatch, validated against allowlist)
|
||||
- The ThemeToggle write-through behavior
|
||||
- The qrcode.react version pinned in package.json
|
||||
- Whether `npm install` was run during the plan or is required as a follow-up
|
||||
- Confirmation the Page uses `redirect('/auth/sign-in')` and does NOT `return error`
|
||||
- The Channels Card placeholder import path (so Plan 05 knows what to swap)
|
||||
- Any deviations from the UI-SPEC (expected: none)
|
||||
</output>
|
||||
</output>
|
||||
|
|
|
|||
|
|
@ -2,90 +2,71 @@
|
|||
phase: 09-user-profile-preferences-new
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [09-01, 09-02, 09-03]
|
||||
wave: 4
|
||||
depends_on: [09-04]
|
||||
files_modified:
|
||||
- app/admin/workflow/channels/page.tsx
|
||||
- app/api/notification-channels/route.ts
|
||||
- app/api/notification-channels/[id]/route.ts
|
||||
- app/admin/workflow/event-keys/page.tsx
|
||||
- app/api/admin/notify-event-keys/route.ts
|
||||
- app/api/admin/notify-event-keys/[key]/route.ts
|
||||
- app/admin/workflow/pipelines/[id]/page.tsx
|
||||
- app/api/pipelines/[id]/executions/route.ts
|
||||
- components/mobile/profile/ProfileChannelsSection.tsx
|
||||
- components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx
|
||||
- components/mobile/profile/ThemeSessionBridge.tsx
|
||||
- components/theme-toggle.tsx
|
||||
- app/layout.tsx
|
||||
- app/mobile/profile/page.tsx
|
||||
- package.json
|
||||
- package-lock.json
|
||||
autonomous: true
|
||||
requirements: [CHAN-06, ROUTE-07]
|
||||
requirements: [CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07, THEME-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admins (role admin or super-admin) can read AND edit any user's personal notification channel via /admin/workflow/channels — including viewing the webhook_url / topic and toggling is_active and triggering test sends"
|
||||
- "/admin/workflow/channels has an Owner column that displays 'Global' for owner_user_id IS NULL or the owner's email otherwise, plus a filter widget that toggles between 'All', 'Global only', and 'Personal only'"
|
||||
- "/admin/workflow/event-keys exists as a real CRUD page for admins (list, create, edit, soft-toggle is_active, change sort_order, delete)"
|
||||
- "/admin/workflow/pipelines/[id] (recent executions panel) gains a 'Show only fallbacks' filter that filters to executions where any execution_step has output_data->'user_route_fallback' set"
|
||||
- "All admin routes use requireAdmin() — non-admin users get 403"
|
||||
- "Non-admin users can never read another user's personal channel via /api/notification-channels (the legacy endpoint hides personal rows for non-admins)"
|
||||
- "Channels Card has a Teams sub-section (URL Input + Save/Clear buttons) and an ntfy sub-section that mints on first save then displays subscribe link + QR code; both show test-send result inline"
|
||||
- "Custom ntfy topic Input shows a `text-xs text-destructive` inline error rendering the response message string when PUT /api/me/channels/ntfy returns 400 (mirrors the Teams URL inline-error pattern)"
|
||||
- "qrcode.react is INSTALLED into node_modules (npm install runs during the task — package.json edit alone is insufficient because npm ci in CI/Docker would break)"
|
||||
- "On session load and after sign-in, ThemeSessionBridge compares session.user.theme to next-themes useTheme() and calls setTheme(session.user.theme) when they differ"
|
||||
- "ThemeToggle (desktop) writes through to PUT /api/me/theme on every setTheme call"
|
||||
- "app/mobile/profile/page.tsx imports ProfileChannelsSection from `./ProfileChannelsSection` (not the Placeholder); the Placeholder file is removed"
|
||||
artifacts:
|
||||
- path: "app/admin/workflow/channels/page.tsx"
|
||||
provides: "Channels list with Owner column + filter; admin edit of personal channels"
|
||||
- path: "app/api/notification-channels/route.ts"
|
||||
provides: "Updated GET to require auth, scope visibility by role; POST stays admin-only"
|
||||
- path: "app/api/notification-channels/[id]/route.ts"
|
||||
provides: "Updated PUT/DELETE/GET to authorize per-row by ownership and role"
|
||||
- path: "app/admin/workflow/event-keys/page.tsx"
|
||||
provides: "New admin CRUD page for notify_event_keys"
|
||||
- path: "app/api/admin/notify-event-keys/route.ts"
|
||||
provides: "GET list / POST create event keys (admin-only)"
|
||||
- path: "app/api/admin/notify-event-keys/[key]/route.ts"
|
||||
provides: "PUT update / DELETE one event key by primary key"
|
||||
- path: "app/admin/workflow/pipelines/[id]/page.tsx"
|
||||
provides: "Pipeline detail page with 'Show only fallbacks' filter on recent executions"
|
||||
- path: "app/api/pipelines/[id]/executions/route.ts"
|
||||
provides: "Updated executions endpoint to support fallbacks_only=1 filter"
|
||||
- path: "components/mobile/profile/ProfileChannelsSection.tsx"
|
||||
provides: "Channels Card (Teams + ntfy sub-sections, QR, test result inline, custom-topic inline-error)"
|
||||
- path: "components/mobile/profile/ThemeSessionBridge.tsx"
|
||||
provides: "Client effect comparing session.user.theme to next-themes; calls setTheme on mismatch"
|
||||
- path: "components/theme-toggle.tsx"
|
||||
provides: "Session-aware ThemeToggle that writes through to /api/me/theme"
|
||||
contains: "/api/me/theme"
|
||||
- path: "app/layout.tsx"
|
||||
provides: "ThemeSessionBridge mounted inside AuthProvider"
|
||||
- path: "package.json"
|
||||
provides: "qrcode.react dependency declared"
|
||||
contains: "qrcode.react"
|
||||
- path: "package-lock.json"
|
||||
provides: "qrcode.react resolved + locked"
|
||||
contains: "qrcode.react"
|
||||
key_links:
|
||||
- from: "/admin/workflow/channels"
|
||||
to: "notification_channels with owner_user_id JOIN user.email"
|
||||
via: "LEFT JOIN \"user\" ON owner_user_id = user.id; renders Owner column"
|
||||
pattern: "owner_user_id"
|
||||
- from: "/admin/workflow/event-keys"
|
||||
to: "notify_event_keys"
|
||||
via: "POST/PUT/DELETE via /api/admin/notify-event-keys"
|
||||
pattern: "notify_event_keys"
|
||||
- from: "/admin/workflow/pipelines/[id]"
|
||||
to: "pipeline_execution_steps.output_data->'user_route_fallback'"
|
||||
via: "fallbacks_only=1 filter on /api/pipelines/[id]/executions"
|
||||
pattern: "user_route_fallback"
|
||||
- from: "ProfileChannelsSection"
|
||||
to: "/api/me/channels, /api/me/channels/teams, /api/me/channels/ntfy, /api/me/channels/ntfy/test"
|
||||
via: "client fetch with PUT/DELETE/POST"
|
||||
pattern: "fetch.*api/me/channels"
|
||||
- from: "ThemeSessionBridge"
|
||||
to: "next-themes useTheme"
|
||||
via: "setTheme(session.user.theme) on mismatch"
|
||||
pattern: "setTheme"
|
||||
- from: "ThemeToggle"
|
||||
to: "/api/me/theme PUT"
|
||||
via: "fetch on setTheme callback"
|
||||
pattern: "method:.*PUT"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Land the admin surfaces required by Phase 9:
|
||||
Land the second half of the Phase 9 mobile UI: the real ProfileChannelsSection
|
||||
(Teams + ntfy with QR code), the ThemeSessionBridge that makes the server's
|
||||
`theme` value canonical app-wide, and the ThemeToggle write-through.
|
||||
|
||||
1. **CHAN-06: Admin full-edit of personal channels.** `/admin/workflow/channels` gains
|
||||
an Owner column + filter. Admins can read/edit any user's personal Teams webhook URL
|
||||
or ntfy topic, toggle is_active, and trigger test sends. Non-admins still cannot
|
||||
see other users' personal channels via the legacy `/api/notification-channels` API.
|
||||
This plan was split out of the original 5-task Plan 04 (the plan checker flagged
|
||||
that as too-large at 11 modified files). Splitting keeps each plan within the
|
||||
4-task / 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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
|
|
@ -95,243 +76,299 @@ business logic — just CRUD + auth-gating).
|
|||
|
||||
<context>
|
||||
@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
|
||||
@.planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@app/admin/workflow/channels/page.tsx
|
||||
@app/api/notification-channels/route.ts
|
||||
@app/api/notification-channels/[id]/route.ts
|
||||
@app/admin/workflow/pipelines/[id]/page.tsx
|
||||
@app/api/pipelines/[id]/executions/route.ts
|
||||
@lib/auth-utils.ts
|
||||
@components/theme-toggle.tsx
|
||||
@components/theme-provider.tsx
|
||||
@app/layout.tsx
|
||||
@app/mobile/profile/page.tsx
|
||||
@components/ui/card.tsx
|
||||
@components/ui/input.tsx
|
||||
@components/ui/separator.tsx
|
||||
@components/ui/button.tsx
|
||||
@lib/services/personal-channels.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 schema (already landed): -->
|
||||
<!-- Endpoint shapes (from Plan 02). -->
|
||||
|
||||
notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at
|
||||
GET /api/me/channels → { channels: Array<{ id, name, channelType, config, isActive, ownerUserId, createdAt, updatedAt }> }
|
||||
PUT /api/me/channels/teams → body { webhook_url } → { channel, test: { ok, status?, error? } }
|
||||
- 400 on invalid host: { error: 'Invalid webhook URL', message: 'webhook_url must be ...' }
|
||||
PUT /api/me/channels/ntfy → body { topic? } → { channel, test }
|
||||
- 400 on invalid custom topic: { error: 'Invalid topic', message: 'topic must match ^[A-Za-z0-9_-]{6,64}$' }
|
||||
DELETE /api/me/channels/{type} → { deleted: true, channelType }
|
||||
POST /api/me/channels/{type}/test → { test: ChannelTestResult }
|
||||
|
||||
notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at
|
||||
PUT /api/me/theme → body { theme: 'light'|'dark'|'system' } → { theme }
|
||||
|
||||
pipeline_execution_steps.output_data is JSONB. After Plan 03, on user-route fallback the value contains:
|
||||
output_data->'user_route_fallback' = { reason: string, user_id?: string, channel_type: string, error?: string }
|
||||
|
||||
<!-- Auth helpers (existing): -->
|
||||
requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
|
||||
|
||||
<!-- Existing admin Channels page state (today; you'll extend, not replace): -->
|
||||
- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1)
|
||||
- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test`
|
||||
- Uses Card + Switch + DataTable-less list rendering
|
||||
|
||||
<!-- Existing executions endpoint shape (legacy /api/workflow/executions vs pipeline-engine): -->
|
||||
GET /api/pipelines/[id]/executions?limit=N → returns { data: PipelineExecution[] }
|
||||
After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 → filters via JSONB predicate
|
||||
<!-- Better Auth client session shape (from @/lib/auth-client useSession): -->
|
||||
session.user = { id, email, name, role, theme?: 'light'|'dark'|'system', timezone?, ... }
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Owner column + role-scoped reads on /admin/workflow/channels</name>
|
||||
<files>app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx</files>
|
||||
<name>Task 1: ProfileChannelsSection (Teams + ntfy with QR code) + qrcode.react install</name>
|
||||
<files>components/mobile/profile/ProfileChannelsSection.tsx, components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx, app/mobile/profile/page.tsx, package.json, package-lock.json</files>
|
||||
|
||||
<read_first>
|
||||
- app/api/notification-channels/route.ts (current GET / POST — no auth today)
|
||||
- app/api/notification-channels/[id]/route.ts (current GET / PUT / DELETE — no auth today)
|
||||
- app/admin/workflow/channels/page.tsx (current rendering structure to extend)
|
||||
- lib/auth-utils.ts (requireAuth, requireAdmin)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-07 admin full edit, CHAN-06)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-UI-SPEC.md ("Section: Channels Card" — full sub-section spec, Copywriting Contract → Channels)
|
||||
- app/api/me/channels/route.ts and [type]/route.ts and [type]/test/route.ts (Plan 02 endpoints — NOTE: ntfy custom-topic 400 returns `{ error, message }` body)
|
||||
- lib/services/personal-channels.ts (TEST_MESSAGE_BODY, isValidNtfyTopic — for awareness; not imported into the client)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-04 ntfy mint-on-first-save, D-06 best-effort test)
|
||||
- components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx (Plan 04 — this gets deleted)
|
||||
- app/mobile/profile/page.tsx (Plan 04 — change one import line)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — `app/api/notification-channels/route.ts` (GET + POST):
|
||||
Step A — Run `npm install qrcode.react` from the repo root (NOT just edit
|
||||
package.json). The install must update both `package.json` AND
|
||||
`package-lock.json`; otherwise `npm ci` in Docker / CI will fail because the
|
||||
lockfile won't have an entry for the new dep. Acceptance below verifies
|
||||
`node_modules/qrcode.react/package.json` exists. Pin to `^4.0.0` (or the
|
||||
current latest stable major).
|
||||
|
||||
Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with:
|
||||
Step B — Create `components/mobile/profile/ProfileChannelsSection.tsx` (`'use client'`):
|
||||
|
||||
GET:
|
||||
- Call `requireAuth()`. On error return error.
|
||||
- If `(session.user.role === 'admin' || session.user.role === 'super-admin')`:
|
||||
- Run a JOIN that returns owner email for personal channels:
|
||||
```sql
|
||||
SELECT nc.*, u.email AS owner_email
|
||||
FROM notification_channels nc
|
||||
LEFT JOIN "user" u ON u.id = nc.owner_user_id
|
||||
ORDER BY nc.owner_user_id NULLS FIRST, nc.name
|
||||
```
|
||||
- Optional `?owner=global|personal|all` filter (default `all`):
|
||||
- `global` → `WHERE owner_user_id IS NULL`
|
||||
- `personal` → `WHERE owner_user_id IS NOT NULL`
|
||||
- Else (non-admin):
|
||||
- Return only global rows (`WHERE owner_user_id IS NULL`) — non-admins see admin-managed channels for selection in pipelines but NOT other users' personal channels.
|
||||
- Response shape: `{ data: rows, total: rows.length }` (preserve existing shape).
|
||||
- Card with CardTitle "Personal Channels" and CardDescription "Receive notifications directly on your devices." (UI-SPEC).
|
||||
- State: `loading`, `channels: { teams?: Channel; ntfy?: Channel }`, `teamsInput: string`, `teamsTest: TestResult | null`, `teamsError: string | null` (inline 400 message), `ntfyTest: TestResult | null`, `ntfyError: string | null`, `customTopicInput: string`, `customTopicError: string | null`.
|
||||
- On mount: `fetch('/api/me/channels')`, then split `data.channels` into `teams` and `ntfy` by `channelType`.
|
||||
- Layout: two sub-sections separated by `<Separator className="my-4" />`.
|
||||
|
||||
POST:
|
||||
- Call `requireAdmin()`. On error return error.
|
||||
- Existing validation logic preserved verbatim (validates `channel_type` against `['teams','telegram','ntfy','webhook']`).
|
||||
- INSERT statement adds `owner_user_id` to the column list, accepting `body.owner_user_id ?? null`. Default behavior: rows created via this admin endpoint are GLOBAL (owner_user_id NULL).
|
||||
**Teams sub-section:**
|
||||
- Label "Microsoft Teams webhook URL"
|
||||
- `<Input placeholder="https://yourorg.webhook.office.com/..." value={teamsInput} onChange={...} />`
|
||||
- Below input: render `teamsError` (if present) as `<p className="text-xs text-destructive">{teamsError}</p>` (UI-SPEC inline-error pattern).
|
||||
- Below that: if `teamsTest` is set, render an inline test result row:
|
||||
- Success: `<div className="flex items-center gap-2 text-xs"><CheckCircle className="w-3.5 h-3.5 text-green-600" /><span className="text-green-600">Channel verified</span></div>`
|
||||
- Failure: `<div className="flex items-center gap-2 text-xs"><XCircle className="w-3.5 h-3.5 text-destructive" /><span className="text-destructive">Test failed — {test.status ?? test.error ?? 'unknown'}</span></div>`
|
||||
- Buttons row: `<Button onClick={save}>Save Teams URL</Button> <Button variant="ghost" onClick={clear} className="text-destructive">Clear</Button>` (both `min-h-[44px]`).
|
||||
- Save (`saveTeams`):
|
||||
```tsx
|
||||
setTeamsError(null);
|
||||
const resp = await fetch('/api/me/channels/teams', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ webhook_url: teamsInput }),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
setTeamsError(body.message || body.error || `Save failed (${resp.status})`);
|
||||
toast.error('Failed to save channel');
|
||||
return;
|
||||
}
|
||||
setChannels((c) => ({ ...c, teams: body.channel }));
|
||||
setTeamsTest(body.test);
|
||||
toast.success('Channel saved');
|
||||
```
|
||||
- Clear: `fetch('/api/me/channels/teams', { method: 'DELETE' })`. Clear local state; `toast.success('Channel removed')`.
|
||||
|
||||
Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE):
|
||||
**ntfy sub-section:**
|
||||
- State A — `channels.ntfy === undefined`:
|
||||
- Label "Mobile push (ntfy)"
|
||||
- Description "Pulse will generate a private topic for you." (text-sm text-muted-foreground)
|
||||
- Button `<Button className="w-full" onClick={enableNtfy}>Enable mobile push</Button>` (min-h-[44px])
|
||||
- `enableNtfy()` calls `fetch('/api/me/channels/ntfy', { method: 'PUT', body: JSON.stringify({}) })`; on 200 set `channels.ntfy = data.channel`, set `ntfyTest = data.test`, `toast.success('Channel saved')`.
|
||||
- State B — `channels.ntfy` exists:
|
||||
- Label "Mobile push (ntfy)"
|
||||
- Subscribe link: `<a href={"https://ntfy.sh/" + topic} target="_blank" rel="noopener noreferrer" className="text-primary text-sm underline">{"https://ntfy.sh/" + topic}</a>`
|
||||
- QR code: `<QRCodeSVG value={"https://ntfy.sh/" + topic} size={200} />` (from `qrcode.react`). Wrap in `<div role="img" aria-label={"Subscribe to " + topic + " on ntfy"}>` for accessibility.
|
||||
- Helper line: `<p className="text-xs text-muted-foreground">Scan with the ntfy app to subscribe.</p>`
|
||||
- Test result inline (same pattern as Teams).
|
||||
- Disclosure: `<details><summary className="text-sm text-primary cursor-pointer">Edit advanced</summary>` — inside the details:
|
||||
- Label "Custom ntfy topic"
|
||||
- `<Input value={customTopicInput} onChange={(e) => { setCustomTopicInput(e.target.value); setCustomTopicError(null); }} />`
|
||||
- **Inline error rendering for the custom-topic 400 (HIGH 7 from plan checker):**
|
||||
```tsx
|
||||
{customTopicError && (
|
||||
<p className="text-xs text-destructive">{customTopicError}</p>
|
||||
)}
|
||||
```
|
||||
- Button "Save custom topic" calling:
|
||||
```tsx
|
||||
const resp = await fetch('/api/me/channels/ntfy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic: customTopicInput }),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
setCustomTopicError(body.message || body.error || `Save failed (${resp.status})`);
|
||||
toast.error('Failed to save channel');
|
||||
return;
|
||||
}
|
||||
setChannels((c) => ({ ...c, ntfy: body.channel }));
|
||||
setNtfyTest(body.test);
|
||||
setCustomTopicError(null);
|
||||
toast.success('Channel saved');
|
||||
```
|
||||
- Bottom buttons row: `<Button onClick={testNow}>Test now</Button>` (calls `POST /api/me/channels/ntfy/test`) `<Button variant="ghost" onClick={removeNtfy} className="text-destructive">Remove</Button>` (calls `DELETE /api/me/channels/ntfy`).
|
||||
|
||||
Each handler:
|
||||
- `requireAuth()` first.
|
||||
- Read the row (`SELECT * FROM notification_channels WHERE id = $1`).
|
||||
- Compute `isOwner = row.owner_user_id === session.user.id`.
|
||||
- Compute `isAdmin = session.user.role === 'admin' || session.user.role === 'super-admin'`.
|
||||
- For GET / PUT / DELETE:
|
||||
- If row is global (`owner_user_id IS NULL`) → require `isAdmin` else 403.
|
||||
- If row is personal → require `isOwner OR isAdmin` else 403 (CHAN-06: admins have full read+edit).
|
||||
- Existing UPDATE / DELETE logic preserved verbatim under the gate.
|
||||
All buttons use shadcn `Button` from `@/components/ui/button`. Test result icons from `lucide-react` (`CheckCircle`, `XCircle`).
|
||||
|
||||
Step C — `app/admin/workflow/channels/page.tsx`:
|
||||
Step C — Delete `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx`. The new real `ProfileChannelsSection.tsx` exports the same named symbol so downstream imports stay valid after the swap.
|
||||
|
||||
- Page top: add a filter Select (using `@/components/ui/select`) labeled "Show:" with options "All" (default), "Global only", "Personal only". State `ownerFilter: 'all' | 'global' | 'personal'`. Reload list when it changes by passing `?owner=...` to `/api/notification-channels`.
|
||||
- Add an "Owner" badge to each channel row card. Render BEFORE the existing channel-type Badge:
|
||||
- When `channel.owner_user_id == null`: `<Badge variant="secondary">Global</Badge>`
|
||||
- Else: `<Badge>Personal: {channel.owner_email ?? channel.owner_user_id}</Badge>` (the `owner_email` field comes from the JOIN added in Step A).
|
||||
- The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit).
|
||||
- Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `<p className="text-xs text-muted-foreground">Personal channels contain user-supplied webhook URLs — handle with care.</p>` (operational hygiene).
|
||||
Step D — Edit `app/mobile/profile/page.tsx` to change ONLY the import line:
|
||||
|
||||
```diff
|
||||
- import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSectionPlaceholder';
|
||||
+ import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection';
|
||||
```
|
||||
|
||||
Do NOT modify any other line in `page.tsx`.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && 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</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/api/notification-channels/route.ts` GET handler calls `requireAuth()` (it does NOT today)
|
||||
- GET handler returns only global rows (`WHERE owner_user_id IS NULL`) for non-admin sessions
|
||||
- GET handler for admin sessions runs `LEFT JOIN "user" u ON u.id = nc.owner_user_id` and selects `u.email AS owner_email`
|
||||
- GET handler accepts an `owner` query parameter with values `global` / `personal` / `all`
|
||||
- POST handler calls `requireAdmin()` (it does NOT today)
|
||||
- `app/api/notification-channels/[id]/route.ts` PUT and DELETE handlers each call `requireAuth()` and authorize via `(isAdmin || isOwner)` predicate against `row.owner_user_id`
|
||||
- `app/admin/workflow/channels/page.tsx` renders an Owner badge on each channel row that displays `Global` for null owner or `Personal: {email}` otherwise
|
||||
- `app/admin/workflow/channels/page.tsx` has a Select filter with the three values (`all`, `global`, `personal`)
|
||||
- Selecting the filter updates the fetch URL with `?owner=`
|
||||
- `npm install qrcode.react` was run during the task — `node_modules/qrcode.react/package.json` exists (MEDIUM 11 from plan checker)
|
||||
- `package.json` includes `"qrcode.react"` in dependencies pinned to `^4.0.0` (or current major)
|
||||
- `package-lock.json` contains `qrcode.react` entries (verified by `grep -q "qrcode.react" package-lock.json`)
|
||||
- `components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx` no longer exists (deleted)
|
||||
- `components/mobile/profile/ProfileChannelsSection.tsx` exists, starts with `'use client';`, exports a NAMED function `ProfileChannelsSection` matching the import in page.tsx
|
||||
- File imports the QR component from `qrcode.react` (`QRCodeSVG` or default)
|
||||
- File contains the exact label `Microsoft Teams webhook URL`
|
||||
- File contains the placeholder `https://yourorg.webhook.office.com/`
|
||||
- File contains the literal `Mobile push (ntfy)`
|
||||
- File contains the literal `Enable mobile push`
|
||||
- File contains the literal `Pulse will generate a private topic for you.`
|
||||
- File contains the literal `Scan with the ntfy app to subscribe.`
|
||||
- File contains the literal `Save Teams URL`
|
||||
- File contains the literal `Clear` (Teams clear button)
|
||||
- File contains the literal `Edit advanced` (disclosure label)
|
||||
- File contains the literal `Test now`
|
||||
- File contains the literal `Remove`
|
||||
- File contains the literal `Channel verified` (success copy)
|
||||
- File contains the literal `Test failed`
|
||||
- File contains the literal toast string `'Channel saved'`
|
||||
- File contains the literal toast string `'Channel removed'`
|
||||
- File makes fetch calls to `/api/me/channels`, `/api/me/channels/teams`, `/api/me/channels/ntfy`, `/api/me/channels/ntfy/test`
|
||||
- File DELETE call uses `method: 'DELETE'` (or `"DELETE"`)
|
||||
- **HIGH 7:** File renders an inline `text-xs text-destructive` error element next to the custom-topic Input, displaying the response body's `message` / `error` string when PUT /api/me/channels/ntfy returns a non-2xx (mirrors the Teams URL inline-error pattern)
|
||||
- File contains a state variable named `customTopicError` (or equivalent) populated from the 400 response body
|
||||
- File renders an analogous inline error for the Teams URL save (`teamsError` state from the 400 response body)
|
||||
- QR code rendered inside an element with `aria-label` referencing the topic (accessibility)
|
||||
- `app/mobile/profile/page.tsx` imports `ProfileChannelsSection` from `@/components/mobile/profile/ProfileChannelsSection` (NOT `Placeholder`)
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
- LOW 14: Manual smoke step recorded in SUMMARY: navigate to `/mobile/profile` after sign-in, confirm no console errors during initial render
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Admin Channels page handles personal-vs-global rows; admins see full data and can edit any row; non-admins (e.g., a pipeline operator without admin role) only see global rows.
|
||||
Channels section renders Teams + ntfy with the full UI-SPEC interaction model (mint, QR, test result inline, advanced override with inline-error, remove). qrcode.react is fully installed (package.json + lockfile + node_modules). The placeholder is gone and the page imports the real component.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: /admin/workflow/event-keys CRUD page + API + pipeline executions fallback filter</name>
|
||||
<files>app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts, app/admin/workflow/pipelines/[id]/page.tsx, app/api/pipelines/[id]/executions/route.ts</files>
|
||||
<name>Task 2: Theme session bridge + ThemeToggle write-through</name>
|
||||
<files>components/mobile/profile/ThemeSessionBridge.tsx, components/theme-toggle.tsx, app/layout.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout)
|
||||
- app/admin/workflow/pipelines/[id]/page.tsx (current recent_executions rendering — add the filter near the top of that section)
|
||||
- app/api/pipelines/[id]/executions/route.ts (current SELECT — extend with the JSONB filter)
|
||||
- lib/auth-utils.ts (requireAdmin signature)
|
||||
- lib/types/pipeline.ts (NotifyEventKey type from Plan 01)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-13 lookup table — admin manages, not a gate)
|
||||
- components/theme-toggle.tsx (current setTheme handlers — three DropdownMenuItems)
|
||||
- components/theme-provider.tsx
|
||||
- app/layout.tsx (where ThemeProvider mounts; the bridge sits inside it)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-16, D-17, D-20)
|
||||
- .planning/REQUIREMENTS.md (THEME-04)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — `app/api/admin/notify-event-keys/route.ts`:
|
||||
Step A — Create `components/mobile/profile/ThemeSessionBridge.tsx`:
|
||||
|
||||
GET:
|
||||
- `requireAdmin()` guard.
|
||||
- `SELECT key, display_label, description, sort_order, is_active, created_at, updated_at FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
|
||||
- Return `{ data: rows.map(camelCase) }`.
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
POST:
|
||||
- `requireAdmin()` guard.
|
||||
- Body: `{ key, display_label, description?, sort_order?, is_active? }`
|
||||
- Validate `key` is non-empty string ≤ 128 chars matching `/^[a-z][a-z0-9_]*$/i` (event key naming).
|
||||
- Validate `display_label` is non-empty string ≤ 200.
|
||||
- INSERT and return the new row. Use `ON CONFLICT (key) DO NOTHING` then re-SELECT — return 409 if key already existed.
|
||||
/**
|
||||
* ThemeSessionBridge (THEME-03 / D-16, D-17).
|
||||
*
|
||||
* On session load and after sign-in, compares session.user.theme to the
|
||||
* next-themes useTheme() value and calls setTheme(session.user.theme) if
|
||||
* different. Server is canonical; this is the bridge that enforces it.
|
||||
*
|
||||
* Renders nothing.
|
||||
*/
|
||||
|
||||
Step B — `app/api/admin/notify-event-keys/[key]/route.ts`:
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
PUT:
|
||||
- `requireAdmin()` guard.
|
||||
- `params.key` is the row to update.
|
||||
- Body fields: `display_label?`, `description?`, `sort_order?`, `is_active?`.
|
||||
- `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5 RETURNING *`
|
||||
- 404 if no row.
|
||||
type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string };
|
||||
|
||||
DELETE:
|
||||
- `requireAdmin()` guard.
|
||||
- `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`
|
||||
- 404 if no row. Return `{ deleted: true, key }`.
|
||||
export function ThemeSessionBridge() {
|
||||
const { data: session } = useSession();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
Step C — `app/admin/workflow/event-keys/page.tsx`:
|
||||
useEffect(() => {
|
||||
if (!session?.user) return;
|
||||
const serverTheme = (session.user as SessionUserWithTheme).theme;
|
||||
if (
|
||||
serverTheme === 'light' ||
|
||||
serverTheme === 'dark' ||
|
||||
serverTheme === 'system'
|
||||
) {
|
||||
if (serverTheme !== theme) {
|
||||
setTheme(serverTheme);
|
||||
}
|
||||
}
|
||||
}, [session?.user, theme, setTheme]);
|
||||
|
||||
- `'use client';` page modeled on `/admin/workflow/channels/page.tsx`.
|
||||
- Header: ArrowLeft back link to `/admin/workflow`, page title "Event Keys".
|
||||
- Body: a list of Cards or rows displaying each event key with its `display_label`, `description`, `sort_order`, `is_active` Switch, and Edit / Delete buttons.
|
||||
- "+ New event key" button opens an inline form (Input for `key`, Input for `display_label`, Input for `description`, NumericInput for `sort_order`).
|
||||
- All writes via `/api/admin/notify-event-keys/...` endpoints.
|
||||
- Render error toast via `sonner` on non-2xx; success toast on save/delete.
|
||||
|
||||
Step D — `app/api/pipelines/[id]/executions/route.ts`:
|
||||
|
||||
Extend the GET handler to accept `?fallbacks_only=1`. When set, the SELECT becomes:
|
||||
|
||||
```sql
|
||||
SELECT pe.*
|
||||
FROM pipeline_executions pe
|
||||
WHERE pe.pipeline_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
)
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $2
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Without the param, behavior is unchanged. Add `requireAuth()` to the handler if it's not already present (defense in depth — admin page already gates the surface but the API should not be unauthenticated).
|
||||
Step B — Mount `<ThemeSessionBridge />` inside `app/layout.tsx`:
|
||||
|
||||
Step E — `app/admin/workflow/pipelines/[id]/page.tsx`:
|
||||
- Find the existing `<ThemeProvider>` block.
|
||||
- Inside the `<ThemeProvider>` children, immediately AFTER `<AuthProvider>` opens (so it has access to `useSession()`), add `<ThemeSessionBridge />`. The exact placement: as the FIRST child of `<AuthProvider>`, before the existing `<div className="min-h-screen ...">`.
|
||||
- Add the import at the top: `import { ThemeSessionBridge } from '@/components/mobile/profile/ThemeSessionBridge';`
|
||||
- Do NOT change any other layout.tsx content.
|
||||
|
||||
- Locate the recent_executions panel (currently rendered from `data.recent_executions`).
|
||||
- Add a small filter chip / toggle ABOVE the executions list: a Switch labeled "Show only fallbacks" (use `@/components/ui/switch` and a `<Label>`). When ON, append `?fallbacks_only=1` to the executions fetch URL and refresh.
|
||||
- For each execution row, when the executions response contains a `has_fallback: true` flag (added by Step D's SELECT — extend it to also return `EXISTS(...) AS has_fallback` so the UI can badge per row), render a `<Badge variant="outline">fallback</Badge>` next to the existing status icon.
|
||||
Step C — Modify `components/theme-toggle.tsx` (THEME-04):
|
||||
|
||||
Note for Step D: To keep the per-row badge cheap, extend the SELECT (always, not only with the filter) to:
|
||||
```sql
|
||||
SELECT pe.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
) AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
WHERE pe.pipeline_id = $1
|
||||
${fallbacks_only ? 'AND has_fallback' : ''} -- composed as parameter; never raw concat
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $2
|
||||
```
|
||||
|
||||
The `has_fallback` filter clause must be conditionally included via a TS branch, not string concatenation of a user value. The query is parameterized; only the predicate-presence is conditional.
|
||||
- Rename or wrap the bare `setTheme` calls so each `DropdownMenuItem onClick` invokes a helper:
|
||||
```tsx
|
||||
const writeTheme = (next: 'light' | 'dark' | 'system') => {
|
||||
setTheme(next);
|
||||
// Fire-and-forget server write. No await — the session bridge re-syncs on next session refresh anyway.
|
||||
fetch('/api/me/theme', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: next }),
|
||||
}).catch(() => {
|
||||
// Silent fail on network error — the desktop affordance is best-effort.
|
||||
// The mobile profile Theme section is the explicit-error UX.
|
||||
});
|
||||
};
|
||||
```
|
||||
- Replace the three `setTheme('light')`, `setTheme('dark')`, `setTheme('system')` calls with `writeTheme('light')`, `writeTheme('dark')`, `writeTheme('system')`.
|
||||
- Do NOT remove the `useTheme` import or the existing UI structure.
|
||||
- The `theme` value read from `useTheme()` stays in scope for the existing icon-rotation logic (no behavior change for the visual toggle).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f app/admin/workflow/event-keys/page.tsx && test -f app/api/admin/notify-event-keys/route.ts && test -f "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts && grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts && grep -q "fallbacks_only\|has_fallback\|user_route_fallback" "app/api/pipelines/[id]/executions/route.ts" && grep -q "Show only fallbacks\|fallbacks_only=1" "app/admin/workflow/pipelines/[id]/page.tsx" && grep -q "has_fallback\|fallback" "app/admin/workflow/pipelines/[id]/page.tsx" && npx tsc --noEmit --pretty 2>&1 | head</automated>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/admin/workflow/event-keys/page.tsx` exists, starts with `'use client';`
|
||||
- File renders a list of event keys and has a "+ New event key" affordance
|
||||
- File issues writes to `/api/admin/notify-event-keys` (POST, PUT, DELETE on the by-key sub-route)
|
||||
- `app/api/admin/notify-event-keys/route.ts` exports GET and POST; both call `requireAdmin()`
|
||||
- GET runs `SELECT ... FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
|
||||
- POST validates `key` matches `/^[a-z][a-z0-9_]*$/i`
|
||||
- `app/api/admin/notify-event-keys/[key]/route.ts` exports PUT and DELETE; both call `requireAdmin()`
|
||||
- PUT uses `UPDATE notify_event_keys SET ... WHERE key = $5` (or equivalent param index)
|
||||
- `app/api/pipelines/[id]/executions/route.ts` SELECT contains `output_data ? 'user_route_fallback'` (the JSONB containment predicate)
|
||||
- GET handler accepts `fallbacks_only` query parameter
|
||||
- GET response per-row contains a `has_fallback` boolean
|
||||
- `app/admin/workflow/pipelines/[id]/page.tsx` contains a Switch labeled `Show only fallbacks`
|
||||
- File appends `fallbacks_only=1` to the executions fetch URL when the toggle is ON
|
||||
- File renders a `<Badge>fallback</Badge>` (or equivalent) on rows where `has_fallback === true`
|
||||
- `npx tsc --noEmit --pretty` reports no errors in any modified file
|
||||
- `components/mobile/profile/ThemeSessionBridge.tsx` exists, starts with `'use client';`
|
||||
- File imports both `useTheme` from `next-themes` and `useSession` from `@/lib/auth-client`
|
||||
- File contains `setTheme(serverTheme)` inside a `useEffect`
|
||||
- File guards `serverTheme` against the three-string allowlist before calling `setTheme`
|
||||
- File returns `null` (renders nothing)
|
||||
- `app/layout.tsx` imports `ThemeSessionBridge` from `@/components/mobile/profile/ThemeSessionBridge`
|
||||
- `app/layout.tsx` mounts `<ThemeSessionBridge />` inside `<AuthProvider>` (so `useSession()` works)
|
||||
- `components/theme-toggle.tsx` contains the literal `/api/me/theme` reference
|
||||
- `components/theme-toggle.tsx` issues a fetch with `method: 'PUT'` (or `"PUT"`)
|
||||
- `components/theme-toggle.tsx` still renders the three `DropdownMenuItem` rows (Light, Dark, System) — visual structure unchanged
|
||||
- `components/theme-toggle.tsx` still imports `useTheme` from `next-themes` (not removed)
|
||||
- LOW 14: Manual smoke step recorded in SUMMARY: navigate to `/mobile/profile` after sign-in, confirm no console errors AND that `<ThemeSessionBridge />` does not throw on first render
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Admins have a real CRUD page for the event-key lookup (D-13), and the pipeline-detail recent-executions panel surfaces user_route_fallback events with a one-click filter (ROUTE-07).
|
||||
Server theme is canonical: ThemeSessionBridge reconciles next-themes from `session.user.theme` on session load. ThemeToggle writes through to the server on every selection.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -343,51 +380,57 @@ After this plan: GET /api/pipelines/[id]/executions?limit=N&fallbacks_only=1 →
|
|||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /admin/* | Admin role required; non-admins must get 403 |
|
||||
| Browser → /api/notification-channels | Previously unauthenticated — closing the gap |
|
||||
| Browser → /api/admin/notify-event-keys | New surface; admin-only |
|
||||
| Browser → /api/pipelines/[id]/executions | New filter param; auth required |
|
||||
| Browser → /api/me/* | Already mitigated by Plan 02; this plan only consumes those endpoints |
|
||||
| Browser DOM → ntfy.sh subscribe link | User's own minted topic; rendered as anchor + QR — no third-party script |
|
||||
|
||||
## STRIDE Threat Register (ASVS L1)
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-09-05-01 | Information Disclosure | `/api/notification-channels` GET | mitigate | Adds `requireAuth()` (was unauthenticated). Non-admin sessions are scoped to global rows only via `WHERE owner_user_id IS NULL`. Admins see all rows. Closes a pre-existing gap |
|
||||
| T-09-05-02 | Elevation of Privilege | `/api/notification-channels/[id]` PUT/DELETE | mitigate | Per-row authorization: `isAdmin || isOwner`. A non-admin can edit only their own personal channel; the legacy admin path for global channels still requires admin |
|
||||
| T-09-05-03 | Information Disclosure | Admin Owner-column rendering | accept | Webhook URLs and ntfy topics ARE shown in the admin UI — D-07 explicitly accepts this trade-off (admins can read user secrets) to enable onboarding/offboarding fixes. Mitigation: small inline disclaimer above the personal-channel list |
|
||||
| T-09-05-04 | Tampering | `/api/admin/notify-event-keys` POST | mitigate | `requireAdmin()` guard + `key` regex `^[a-z][a-z0-9_]*$/i` + length limits. `ON CONFLICT (key) DO NOTHING` prevents accidental overwrite of an existing key — UI must use PUT for updates |
|
||||
| T-09-05-05 | Elevation of Privilege | `/admin/workflow/event-keys` page | mitigate | Page issues all writes through the gated API routes; even if a non-admin reaches the page URL directly, the API returns 403. Page does not embed admin-only secrets in the rendered HTML beyond what the API would return |
|
||||
| T-09-05-06 | SQL Injection | executions endpoint `fallbacks_only` filter | mitigate | The `fallbacks_only` query param is converted to a boolean (`searchParams.get('fallbacks_only') === '1'`) and used to choose which parameterized SQL string to execute — never concatenated into the SQL. Pipeline `id` and limit remain parameterized via `$1` / `$2` |
|
||||
| T-09-05-07 | Information Disclosure / Logging | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/pipelines/[id]`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click) |
|
||||
| T-09-05-01 | Information Disclosure | QR code rendering | mitigate | `qrcode.react` is a pure client-side SVG library; no network calls at render time. The QR encodes only the user's own ntfy subscribe URL — no secrets |
|
||||
| T-09-05-02 | Tampering | Optimistic theme update rollback | mitigate | `ProfileThemeSection` (Plan 04) rolls back `setTheme(previous)` on PUT error; `ThemeSessionBridge` (this plan) re-syncs from server on next session refresh as the canonical source |
|
||||
| T-09-05-03 | Spoofing | `ThemeSessionBridge` | mitigate | Reads `session.user.theme` from `useSession()` (Better Auth signed cookie). Validates against the three-string allowlist before calling `setTheme` — drops any unexpected value silently |
|
||||
| T-09-05-04 | Information Disclosure | Inline test-result error display | mitigate | The error string from `/api/me/channels` is already truncated to 200 chars (Plan 02 / `lib/services/personal-channels.ts`). UI renders it `text-xs` and only on the user's own channel form — never on another user's data |
|
||||
| T-09-05-05 | Information Disclosure | Inline custom-topic 400 message | mitigate | The `message` from the Plan 02 400 response is rendered verbatim inside `text-xs text-destructive`. React auto-escapes string children, and the message is server-controlled (e.g., "topic must match ^[A-Za-z0-9_-]{6,64}$"), not user-controlled. Mirrors the existing Teams URL inline-error pattern |
|
||||
| T-09-05-06 | XSS | Subscribe link `target="_blank"` | mitigate | Anchor uses `rel="noopener noreferrer"`. Topic value flows from server-minted UUID-prefixed string (Plan 02), not user input |
|
||||
| T-09-05-07 | DoS / Bundle bloat | `qrcode.react` dependency | accept | ~10 KB gzip; loaded only on `/mobile/profile`. Reviewed for "no network-call side effects at render time" per UI-SPEC Registry Safety section. Lockfile entry ensures reproducible installs (CI/Docker safe) |
|
||||
| T-09-05-08 | CSRF | Fetch calls from client | mitigate | Better Auth uses signed httpOnly session cookies; same-origin fetch is implicitly authenticated. No third-party origin can post on behalf of the user without the cookie |
|
||||
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V4.1.1 (per-resource authorization), V4.2.2 (data minimization for non-admins), V5.1.3 (input validation on the event key), V12.1.1 (parameterized SQL).
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V14.4.5 (anti-clickjacking via SameSite cookie), V11.1.5 (XSS via React auto-escaping + rel=noopener), V14.2.3 (dependency lockfile reproducibility).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- All five admin/API files compile under `npx tsc --noEmit --pretty`.
|
||||
- Hitting `/api/notification-channels` without a session returns 401.
|
||||
- Hitting `/api/notification-channels` as a non-admin user returns only global rows.
|
||||
- Hitting `/api/notification-channels/[id]` PUT for someone else's personal row as a non-admin returns 403.
|
||||
- Hitting `/api/admin/notify-event-keys` as a non-admin returns 403.
|
||||
- The recent-executions panel renders a `fallback` badge on rows where `has_fallback === true`, and toggling "Show only fallbacks" filters to those rows.
|
||||
- `npm install` (re-runs idempotently) yields `node_modules/qrcode.react/package.json`.
|
||||
- `npx tsc --noEmit --pretty` exits 0.
|
||||
- `npm run build` (turbopack) builds without errors.
|
||||
- Manual smoke (post-deploy):
|
||||
1. Configure Teams URL with bad host → inline `text-xs text-destructive` shows the server's `message`.
|
||||
2. Configure Teams URL with valid `https://*.webhook.office.com` → test send works → save → toast.
|
||||
3. Enable mobile push → topic minted → QR visible → subscribe link works.
|
||||
4. Open "Edit advanced" → enter `bad space` topic → 400 → inline error shows under the Input.
|
||||
5. Open desktop ThemeToggle → switch theme → reload mobile profile → matches.
|
||||
6. LOW 14: navigate to /mobile/profile after sign-in, no console errors.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `/admin/workflow/channels` shows Owner column + filter; admins can edit any personal row; non-admins see only global rows.
|
||||
2. `/admin/workflow/event-keys` exists as a real CRUD page; reachable from `/admin/workflow` (the executor can either add a link there or document the URL in the SUMMARY for a future small wire-up).
|
||||
3. `/admin/workflow/pipelines/[id]` has a "Show only fallbacks" toggle and renders a fallback badge per row.
|
||||
4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization.
|
||||
5. `npx tsc --noEmit --pretty` exits 0.
|
||||
1. `ProfileChannelsSection.tsx` exists; the placeholder file is deleted; `app/mobile/profile/page.tsx` imports the real component.
|
||||
2. `qrcode.react` is installed (node_modules + package.json + package-lock.json all consistent).
|
||||
3. `ThemeSessionBridge` is mounted in `app/layout.tsx` and renders nothing.
|
||||
4. `ThemeToggle` writes through to `/api/me/theme` on every selection.
|
||||
5. The custom ntfy topic Input renders an inline error `text-xs text-destructive` on 400, mirroring the Teams URL pattern.
|
||||
6. `npx tsc --noEmit --pretty` exits 0.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md` documenting:
|
||||
- The Owner column rendering rule (Global vs Personal: {email})
|
||||
- The owner-filter URL parameter values supported by `/api/notification-channels`
|
||||
- The new event-keys CRUD page URL and the regex used to validate event keys
|
||||
- The exact JSONB predicate added to the executions query (`output_data ? 'user_route_fallback'`)
|
||||
- The `has_fallback` per-row flag added to the executions response
|
||||
- The deliberate redirection of ROUTE-07's `/admin/workflow/executions` filter onto the per-pipeline detail page (with rationale: pipeline_execution_steps table is the source of `user_route_fallback`, and that's where the existing pipeline-engine executions UI lives)
|
||||
- Whether a link to `/admin/workflow/event-keys` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred)
|
||||
- The 2 new files (ProfileChannelsSection, ThemeSessionBridge) and the 3 modified files (theme-toggle, layout, page.tsx)
|
||||
- The qrcode.react version pinned in package.json (and confirmation `package-lock.json` was regenerated)
|
||||
- The ThemeSessionBridge sync rule (compares session.user.theme to useTheme(), calls setTheme on mismatch, validated against allowlist)
|
||||
- The ThemeToggle write-through behavior
|
||||
- The custom-topic inline-error UX (HIGH 7 fix)
|
||||
- The placeholder swap (`ProfileChannelsSectionPlaceholder.tsx` deleted, page.tsx import updated)
|
||||
- LOW 14: confirmation of manual smoke (navigate to /mobile/profile after sign-in, no console errors)
|
||||
- Any deviations from the UI-SPEC (expected: none)
|
||||
</output>
|
||||
</output>
|
||||
|
|
|
|||
523
.planning/phases/09-user-profile-preferences-new/09-06-PLAN.md
Normal file
523
.planning/phases/09-user-profile-preferences-new/09-06-PLAN.md
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
---
|
||||
phase: 09-user-profile-preferences-new
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [09-01, 09-02, 09-03]
|
||||
files_modified:
|
||||
- app/admin/workflow/channels/page.tsx
|
||||
- app/api/notification-channels/route.ts
|
||||
- app/api/notification-channels/[id]/route.ts
|
||||
- app/admin/workflow/event-keys/page.tsx
|
||||
- app/api/admin/notify-event-keys/route.ts
|
||||
- app/api/admin/notify-event-keys/[key]/route.ts
|
||||
- app/admin/workflow/executions/page.tsx
|
||||
- app/api/admin/pipeline-executions/route.ts
|
||||
autonomous: true
|
||||
requirements: [CHAN-06, SUB-01, ROUTE-07]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admins (role admin or super-admin) can read AND edit any user's personal notification channel via /admin/workflow/channels — including viewing the webhook_url / topic and toggling is_active and triggering test sends"
|
||||
- "/admin/workflow/channels has an Owner column that displays 'Global' for owner_user_id IS NULL or the owner's email otherwise, plus a filter widget that toggles between 'All', 'Global only', and 'Personal only'"
|
||||
- "/admin/workflow/event-keys exists as a real CRUD page for admins (list, create, edit, soft-toggle is_active, change sort_order, delete)"
|
||||
- "/admin/workflow/executions exists as a NEW admin page (not the legacy workflow_executions surface) that lists pipeline-engine executions across all pipelines with a 'Show only fallbacks' filter that filters to executions where any pipeline_execution_steps row has output_data ? 'user_route_fallback'"
|
||||
- "All admin routes use requireAdmin() — non-admin users get 403"
|
||||
- "Non-admin users can never read another user's personal channel via /api/notification-channels (the legacy endpoint hides personal rows for non-admins)"
|
||||
- "POST /api/notification-channels still accepts all four channel_type values (teams, telegram, ntfy, webhook) — the existing global-channel allowlist is unchanged"
|
||||
artifacts:
|
||||
- path: "app/admin/workflow/channels/page.tsx"
|
||||
provides: "Channels list with Owner column + filter; admin edit of personal channels"
|
||||
- path: "app/api/notification-channels/route.ts"
|
||||
provides: "Updated GET to require auth, scope visibility by role; POST stays admin-only"
|
||||
- path: "app/api/notification-channels/[id]/route.ts"
|
||||
provides: "Updated PUT/DELETE/GET to authorize per-row by ownership and role"
|
||||
- path: "app/admin/workflow/event-keys/page.tsx"
|
||||
provides: "New admin CRUD page for notify_event_keys"
|
||||
- path: "app/api/admin/notify-event-keys/route.ts"
|
||||
provides: "GET list / POST create event keys (admin-only)"
|
||||
- path: "app/api/admin/notify-event-keys/[key]/route.ts"
|
||||
provides: "PUT update / DELETE one event key by primary key"
|
||||
- path: "app/admin/workflow/executions/page.tsx"
|
||||
provides: "NEW admin page listing pipeline-engine executions with 'Show only fallbacks' filter (ROUTE-07)"
|
||||
- path: "app/api/admin/pipeline-executions/route.ts"
|
||||
provides: "GET list across all pipelines with optional fallbacks_only filter"
|
||||
key_links:
|
||||
- from: "/admin/workflow/channels"
|
||||
to: "notification_channels with owner_user_id JOIN user.email"
|
||||
via: "LEFT JOIN \"user\" ON owner_user_id = user.id; renders Owner column"
|
||||
pattern: "owner_user_id"
|
||||
- from: "/admin/workflow/event-keys"
|
||||
to: "notify_event_keys"
|
||||
via: "POST/PUT/DELETE via /api/admin/notify-event-keys"
|
||||
pattern: "notify_event_keys"
|
||||
- from: "/admin/workflow/executions"
|
||||
to: "pipeline_executions + pipeline_execution_steps.output_data->'user_route_fallback'"
|
||||
via: "GET /api/admin/pipeline-executions?fallbacks_only=1"
|
||||
pattern: "user_route_fallback"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Land the admin surfaces required by Phase 9:
|
||||
|
||||
1. **CHAN-06: Admin full-edit of personal channels.** `/admin/workflow/channels` gains
|
||||
an Owner column + filter. Admins can read/edit any user's personal Teams webhook URL
|
||||
or ntfy topic, toggle is_active, and trigger test sends. Non-admins still cannot
|
||||
see other users' personal channels via the legacy `/api/notification-channels` API.
|
||||
|
||||
2. **SUB-01 admin CRUD: `/admin/workflow/event-keys`.** A new small admin page
|
||||
(D-13) for managing the `notify_event_keys` lookup. Backed by two new API routes.
|
||||
|
||||
3. **ROUTE-07: New `/admin/workflow/executions` page with the fallback filter.**
|
||||
The plan checker's HIGH-3 issue is resolved here by building the filter on a
|
||||
NEW route at `/admin/workflow/executions` (not the legacy workflow-engine
|
||||
surface, which is `/admin/workflow/executions` in the legacy `workflow_executions`
|
||||
table — that table doesn't carry pipeline-engine fallback data anyway). This
|
||||
plan creates a fresh admin page and a fresh API route over the pipeline-engine
|
||||
`pipeline_executions` + `pipeline_execution_steps` tables, listing executions
|
||||
across ALL pipelines with the `user_route_fallback` filter. CONTEXT.md D-11
|
||||
specifically calls out `/admin/workflow/executions` as the surface for this
|
||||
filter — building it here honors the locked decision verbatim.
|
||||
|
||||
Out of scope:
|
||||
- Backfilling existing notification_channels rows to set owner_user_id (they stay
|
||||
global / NULL).
|
||||
- Notifying users when their personal channel changes (deferred per CONTEXT
|
||||
"channel-rotation flow" deferred idea).
|
||||
|
||||
Output: 3 modified existing files, 5 new files (3 new pages + 2 new API routes — the
|
||||
pipeline-executions API route is also new because we're scoping over all pipelines).
|
||||
|
||||
This plan stays small per task by keeping API route handlers minimal (no new
|
||||
business logic — just CRUD + auth-gating) and keeping the SQL parameterized.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@CLAUDE.md
|
||||
@app/admin/workflow/channels/page.tsx
|
||||
@app/api/notification-channels/route.ts
|
||||
@app/api/notification-channels/[id]/route.ts
|
||||
@app/admin/workflow/pipelines/[id]/page.tsx
|
||||
@app/api/pipelines/[id]/executions/route.ts
|
||||
@lib/auth-utils.ts
|
||||
@lib/services/personal-channels.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 schema (already landed): -->
|
||||
|
||||
notification_channels (post-Plan-01): id, name, channel_type, config, is_active, owner_user_id (NULL=global), created_at, updated_at
|
||||
|
||||
notify_event_keys: key (PK), display_label, description, sort_order, is_active, created_at, updated_at
|
||||
|
||||
pipeline_execution_steps.output_data is JSONB. After Plan 03, on user-route fallback the value contains:
|
||||
output_data->'user_route_fallback' = { reason: string, user_id?: string, channel_type: string, error?: string }
|
||||
|
||||
<!-- Auth helpers (existing): -->
|
||||
requireAuth() / requireAdmin() / requirePermission(resource, action) from lib/auth-utils.ts
|
||||
|
||||
<!-- Existing admin Channels page state (today; you'll extend, not replace): -->
|
||||
- Reads from `/api/notification-channels` (no auth today — gap to close in Task 1)
|
||||
- Writes via `/api/notification-channels`, `/api/notification-channels/[id]`, `/api/notification-channels/[id]/test`
|
||||
- Uses Card + Switch + DataTable-less list rendering
|
||||
|
||||
<!-- New executions API shape (this plan creates it): -->
|
||||
GET /api/admin/pipeline-executions?limit=N&fallbacks_only=1&pipeline_id=N → { data: Array<execution + has_fallback boolean> }
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Owner column + role-scoped reads on /admin/workflow/channels</name>
|
||||
<files>app/api/notification-channels/route.ts, app/api/notification-channels/[id]/route.ts, app/admin/workflow/channels/page.tsx</files>
|
||||
|
||||
<read_first>
|
||||
- app/api/notification-channels/route.ts (current GET / POST — no auth today)
|
||||
- app/api/notification-channels/[id]/route.ts (current GET / PUT / DELETE — no auth today)
|
||||
- app/admin/workflow/channels/page.tsx (current rendering structure to extend)
|
||||
- lib/auth-utils.ts (requireAuth, requireAdmin)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-07 admin full edit, CHAN-06)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — `app/api/notification-channels/route.ts` (GET + POST):
|
||||
|
||||
Make this route auth-aware AND role-scoped. The existing implementation has no auth gate (security gap). Replace handlers with:
|
||||
|
||||
GET:
|
||||
- Call `requireAuth()`. On error return error.
|
||||
- If `(session.user.role === 'admin' || session.user.role === 'super-admin')`:
|
||||
- Run a JOIN that returns owner email for personal channels:
|
||||
```sql
|
||||
SELECT nc.*, u.email AS owner_email
|
||||
FROM notification_channels nc
|
||||
LEFT JOIN "user" u ON u.id = nc.owner_user_id
|
||||
ORDER BY nc.owner_user_id NULLS FIRST, nc.name
|
||||
```
|
||||
- Optional `?owner=global|personal|all` filter (default `all`):
|
||||
- `global` → `WHERE owner_user_id IS NULL`
|
||||
- `personal` → `WHERE owner_user_id IS NOT NULL`
|
||||
- Else (non-admin):
|
||||
- Return only global rows (`WHERE owner_user_id IS NULL`) — non-admins see admin-managed channels for selection in pipelines but NOT other users' personal channels.
|
||||
- Response shape: `{ data: rows, total: rows.length }` (preserve existing shape).
|
||||
|
||||
POST:
|
||||
- Call `requireAdmin()`. On error return error.
|
||||
- Existing validation logic preserved verbatim — the POST handler MUST continue to accept all four `channel_type` values: `teams`, `telegram`, `ntfy`, `webhook` (LOW 12 from plan checker — explicit acceptance criterion).
|
||||
- INSERT statement adds `owner_user_id` to the column list, accepting `body.owner_user_id ?? null`. Default behavior: rows created via this admin endpoint are GLOBAL (owner_user_id NULL).
|
||||
|
||||
Step B — `app/api/notification-channels/[id]/route.ts` (GET / PUT / DELETE):
|
||||
|
||||
Each handler:
|
||||
- `requireAuth()` first.
|
||||
- Read the row (`SELECT * FROM notification_channels WHERE id = $1`).
|
||||
- Compute `isOwner = row.owner_user_id === session.user.id`.
|
||||
- Compute `isAdmin = session.user.role === 'admin' || session.user.role === 'super-admin'`.
|
||||
- For GET / PUT / DELETE:
|
||||
- If row is global (`owner_user_id IS NULL`) → require `isAdmin` else 403.
|
||||
- If row is personal → require `isOwner OR isAdmin` else 403 (CHAN-06: admins have full read+edit).
|
||||
- Existing UPDATE / DELETE logic preserved verbatim under the gate.
|
||||
|
||||
Step C — `app/admin/workflow/channels/page.tsx`:
|
||||
|
||||
- Page top: add a filter Select (using `@/components/ui/select`) labeled "Show:" with options "All" (default), "Global only", "Personal only". State `ownerFilter: 'all' | 'global' | 'personal'`. Reload list when it changes by passing `?owner=...` to `/api/notification-channels`.
|
||||
- Add an "Owner" badge to each channel row card. Render BEFORE the existing channel-type Badge:
|
||||
- When `channel.owner_user_id == null`: `<Badge variant="secondary">Global</Badge>`
|
||||
- Else: `<Badge>Personal: {channel.owner_email ?? channel.owner_user_id}</Badge>` (the `owner_email` field comes from the JOIN added in Step A).
|
||||
- The existing edit/delete/test buttons stay on every row regardless of ownership — this is the admin surface (D-07: full edit).
|
||||
- Add a small disclaimer above the channel list when `ownerFilter !== 'global'`: `<p className="text-xs text-muted-foreground">Personal channels contain user-supplied webhook URLs — handle with care.</p>` (operational hygiene).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -q "requireAuth\|requireAdmin" app/api/notification-channels/route.ts && 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 && grep -q "teams\|telegram\|ntfy\|webhook" app/api/notification-channels/route.ts && npx tsc --noEmit --pretty 2>&1 | head</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/api/notification-channels/route.ts` GET handler calls `requireAuth()` (it does NOT today)
|
||||
- GET handler returns only global rows (`WHERE owner_user_id IS NULL`) for non-admin sessions
|
||||
- GET handler for admin sessions runs `LEFT JOIN "user" u ON u.id = nc.owner_user_id` and selects `u.email AS owner_email`
|
||||
- GET handler accepts an `owner` query parameter with values `global` / `personal` / `all`
|
||||
- POST handler calls `requireAdmin()` (it does NOT today)
|
||||
- **LOW 12 (explicit acceptance):** POST handler still accepts all four `channel_type` values: `'teams'`, `'telegram'`, `'ntfy'`, `'webhook'` (the global-channel allowlist is unchanged from today)
|
||||
- `app/api/notification-channels/[id]/route.ts` PUT and DELETE handlers each call `requireAuth()` and authorize via `(isAdmin || isOwner)` predicate against `row.owner_user_id`
|
||||
- `app/admin/workflow/channels/page.tsx` renders an Owner badge on each channel row that displays `Global` for null owner or `Personal: {email}` otherwise
|
||||
- `app/admin/workflow/channels/page.tsx` has a Select filter with the three values (`all`, `global`, `personal`)
|
||||
- Selecting the filter updates the fetch URL with `?owner=`
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Admin Channels page handles personal-vs-global rows; admins see full data and can edit any row; non-admins (e.g., a pipeline operator without admin role) only see global rows. POST allowlist for `channel_type` remains all four values.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: /admin/workflow/event-keys CRUD page + API</name>
|
||||
<files>app/admin/workflow/event-keys/page.tsx, app/api/admin/notify-event-keys/route.ts, app/api/admin/notify-event-keys/[key]/route.ts</files>
|
||||
|
||||
<read_first>
|
||||
- app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft back link + container layout)
|
||||
- lib/auth-utils.ts (requireAdmin signature)
|
||||
- lib/types/pipeline.ts (NotifyEventKey type from Plan 01)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-13 lookup table — admin manages, not a gate)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Step A — `app/api/admin/notify-event-keys/route.ts`:
|
||||
|
||||
GET:
|
||||
- `requireAdmin()` guard.
|
||||
- `SELECT key, display_label, description, sort_order, is_active, created_at, updated_at FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
|
||||
- Return `{ data: rows.map(camelCase) }`.
|
||||
|
||||
POST:
|
||||
- `requireAdmin()` guard.
|
||||
- Body: `{ key, display_label, description?, sort_order?, is_active? }`
|
||||
- Validate `key` is non-empty string ≤ 128 chars matching `/^[a-z][a-z0-9_]*$/i` (event key naming).
|
||||
- Validate `display_label` is non-empty string ≤ 200.
|
||||
- INSERT and return the new row. Use `ON CONFLICT (key) DO NOTHING` then re-SELECT — return 409 if key already existed.
|
||||
|
||||
Step B — `app/api/admin/notify-event-keys/[key]/route.ts`:
|
||||
|
||||
PUT:
|
||||
- `requireAdmin()` guard.
|
||||
- `params.key` is the row to update.
|
||||
- Body fields: `display_label?`, `description?`, `sort_order?`, `is_active?`.
|
||||
- `UPDATE notify_event_keys SET display_label = COALESCE($1, display_label), description = COALESCE($2, description), sort_order = COALESCE($3, sort_order), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE key = $5 RETURNING *`
|
||||
- 404 if no row.
|
||||
|
||||
DELETE:
|
||||
- `requireAdmin()` guard.
|
||||
- `DELETE FROM notify_event_keys WHERE key = $1 RETURNING key`
|
||||
- 404 if no row. Return `{ deleted: true, key }`.
|
||||
|
||||
Step C — `app/admin/workflow/event-keys/page.tsx`:
|
||||
|
||||
- `'use client';` page modeled on `/admin/workflow/channels/page.tsx`.
|
||||
- Header: ArrowLeft back link to `/admin/workflow`, page title "Event Keys".
|
||||
- Body: a list of Cards or rows displaying each event key with its `display_label`, `description`, `sort_order`, `is_active` Switch, and Edit / Delete buttons.
|
||||
- "+ New event key" button opens an inline form (Input for `key`, Input for `display_label`, Input for `description`, NumericInput for `sort_order`).
|
||||
- All writes via `/api/admin/notify-event-keys/...` endpoints.
|
||||
- Render error toast via `sonner` on non-2xx; success toast on save/delete.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f app/admin/workflow/event-keys/page.tsx && test -f app/api/admin/notify-event-keys/route.ts && test -f "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "requireAdmin" app/api/admin/notify-event-keys/route.ts && grep -q "requireAdmin" "app/api/admin/notify-event-keys/[key]/route.ts" && grep -q "notify_event_keys" app/api/admin/notify-event-keys/route.ts && npx tsc --noEmit --pretty 2>&1 | head</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/admin/workflow/event-keys/page.tsx` exists, starts with `'use client';`
|
||||
- File renders a list of event keys and has a "+ New event key" affordance
|
||||
- File issues writes to `/api/admin/notify-event-keys` (POST, PUT, DELETE on the by-key sub-route)
|
||||
- `app/api/admin/notify-event-keys/route.ts` exports GET and POST; both call `requireAdmin()`
|
||||
- GET runs `SELECT ... FROM notify_event_keys ORDER BY sort_order ASC, key ASC`
|
||||
- POST validates `key` matches `/^[a-z][a-z0-9_]*$/i`
|
||||
- `app/api/admin/notify-event-keys/[key]/route.ts` exports PUT and DELETE; both call `requireAdmin()`
|
||||
- PUT uses `UPDATE notify_event_keys SET ... WHERE key = $5` (or equivalent param index)
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
Admins have a real CRUD page for the event-key lookup (D-13) — D-13 surface lives at the URL CONTEXT.md specifies.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: NEW /admin/workflow/executions page + API for ROUTE-07 fallback filter</name>
|
||||
<files>app/admin/workflow/executions/page.tsx, app/api/admin/pipeline-executions/route.ts</files>
|
||||
|
||||
<read_first>
|
||||
- app/admin/workflow/channels/page.tsx (admin page conventions: Card + ArrowLeft + container)
|
||||
- app/admin/workflow/pipelines/[id]/page.tsx (current per-pipeline executions panel — borrow rendering ideas; DO NOT modify this file in this plan)
|
||||
- app/api/pipelines/[id]/executions/route.ts (per-pipeline executions endpoint — borrow JSONB predicate idea; DO NOT modify in this plan)
|
||||
- lib/types/pipeline.ts (PipelineExecution type if exported; otherwise inline)
|
||||
- .planning/phases/09-user-profile-preferences-new/09-CONTEXT.md (D-11 ROUTE-07 surface = `/admin/workflow/executions`, locked decision)
|
||||
- .planning/REQUIREMENTS.md (ROUTE-07 — verbatim text says `/admin/workflow/executions`)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**HIGH 3 (plan checker) resolution:** CONTEXT.md and REQUIREMENTS.md both name
|
||||
`/admin/workflow/executions` as the surface. The legacy `workflow_executions`
|
||||
table doesn't carry pipeline-engine `user_route_fallback` data. Build a NEW
|
||||
page at `/admin/workflow/executions` over the pipeline-engine tables — that
|
||||
is where the data lives and the URL the user locked in. We are NOT touching
|
||||
the legacy `workflow_executions` table or any existing admin route.
|
||||
|
||||
Step A — Create `app/api/admin/pipeline-executions/route.ts`:
|
||||
|
||||
```typescript
|
||||
// GET /api/admin/pipeline-executions?limit=50&fallbacks_only=1&pipeline_id=NN
|
||||
//
|
||||
// Lists pipeline-engine executions across ALL pipelines (or one pipeline
|
||||
// when pipeline_id is provided). Joins a `has_fallback` boolean computed
|
||||
// from pipeline_execution_steps.output_data ? 'user_route_fallback'.
|
||||
// ROUTE-07 / D-11 — admin filter for user_route_fallback events.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/auth-utils';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const { error } = await requireAdmin();
|
||||
if (error) return error;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const fallbacksOnly = searchParams.get('fallbacks_only') === '1';
|
||||
const pipelineIdRaw = searchParams.get('pipeline_id');
|
||||
const pipelineId = pipelineIdRaw && /^\d+$/.test(pipelineIdRaw)
|
||||
? Number(pipelineIdRaw) : null;
|
||||
const limitRaw = searchParams.get('limit');
|
||||
const limit = limitRaw && /^\d+$/.test(limitRaw)
|
||||
? Math.min(Number(limitRaw), 500) : 100;
|
||||
|
||||
// HIGH 4 (plan checker) — choose ONE of two complete parameterized SQL
|
||||
// strings; never use a SELECT-list alias inside its own WHERE clause
|
||||
// (PostgreSQL rejects that).
|
||||
try {
|
||||
const params: any[] = [];
|
||||
let sql: string;
|
||||
|
||||
if (fallbacksOnly && pipelineId !== null) {
|
||||
params.push(pipelineId, limit);
|
||||
sql = `
|
||||
SELECT pe.*, true AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
WHERE pe.pipeline_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
)
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
} else if (fallbacksOnly) {
|
||||
params.push(limit);
|
||||
sql = `
|
||||
SELECT pe.*, true AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
)
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $1
|
||||
`;
|
||||
} else if (pipelineId !== null) {
|
||||
params.push(pipelineId, limit);
|
||||
sql = `
|
||||
SELECT pe.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
) AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
WHERE pe.pipeline_id = $1
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
} else {
|
||||
params.push(limit);
|
||||
sql = `
|
||||
SELECT pe.*,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pipeline_execution_steps pes
|
||||
WHERE pes.execution_id = pe.id
|
||||
AND pes.output_data ? 'user_route_fallback'
|
||||
) AS has_fallback
|
||||
FROM pipeline_executions pe
|
||||
ORDER BY pe.created_at DESC
|
||||
LIMIT $1
|
||||
`;
|
||||
}
|
||||
|
||||
const result = await postgresClient.query<any>(sql, params);
|
||||
return NextResponse.json({ data: result.rows });
|
||||
} catch (e) {
|
||||
console.error('GET /api/admin/pipeline-executions failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read executions', message: e instanceof Error ? e.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Critical: NEVER concatenate a column alias (`has_fallback`) into a WHERE
|
||||
clause on the same SELECT. PostgreSQL rejects `WHERE has_fallback` because
|
||||
aliases aren't visible in WHERE — only in ORDER BY, GROUP BY, and the outer
|
||||
layer of a subquery. The pseudo-SQL in the original plan had this bug
|
||||
(`${fallbacks_only ? 'AND has_fallback' : ''}`); the four parameterized
|
||||
strings above replace it with the EXISTS predicate inlined into WHERE.
|
||||
|
||||
Step B — Create `app/admin/workflow/executions/page.tsx` (`'use client'`):
|
||||
|
||||
- Header: ArrowLeft back link to `/admin/workflow`, page title "Pipeline Executions".
|
||||
- Above the list: a row with `<Switch />` labeled "Show only fallbacks" (use `@/components/ui/switch` and `<Label>`). State `fallbacksOnly: boolean`.
|
||||
- Optional pipeline filter: a `<Select>` listing existing pipelines (fetch `/api/pipelines` if a list endpoint exists; otherwise leave the field as a free-text "Pipeline ID" Input). Default = `all`.
|
||||
- List: each row renders `id`, `pipeline_id`, `status`, `started_at`, `duration_ms`, and a `<Badge variant="outline">fallback</Badge>` when `has_fallback === true`.
|
||||
- On toggle change OR pipeline-filter change: re-fetch `/api/admin/pipeline-executions?fallbacks_only=1&pipeline_id=...&limit=100` and re-render.
|
||||
- Empty state: `<p className="text-sm text-muted-foreground">No executions match the current filter.</p>`
|
||||
- Loading state: render a small Skeleton.
|
||||
- Each row clickable: `<Link href={"/admin/workflow/pipelines/" + pipeline_id}>` so the admin can drill into the per-pipeline detail page (which already exists).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>test -f app/admin/workflow/executions/page.tsx && test -f app/api/admin/pipeline-executions/route.ts && grep -q "requireAdmin" app/api/admin/pipeline-executions/route.ts && grep -q "user_route_fallback" app/api/admin/pipeline-executions/route.ts && grep -q "fallbacks_only" app/api/admin/pipeline-executions/route.ts && grep -q "has_fallback" app/api/admin/pipeline-executions/route.ts && ! grep -q "AND has_fallback" app/api/admin/pipeline-executions/route.ts && grep -q "Show only fallbacks" app/admin/workflow/executions/page.tsx && grep -q "fallbacks_only=1\|fallbacks_only=" app/admin/workflow/executions/page.tsx && grep -q "fallback" app/admin/workflow/executions/page.tsx && npx tsc --noEmit --pretty 2>&1 | head</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `app/admin/workflow/executions/page.tsx` exists at the URL CONTEXT.md / REQUIREMENTS.md locks (`/admin/workflow/executions`)
|
||||
- File starts with `'use client';`
|
||||
- File contains a Switch labeled `Show only fallbacks`
|
||||
- File appends `fallbacks_only=1` to the executions fetch URL when the toggle is ON
|
||||
- File renders a `<Badge>fallback</Badge>` (or equivalent) on rows where `has_fallback === true`
|
||||
- `app/api/admin/pipeline-executions/route.ts` exports `GET` and calls `requireAdmin()`
|
||||
- The route handler accepts `fallbacks_only`, `pipeline_id`, and `limit` query parameters
|
||||
- The route handler implements **four** complete parameterized SQL strings (selected by the boolean flags); the `has_fallback` SELECT-list alias is NEVER referenced inside the same SELECT's WHERE clause (HIGH 4 from plan checker — verified by `! grep -q "AND has_fallback" route.ts`)
|
||||
- Each SQL string contains the JSONB containment predicate `output_data ? 'user_route_fallback'`
|
||||
- Each SQL string is parameterized: `pipeline_id` is `$1` or `$2`; `limit` always last; pipeline_id passes a `/^\d+$/` regex coercion before binding
|
||||
- Per-row response contains `has_fallback: boolean` (either as constant `true` in the fallbacks-only branches or as an EXISTS subquery in the unfiltered branches)
|
||||
- This plan does NOT modify `app/admin/workflow/pipelines/[id]/page.tsx` or `app/api/pipelines/[id]/executions/route.ts` (the new admin surface is fully fresh — preserves the locked URL without disturbing existing pipeline-detail surfaces)
|
||||
- `npx tsc --noEmit --pretty` reports no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>
|
||||
The ROUTE-07 fallback filter exists at the locked URL `/admin/workflow/executions`, sourced from the pipeline-engine tables (the only tables that carry `user_route_fallback`). The SQL avoids the alias-in-WHERE bug by selecting one of four complete parameterized strings.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /admin/* | Admin role required; non-admins must get 403 |
|
||||
| Browser → /api/notification-channels | Previously unauthenticated — closing the gap |
|
||||
| Browser → /api/admin/notify-event-keys | New surface; admin-only |
|
||||
| Browser → /api/admin/pipeline-executions | New surface; admin-only |
|
||||
|
||||
## STRIDE Threat Register (ASVS L1)
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-09-06-01 | Information Disclosure | `/api/notification-channels` GET | mitigate | Adds `requireAuth()` (was unauthenticated). Non-admin sessions are scoped to global rows only via `WHERE owner_user_id IS NULL`. Admins see all rows. Closes a pre-existing gap |
|
||||
| T-09-06-02 | Elevation of Privilege | `/api/notification-channels/[id]` PUT/DELETE | mitigate | Per-row authorization: `isAdmin || isOwner`. A non-admin can edit only their own personal channel; the legacy admin path for global channels still requires admin |
|
||||
| T-09-06-03 | Information Disclosure | Admin Owner-column rendering | accept | Webhook URLs and ntfy topics ARE shown in the admin UI — D-07 explicitly accepts this trade-off (admins can read user secrets) to enable onboarding/offboarding fixes. Mitigation: small inline disclaimer above the personal-channel list |
|
||||
| T-09-06-04 | Tampering | `/api/admin/notify-event-keys` POST | mitigate | `requireAdmin()` guard + `key` regex `^[a-z][a-z0-9_]*$/i` + length limits. `ON CONFLICT (key) DO NOTHING` prevents accidental overwrite of an existing key — UI must use PUT for updates |
|
||||
| T-09-06-05 | Elevation of Privilege | `/admin/workflow/event-keys` page | mitigate | Page issues all writes through the gated API routes; even if a non-admin reaches the page URL directly, the API returns 403. Page does not embed admin-only secrets in the rendered HTML beyond what the API would return |
|
||||
| T-09-06-06 | SQL Injection | executions endpoint | mitigate | All user input is parameterized: `pipeline_id` validated against `/^\d+$/` before binding to `$1`, `limit` validated against `/^\d+$/` and capped at 500. `fallbacks_only` is a boolean (`searchParams.get('fallbacks_only') === '1'`) used to choose which of FOUR complete SQL strings to execute — never concatenated. The earlier alias-in-WHERE bug (HIGH 4) is fixed by inlining the EXISTS predicate into WHERE in the fallbacks-only branches |
|
||||
| T-09-06-07 | Information Disclosure | per-row `has_fallback` flag | accept | The `EXISTS()` subquery is admin-only context (page is `/admin/workflow/executions`, gated by `requireAdmin()`). Reveals only that a fallback occurred, not the personal user_id (which is in the step output_data and only loaded on row click via the existing pipeline-detail page) |
|
||||
| T-09-06-08 | Tampering | LOW 12 channel_type allowlist | mitigate | POST handler retains the existing four-value `channel_type` allowlist (`teams`, `telegram`, `ntfy`, `webhook`); acceptance criteria explicitly verify it is unchanged |
|
||||
|
||||
No `high` severity unmitigated. ASVS L1 satisfied: V4.1.1 (per-resource authorization), V4.2.2 (data minimization for non-admins), V5.1.3 (input validation on the event key), V12.1.1 (parameterized SQL — validated by absence of alias-in-WHERE).
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- All seven admin/API files compile under `npx tsc --noEmit --pretty`.
|
||||
- Hitting `/api/notification-channels` without a session returns 401.
|
||||
- Hitting `/api/notification-channels` as a non-admin user returns only global rows.
|
||||
- Hitting `/api/notification-channels/[id]` PUT for someone else's personal row as a non-admin returns 403.
|
||||
- Hitting `/api/admin/notify-event-keys` as a non-admin returns 403.
|
||||
- Hitting `/api/admin/pipeline-executions` as a non-admin returns 403.
|
||||
- Hitting `/api/admin/pipeline-executions?fallbacks_only=1` as an admin returns ONLY rows where some step output_data contains `user_route_fallback`.
|
||||
- Hitting `/api/admin/pipeline-executions` as an admin returns ALL recent rows, each with `has_fallback: boolean`.
|
||||
- LOW 12: POSTing `{ name: 'test', channel_type: 'webhook', config: {} }` to `/api/notification-channels` as admin still creates a webhook-type global channel (no behavior change).
|
||||
- The new admin executions page renders at `/admin/workflow/executions` and the toggle filter works.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `/admin/workflow/channels` shows Owner column + filter; admins can edit any personal row; non-admins see only global rows.
|
||||
2. `/admin/workflow/event-keys` exists as a real CRUD page; reachable from `/admin/workflow` (the executor can either add a link there or document the URL in the SUMMARY for a future small wire-up).
|
||||
3. `/admin/workflow/executions` exists as a NEW admin page over the pipeline-engine tables, with a "Show only fallbacks" toggle and per-row fallback badge — at the URL CONTEXT.md / REQUIREMENTS.md lock.
|
||||
4. All admin API routes are gated by `requireAdmin()`; the legacy notification-channels API now uses `requireAuth()` + per-row authorization.
|
||||
5. The pipeline-executions SQL handler uses **four** complete parameterized strings (no alias-in-WHERE bug).
|
||||
6. POST `/api/notification-channels` channel_type allowlist is unchanged — all four values still accepted.
|
||||
7. `npx tsc --noEmit --pretty` exits 0.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-user-profile-preferences-new/09-06-SUMMARY.md` documenting:
|
||||
- The Owner column rendering rule (Global vs Personal: {email})
|
||||
- The owner-filter URL parameter values supported by `/api/notification-channels`
|
||||
- The new event-keys CRUD page URL and the regex used to validate event keys
|
||||
- The exact JSONB predicate used by the new pipeline-executions endpoint (`output_data ? 'user_route_fallback'`)
|
||||
- The `has_fallback` per-row flag on the executions response
|
||||
- Confirmation: the ROUTE-07 filter lives at the LOCKED URL `/admin/workflow/executions` — no silent rerouting
|
||||
- Confirmation: the executions route uses FOUR complete parameterized SQL strings; alias-in-WHERE bug is gone
|
||||
- Confirmation: POST `/api/notification-channels` channel_type allowlist (`teams`, `telegram`, `ntfy`, `webhook`) is unchanged
|
||||
- Whether a link to `/admin/workflow/event-keys` and `/admin/workflow/executions` was added on `/admin/workflow` (small wire-up — note in SUMMARY if deferred)
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue