Files
Codeman/src/web/routes/webhook-routes.ts
T
Codeman maintainer 6d6e7da481 fix(notifications): main Save keeps webhook edits, glue test, docs and nits (#523 review)
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).

Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.

Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.

Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.

Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
  PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
  the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
  CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
  tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
  row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
  confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
  moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).

Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00

110 lines
3.9 KiB
TypeScript

/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}