mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
co-authored by
Claude Sonnet 5.5
parent
0ff57ce304
commit
d68a173a23
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}/•••`);
|
||||
|
||||
Reference in New Issue
Block a user