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:
parent
1ab3bfe2b3
commit
2ff2dc9904
4 changed files with 66 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue