From 2ff2dc9904ea0ef5a20c1299987083b0533945bc Mon Sep 17 00:00:00 2001 From: lorentz Date: Mon, 11 May 2026 06:42:52 -0400 Subject: [PATCH] fix(09.1-01): pulse-me- prefix, company ntfy server + bearer auth for personal channels - NTFY_TOPIC_RE tightened to ^pulse-me-[A-Za-z0-9-]{6,64}$ (rejects noc-*, soc-*, bare pulse-) - mintNtfyTopic() now returns pulse-me-XXXXXXXX (8 hex chars, same entropy) - sendChannelTest (ntfy): forced to NTFY_BASE_URL + NTFY_PULSE_TOKEN; drops channel.config.auth_token path - sendNtfy (notify.ts): personal/global branch on owner_user_id; personal -> company server + bearer NTFY_PULSE_TOKEN - approval.ts ntfy branch: same personal/global split (soft fallback when token missing) - ticket-digest-service.ts: deliver() + getAvailableChannels() SELECTs now include owner_user_id; ntfy branch applies same split --- lib/services/personal-channels.ts | 27 +++++++++++++++++++------ lib/services/pipeline-steps/approval.ts | 16 ++++++++++++--- lib/services/pipeline-steps/notify.ts | 20 +++++++++++++++--- lib/services/ticket-digest-service.ts | 19 +++++++++++++---- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/lib/services/personal-channels.ts b/lib/services/personal-channels.ts index b1175c8..7d2dd71 100644 --- a/lib/services/personal-channels.ts +++ b/lib/services/personal-channels.ts @@ -40,9 +40,16 @@ export function isValidTeamsWebhookUrl(input: unknown): input is string { } } -/** ntfy topic format guard (CHAN-03 / D-04). Used when a user supplies a - * custom topic via "Edit advanced". Default flow mints via mintNtfyTopic. */ -const NTFY_TOPIC_RE = /^[A-Za-z0-9_-]{6,64}$/; +/** + * 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}$/; export function isValidNtfyTopic(input: unknown): input is string { return typeof input === 'string' && NTFY_TOPIC_RE.test(input); @@ -55,7 +62,7 @@ export function isValidNtfyTopic(input: unknown): input is string { */ export function mintNtfyTopic(): string { const id = randomUUID().replace(/-/g, '').slice(0, 8); - return `pulse-${id}`; + return `pulse-me-${id}`; } export type ChannelTestResult = @@ -101,14 +108,22 @@ export async function sendChannelTest(channel: NotificationChannel): Promise = { 'Content-Type': 'text/plain', 'Title': 'Pulse channel verified', + 'Authorization': `Bearer ${token}`, }; - if (channel.config.auth_token) headers['Authorization'] = `Bearer ${channel.config.auth_token}`; const resp = await fetch(`${serverUrl}/${topic}`, { method: 'POST', headers, diff --git a/lib/services/pipeline-steps/approval.ts b/lib/services/pipeline-steps/approval.ts index 01a8f4d..931319c 100644 --- a/lib/services/pipeline-steps/approval.ts +++ b/lib/services/pipeline-steps/approval.ts @@ -111,7 +111,12 @@ async function sendApprovalNotification( }), }); } else if (channel.channel_type === 'ntfy') { - const serverUrl = channel.config.server_url || 'https://ntfy.sh'; + // 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', @@ -120,10 +125,15 @@ async function sendApprovalNotification( `http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST` ).join('; '), }; - if (channel.config.auth_token) { + 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, diff --git a/lib/services/pipeline-steps/notify.ts b/lib/services/pipeline-steps/notify.ts index 6c572d2..6fef926 100644 --- a/lib/services/pipeline-steps/notify.ts +++ b/lib/services/pipeline-steps/notify.ts @@ -360,13 +360,20 @@ async function sendNtfy( config: Record, message: string, ): Promise { - const serverUrl = channel.config.server_url || 'https://ntfy.sh'; 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', }; @@ -377,7 +384,14 @@ async function sendNtfy( if (config.priority || channel.config.default_priority) { headers['Priority'] = config.priority || channel.config.default_priority; } - if (channel.config.auth_token) { + + 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}`; } diff --git a/lib/services/ticket-digest-service.ts b/lib/services/ticket-digest-service.ts index 443cf0b..61413c8 100644 --- a/lib/services/ticket-digest-service.ts +++ b/lib/services/ticket-digest-service.ts @@ -156,7 +156,7 @@ export class TicketDigestService { async getAvailableChannels(): Promise { const r = await postgresClient.query( - 'SELECT id, name, channel_type, config, is_active FROM notification_channels ORDER BY name' + 'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels ORDER BY name' ); return r.rows as NotificationChannel[]; } @@ -609,7 +609,7 @@ Rules: if (ids.length === 0) return []; const channelRows = await postgresClient.query( - 'SELECT id, name, channel_type, config, is_active FROM notification_channels WHERE id = ANY($1)', + 'SELECT id, name, channel_type, config, is_active, owner_user_id FROM notification_channels WHERE id = ANY($1)', [ids] ); const channels = channelRows.rows as NotificationChannel[]; @@ -646,11 +646,22 @@ Rules: body: JSON.stringify({ chat_id, text: plainText, parse_mode: parse_mode || 'HTML' }), }); } else if (ch.channel_type === 'ntfy') { - const server = ch.config.server_url || 'https://ntfy.sh'; + // 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 (ch.config.auth_token) headers['Authorization'] = `Bearer ${ch.config.auth_token}`; + 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 }); } else {