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>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent 52267f8617
commit 6d6e7da481
15 changed files with 427 additions and 84 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
*
+43 -2
View File
@@ -6,13 +6,17 @@
* Key exports:
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
*
* Persistence:
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
*
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. No dependencies on other domain modules.
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
*/
/** A registered push subscription */
@@ -32,3 +36,40 @@ export interface VapidKeys {
privateKey: string;
generatedAt: number;
}
/** Services the webhook channel can format a message for. */
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
export type WebhookUrgency = 'critical' | 'warning' | 'info';
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
export interface WebhookConfig {
enabled: boolean;
kind: WebhookKind;
url: string;
scope: WebhookScope;
}
/** One delivery attempt. `error` never contains the URL. */
export interface WebhookResult {
ok: boolean;
status?: number;
error?: string;
at: number;
}
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
export interface WebhookStatus {
enabled: boolean;
kind: WebhookKind;
scope: WebhookScope;
hasUrl: boolean;
/** Scheme + host only; the path and query are the secret. */
urlMasked: string;
lastResult: WebhookResult | null;
}