/** * Personal Channels Service — shared validation + test-send helpers. * * Used by: * - app/api/me/channels/[type]/route.ts (PUT upsert + DELETE) * - app/api/me/channels/[type]/test/route.ts (POST test) * - lib/services/pipeline-steps/notify.ts (route_to_user, Plan 03) * - app/api/admin/channels/route.ts (admin extension, Plan 06) * * CHAN-02, CHAN-03, CHAN-04, CHAN-05, CHAN-07 / D-02..D-06 */ import { randomUUID } from 'crypto'; import { NotificationChannel, ChannelType } from '@/lib/types/pipeline'; /** Test-send copy used by every personal-channel save and POST :test. */ export const TEST_MESSAGE_BODY = 'Pulse channel verified — you can ignore this message.'; /** Allowed Teams webhook hosts (CHAN-04 / D-05). */ const TEAMS_HOST_PATTERNS = [ /^[a-z0-9-]+\.webhook\.office\.com$/i, /^[a-z0-9-]+\.logic\.azure\.com$/i, /^[a-z0-9-]+\.[a-z]+\.logic\.azure\.com$/i, // regional subdomains, e.g. prod-12.eastus.logic.azure.com ]; /** * Validate a Teams incoming webhook URL (CHAN-04 / D-05). * Must be https:// and hostname must match *.webhook.office.com or *.logic.azure.com. * Prevents SSRF to internal hosts (T-09-02-06 mitigation). */ export function isValidTeamsWebhookUrl(input: unknown): input is string { if (typeof input !== 'string' || input.length === 0 || input.length > 2048) return false; try { const u = new URL(input); if (u.protocol !== 'https:') return false; return TEAMS_HOST_PATTERNS.some((re) => re.test(u.hostname)); } catch { return false; } } /** * 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); } /** * Mint a Pulse-namespaced ntfy topic (CHAN-03 / D-04). * 8 hex chars from a UUID gives 32 bits of entropy — sufficient collision- * resistance for ntfy public tier at expected user counts (T-09-02-07 mitigation). */ export function mintNtfyTopic(): string { const id = randomUUID().replace(/-/g, '').slice(0, 8); return `pulse-me-${id}`; } export type ChannelTestResult = | { ok: true } | { ok: false; status?: number; error: string }; /** * Issue a single best-effort test send to a personal channel. Mirrors the * existing app/api/notification-channels/[id]/test/route.ts flow but with the * Phase 9 test-message body. * * IMPORTANT: Does NOT log channel.config (which may contain webhook URLs or * auth tokens). Only logs channel id and channel_type. (T-09-02-05 mitigation) */ export async function sendChannelTest(channel: NotificationChannel): Promise { try { switch (channel.channel_type) { case 'teams': { const url = channel.config.webhook_url; if (!url) return { ok: false, error: 'Teams channel missing webhook_url' }; const card = { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: { type: 'AdaptiveCard', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', version: '1.4', body: [ { type: 'TextBlock', text: 'Pulse channel verified', weight: 'bolder', size: 'medium' }, { type: 'TextBlock', text: TEST_MESSAGE_BODY, wrap: true }, ], }, }], }; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(card), }); if (!resp.ok) return { ok: false, status: resp.status, error: (await resp.text()).slice(0, 200) }; return { ok: true }; } 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 }; } default: return { ok: false, error: `Unsupported personal channel type: ${channel.channel_type}` }; } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : 'unknown error' }; } } /** ChannelType values that personal channels may take in Phase 9 (D-02). */ export const PERSONAL_CHANNEL_TYPES: ChannelType[] = ['teams', 'ntfy']; export function isPersonalChannelType(t: unknown): t is 'teams' | 'ntfy' { return t === 'teams' || t === 'ntfy'; }