diff --git a/.changeset/webhook-notifications.md b/.changeset/webhook-notifications.md new file mode 100644 index 00000000..ae9ffab1 --- /dev/null +++ b/.changeset/webhook-notifications.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Webhook notifications. Settings → Notifications → "Webhook" posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it is stored in its own 0600 file, never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery refuses link-local and cloud-metadata targets, does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text. diff --git a/docs/api-reference.md b/docs/api-reference.md index c0f7235d..f7162767 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -713,6 +713,18 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route | `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | | `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | +## Webhook notifications + +Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. + +| Method | Path | Body | Notes | +| ------ | -------------------- | -------------------------------------------- | ----- | +| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. | +| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. | +| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. | + +Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/test/webhook-settings.browser.test.ts b/test/webhook-settings.browser.test.ts index 3091e4a7..1832b382 100644 --- a/test/webhook-settings.browser.test.ts +++ b/test/webhook-settings.browser.test.ts @@ -49,14 +49,20 @@ describe('Webhook settings in a real browser', () => { const result = () => page.textContent('#webhookResult'); + // The checkbox sits behind a styled slider, so click the switch like a user does. + const setSwitch = async (on: boolean) => { + if ((await page.isChecked('#webhookEnabled')) !== on) await page.click('label.switch:has(#webhookEnabled)'); + expect(await page.isChecked('#webhookEnabled')).toBe(on); + }; + it('shows the group, starts empty, and refuses to enable without a URL', async () => { expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.'); - await page.check('#webhookEnabled'); + await setSwitch(true); await page.click('#webhookSaveBtn'); await page.waitForFunction(() => /Add a webhook URL/.test(document.getElementById('webhookResult')?.textContent ?? '') ); - await page.uncheck('#webhookEnabled'); + await setSwitch(false); }); it('refuses a cloud-metadata URL with the server’s reason', async () => { @@ -70,7 +76,7 @@ describe('Webhook settings in a real browser', () => { it('saves a URL, shows only scheme and host, and empties the secret field', async () => { await page.selectOption('#webhookKind', 'ntfy'); await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/${SECRET}`); - await page.check('#webhookEnabled'); + await setSwitch(true); await page.click('#webhookSaveBtn'); await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? '')); expect(await page.textContent('#webhookUrlHint')).toBe(`Saved: http://127.0.0.1:${receiverPort}/•••`);