docs+test(notifications): api-reference, changeset, switch click in browser test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-02 22:34:46 +08:00
co-authored by Claude Sonnet 5.5
parent 0ff57ce304
commit d68a173a23
3 changed files with 26 additions and 3 deletions
+5
View File
@@ -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.
+12
View File
@@ -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
+9 -3
View File
@@ -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}/•••`);