- 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
149 lines
5.8 KiB
TypeScript
149 lines
5.8 KiB
TypeScript
/**
|
|
* 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<ChannelTestResult> {
|
|
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<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 };
|
|
}
|
|
|
|
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';
|
|
}
|