diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8804d46..7b98aa2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -31,6 +31,7 @@ Decimal phases appear between their surrounding integers in numeric order. - [ ] **Phase 7.1: User Timezone Fix (INSERTED — urgent)** — Per-user IANA timezone column + viewer-tz date math so dashboards and filters render the right "today" - [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern - [ ] **Phase 9: User Profile & Preferences (NEW)** — `/mobile/profile` settings page (timezone chooser, theme, mobile push, Teams + ntfy channels) +- [ ] **Phase 9.1: ntfy Backend Fix (INSERTED — urgent)** — Personal ntfy channels target the company ntfy server with bearer auth + `pulse-me-` prefix (UAT gap closure) ## Phase Details @@ -197,6 +198,21 @@ Decimal phases appear between their surrounding integers in numeric order. - [x] 09-06-PLAN.md — Admin surfaces: channels Owner column + filter, event-keys CRUD page, NEW /admin/workflow/executions with fallback filter (CHAN-06, SUB-01, ROUTE-07) **UI hint**: yes +### Phase 9.1: ntfy Backend Fix (INSERTED — urgent) +**Goal**: A logged-in user enabling mobile push from `/mobile/profile` gets a topic published to `https://ntfy.wulfconsulting.cloud` (not the public `ntfy.sh`) with bearer auth via `NTFY_PULSE_TOKEN`, using the `pulse-me-` reserved prefix so personal channels never collide with the `noc-*` / `soc-*` namespaces reserved for NOC/SOC operations. +**Depends on**: Phase 9 (personal channels feature must exist) +**Requirements**: CHAN-03, CHAN-05, CHAN-07, ROUTE-04 (gap closure — re-targeting the existing implementation) +**Source**: `.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md` Test 1 — diagnosed gap +**Success Criteria** (what must be TRUE): + 1. `mintNtfyTopic()` returns `pulse-me-XXXXXXXX`; `NTFY_TOPIC_RE` enforces `^pulse-me-[A-Za-z0-9-]{6,64}$`; custom topics matching `pulse-`, `noc-`, `soc-`, or arbitrary names are rejected + 2. All four ntfy publish paths used for personal channels (`sendChannelTest`, `pipeline-steps/notify.ts sendNtfy`, `pipeline-steps/approval.ts` ntfy branch, `ticket-digest-service.ts deliver()` ntfy branch) target `${NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'}` and send `Authorization: Bearer ${NTFY_PULSE_TOKEN}` when `channel.owner_user_id` is set + 3. Global / admin ntfy channels (`owner_user_id IS NULL`) preserve their existing `channel.config.server_url` / `channel.config.auth_token` behavior — out-of-scope per gap diagnosis + 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 +**UI hint**: no (backend-heavy; one component edit for QR/link target) + ## Progress **Execution Order:** @@ -214,6 +230,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral | 7.1. User Timezone Fix | 0/5 | Not started | - | | 8. Engagement User Profile | 0/2 | Not started | - | | 9. User Profile & Preferences | 0/5 | Not started | - | +| 9.1. ntfy Backend Fix | 0/1 | Not started | - | --- *Roadmap created: 2026-05-03* diff --git a/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md b/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md new file mode 100644 index 0000000..8570c98 --- /dev/null +++ b/.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md @@ -0,0 +1,437 @@ +--- +phase: 09.1-ntfy-backend-fix +plan: "01" +type: execute +wave: 1 +depends_on: [] +gap_closure: true +autonomous: true +requirements: [CHAN-03, CHAN-05, CHAN-07, ROUTE-04] +files_modified: + - lib/services/personal-channels.ts + - lib/services/pipeline-steps/notify.ts + - lib/services/pipeline-steps/approval.ts + - lib/services/ticket-digest-service.ts + - components/mobile/profile/ProfileChannelsSection.tsx +must_haves: + truths: + - "Personal ntfy channels mint topics with the `pulse-me-` prefix (8 hex chars of entropy)" + - "Custom ntfy topics submitted by users via 'Edit advanced' are accepted only when they match `^pulse-me-[A-Za-z0-9-]{6,64}$` — `pulse-`, `noc-`, `soc-`, and arbitrary names are rejected" + - "All ntfy send paths used for personal channels (sendChannelTest, notify.ts sendNtfy, approval.ts ntfy branch, ticket-digest-service.ts ntfy branch) target the company server (https://ntfy.wulfconsulting.cloud by default) and send Authorization: Bearer ${NTFY_PULSE_TOKEN} when the channel is personal" + - "QR code and subscribe link in /mobile/profile point at the company ntfy host (NEXT_PUBLIC_NTFY_BASE_URL), not ntfy.sh" + - "Inline error copy under the custom-topic Input reads 'Topic must start with pulse-me-' on 400" + - "Existing global / admin ntfy rows (owner_user_id IS NULL) that set their own config.server_url / config.auth_token are still honored — only personal channels (owner_user_id IS NOT NULL) are forced to the company server + NTFY_PULSE_TOKEN" + artifacts: + - path: "lib/services/personal-channels.ts" + provides: "Updated NTFY_TOPIC_RE, mintNtfyTopic, sendChannelTest forcing company server + bearer auth for personal ntfy" + contains: "pulse-me-" + - path: "lib/services/pipeline-steps/notify.ts" + provides: "sendNtfy uses NTFY_BASE_URL when channel is personal; bearer auth from NTFY_PULSE_TOKEN for personal channels" + contains: "owner_user_id" + - path: "lib/services/pipeline-steps/approval.ts" + provides: "ntfy approval branch uses NTFY_BASE_URL + NTFY_PULSE_TOKEN for personal channels" + - path: "lib/services/ticket-digest-service.ts" + provides: "deliver() ntfy branch uses NTFY_BASE_URL + NTFY_PULSE_TOKEN when channel is personal" + - path: "components/mobile/profile/ProfileChannelsSection.tsx" + provides: "QR + subscribe link target NEXT_PUBLIC_NTFY_BASE_URL; inline error copy updated" + contains: "NEXT_PUBLIC_NTFY_BASE_URL" + key_links: + - from: "lib/services/personal-channels.ts mintNtfyTopic" + to: "PUT /api/me/channels/ntfy" + via: "first-save mint path returns pulse-me-XXXXXXXX" + pattern: "pulse-me-" + - from: "lib/services/personal-channels.ts sendChannelTest (ntfy)" + to: "process.env.NTFY_BASE_URL + process.env.NTFY_PULSE_TOKEN" + via: "Authorization: Bearer header on POST to {NTFY_BASE_URL}/{topic}" + pattern: "NTFY_PULSE_TOKEN" + - from: "components/mobile/profile/ProfileChannelsSection.tsx" + to: "process.env.NEXT_PUBLIC_NTFY_BASE_URL" + via: "QR `value` prop + subscribe `href`" + pattern: "NEXT_PUBLIC_NTFY_BASE_URL" +--- + + +Close the major gap surfaced in `09-HUMAN-UAT.md` Test 1: personal ntfy channels currently target the public `ntfy.sh` server with a `pulse-` prefix and no enforced auth. Production runs a private ntfy instance at `https://ntfy.wulfconsulting.cloud` with bearer auth (`NTFY_PULSE_TOKEN`) and reserves `noc-*` / `soc-*` topic prefixes for NOC/SOC. Personal channels must use the namespaced `pulse-me-` prefix and the company server with the company token. + +Purpose: make the Phase 9 personal-channel feature actually deliverable on this Pulse deployment. + +Output: regex tightened, prefix changed to `pulse-me-`, all four ntfy send sites (sendChannelTest, notify.ts, approval.ts, ticket-digest-service.ts) routed at the company server with bearer auth for personal channels, and the mobile profile UI QR/subscribe-link/error-copy aligned. Existing global/admin ntfy rows continue to honor their own config.server_url + config.auth_token (out_of_scope preserved). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-user-profile-preferences-new/09-HUMAN-UAT.md +@.planning/phases/09-user-profile-preferences-new/09-CONTEXT.md +@.planning/phases/09-user-profile-preferences-new/09-02-SUMMARY.md +@.planning/phases/09-user-profile-preferences-new/09-05-SUMMARY.md +@CLAUDE.md +@lib/services/personal-channels.ts +@lib/services/pipeline-steps/notify.ts +@lib/services/pipeline-steps/approval.ts +@lib/services/ticket-digest-service.ts +@components/mobile/profile/ProfileChannelsSection.tsx + + + + + +Env vars (read at runtime via process.env): +- NTFY_BASE_URL — server-side. Default 'https://ntfy.wulfconsulting.cloud'. +- NTFY_PULSE_TOKEN — server-side. Already in .env. Used as Bearer token for personal ntfy publishes. +- NEXT_PUBLIC_NTFY_BASE_URL — client-side (exposed by Next.js because of NEXT_PUBLIC_ prefix). Default 'https://ntfy.wulfconsulting.cloud'. + +NotificationChannel shape (from `lib/types/pipeline.ts` — already imported in all four touched files): +```ts +interface NotificationChannel { + id: number; + name: string; + channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook'; + config: Record; // { topic, server_url?, auth_token?, ... } for ntfy + is_active: boolean; + owner_user_id?: string | null; // NULL for global/admin rows, set for personal rows + // ... +} +``` + +The discriminator `owner_user_id` (added in Phase 9 migration 085) is the canonical signal for "this is a personal channel" — use it directly. Do NOT introduce a separate "isPersonal" flag. + + + + + + + Task 1: Update personal-channels.ts (regex + prefix + bearer auth) and propagate to the three other ntfy publish sites + lib/services/personal-channels.ts, lib/services/pipeline-steps/notify.ts, lib/services/pipeline-steps/approval.ts, lib/services/ticket-digest-service.ts + +Backend-only edits. Make the four changes below. Do NOT introduce a new file — all logic lives next to its existing site. Do NOT add Zod. Do NOT touch routes, DB schema, or migrations. Do NOT write to `.env` (it is committed and may carry secrets); `NTFY_PULSE_TOKEN` is already present there. + +**A. `lib/services/personal-channels.ts`** — three surgical edits: + +1. Replace the regex constant (around line 45): + ```ts + const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{6,64}$/; + ``` + with: + ```ts + /** + * Personal ntfy topic format (UAT-FIX-01): + * - MUST start with `pulse-me-` (reserved prefix for personal channels; + * `noc-*` and `soc-*` are reserved for NOC/SOC operations). + * - Followed by 6-64 chars from [A-Za-z0-9-] (no underscores after the + * prefix — keeps topics clean for URL display). + * Custom topics submitted via the /mobile/profile "Edit advanced" disclosure + * must satisfy this regex; minted topics (mintNtfyTopic) satisfy it by construction. + */ + const NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/; + ``` + The `isValidNtfyTopic` function is unchanged (still `NTFY_TOPIC_RE.test(input)`). + +2. Update `mintNtfyTopic()` (around line 56) — keep the 8 hex chars of entropy but change the prefix to `pulse-me-`: + ```ts + export function mintNtfyTopic(): string { + const id = randomUUID().replace(/-/g, '').slice(0, 8); + return `pulse-me-${id}`; + } + ``` + +3. Update `sendChannelTest` ntfy branch (around line 103). The current code reads `channel.config.server_url || 'https://ntfy.sh'` and reads `channel.config.auth_token` from the channel row. For personal channels (this function is only called from `/api/me/channels/[type]/...` routes, so EVERY channel passed in is personal), force the company server and the company token: + + Replace the `case 'ntfy':` block body (preserve the `case 'ntfy': { ... }` shell and surrounding error returns): + ```ts + case 'ntfy': { + // Personal channels (owner_user_id set) are forced to the company ntfy + // server with the company bearer token (UAT-FIX-01). The channel.config + // .server_url / .auth_token fields are ignored for personal rows. + const serverUrl = process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'; + const token = process.env.NTFY_PULSE_TOKEN; + const topic = channel.config.topic; + if (!topic) return { ok: false, error: 'ntfy channel missing topic' }; + if (!token) { + // Fail loud on misconfiguration — without the token publishes are 401. + return { ok: false, error: 'NTFY_PULSE_TOKEN not configured' }; + } + const headers: Record = { + 'Content-Type': 'text/plain', + 'Title': 'Pulse channel verified', + 'Authorization': `Bearer ${token}`, + }; + const resp = await fetch(`${serverUrl}/${topic}`, { + method: 'POST', + headers, + body: TEST_MESSAGE_BODY, + }); + if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) }; + return { ok: true }; + } + ``` + Reason for full replacement: the current branch reads `channel.config.auth_token` which is fine for legacy admin rows but personal rows never store a token (and shouldn't — admins can read another user's webhook URL per D-07, but a per-channel token is just dead config). Drop the channel.config.auth_token read entirely for this path. + +**B. `lib/services/pipeline-steps/notify.ts`** — surgical edit to `sendNtfy` (around line 358): + +This function is called from BOTH the global-channel dispatch path (`dispatchToGlobalChannel` — owner_user_id IS NULL) AND the personal-route path (`dispatchUserRoute` — owner_user_id IS NOT NULL). The channel row carries the discriminator. Branch on it: + +Replace the `sendNtfy` function body (around lines 358-396). Preserve the function signature and the `notified: true, channel: 'ntfy'` success shape. Insert a personal-vs-global branch at the top: + +```ts +async function sendNtfy( + channel: NotificationChannel, + config: Record, + message: string, +): Promise { + const topic = channel.config.topic; + if (!topic) { + return { success: false, error: 'ntfy channel missing topic' }; + } + + // Personal channels (owner_user_id set) are forced to the company ntfy + // server with the company bearer token (UAT-FIX-01). Global / admin rows + // (owner_user_id NULL) retain their existing config-driven behavior so + // legacy ntfy.sh deployments and custom self-hosted instances keep working. + const isPersonal = !!channel.owner_user_id; + const serverUrl = isPersonal + ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud') + : (channel.config.server_url || 'https://ntfy.sh'); + + const headers: Record = { + 'Content-Type': 'text/plain', + }; + + if (config.title || channel.config.default_title) { + headers['Title'] = config.title || channel.config.default_title; + } + if (config.priority || channel.config.default_priority) { + headers['Priority'] = config.priority || channel.config.default_priority; + } + + if (isPersonal) { + const token = process.env.NTFY_PULSE_TOKEN; + if (!token) { + return { success: false, error: 'NTFY_PULSE_TOKEN not configured' }; + } + headers['Authorization'] = `Bearer ${token}`; + } else if (channel.config.auth_token) { + headers['Authorization'] = `Bearer ${channel.config.auth_token}`; + } + + const resp = await fetch(`${serverUrl}/${topic}`, { + method: 'POST', + headers, + body: message, + }); + + if (!resp.ok) { + const errText = await resp.text(); + return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` }; + } + + return { success: true, output: { notified: true, channel: 'ntfy' } }; +} +``` + +**C. `lib/services/pipeline-steps/approval.ts`** — surgical edit to the `else if (channel.channel_type === 'ntfy')` branch (around line 113-131). Apply the same personal-vs-global split: + +Replace the `} else if (channel.channel_type === 'ntfy') { ... }` block with: +```ts + } else if (channel.channel_type === 'ntfy') { + // Personal channels forced to company server + token (UAT-FIX-01). + // Global rows retain their config-driven behavior. + const isPersonal = !!channel.owner_user_id; + const serverUrl = isPersonal + ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud') + : (channel.config.server_url || 'https://ntfy.sh'); + const headers: Record = { + 'Title': 'Approval Required', + 'Priority': 'high', + 'Tags': 'warning', + 'Actions': options.map(opt => + `http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST` + ).join('; '), + }; + if (isPersonal) { + const token = process.env.NTFY_PULSE_TOKEN; + if (token) headers['Authorization'] = `Bearer ${token}`; + // If token missing, send unauthenticated — approval is best-effort and + // the parent try/catch logs failures. Loud failure would block the + // whole approval step for one missing env var. + } else if (channel.config.auth_token) { + headers['Authorization'] = `Bearer ${channel.config.auth_token}`; + } + await fetch(`${serverUrl}/${channel.config.topic}`, { + method: 'POST', + headers, + body: message, + }); + } +``` +Rationale for the softer fallback here (vs. `notify.ts`'s loud error): the approval step is wrapped in a try/catch and just logs failures (see line 134); a hard return is not in this code path's vocabulary. + +**D. `lib/services/ticket-digest-service.ts`** — surgical edit to the `deliver()` method's ntfy branch (around line 648-655): + +Replace the `} else if (ch.channel_type === 'ntfy') { ... }` block with: +```ts + } else if (ch.channel_type === 'ntfy') { + // Personal channels forced to company server + token (UAT-FIX-01). + // Global rows retain config-driven behavior so admin-configured + // digest channels keep working. + const isPersonal = !!(ch as NotificationChannel & { owner_user_id?: string | null }).owner_user_id; + const server = isPersonal + ? (process.env.NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud') + : (ch.config.server_url || 'https://ntfy.sh'); + const topic = ch.config.topic; + if (!topic) throw new Error('ntfy missing topic'); + const headers: Record = { 'Content-Type': 'text/plain', 'Title': `Ticket Digest — ${stats.period.label}` }; + if (isPersonal) { + const token = process.env.NTFY_PULSE_TOKEN; + if (token) headers['Authorization'] = `Bearer ${token}`; + } else if (ch.config.auth_token) { + headers['Authorization'] = `Bearer ${ch.config.auth_token}`; + } + if (ch.config.default_priority) headers['Priority'] = ch.config.default_priority; + res = await fetch(`${server}/${topic}`, { method: 'POST', headers, body: plainText }); + } +``` + +Note on the type cast: the local `NotificationChannel` interface in `ticket-digest-service.ts` (around line 28) does NOT declare `owner_user_id`. Rather than mutate that local interface and risk type churn elsewhere, the cast above reads the column at runtime. The select at line 612 already pulls `id, name, channel_type, config, is_active` — extend that select to include `owner_user_id`: + +In `getAvailableChannels` (line 158) AND in `deliver` (line 612), change: +```ts +'SELECT id, name, channel_type, config, is_active FROM notification_channels ...' +``` +to include the column: +```ts +'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels ...' +``` +This keeps the runtime cast honest. The local interface stays as-is — the field is read via the cast and is allowed to be undefined. + +Run type check after all four files are saved. + + + npx tsc --noEmit --pretty + + +- `lib/services/personal-channels.ts` contains `NTFY_TOPIC_RE = /^pulse-me-[A-Za-z0-9-]{6,64}$/` and `mintNtfyTopic()` returns `pulse-me-XXXXXXXX`. +- `sendChannelTest`'s ntfy branch posts to `${NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'}/${topic}` with `Authorization: Bearer ${NTFY_PULSE_TOKEN}` — no read of `channel.config.server_url` / `channel.config.auth_token` in this branch. +- `lib/services/pipeline-steps/notify.ts` `sendNtfy` branches on `channel.owner_user_id`: personal → company server + bearer NTFY_PULSE_TOKEN; global → existing config-driven behavior preserved. +- `lib/services/pipeline-steps/approval.ts` ntfy branch applies the same personal-vs-global split. +- `lib/services/ticket-digest-service.ts` `deliver()` ntfy branch applies the same split, and both `getAvailableChannels` + `deliver` SELECTs include `owner_user_id`. +- `npx tsc --noEmit --pretty` exits 0. + + + + + Task 2: Update ProfileChannelsSection.tsx (QR + subscribe link + inline error copy) and verify with existing tests + components/mobile/profile/ProfileChannelsSection.tsx + +Three surgical edits to `components/mobile/profile/ProfileChannelsSection.tsx`: + +1. Introduce a module-level constant (just after the imports, before the `interface Channel` block at line 32): + ```ts + // Personal ntfy channels target the company ntfy instance. The fallback + // matches the server-side default in personal-channels.ts / notify.ts so + // the UI and the publish path stay aligned even when the env var is unset + // in a dev shell (UAT-FIX-01). + const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'; + ``` + +2. Update the subscribe link and QR code in the State B branch (around lines 281-296). Replace both `https://ntfy.sh/${ntfyTopic}` occurrences with `${NTFY_BASE}/${ntfyTopic}`: + ```tsx + + {`${NTFY_BASE}/${ntfyTopic}`} + + +
+ +
+ ``` + +3. The inline-error pattern for the custom-topic Input (line 318-320) currently reads the server's `message` body — and the server already returns the regex string. The Phase 9 plan's contract is "render `body.message || body.error` inline" and the server now returns a message anchored to the new regex. We keep that pattern (no change to `setCustomTopicError`), BUT we also add a fallback hint string so the field has a useful placeholder/help line even before the user submits. + + Find the `` block (around line 310-317). Just AFTER the Input, BEFORE the `{customTopicError && ...}` line, add a tiny help line: + ```tsx +

+ Topic must start with pulse-me-. +

+ ``` + This gives the user actionable guidance without depending on the server error text (which is already rendered below it on 400). + + Do NOT remove the existing `{customTopicError && (

{customTopicError}

)}` — that line continues to render the server's `message` on 400. The help line and the error line are stacked. + +No other edits to this file. Do NOT touch the Teams sub-section. Do NOT touch any state variables. Do NOT introduce new imports. + +Also: per the UAT gap (cleanup item) — there may be stray rows in `notification_channels` with the old `pulse-` topic prefix from QA testing. The cleanup is optional (UAT note says "low-volume QA data only"). Skip the cleanup step in this plan — if the user has stale test rows, they can DELETE manually from the admin Channels page (D-07 grants admins full edit access). If you find a stale row blocking your own smoke test, delete it via the admin UI rather than adding SQL to this plan. +
+ + npx tsc --noEmit --pretty && npx vitest run lib/services/pipeline-steps/notify.test.ts + + +- `components/mobile/profile/ProfileChannelsSection.tsx` defines `const NTFY_BASE = process.env.NEXT_PUBLIC_NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'` at module scope. +- The subscribe link `href` and rendered text both use `${NTFY_BASE}/${ntfyTopic}` — `ntfy.sh` no longer appears anywhere in the file (grep confirms: `grep -n "ntfy.sh" components/mobile/profile/ProfileChannelsSection.tsx` returns nothing). +- A muted help line `Topic must start with pulse-me-` renders between the custom-topic Input and the `customTopicError` paragraph. +- `npx tsc --noEmit --pretty` exits 0. +- `npx vitest run lib/services/pipeline-steps/notify.test.ts` passes (the muted-user behavioral test must still pass — the personal/global branch change in sendNtfy MUST NOT regress mute semantics). + +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Client → Pulse API | User-supplied custom ntfy topic crosses here (validated against `NTFY_TOPIC_RE`) | +| Pulse server → ntfy.wulfconsulting.cloud | Bearer-authenticated publish; token sourced from server env | +| Browser → ntfy.wulfconsulting.cloud | Read-only subscribe (no token exposure — subscription happens in the user's ntfy app, not in Pulse) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09.1-01 | Spoofing | Personal ntfy topic | mitigate | `pulse-me-` prefix enforcement keeps personal topics out of the `noc-*`/`soc-*` namespace — a user cannot mint or save a topic that would receive NOC/SOC traffic | +| T-09.1-02 | Tampering | Custom topic Input | mitigate | Server-side regex `^pulse-me-[A-Za-z0-9-]{6,64}$` enforced in `personal-channels.ts` `isValidNtfyTopic`; client-side help line is advisory only — server is the gate | +| T-09.1-03 | Information Disclosure | NTFY_PULSE_TOKEN | mitigate | Token only read in server-side modules (`personal-channels.ts`, `notify.ts`, `approval.ts`, `ticket-digest-service.ts`); never exposed via NEXT_PUBLIC_ env var; never logged (existing `sendChannelTest` does not log `channel.config`, and the new branch does not log `token`) | +| T-09.1-04 | Information Disclosure | Bearer header in fetch error path | accept | If `fetch` throws and the error message includes the request, it could surface the Authorization header. The existing `e instanceof Error ? e.message : 'unknown error'` path returns a plain message string from `Error.message`, not the full request. No additional masking added — risk is low and well-scoped | +| T-09.1-05 | Denial of Service | Missing NTFY_PULSE_TOKEN | mitigate | `notify.ts` and `personal-channels.ts` return a structured error (`'NTFY_PULSE_TOKEN not configured'`) when the token is unset for a personal send — fails loud rather than hitting ntfy unauthenticated and burning quota; `approval.ts` uses a softer fallback to match its existing best-effort posture | +| T-09.1-06 | Elevation of Privilege | Global ntfy rows | accept | Global rows (`owner_user_id IS NULL`) retain their existing `channel.config.server_url` / `channel.config.auth_token` behavior — out-of-scope per UAT diagnosis. Admin-created channels can still target `ntfy.sh` or self-hosted instances with custom tokens | + + + +After both tasks land: + +1. `npx tsc --noEmit --pretty` — must exit 0 (no type errors introduced). +2. `npx vitest run lib/services/pipeline-steps/notify.test.ts` — must pass (the muted-user behavioral test confirms the personal/global branch did not regress mute semantics). +3. Grep sanity checks: + - `grep -rn "ntfy.sh" lib/services/personal-channels.ts` → empty (no fallback to public server). + - `grep -rn "pulse-" lib/services/personal-channels.ts` → matches reference `pulse-me-` only. + - `grep -n "NEXT_PUBLIC_NTFY_BASE_URL" components/mobile/profile/ProfileChannelsSection.tsx` → one match. + - `grep -n "ntfy.sh" components/mobile/profile/ProfileChannelsSection.tsx` → empty. + - `grep -n "owner_user_id" lib/services/pipeline-steps/notify.ts lib/services/pipeline-steps/approval.ts lib/services/ticket-digest-service.ts` → at least one match in each (the personal-vs-global branch). +4. Manual UAT retest (gap-closure spot check, optional in this plan): + - Open `/mobile/profile`, enable mobile push → minted topic starts with `pulse-me-` → QR code value reads `https://ntfy.wulfconsulting.cloud/pulse-me-XXXXXXXX` → ntfy app subscribe works → "Test now" delivers a notification. + - Open "Edit advanced", enter `bad-topic` → server returns 400, inline error reads message anchored to new regex. + + + +- All four backend files send to `${NTFY_BASE_URL || 'https://ntfy.wulfconsulting.cloud'}` for personal ntfy channels (owner_user_id IS NOT NULL) with `Authorization: Bearer ${NTFY_PULSE_TOKEN}`. +- Global ntfy rows (owner_user_id IS NULL) continue to use `channel.config.server_url || 'https://ntfy.sh'` and `channel.config.auth_token` — out-of-scope behavior preserved. +- Personal topics minted server-side use the `pulse-me-` prefix; the regex rejects `pulse-`, `noc-`, `soc-`, and arbitrary names. +- `/mobile/profile` QR + subscribe link target the company server. +- `npx tsc --noEmit --pretty` and `npx vitest run lib/services/pipeline-steps/notify.test.ts` both pass. + + + +After completion, create `.planning/phases/09.1-ntfy-backend-fix/09.1-01-SUMMARY.md` per the standard summary template, including a Gap Closure section that maps each `missing` item from `09-HUMAN-UAT.md` to the file/line where it was addressed. +