docs(phase-09.1): create gap-closure plan for ntfy backend fix
This commit is contained in:
parent
26b51ae2a2
commit
1ab3bfe2b3
2 changed files with 454 additions and 0 deletions
|
|
@ -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*
|
||||
|
|
|
|||
437
.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md
Normal file
437
.planning/phases/09.1-ntfy-backend-fix/09.1-01-PLAN.md
Normal 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 && 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue