docs(phase-09.1): create gap-closure plan for ntfy backend fix

This commit is contained in:
lorentz 2026-05-10 23:21:14 -04:00
parent 26b51ae2a2
commit 1ab3bfe2b3
2 changed files with 454 additions and 0 deletions

View file

@ -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 37 (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*

View file

@ -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"
---
<objective>
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).
</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-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
<interfaces>
<!-- Key environment variables and types the executor needs -->
<!-- NTFY_PULSE_TOKEN already exists in /opt/stacks/pulse/.env (do NOT write to .env — it is committed and may have other secrets) -->
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<string, any>; // { 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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update personal-channels.ts (regex + prefix + bearer auth) and propagate to the three other ntfy publish sites</name>
<files>lib/services/personal-channels.ts, lib/services/pipeline-steps/notify.ts, lib/services/pipeline-steps/approval.ts, lib/services/ticket-digest-service.ts</files>
<action>
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<string, string> = {
'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<string, any>,
message: string,
): Promise<StepExecutorResult> {
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<string, string> = {
'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<string, string> = {
'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<string, string> = { '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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
- `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.
</done>
</task>
<task type="auto">
<name>Task 2: Update ProfileChannelsSection.tsx (QR + subscribe link + inline error copy) and verify with existing tests</name>
<files>components/mobile/profile/ProfileChannelsSection.tsx</files>
<action>
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
<a
href={`${NTFY_BASE}/${ntfyTopic}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm underline break-all"
>
{`${NTFY_BASE}/${ntfyTopic}`}
</a>
<div
role="img"
aria-label={`Subscribe to ${ntfyTopic} on ntfy`}
className="flex justify-center"
>
<QRCodeSVG value={`${NTFY_BASE}/${ntfyTopic}`} size={200} />
</div>
```
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 `<Input id="custom-ntfy-topic" ... />` block (around line 310-317). Just AFTER the Input, BEFORE the `{customTopicError && ...}` line, add a tiny help line:
```tsx
<p className="text-xs text-muted-foreground">
Topic must start with <code>pulse-me-</code>.
</p>
```
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 && (<p className="text-xs text-destructive">{customTopicError}</p>)}` — 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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty &amp;&amp; npx vitest run lib/services/pipeline-steps/notify.test.ts</automated>
</verify>
<done>
- `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 <code>pulse-me-</code>` 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).
</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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.
</output>