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.
+
+
+