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
This commit is contained in:
lorentz 2026-05-11 06:42:52 -04:00
parent 1ab3bfe2b3
commit 2ff2dc9904
4 changed files with 66 additions and 16 deletions

View file

@ -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<Cha
}
case 'ntfy': {
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
// 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}`,
};
if (channel.config.auth_token) headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
const resp = await fetch(`${serverUrl}/${topic}`, {
method: 'POST',
headers,

View file

@ -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<string, string> = {
'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,

View file

@ -360,13 +360,20 @@ async function sendNtfy(
config: Record<string, any>,
message: string,
): Promise<StepExecutorResult> {
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<string, string> = {
'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}`;
}

View file

@ -156,7 +156,7 @@ export class TicketDigestService {
async getAvailableChannels(): Promise<NotificationChannel[]> {
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<string, string> = { '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 {