From 487a92a598e66434ef934b5bb19d5681c0c7920e Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:15:54 -0400 Subject: [PATCH 01/22] docs(notifications): specify the first notification channels, publicUrl and screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 08 gains publicUrl (§5.8), the per-kind --notificationChannel parameters and the channels CLI; spec 03 the channels API, the channels WS topic, the publicUrl check route and the platform mapping (§9.5); spec 04 the channels pages; specs 09 and 10 the tests, error codes and redaction sinks. D-33 to D-39 record what N1 implements, and D-40 the platform message formats (Telegram HTML, Discord embeds). --- specs/00-decisions.md | 30 +++++++++--- specs/03-admin-backend.md | 60 ++++++++++++++++++++---- specs/04-admin-frontend.md | 18 ++++++- specs/08-cli-arguments-and-config.md | 35 ++++++++++++-- specs/09-testing.md | 8 ++++ specs/10-error-handling-and-telemetry.md | 11 ++++- 6 files changed, 138 insertions(+), 24 deletions(-) diff --git a/specs/00-decisions.md b/specs/00-decisions.md index fdebc3e..2e0305e 100644 --- a/specs/00-decisions.md +++ b/specs/00-decisions.md @@ -655,7 +655,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** binding on every channel; the storage (`secret_refs_json`) exists since the notification foundations (N0), the checks arrive with the first channels (N1). +**Implementation:** binding on every channel; the storage (`secret_refs_json`) since the notification foundations (N0); the checks (API validation, the exit-64 flag refusal, the set/missing check that never shows a value) since the first channels (N1). **Context.** BrowserHive is local-first. A relay, a shared bot or a hosted callback would make BrowserHive a service with an operator, an uptime and a data-protection story, and would see every user's messages. Channel credentials (bot tokens, webhook URLs, access tokens) are live secrets; the database is backed up (`db backup`) and copied around. @@ -672,7 +672,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** the notification foundations (N0): tables, worker, breaker, retention, metrics; platform adapters follow (N1). +**Implementation:** the notification foundations (N0): tables, worker, breaker, retention, metrics; the Telegram, Discord webhook, ntfy and generic webhook adapters since N1. **Context.** An external platform can be down, rate-limited or misconfigured, and BrowserHive can stop at any moment. A delivery made from an in-memory callback is lost on restart, and the producer's in-memory de-duplication set does not survive one either. A channel failure reported as a system degradation would itself produce a notification, delivered through the failing channel: a feedback loop. @@ -695,7 +695,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** `expires_at` and the sweeper exist since the notification foundations (N0); rules, platform deletes and the wizard arrive with the channels (N1). +**Implementation:** `expires_at` and the sweeper since the notification foundations (N0); the rules, platform deletes, the 47 h Telegram cap and the wizard since the first channels (N1). **Context.** Users want notifications that clean themselves up. No platform offers a per-message timer to bots; Telegram's auto-delete timer is a whole-chat setting chosen by the user, and a bot may delete its own messages only within 48 hours of sending them. @@ -707,7 +707,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** with the first external channels (N1); the contract's `image` block and `privacy.has_image` exist since N0. +**Implementation:** implemented with the first external channels (N1): capture at attention, vault-confirm and crash, per-channel variant selection, masking (spec 03 §9.5); the contract's `image` block and `privacy.has_image` since N0. **Context.** A screenshot is the most useful and the most dangerous thing a notification can carry: a logged-in page, an inbox, a balance, or a credential being typed. @@ -717,7 +717,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** with the first external channels (N1); the `LinkBuilder` port exists since N0. +**Implementation:** implemented in N1: the config key (08 §5.8), the link builder, host and origin trust, the `/health` `instance_id` check in `doctor` and on the System page; the `LinkBuilder` port since N0. **Context.** "Open session" in a notification is a link, and a `127.0.0.1` link does nothing on a phone. Users who reach their dashboard remotely do it through their own reverse proxy, Cloudflare, Caddy, nginx or a Tailscale name, which today also needs `allowedHosts` and can fail the origin check when a proxy rewrites `Host`. @@ -727,7 +727,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** webhook mode with the first external channels (N1), bot mode with act buttons (N2); `notification_channels.mode` exists since N0. +**Implementation:** webhook mode implemented in N1 (the renderer already draws bot mode for the setup's comparison); bot mode with act buttons in N2; `notification_channels.mode` since N0. **Context.** A Discord webhook takes 30 seconds to create and needs no connection, but its messages cannot carry interactive buttons. A bot needs a Developer Portal application and a gateway connection, and is the only way to receive button presses without a public endpoint (an Interactions Endpoint URL and the gateway are mutually exclusive). @@ -737,10 +737,26 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** the registry merge and clash rule since the notification foundations (N0); the flag and its parser with the first external channels (N1). +**Implementation:** the registry merge and clash rule since the notification foundations (N0); the flag, its parser and the read-only dashboard rows implemented in N1. **Context.** Some users want a channel that exists from the first start (a server, a container) without clicking through the dashboard. Nested per-channel rules do not fit the flat config grammar (spec 08 §2), and a second copy of a channel in the config file and the database would be two truths. Process arguments are visible to other users of the machine (`ps`). **Decision.** Startup channels are declared only with a repeatable `--notificationChannel` flag (spec 08 §5.7): not in the config file and not in the environment. A startup channel references secrets by environment variable **name** (`token=env:BH_TG_TOKEN`); an inline secret is a usage error (exit 64). At each start the startup channels are projected into `notification_channels` with `source = 'startup'` (their configuration columns rewritten from the flags, their status and failure counters kept), so deliveries keep their foreign keys and the breaker state survives restarts; a startup channel no longer passed is removed with its delivery log. The dashboard and API show them read-only with a "from startup" badge. A startup channel whose name matches a dashboard channel stops startup with `CONFIG_INVALID` (exit 64); neither silently shadows the other. **Alternatives considered.** *A `notificationChannels` config key with a URI grammar* (the research's P1): secrets would sit in a file that gets committed, and per-channel rules would need a grammar the config ladder does not have. *Seeding the database from config on first run*: friendlier once, but the file would then lie about what is configured. + +## D-40 Platform message formats: Telegram HTML messages, Discord embeds + +**Status:** Accepted + +**Implementation:** the first external channels (N1). + +**Context.** The plan preferred Telegram's Rich Messages (`sendRichMessage`, Bot API 10.1, June 2026) and left Discord's Components V2 against embeds to a spike. Rich Messages add headings, tables and footers, but they are three months old, their rendering on older Telegram clients is unverified, and no real bot was available to the first channels' build to check them on phones. Discord's documentation states that a webhook message with `IS_COMPONENTS_V2` may carry only components: `content`, `embeds` and `files[n]` fail with 400, so a V2 webhook message cannot upload a screenshot. + +**Decision.** +- Telegram channels send classic messages: `sendMessage`/`sendPhoto` with `parse_mode: HTML` (escaping only `<`, `>`, `&`), an inline keyboard for links, `editMessageText`/`editMessageCaption` for revisions. `degrade` already turns tables into lists and headings into bold text for this renderer (`richBlocks: false`, `tables: false`). Rich Messages stay a later, opt-in renderer once they are verified on real clients; the contract needs no change for it. +- Discord webhooks send one embed (colour by severity, fields, image as `attachment://`) plus an action row of link buttons (`with_components=true`), not Components V2. + +**Consequences.** Every Telegram client renders the messages; tables arrive as lists. The Telegram renderer is swappable per channel later without touching producers or the outbox. + +**Alternatives considered.** *Rich Messages first*: better structure, unverifiable here, and a formatting mistake would fail every send with a 400. *Discord Components V2*: no screenshots through a webhook. diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index 8f27847..bd1c4a1 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -33,7 +33,7 @@ core/src/interface/http/ app.ts builds the OpenAPIHono app: middleware stack + route tables + static + openapi doc define-route.ts defineRoute() helper (§1.2) middleware/ request-id, access-log, secure-headers, host-guard, origin-guard, body-limit, auth, authorize, rate-limit, error-handler - routes/ one file per resource: auth, sessions, tool-calls, pages, attention, vault, blocklist, system, logs, notifications, preferences, metrics, activity, artifacts + routes/ one file per resource: auth, sessions, tool-calls, pages, attention, vault, blocklist, system, logs, notifications, channels, preferences, metrics, activity, artifacts serializers/ row/domain → wire DTO mappers (the only place that knows both shapes) problem.ts problem+json helper (D-07) core/src/interface/ws/ @@ -71,7 +71,7 @@ export const terminateSession = defineRoute({ Handler contract: **validate → authorize → service → serialize**. Validation and authorization are performed by the dispatcher before `handler` runs; the handler receives typed input and a `RequestPrincipal`. Handlers return `{status, body}` typed against `responses`; returning a shape that does not satisfy the schema is a compile error (`@hono/zod-openapi`, D-05). Anything thrown is an `AppError` or is wrapped as `INTERNAL_ERROR` by the error handler. -`services` is a narrow record (`sessions`, `attention`, `vault`, `auth`, `blocklist`, `analytics`, `notifications`, `system`, `logs`, `preferences`, `artifacts`) injected at composition. No handler touches storage. +`services` is a narrow record (`sessions`, `attention`, `vault`, `auth`, `blocklist`, `analytics`, `notifications`, `channels`, `publicUrl`, `system`, `logs`, `preferences`, `artifacts`) injected at composition. No handler touches storage. ### 1.3 Versioning @@ -86,13 +86,13 @@ All routes are under `/api/v1`. Breaking changes bump the prefix; additive chang | 1 | `requestId` | Accepts `X-Request-Id` (≤ 128 chars, `[A-Za-z0-9_-]`) or mints a ULID; echoes it; seeds the request context (10 §telemetry). | | 2 | `accessLog` | One structured line (`http.access` "request completed") per request on completion: `route_pattern`, method, status, `duration_ms`, principal subject, bytes, request id. Level (`accessLogLevel()`): `debug` for health checks, non-API paths (dashboard assets, SPA `index.html`, trace viewer) and successful (`< 400`) `GET`/`HEAD` API requests by a password-session operator (the dashboard's own reads); `info` for everything else (mutations, bearer/agent REST, `/mcp`, any status ≥ 400). Absent correlation keys are omitted, never `null`. | | 3 | `secureHeaders` | `Content-Security-Policy` (below), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` (except the trace-viewer path which is `SAMEORIGIN`), `Referrer-Policy: no-referrer`, `Permissions-Policy` minimal, `Cross-Origin-Opener-Policy: same-origin`. HSTS only when the request arrived over TLS behind `--trustedProxies`. | -| 4 | `hostGuard` | Host allow-list, compared by name with the port ignored: loopback names, the configured `--host`, `--allowedHosts` entries, and any IP literal when `host` is a wildcard bind. Mismatch → 421 `HOST_NOT_ALLOWED`. This is the DNS-rebinding defense. The MCP handler runs the same `isHostAllowed` (02 §1.2), so `/mcp` and the dashboard can never disagree about a Host. | -| 5 | `originGuard` | On mutating methods and WS upgrades: `Origin` (or `Sec-Fetch-Site`) must be same-origin (loopback aliases equal, port-aware). Missing both headers → allowed only when authenticated by bearer (non-browser client). Failure → 403 `ORIGIN_NOT_ALLOWED`. | +| 4 | `hostGuard` | Host allow-list, compared by name with the port ignored: loopback names, the configured `--host`, `--allowedHosts` entries, the host of `--publicUrl` (08 §5.8, D-37), and any IP literal when `host` is a wildcard bind. Mismatch → 421 `HOST_NOT_ALLOWED`. This is the DNS-rebinding defense. The MCP handler runs the same `isHostAllowed` (02 §1.2), so `/mcp` and the dashboard can never disagree about a Host. | +| 5 | `originGuard` | On mutating methods and WS upgrades: `Origin` (or `Sec-Fetch-Site`) must be same-origin (loopback aliases equal, port-aware), or equal to the origin of `--publicUrl` (a reverse proxy that rewrites `Host` to the upstream address still passes, D-37). Missing both headers → allowed only when authenticated by bearer (non-browser client). Failure → 403 `ORIGIN_NOT_ALLOWED`. | | 6 | `bodyLimit` | 1 MiB default; 64 KiB on `/auth/login`; 16 MiB on `/vault/import`. Exceeded → 413 `PAYLOAD_TOO_LARGE`. | | 7 | `authenticate` | Runs the provider chain (§3.2). Sets `principal` or leaves it null for public routes. Present-but-invalid credential → 401 immediately. | | 8 | `passwordChangeGate` | If `principal.must_change_password`, only `/auth/me`, `/auth/change-password`, `/auth/logout` proceed; everything else (incl. WS upgrade) → 403 `PASSWORD_CHANGE_REQUIRED`. | | 9 | `authorize` | `Authorizer.can(principal, route.scope, resource)`; failure → 403 `FORBIDDEN`. Public routes skip. | -| 10 | `rateLimit` | Token buckets keyed by principal (or client IP for public routes): default 600 req/min (`DEFAULT_RATE`, bucket `default:`); **operator reads** — `GET`/`HEAD` by a principal authenticated with the password-session cookie on a route without its own rule, including `/auth/me` — get a separate 6000 req/min bucket (`OPERATOR_READ_RATE`, bucket `read:`), because every dashboard tab shares the operator principal and one page load issues 12–17 reads; mutations, bearer and grant callers stay on the default bucket; `/auth/login` 5/min/IP with 5-minute lockout after the 6th; `/vault/unlock` 5/min; `/search` and `/client-errors` 30/min. Emits `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` on 429 `RATE_LIMITED`. Buckets swept every 60 s. | +| 10 | `rateLimit` | Token buckets keyed by principal (or client IP for public routes): default 600 req/min (`DEFAULT_RATE`, bucket `default:`); **operator reads** — `GET`/`HEAD` by a principal authenticated with the password-session cookie on a route without its own rule, including `/auth/me` — get a separate 6000 req/min bucket (`OPERATOR_READ_RATE`, bucket `read:`), because every dashboard tab shares the operator principal and one page load issues 12–17 reads; mutations, bearer and grant callers stay on the default bucket; `/auth/login` 5/min/IP with 5-minute lockout after the 6th; `/vault/unlock` 5/min; `/search` and `/client-errors` 30/min; `/channels/{id}/test` 10/min (it makes an outbound request); `/channels/telegram/connect` 6/min. Emits `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` on 429 `RATE_LIMITED`. Buckets swept every 60 s. | | 11 | `loginSemaphore` | At most 2 concurrent Argon2 verifications process-wide; excess → 503 `RATE_LIMITED` with `Retry-After: 1`. Prevents the 64 MiB-per-attempt memory DoS. | | 12 | `etag` | Weak ETag on JSON GET responses; `If-None-Match` → 304. | | — | `errorHandler` (`app.onError`) | `AppError` → problem+json with its status; unknown → 500 `INTERNAL_ERROR`, private message logged with stack, public message generic. `notFound` → 404 problem; wrong method → 405 with `Allow`. | @@ -153,7 +153,7 @@ Tokens at rest: `credentials.secret_hash` = SHA-256 of the token; `public_prefix ### 3.5 Authorization -Scopes (v1): `sessions:read`, `sessions:write`, `sessions:takeover`, `attention:read`, `attention:resolve`, `vault:read`, `vault:write`, `vault:confirm`, `blocklist:read`, `blocklist:write`, `system:read`, `system:write`, `logs:read`, `notifications:read`, `notifications:write`, `preferences:write`, `mcp:tools`. Operators hold all scopes; agents hold `mcp:tools` only; an operator `api_token` may be issued with a subset. `Authorizer.can()` is the only enforcement point; route descriptors declare the scope; WS commands map to the same scopes (`input` → `sessions:takeover`). A `tenant_id` filter is applied in one data-access chokepoint (`ScopedRepositories(principal)`), a no-op in v1. +Scopes (v1): `sessions:read`, `sessions:write`, `sessions:takeover`, `attention:read`, `attention:resolve`, `vault:read`, `vault:write`, `vault:confirm`, `blocklist:read`, `blocklist:write`, `system:read`, `system:write`, `logs:read`, `notifications:read`, `notifications:write`, `channels:read`, `channels:write`, `preferences:write`, `mcp:tools`. `channels:*` are separate from `notifications:*` because a channel is a delivery target that leaves the machine: a token that may dismiss notifications may not add a webhook. Operators hold all scopes; agents hold `mcp:tools` only; an operator `api_token` may be issued with a subset. `Authorizer.can()` is the only enforcement point; route descriptors declare the scope; WS commands map to the same scopes (`input` → `sessions:takeover`). A `tenant_id` filter is applied in one data-access chokepoint (`ScopedRepositories(principal)`), a no-op in v1. --- @@ -165,7 +165,7 @@ Conventions (§5) apply to every list. `Auth` column: **S** operator session or | Method | Path | Auth | Request | Response | Errors | |---|---|---|---|---|---| -| GET | `/health` | P | — | `{status:'starting'|'ready'|'degraded'|'stopping', phase, version, uptime_ms, checks:{db, browser, listeners}}`; 200 when ready; otherwise 503 with **the same JSON body** (`application/json`, not problem+json) so a client can render the degraded state | — | +| GET | `/health` | P | — | `{status:'starting'|'ready'|'degraded'|'stopping', phase, version, uptime_ms, instance_id, checks:{db, browser, listeners}}` (`instance_id` is random per start; the `publicUrl` check compares it, 08 §5.8); 200 when ready; otherwise 503 with **the same JSON body** (`application/json`, not problem+json) so a client can render the degraded state | — | | POST | `/auth/login` | P | `{password}` | `{ok:true, must_change_password, session:{id_prefix, expires_at}}` + `Set-Cookie` | 401 `INVALID_CREDENTIALS`, 429 `RATE_LIMITED` (`retry_after_ms` in details) | | POST | `/auth/logout` | S | — | `{ok:true}`; clears cookie; closes the session's WS connections | — | | GET | `/auth/me` | S/A | — | `{principal:{subject, kind, display, scopes, must_change_password}, session?:{id_prefix, created_at, last_seen_at, expires_at}}` | — | @@ -270,6 +270,7 @@ One broker (D-15) backs two resource views; paths stay recognizable. | GET | `/system/config` | S | — | `{keys:[{key, value, source:'default'|'env'|'env(otel)'|'file'|'cli'|'derived', refs?, template?, shadowed:[{source, value, refs?}], secret}]}` — secret values `"[REDACTED]"`. `refs` lists the `{env:NAME}` references a config-file value used, `[{scheme:'env', ref, from:'value'|'default', at?}]`; `template` is the value as written in the file, never present when `secret`. `secret` is decided per run: keys flagged secret, plus keys whose value came through a reference with a credential-looking name (08 §3.1) | | GET | `/system/realtime` | S | — | `{connections:[{connection_id, principal, connected_at, last_seen_at, topics, screencasts, buffered_bytes, dropped_frames, messages_out}]}` | | GET | `/system/mcp/connections` | S | `limit` 1..200 (default 50), `offset` ≥ 0 (default 0) | `{connections:[McpConnectionRow], live, total, now}` — live connections first (most recently seen first), then recent closed ones; skips `offset` rows of that order and returns up to `limit`; `live` counts open rows and `total` every stored row, so a client pages with `offset` += `limit` while `offset < total`. `McpConnectionRow` = `connection_id, transport, principal, live, harness, harness_label, harness_source, model, model_source, workspace, client_name, client_version, client_title, protocol_version, user_agent, ip, connected_at, last_seen_at, closed_at, sessions, conflicts:[{source, value, harness}], meta:{…}` (02 §1.4; rows written before schema v3 read their harness from the stored header value, else `unknown`) | +| GET | `/system/public-url` | S | `refresh` (bool: run the check now instead of returning the cached result, cached 60 s) | `{configured, url, local_url, host_trusted, outcome:'ok'|'elsewhere'|'login'|'unreachable'|'unset', detail, status_code, checked_at, insecure}` — the check of 08 §5.8 (`insecure`: `http:` on a non-loopback host) | | PATCH | `/system/log-level` | S (`system:write`) | `{spec:'info,sessions=debug'}` | `{ok:true, effective}` | | GET | `/system/events` | S | `since?`, `severity[]?` | `Page` (degradations; 10 §system_events) | | GET | `/logs` | S (`logs:read`) | `dir` (`desc` default \| `asc`), `cursor`, `after_seq`, `level[]` (exact set), `module[]` (root prefix), `session_id`, `trace_id`, `request_id`, `q`, `since`, `until`, `limit` 1..1000 (default 200) | `Page` + `latest_seq` from the ring buffer (5 000 records). `dir=desc`: newest matching records first, `next_cursor` pages to **older** records (`null` when none are left); `dir=asc`: oldest held first, the cursor pages to newer. A cursor is bound to the `dir` that minted it (other `dir` → 400 `VALIDATION_FAILED`). `after_seq`: only records with `seq > after_seq` (reconnect gap fill, echoed in `applied.filters`). `latest_seq` is the newest `seq` assigned (0 when empty). In `LogRecord`, `trace_id`, `span_id`, `request_id`, `session_id`, `principal`, `transport` and `err` are absent, never `null`; misfit values move to `fields.`; `err` is always a serialized error | @@ -290,6 +291,27 @@ One broker (D-15) backs two resource views; paths stay recognizable. | PUT | `/me/preferences` | S (`preferences:write`) | `{preferences}` (≤ 64 KiB, zod-validated known keys, unknown keys rejected) | `{ok:true, updated_at}` | | GET | `/search` | S | `q` (≥ 2 chars), `limit` ≤ 20 | `{sessions:[{session_id, slug}], tools:[name], vault_handles:[handle], patterns:[pattern]}` — command-palette entity search | +### 4.8.1 Notification channels (D-33, D-37, D-38, D-39; §9.5) + +`ChannelView` = `channel_id, name, kind, mode, source ('db'|'startup'), status ('active'|'paused'|'broken'), target (non-secret coordinates, per kind §9.5), target_hint (a short, lossy rendering for lists: "chat …3456", "ntfy.sh/bh-…", "discord webhook"), secret_refs ({param: ENV_NAME}), secrets ([{param, env, set}]: whether each named variable is set, never its value), rules (NotificationChannelRules), capabilities, ready (the adapter could be built), problem (why not: "BH_TG_TOKEN is not set"), failure_count, last_error, last_ok_at, last_failure_at, created_at, updated_at, stats {sent_24h, failed_24h, suppressed_24h, pending, last_delivery_at, last_status}`. No response ever carries a secret value; requests carry only environment variable names (`SecretEnvName`: not `BROWSERHIVE_*`), and a body that looks like it holds a secret value where a name belongs is a 400 `VALIDATION_FAILED` that never echoes it. + +| Method | Path | Auth | Request | Response | Errors | +|---|---|---|---|---|---| +| GET | `/channels` | S (`channels:read`) | — | `{data: ChannelView[], now}` (dashboard channels and startup channels, by name) | — | +| POST | `/channels` | S (`channels:write`) | `ChannelInput {name, kind, mode?, target, secret_refs, rules?}` (kinds `telegram`, `discord` (mode `webhook`), `ntfy`, `webhook`) | 201 `{channel: ChannelView}` | 400 `VALIDATION_FAILED` (a Telegram TTL above 47 h, an unknown target key, a missing required secret), 409 `CHANNEL_NAME_TAKEN`, 400 `CHANNEL_KIND_UNAVAILABLE` (`discord` mode `bot`, and the reserved platforms, until they ship) | +| GET | `/channels/{channel_id}` | S (`channels:read`) | — | `{channel: ChannelView}` | 404 `CHANNEL_NOT_FOUND` | +| PATCH | `/channels/{channel_id}` | S (`channels:write`) | partial `ChannelInput` (not `kind`) | `{channel}` | 404, 409 `CHANNEL_READ_ONLY` (a startup channel: it is edited with its flag), 409 `CHANNEL_NAME_TAKEN` | +| DELETE | `/channels/{channel_id}` | S (`channels:write`) | — | `{ok:true}` (the channel's delivery log goes with it) | 404, 409 `CHANNEL_READ_ONLY` | +| POST | `/channels/{channel_id}/pause` | S (`channels:write`) | — | `{channel}` (pending jobs become `suppressed: channel_paused`; allowed on startup channels, and the pause survives restarts) | 404 | +| POST | `/channels/{channel_id}/resume` | S (`channels:write`) | — | `{channel}` (`active`, consecutive failures reset; also how a `broken` channel is retried) | 404 | +| POST | `/channels/{channel_id}/test` | S (`channels:write`) | — | `{ok, delivery: DeliveryRow, error?: {code, message}}` — sends a `test` notification (system · info) through the adapter now, outside the outbox queue, and records it in the delivery log; the message carries an "Open dashboard" link (the human `publicUrl` proof). Rate-limited 10/min | 404, 409 `CHANNEL_NOT_READY` (no adapter: a variable is unset) | +| POST | `/channels/preview` | S (`channels:read`) | `{channel_id}` or a draft `{kind, mode?, target?, rules?}`, plus `sample` (`attention`, `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`) | `ChannelPreview {kind, mode, sample, capabilities, message (as the channel receives it: content level, image rule, degrade), requests: [{method, path, body}] (the platform request(s) the renderer produces, with every secret replaced by its variable name), notes[]}` — **pure, sends nothing**; the dashboard's mocks draw from `requests` | 404 | +| GET | `/channels/deliveries` | S (`channels:read`) | filters `channel_id`, `notification_id`, `status[]`, `op[]`, `kind[]` (notification kind), cursor (`seq`), `limit` | `Page` newest first — `seq, channel_id, channel_name, channel_kind, notification_id, notification_kind, notification_title, revision, op, status, reason, attempts, next_attempt_at, last_error, duration_ms, message_ref, created_at, updated_at` | — | +| GET | `/channels/deliveries/{seq}` | S (`channels:read`) | — | `{delivery: DeliveryRow, message: NotificationMessage | null}` — the notification's current message as this channel is shown it (content level and degrade applied: the redacted payload) | 404 `DELIVERY_NOT_FOUND` | +| GET | `/channels/env` | S (`channels:read`) | `names` (csv of `SecretEnvName`, ≤ 16) | `{vars: [{name, set}]}` — whether each variable is set and non-empty in the server's environment; never a value | 400 | +| POST | `/channels/telegram/connect` | S (`channels:write`) | `{token_env, thread?}` | `{connect_id, bot_username, link: 'https://t.me/?start=', group_link: 'https://t.me/?startgroup=', expires_at}` — checks the token with `getMe`, then long-polls `getUpdates` for 2 minutes waiting for `/start ` in a private chat or a group; one connect per token at a time (a new one cancels the old) | 400, 409 `CHANNEL_NOT_READY` (the variable is unset), 502 `CHANNEL_PLATFORM_ERROR` (Telegram refused the token) | +| GET | `/channels/telegram/connect/{connect_id}` | S (`channels:read`) | — | `{status:'waiting'|'connected'|'expired'|'failed', chat?:{id, title, type}, user?:{id, name}, error?}` — `user` becomes the first allow-list entry (N2 act buttons) | 404 | + ### 4.9 Client errors | POST | `/client-errors` | S | `{message, stack?, route, user_agent, build}` (rate-limited 30/min) | 204 | @@ -383,6 +405,7 @@ Ordering: `screencast.start`, `screencast.stop` and `screencast.set_size` from o | `pages` | `page.visited` `{row}` fleet-wide (scope `sessions:read`), so overview and websites views update live; also published on `session:` | | `blocklist` | `blocklist.hit` `{row}`, `blocklist.reloaded` `{patterns, skipped}` | | `system` | `system.degraded` `{event: SystemEvent}`, `system.recovered`, `tick`, `capacity` `{live, max}`, `retention.completed` | +| `channels` | `channel.changed` `{channel: ChannelView}` (created, edited, paused, resumed, broken, or its stats moved), `channel.removed` `{channel_id}`, `delivery.updated` `{delivery: DeliveryRow}` (a job enqueued or its status changed; the live delivery log) — scope `channels:read` | | `notifications` | `notification.created` `{notification}` (first occurrence), `notification.updated` `{notification}` (the full row: a group grew — `count`, `title`, `body`, `updated_at`, `source_event_id`, `revision` changed — a lifecycle revision changed `state` and `revision` (`updated_at` unchanged), or it was read/dismissed; clients upsert by `notification_id` and re-position by `updated_at`) | | `logs` | `log.record` `{record}` (droppable, live only) | | `screencast:` | `meta`, `started`, `stopped`, `failed {code}` | @@ -684,7 +707,7 @@ Rows whose classification columns are NULL (written by an older reader in the co `NotificationChannel` (`ports/notification-channel.ts`) is the platform seam: `id`, `name`, `kind`, `capabilities` (rich blocks, tables, images, act buttons, open links, edit, delete, replies, delete window, max title/text length, max buttons), `send(delivery) → {ref}`, `edit(ref, delivery) → {ref}`, `delete(ref)`. A delivery is the restricted, degraded message plus the `LinkBuilder` and, where the platform supports replies, the ref of the first message of the thread. A platform failure is a `ChannelSendError` (`retryable`, `retryAfterMs`, `code`: `rate_limited`, `unavailable`, `timeout`, `auth`, `rejected`, `message_gone`, `too_old`). The in-app channel (`kind: in-app`) implements the same port and is delivered **inline after the commit**: the row is the delivery, so it has no outbox rows. -`ChannelRegistry` (`app/notifications/channel-registry.ts`) holds the configured channels (`notification_channels`) and builds an adapter for each through factories registered per kind by composition (none are registered yet: without a factory a channel's jobs are suppressed with reason `no_adapter`). At start it projects the startup channels (`--notificationChannel`, 08 §5.7, D-39) into rows with `source = 'startup'`: configuration columns rewritten, status and failure counters kept, rows no longer declared removed; a name that a `source = 'db'` channel already uses stops startup with `CONFIG_INVALID` (exit 64). Channel rows store environment variable names only (D-33). +`ChannelRegistry` (`app/notifications/channel-registry.ts`) holds the configured channels (`notification_channels`) and builds an adapter for each through factories registered per kind by composition (`telegram`, `discord`, `ntfy` and `webhook` since N1, §9.5). A channel whose adapter cannot be built (no factory for its kind, or a secret variable that is unset) keeps its row, reports why as `problem` in the API, and its jobs are suppressed with reason `no_adapter`. At start it projects the startup channels (`--notificationChannel`, 08 §5.7, D-39) into rows with `source = 'startup'`: configuration columns rewritten, status and failure counters kept, rows no longer declared removed; a name that a `source = 'db'` channel already uses stops startup with `CONFIG_INVALID` (exit 64). Channel rows store environment variable names only (D-33). ### 9.4 The outbox (D-34) @@ -697,6 +720,27 @@ Rows whose classification columns are NULL (written by an older reader in the co - **Breaker.** A success resets the channel's consecutive `failure_count` and sets `last_ok_at`; a failure increments it and records `last_error`. At 5 the channel becomes `broken`, its pending jobs are `suppressed: channel_paused`, `notification.channel.changed` is published and the `channel.broken` in-app notification is produced. No `system.degraded` is ever raised for a channel. Resuming a channel through the channel API sets it `active` and resets the count. - **Backlog collapse.** When more than 20 `info` sends are pending for one channel, the newest is sent with a "you missed N" footer and the others are `superseded: collapsed`. - **Telemetry.** `browserhive.notifications.deliveries{channel_kind,status}` counts each finished job; each platform call is a `notification.deliver` span (10 §6, §7). +### 9.5 Platforms (N1: Telegram, Discord webhook, ntfy, generic webhook) + +Adapters live in `core/src/infra/notifications/` (the only code that calls a platform; `fetch` only there). Each is a **pure renderer** (`NotificationMessage` + links + capabilities → the platform request) plus a **transport** (the call, the error classification of §9.3 and the returned ref). Composition registers one factory per kind; the renderers are also registered for `POST /channels/preview`, so a preview is byte-for-byte the request a send makes. Shared: one HTTP helper (timeout 10 s, `Retry-After`/`retry_after` → `rate_limited` with `retryAfterMs`, 5xx/network → `unavailable`/`timeout`, 401/403 → `auth`, 404 on an edit or delete → `message_gone`, other 4xx → `rejected`; every URL and error text passes the `Redactor`, so a token in a URL path never reaches a log or `last_error`). + +| | Telegram (bot) | Discord (webhook mode) | ntfy | Generic webhook | +|---|---|---|---|---| +| Channel config | target `{chat_id, thread_id?, chat_title?, bot_username?}`; secret `token` | secret `webhook` (the webhook URL); mode `webhook` | target `{server (default https://ntfy.sh), topic?}`; secrets `token?`, `topic?` (a topic from a variable) | target `{url?}`; secrets `url?` (a URL from a variable), `secret?` (HMAC key) | +| Message | `sendMessage` with `parse_mode: HTML` (only `<`, `>`, `&` escaped; `b`, `i`, `code`, `pre`, `blockquote expandable`), ≤ 4096 characters; with an image `sendPhoto` (multipart, JPEG) and the text as the caption, ≤ 1024 | `POST ?wait=true&with_components=true`: one embed (title, description, fields inline, colour by severity, footer, timestamp) and an action row of link buttons; with an image `multipart` `payload_json` + `files[0]`, shown as the embed image `attachment://…` | `POST /` JSON `{topic, title, message, priority, tags, click, actions, markdown:false}`; with an image `PUT /` with the JPEG as the body and the fields as query parameters | `POST ` with the envelope `{schema, event:'notification', delivered_at, channel:{id,name}, links:{open}, message}` (the contract itself) | +| Links | inline keyboard URL buttons (≤ 3 per row) | link buttons (style 5) | `view` actions (≤ 3) and `click` | `links` map of absolute URLs | +| Edit | `editMessageText` / `editMessageCaption`, with the keyboard (`reply_markup`) | `PATCH …/messages/{id}?with_components=true` keeping the attachment (`attachments: [{id}]`) | re-publish with the same `sequence_id` (`X-Sequence-ID`) | re-POST with the higher `revision` (consumers keep the highest) | +| Delete | `deleteMessage`, only < 48 h after sending (`deleteWindowMs` 48 h; the setup caps TTLs at 47 h; "message can't be deleted" → `too_old`) | `DELETE …/messages/{id}` | `DELETE //` | n/a (`delete: false`) | +| Severity | a leading emoji (ℹ️ ⚠️ 🔴 🚨) and bold title; edits are silent (`disable_notification` on silent sends) | embed colour (info blue, warn amber, error red, critical magenta) | `priority` 3/4/4/5 (edits 2, silent) and `tags` (`information_source`, `warning`, `rotating_light`, `sos`) | as-is | +| Replies | the wrap-up of a thread replies to its first message (`reply_parameters`, `allow_sending_without_reply`) | no | no | no | +| Signature | — | — | — | `X-BrowserHive-Signature: sha256=` and `X-BrowserHive-Timestamp` when a secret is set | + +- **Act buttons in N1.** No platform has `actButtons` yet (the Telegram callback loop, Discord bot mode and the ntfy second topic arrive with N2), so `degrade` turns every act button into its `open` fallback. The renderers already draw an act button when a channel declares the capability (a Telegram `callback_data` / Discord `custom_id` of `bh1:` from an injected token hook); the Discord preview of **bot** mode uses that path with the placeholder `bh1:preview`, so the "What's the difference?" panel draws both modes from the same renderer. +- **Links without `publicUrl`.** The link builder is local (`http://127.0.0.1:9876/…`). Telegram and Discord refuse or cannot open such URLs on a phone, so their renderers then put the links in the text under an "Open on this computer" line instead of buttons; ntfy keeps its `view` actions labelled "Open on this computer"; the webhook sends them in `links` with `local: true`. +- **Screenshots (D-36).** Three triggers, each a producer asking for an image: an attention request (every reason, CAPTCHA included: a CAPTCHA hand-off is an attention request whose reason says so, and there is no separate detector), a vault confirmation (captured when the request is created, which is before the fill sequence starts: the fill waits for the approval; never while the session's secret window is open) and a session crash (the session's last stored screenshot, when one exists). The capture happens only when at least one active channel has `images[category]` on at content level `full`, and never when `recordToolResults=none`. It is a JPEG (quality 70, at most 1280 px wide) of the session's active page, masked (`mask` over `input`, `textarea`, `select` and `[contenteditable]`) when any such channel sets `mask_images`, and unmasked when any does not; the message then carries one `image` block per variant and the outbox keeps, per channel, only the variant that channel may see (`masked` true for `mask_images`) or none when `images[category]` is off. A crash image is never masked (it is a stored frame), so a masking channel gets no crash image. Images are files in `/notifications/images/` (0600, named by an opaque ref), read by the adapters through an injected reader, and pruned after 7 days; the delivery log shows `image: yes/no`, never the bytes. +- **The generic webhook** posts the contract (D-32) so users build their own consumers. Its URL is operator-supplied: `file:`, `data:` and every scheme but `http(s)` are refused when the channel is saved; a redirect is not followed when it changes scheme or host (`rejected: redirect`); a private or loopback target (Home Assistant, Gotify on the LAN) is allowed with a warning in the channel's `problem` field (SSRF note: the daemon makes the request from inside the network). +- **Rate limits.** Telegram answers 429 with `parameters.retry_after` (seconds), Discord with `retry_after` (float seconds) and `Retry-After`, ntfy with `Retry-After`; all become `rate_limited` with `retryAfterMs`. Edit spacing stays 3 s for every platform (§9.4). + ## 10. Design notes - `POST /sessions/{id}/input` exists so takeover can be scripted without a WebSocket client; it shares the attention gate and audit path with the WS `input` command. diff --git a/specs/04-admin-frontend.md b/specs/04-admin-frontend.md index d4902f6..af16ad4 100644 --- a/specs/04-admin-frontend.md +++ b/specs/04-admin-frontend.md @@ -80,12 +80,12 @@ packages/dashboard/ │ ├── __root.tsx, index.tsx (→ /overview), theme.tsx (dev only) │ ├── _auth.tsx inside AuthGate + AppShell │ ├── _auth/overview, sessions, sessions_.$id, sessions_.$id_.live (redirect), attention, websites, navigation (redirect), - │ │ blocklist, vault, vault_.log, logs, system, notifications + │ │ blocklist, vault, vault_.log, logs, system, notifications, notifications_.channels(.new, .$channelId), notifications_.log │ └── _public/login.tsx, change-password.tsx ├── features/ │ ├── overview/ tiles, activity chart, panels, live-hold.ts (useLiveHold), NewRowsPill, window anchor │ ├── sessions/ list/, detail/, activity/, live/, detail-search.ts, route-redirects.ts - │ ├── attention/ websites/ blocklist/ notifications/ + │ ├── attention/ websites/ blocklist/ notifications/ (channels/: cards, wizard, platform mocks, delivery log) │ ├── vault/ bindings/, groups/, confirm/, status/, tester/, transfer/, log/ │ ├── logs/ system/ (status/, tokens/, config/) auth/ theme/ │ ├── harness/ HarnessName, ClientPanel, HarnessesCard, McpConnectionsPanel (D-30; shared by sessions, overview, system) @@ -554,6 +554,7 @@ Search: `tab` (`status|tokens|config`, default `status`), `key` (config filter). - **Status**: notices shown once (evaluate with vault risk, degraded health, disk pressure). KPI tiles: Uptime, Live sessions (→ `/sessions`), Open attention (→ `/attention`), MCP clients, Database (phones: Uptime spans the first row then 2×2; md: Database spans two columns; xl: 5 columns). Health and Runtime share one card: Health reads the 503 body when degraded instead of vanishing; Runtime lists versions in sans tabular figures, and Chromium shows `runtime.chromium` as the version, "Available, version not reported" (muted, info hint) when it is `null` but sessions are live or `health.checks.browser === 'ok'`, and "Not installed — run browserhive init…" only when nothing launches. **Browsers and sandbox** (when `/system` carries `browser`, D-26, D-27): the description names the default channel and the sandbox mode (and "running as root"); one row per channel (default first): product label, channel in mono, a `default` pill, bundled/installed and the version, the executable path in mono, Chrome's reason in warn text when the sandbox is unavailable, and a status dot at the right (`sandboxed` success; `falls back: no sandbox` or `cannot sandbox here` warn; `not checked yet`, `sandbox off` muted; `no sandbox as root` warn); a channel that is not installed shows "not installed" and no dot. Storage & retention beside a stack of Degradations (compact when empty) and Dashboard connections; **MCP connections** (`GET /system/mcp/connections`, refreshed with the page's other queries): a panel whose header counts live connections and carries the self-reported `InfoDot`; rows, live first then recent, **10 per page** by default (the shared pager under the list: rows per page 10/25/50, prev/next, `1–10 of 23`; hidden when everything fits on one page; each page is fetched with `limit`/`offset`, and a page left past the end by pruning falls back to the last page), each a whole-row button that opens a detail popover: harness label with a `live` pill or the closed time, `transport · client name version`, model/workspace when reported, session count, last seen; a warn "conflicting signals" chip when `conflicts` is non-empty. The popover lists harness and source, model and source, workspace, client name/version/title, protocol version, User-Agent (mono, wraps), IP, connected/last seen/closed, the conflicts ("`clientInfo` said `cursor`") and the meta table. Empty: "No MCP client has connected yet" with the MCP clients guide link; Migrations. - **Agent tokens**: auth-off notice (from `auth_mode`), aligned create form, compact empty state, table with width-dependent columns, once-shown token dialog with MCP snippets in `DialogBody`; "Agent tokens" + count pill only when > 0. +- **Public address** (`GET /system/public-url`, D-37, 08 §5.8): a card with the `publicUrl` (or "Not set" and what that means: links in notifications open on this computer only), the check's outcome as a status dot and sentence (`ok` "Points to this BrowserHive", `elsewhere` "Points to a different server", `login` "Reachable, behind a login — can't confirm from here", `unreachable` "Not reachable from this machine — may still work from outside"), when it was checked and a "Check again" button (`refresh=1`), the trusted-host note, the plain-`http` warning, a hint when the dashboard is being viewed at an address other than `publicUrl`, and a docs link to the public-address guide (Tailscale, reverse proxies). - **Configuration**: settings in effect as grouped key/value panels in two independent stacks (Server + Integrations beside Sessions + Stealth, `items-start`); mono only for Bind address and Data directory; paths wrap at `/`. "All configuration keys" table: key, wrapping value, source chip, "overrides env 9000" lines ("overrides file … via $VAR" when the shadowed value used references), filter + "only changed from defaults" + "only values from references". A value that came through config-file references (08 §3.1) shows one mono chip per variable (`$OTLP_HOST`, "default" marked) under the source chip; the chip is a button whose popover names the variable and, for keys that are not secret, shows the value as written in the file (`http://{env:OTLP_HOST}:4318`); a secret key keeps its `redacted` chip and still shows the variable. The filter also matches variable names. The panel's info popover explains references and links to the configuration guide's References section. ### 12.11 `/notifications` @@ -566,6 +567,19 @@ Search: `read` (`all|unread|read`, default `all`), `type` (csv), `range` (`24h|7 - Live inserts and group updates hold while reading (`useLiveHold` with `getVersion = updated_at`). - **Toast preferences** panel (mounted after the list loads): pop-up toasts switch, "Toast for" type checkboxes (default `DEFAULT_TOAST_TYPES`, disabled while toasts are off), Save enabled only with changes; other stored preference keys are preserved. +### 12.11.1 `/notifications/channels` — channels (D-33, D-35, D-36, D-37, D-38, D-39) + +The Notifications area has three sibling pages reached from a segmented header (Inbox · Channels · Delivery log); the sidebar keeps one Notifications entry. + +- **List.** One card per channel (responsive grid): platform mark and name, "from startup" badge for startup channels (read-only: no Edit, Duplicate or Delete, a tooltip names the flag), status (`active` success dot, `paused` muted, `broken` danger with the last error), where it sends (`target_hint`), the secrets line (each variable with set ✓ / missing ✗), last delivery (relative time and status), 24 h counts (sent, failed, suppressed) and pending. Actions: Send test (shows the result inline, with the classified error), Pause/Resume, Edit, Duplicate, Delete (confirm; "its delivery log goes with it"). The whole card opens the channel. Empty state: what channels are, the four platforms and "Add channel". Live through the `channels` WS topic. +- **Add channel** (`/notifications/channels/new`, also the edit form at `/notifications/channels/$channelId`): a wizard with a step rail; the draft is kept in `localStorage` (`bh.channelDraft`), so it survives a reload and the BrowserHive restart that setting a variable needs, and is cleared on save. + 1. **Platform**: cards for Telegram, Discord, ntfy and Webhook (and the upcoming ones, disabled). Discord asks for the mode (webhook, the default; bot, disabled with "arrives with act buttons") and opens **What's the difference?**: a panel comparing the modes (setup time, buttons, connection; D-38) and the two message styles drawn side by side from the preview endpoint (`mode: webhook` and `mode: bot`) with the current theme; a documented slot (`features/notifications/channels/discord-shots.ts`) accepts real screenshots of BrowserHive's own messages later, never images copied from Discord or the web. + 2. **Credentials**: a suggested variable name (`BH_TELEGRAM_TOKEN`, `BH_DISCORD_WEBHOOK`, `BH_NTFY_TOKEN`, `BH_WEBHOOK_SECRET`; never `BROWSERHIVE_*`, which the config loader reserves, and editable), the exact line for how BrowserHive runs, in tabs: shell (`export …`), systemd (`Environment=`/`EnvironmentFile=`), Docker (`-e` / compose `environment:`), and the config file (`{env:NAME}` is for config keys; channels read the variable directly, which the tab explains), with copy buttons; the live **set ✓ / missing ✗** state (`GET /channels/env`, polled every 3 s while the step is open), and "Restart BrowserHive after setting it" when missing. + 3. **Connect**: Telegram: the bot's name from the token, a one-tap `t.me/?start=` link and QR code (and "Add to a group" `startgroup` link), a 2-minute countdown while the server waits, then the captured chat (title, type) and the person who connected it; a manual chat id field as the fallback. Discord: nothing more in webhook mode. ntfy: server (default `https://ntfy.sh`) and topic (a random suggestion `bh-`, or from a variable), a QR code and link that subscribe the phone app, and the ntfy.sh warning when screenshots are on. Webhook: the URL (or its variable), the signature secret variable, and the SSRF note for private addresses. + 4. **What to send**: presets as cards — *Needs me now* (needs-you), *Problems* (needs-you + problems), *Wrap-ups* (wrap-ups), *Everything* — then an Advanced disclosure: categories, minimum severity, session globs, harness, quiet hours with a time zone picker (default the browser's), content level (`counts`/`titles`/`full` with what each sends), screenshots per category (off by default; enabling one requires `full`; "Mask form fields" on by default; the ntfy.sh warning), TTL per category (Never by default; 15 min…7 d; Telegram capped at 47 h with the reason; the honest note that a lock-screen preview cannot be taken back), delete when resolved per category (off). The name field lives here. + 5. **Preview and test**: sample picker (attention, resolved, vault confirm, tool errors, crash, degraded, test) and a **near-exact mock** of the platform (Telegram chat bubble with HTML formatting, photo and inline keyboard; Discord embed with colour bar, fields, image and link buttons; ntfy Android-style notification with priority, tags and actions) drawn only from `POST /channels/preview` `requests`, so the mock and a real send share the renderer. **Save** creates the channel; **Send test** then delivers a real message whose "Open dashboard" link is the `publicUrl` check from the phone, and shows the result. +- **Delivery log** (`/notifications/log`): a table newest first (time, channel, notification title and kind, revision, op, status pill with the reason in words, attempts, latency), filters (channel, status, op, kind) in the URL, live through the `channels` topic with the live-hold pill; a row opens a side sheet with the timeline of that notification on every channel ("why wasn't this sent?": every suppressed or failed row explained in a sentence from the reason code), the last error, the message ref, and the redacted message as the channel was shown it (`GET /channels/deliveries/{seq}`). + ### 12.12 `/login`, `/change-password` (public layout) `AuthLayout`: theme menu top-right, brand mark above a centred card, title + subtitle; sets `document.title` ("Sign in · BrowserHive" or the change-password title). diff --git a/specs/08-cli-arguments-and-config.md b/specs/08-cli-arguments-and-config.md index 25bbea5..23504f9 100644 --- a/specs/08-cli-arguments-and-config.md +++ b/specs/08-cli-arguments-and-config.md @@ -17,7 +17,7 @@ related: > RFC 2119 when they appear in uppercase. `D-NN` identifiers refer to entries in the > [decision log](00-decisions.md). Terminology follows the [specification index](README.md#conventions). -Governing decisions: D-06 (ladder and naming), D-02 (single port), D-08 (telemetry knobs), D-18 (`init`/`doctor`), D-19 (toolchain), D-24 (data dir), D-26 (browser choice), D-27 (sandbox), D-28 (`init` writes the config file), D-29 (references in config-file values), D-30 (identity environment variables). +Governing decisions: D-06 (ladder and naming), D-02 (single port), D-08 (telemetry knobs), D-18 (`init`/`doctor`), D-19 (toolchain), D-24 (data dir), D-26 (browser choice), D-27 (sandbox), D-28 (`init` writes the config file), D-29 (references in config-file values), D-30 (identity environment variables), D-37 (`publicUrl`), D-39 (startup notification channels). --- @@ -234,6 +234,7 @@ Columns: key · type / grammar · default · validation · consumer · boot/runt | `allowInsecureBind` | boolean | `false` | acknowledges a non-loopback bind without auth | bind guard | boot | | `trustedProxies` | list of CIDR/IP | `[]` | `X-Forwarded-For` honored only from these peers | http middleware | boot | | `allowedHosts` | list of host names / IP literals (no port) | `[]` | extra names the `Host` check accepts besides loopback and `host` (03 §2); ports are ignored, so one entry covers a proxy on 443 and a port mapping alike; also valid on a loopback bind (a same-machine proxy that preserves `Host`) | host guard, `/mcp` | boot | +| `publicUrl` | url | — | where the operator made the dashboard reachable (a reverse proxy, Cloudflare, Caddy, nginx or a Tailscale name, §5.8, D-37): absolute `http:`/`https:`, no query or fragment, a trailing `/` is dropped; its host joins the `Host` allow-list and its origin the origin guard; notification links are `publicUrl + path`; a warning (not an error) when it is `http:` on a non-loopback host | link builder, host guard, origin guard, `doctor` | boot | | `admin` | boolean | `false` | enables dashboard, REST, WS, trace viewer; requires http | composition | boot | | `dataDir` | path | OS default (D-24) | absolute after resolution; created 0700 | DataDir | boot | | `shutdownTimeout` | duration | `20s` | total budget for graceful stop (listeners 2 s → sessions → storage) | composition | boot | @@ -328,11 +329,30 @@ A repeatable flag that declares a notification channel for this run (D-39, 03 § --notificationChannel "webhook:name=ops,url=https://hooks.example.net/bh,secret=env:BH_HOOK_SECRET" ``` -- **Grammar.** `kind` is a `NotificationChannelKind` with a startup adapter (`telegram`, `discord`, `ntfy`, `webhook` in the first release). Parameters are `name=value` pairs separated by `,`; a list value joins its items with `+`; a value cannot contain `,` (percent-encode it as `%2C`). `name` is required, `[a-z0-9][a-z0-9-]{0,31}`, and unique across all startup channels. +- **Grammar.** `kind` is a `NotificationChannelKind` with a startup adapter (`telegram`, `discord`, `ntfy`, `webhook` in the first release). Parameters are `name=value` pairs separated by `,`; a list value joins its items with `+`; a value cannot contain `,` (percent-encode it as `%2C`; `%` itself is `%25`). `name` is required, `[a-z0-9][a-z0-9-]{0,31}`, and unique across all startup channels. A parameter given twice is a usage error. +- **Parameters per kind** (target parameters are stored in `target_json`, secret parameters as variable names in `secret_refs_json`): + +| Kind | Required | Optional | Secret (always `env:NAME`) | +|---|---|---|---| +| `telegram` | `token`, `chat` (a chat id: `123456`, `-100…` for groups and channels) | `thread` (a forum topic id) | `token` | +| `discord` | `webhook` | `mode` (`webhook`, the only mode of the first release; `bot` is a usage error naming the dashboard) | `webhook` | +| `ntfy` | `topic` (a literal topic, or `env:NAME`) | `server` (absolute `http(s)` URL, default `https://ntfy.sh`), `token` | `token`, and `topic` when written `env:NAME` | +| `webhook` | `url` (absolute `http(s)` URL, or `env:NAME`) | `secret` (the HMAC key) | `secret`, and `url` when written `env:NAME` | + - **Secrets are environment variable names.** Every secret-bearing parameter (`token`, `webhook`, `secret`, `password`, and a Discord bot `token`) takes `env:NAME`, where `NAME` matches `[A-Za-z_][A-Za-z0-9_]*` and does not start with `BROWSERHIVE_`. An inline value is a usage error, exit 64, and the message never echoes the value: `browserhive: --notificationChannel 'phone': token must name an environment variable (token=env:NAME), never contain the secret: other users of this machine can read process arguments.` A referenced variable that is unset or empty is a usage error naming the variable. An ntfy `topic` on a public server acts as a credential too; a literal topic is accepted with a `warn` recommending `topic=env:NAME`. -- **Rules.** Optional parameters mirror the channel rules of the dashboard: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info|warn|error|critical`), `sessions` (slug globs), `content` (`counts|titles|full`, default `titles`), `quiet` (`HH:MM-HH:MM`) with `tz` (IANA name, default the host's), `ttl.` (a duration; default never, D-35), `deleteWhenResolved` (`true|false`, default `false`). Unknown parameters are usage errors with a "did you mean". +- **Rules.** Optional parameters mirror the channel rules of the dashboard: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info|warn|error|critical`), `sessions` (slug globs), `harness` (harness slugs), `content` (`counts|titles|full`, default `titles`), `quiet` (`HH:MM-HH:MM`) with `tz` (IANA name, default the host's), `ttl.` (a duration; default never, D-35; a Telegram TTL above `47h` is a usage error because Telegram lets a bot delete its messages for 48 hours only), `deleteWhenResolved` (`true|false` for every category, or a `+` list of categories; default `false`), `images` (`+` list of categories whose notifications carry a screenshot, D-36; default none) and `maskImages` (`true|false`, default `false`). Unknown parameters are usage errors with a "did you mean". Every problem of every flag is reported together, like the other usage errors. +- **Where it is accepted.** `serve` (the default command) and `doctor` (which checks the channels without starting them). The programmatic API takes the same strings as `notificationChannels: string[]` (§9); a programmatic caller passes secrets through `env` as well. - **Read-only.** A startup channel is projected into `notification_channels` with `source = 'startup'` at each start; the dashboard and API show it with a "from startup" badge and refuse to edit or delete it; pausing and resuming are allowed, and a pause survives restarts like the breaker state. A startup channel whose `name` a dashboard channel already uses stops startup with `CONFIG_INVALID` (exit 64): `browserhive: notification channel 'phone' is defined by --notificationChannel and in the dashboard. Rename one of them.` +### 5.8 The public address (`publicUrl`) + +`publicUrl` is where the operator made the dashboard reachable from elsewhere: a reverse proxy with their own domain, a Cloudflare tunnel, Caddy, nginx or a Tailscale name (D-37). BrowserHive provides none of that and opens no port for it. + +- **Validation.** An absolute `http:` or `https:` URL without query, fragment or credentials; a trailing `/` is dropped, a path prefix is kept (`https://example.net/bh`). A usage error otherwise (exit 64, the url grammar message). `http:` on a host that is not loopback starts with one `warn` log line (`publicUrl is plain http`) and a `doctor` warning, because links then travel and open without TLS. +- **Links.** Every open link of a notification is `publicUrl + path` (`https://bh.example.net/sessions/…?live=1`). Without `publicUrl` links point at the local listener (`http://127.0.0.1:9876/…`) and are labelled "Open on this computer" (03 §9.5). Links never carry a token; opening one still requires a login. +- **Trust.** The `publicUrl` host is added to the `Host` allow-list (as if it were in `allowedHosts`) and its origin is accepted by the origin guard (03 §2), so one key makes a reverse proxy work, including one that rewrites `Host` to the upstream address. +- **The check.** Every start mints a random `instance_id` that `GET /health` reports. `doctor`, the System page and `GET /api/v1/system/public-url` fetch `/health` (5 s timeout, redirects not followed) and compare it: `ok` (points to this BrowserHive), `elsewhere` (another BrowserHive answered, or a server that is not BrowserHive), `login` (reachable, but a login or an access proxy answered, for example Cloudflare Access: it cannot be confirmed from here), `unreachable` (no answer from this machine; it may still work from outside, for example behind a router without hairpin NAT), or `unset`. Only `elsewhere` is a `doctor` failure; `login` and `unreachable` are warnings. The dashboard's test message carries an "Open dashboard" link, which is the human proof from the phone. + ## 6. How the schema drives everything One zod object in `packages/contracts/src/config/schema.ts` is the sole definition: @@ -380,6 +400,7 @@ browserhive config show|schema|validate browserhive db status|backup|restore |migrate browserhive admin reset-password browserhive admin tokens list|create |revoke +browserhive channels list | test | preview [--sample ] browserhive version | --version | -v browserhive --help | -h (also per command) ``` @@ -394,7 +415,7 @@ Positional rules: the first argument is the command if it is a known command wor *Choosing the default browser.* On a terminal (stdin is a TTY, `CI` is unset, not in a container) and without `--channel`, `init` prints a menu of the installed channels plus, when Chrome is missing and Google ships a build for the platform, "Install Google Chrome (needs administrator rights)". Each entry lists pros and cons computed from what was detected (sandbox verdicts, the version the bundled build reports against the installed Chrome, how far an installed browser is ahead of the tested build, managed policies, Edge's stealth incoherence), never fixed prose. The current value is marked `← current` and is the default answer: pressing Enter changes nothing and writes nothing. Chrome is labelled "recommended for stealth" but never pre-selected. A different choice asks `Save defaultChannel= to ? [Y/n]` and merges the one key into the config file in use (other keys, their order and `$schema` kept) or, with none, creates `/browserhive.config.json` with mode 0600 (D-28). Without a terminal nothing is asked: `--channel` without `--yes` fails the step and writes nothing; a channel that is not installed fails without changing anything (no silent switch); `--installChrome` installs without changing the default unless `--channel chrome` is also given. When an env var or flag still overrides the saved value, `init` says so. When the config file's `defaultChannel` is a reference (§3.1), `init` does not rewrite it (that would silently drop the reference): it names the variable to set and changes nothing. -**`doctor`** — prints a table and exits 0 (all ✓), 1 (any ✗), or 2 (warnings only). Checks: Bun version ≥ 1.4; Chromium present for the resolved `stealthDriver` (Playwright and Patchright paths, exact versions); Google Chrome and Microsoft Edge (version and path, or "not installed", which is fine); the configured `defaultChannel` installed (✗ when it is not, naming the install command); version drift (! when the installed browser in use is more than one major version ahead of the tested Chromium); managed policies (✗ when a policy such as `RemoteDebuggingAllowed=false` blocks automation of the configured channel, ! for another channel); the sandbox per installed browser, probed with one headless launch (✓ sandboxes; under `auto` ✓ with "falls back to no sandbox" and the reason, since sessions still launch; under `on` ✗ for the configured channel and ! for others; under `off` ✓ with the verdict as information); running as root or in a container (! only under `sandbox=on`); in text mode, when the configured browser cannot sandbox, the same guidance block as the `sandbox=on` refusal is printed under the table; data dir exists, owner-only permissions, free disk; config file found and valid (runs the full resolver and prints shadow lines; the detail adds `· N from references` when config-file values used references (§3.1)); one `reference` warning per reference that fell back to its `:-` default (`otelEndpoint: OTLP_HOST is not set (or empty), so the config file's default is used`), never showing the default's text; port free on the resolved host; `bw` CLI on PATH when `vault=bitwarden`; unrecognised data files in the data dir (check `unrecognised data files`, warning: "unrecognised data file 'events.db' found; BrowserHive does not read or migrate it"); database opens, `user_version`, `min_reader_version`, pending migrations, last backup; OTLP endpoint reachable when `otel=true` (HEAD request, 2 s timeout, warning only); `maxSessions` vs available RAM sanity; `authTokens` supplied via a config file with mode broader than 0600 (warning), unless every token in the file's `authTokens` value is a reference (`"ci:{env:CI_TOKEN}"`, `"{env:BH_TOKENS}"`), in which case the file holds no secret and the check is ✓ naming the variables (`authTokens in come from $CI_TOKEN; the file holds no token`); a reference with a non-empty default counts as a token written in the file. `--json` emits the same as an array of `{ check, status, detail }`. `--printApparmorProfile` prints an AppArmor profile for the configured channel's browser (shaped like Ubuntu's own `/etc/apparmor.d/chrome`: `flags=(unconfined)` plus `userns`) and exits; it never installs anything. `--print-apparmor-profile` is an unsupported spelling (§2). +**`doctor`** — prints a table and exits 0 (all ✓), 1 (any ✗), or 2 (warnings only). Checks: Bun version ≥ 1.4; Chromium present for the resolved `stealthDriver` (Playwright and Patchright paths, exact versions); Google Chrome and Microsoft Edge (version and path, or "not installed", which is fine); the configured `defaultChannel` installed (✗ when it is not, naming the install command); version drift (! when the installed browser in use is more than one major version ahead of the tested Chromium); managed policies (✗ when a policy such as `RemoteDebuggingAllowed=false` blocks automation of the configured channel, ! for another channel); the sandbox per installed browser, probed with one headless launch (✓ sandboxes; under `auto` ✓ with "falls back to no sandbox" and the reason, since sessions still launch; under `on` ✗ for the configured channel and ! for others; under `off` ✓ with the verdict as information); running as root or in a container (! only under `sandbox=on`); in text mode, when the configured browser cannot sandbox, the same guidance block as the `sandbox=on` refusal is printed under the table; data dir exists, owner-only permissions, free disk; config file found and valid (runs the full resolver and prints shadow lines; the detail adds `· N from references` when config-file values used references (§3.1)); one `reference` warning per reference that fell back to its `:-` default (`otelEndpoint: OTLP_HOST is not set (or empty), so the config file's default is used`), never showing the default's text; port free on the resolved host; `bw` CLI on PATH when `vault=bitwarden`; unrecognised data files in the data dir (check `unrecognised data files`, warning: "unrecognised data file 'events.db' found; BrowserHive does not read or migrate it"); database opens, `user_version`, `min_reader_version`, pending migrations, last backup; OTLP endpoint reachable when `otel=true` (HEAD request, 2 s timeout, warning only); `maxSessions` vs available RAM sanity; `publicUrl` (§5.8: skipped when unset; ✓ `ok`, ! `login`/`unreachable`/plain `http` on a public host, ✗ `elsewhere`; `doctor` starts no server, so it compares the answer's `instance_id` with the one the local listener `http://:/health` reports when a server is running, and otherwise only reports whether a BrowserHive answered); notification channels (each `--notificationChannel` parses, and every environment variable named by a startup or dashboard channel is set: ✗ naming the channel and the variable, never a value; dashboard channels are read from the database without migrating it); `authTokens` supplied via a config file with mode broader than 0600 (warning), unless every token in the file's `authTokens` value is a reference (`"ci:{env:CI_TOKEN}"`, `"{env:BH_TOKENS}"`), in which case the file holds no secret and the check is ✓ naming the variables (`authTokens in come from $CI_TOKEN; the file holds no token`); a reference with a non-empty default counts as a token written in the file. `--json` emits the same as an array of `{ check, status, detail }`. `--printApparmorProfile` prints an AppArmor profile for the configured channel's browser (shaped like Ubuntu's own `/etc/apparmor.d/chrome`: `flags=(unconfined)` plus `userns`) and exits; it never installs anything. `--print-apparmor-profile` is an unsupported spelling (§2). **`purge`** — resolves only `dataDir` (so a broken config file can never prevent starting over); prints an inventory (row counts per table via a read-only, non-migrating connection; directory sizes; absolute paths; total); default targets are the database (+ WAL/SHM) and `sessions/`; `--all` adds auth states, uploads, backups and admin credentials; the vault policy tables live in the database and are dropped with it, so the inventory states that vault bindings are lost; requires typing `YES`; `--all` asks a second `YES`; `--dryRun` prints and exits 0; `--yes` skips prompts; without a TTY and without `--yes` it refuses (exit 1); warns when open sessions exist in the DB. `--dry-run` is an unsupported spelling answered with a hint naming `--dryRun` (§5.5). @@ -404,6 +425,8 @@ Positional rules: the first argument is the command if it is a known command wor **`admin tokens list | create | revoke `** — manages agent bearer tokens for `auth=token` (D-09). Tokens are stored hashed, so `create` is the only time the plaintext is shown; `list` shows principal, public prefix, created/last-used timestamps; `revoke` is immediate. Works against the database directly (refuses while the server is running, lock file) or via the REST API when a server is up (`--url`, cookie/bearer). Output is a table, `--json` for scripts. +**`channels list | test | preview [--sample ]`** — notification channels (03 §9.5). `list` prints name, platform, status (with "from startup" for startup channels), where it sends (`target_hint`), whether its secret variables are set, the last delivery and the 24 h counts. `test ` sends a real test message through the channel and exits 0 when the platform accepted it, 1 otherwise (printing the classified error). `preview ` renders a sample notification exactly as the channel would send it and sends nothing; `--sample` is one of `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`; text mode prints the platform request (method, path without secrets, body), `--json` the whole `ChannelPreview`. All three talk to a running server over the REST API: `--url` (default `http://127.0.0.1:` from the resolved configuration) with `--token` (an operator bearer with `channels:read`/`channels:write`) or `--cookie`; without a reachable server they fail with exit 1 and say so. `--json` everywhere. + **`admin reset-password`** — generates a new seed password, marks `must_change_password`, prints it once, refuses while the server is running (lock file), writes `admin/credentials.txt` (0600). **`version`** — `browserhive 0.1.0 (bun 1.4.2, sqlite 3.53.2, playwright 1.63.0, patchright 1.63.0|not installed)`; `--json` for machines. @@ -427,6 +450,7 @@ COMMANDS config Show, validate, or export the configuration schema db Inspect, back up, restore, or migrate the database admin Administrative actions (reset-password, tokens) + channels List, test and preview notification channels version Print version information FLAGS — server @@ -491,7 +515,7 @@ Rules: colour per `color`; the seed password and seeded agent token are printed ## 9. Programmatic API -`createServer(options: CreateServerOptions)` in `browserhive` accepts the **typed** camelCase keys of the schema directly (`{ port: 9876, admin: true, sessionLease: '2h' | 7_200_000 }`), never strings that are re-parsed. The options object is one source with provenance `cli` (it plays the role of flags); `env` (default `process.env`, `{}` isolates), `configFile: false | string` (default: discovery per §3), `output: { stdout, stderr }` sinks, `logger` (external sink adapter), and `hostMemory` (for tests) are the only extra fields. `createServer` resolves and validates eagerly (throws `ConfigError` with the same messages as the CLI), then returns `{ config, provenance, url, listen(), stop(deadline?) }`. +`createServer(options: CreateServerOptions)` in `browserhive` accepts the **typed** camelCase keys of the schema directly (`{ port: 9876, admin: true, sessionLease: '2h' | 7_200_000 }`), never strings that are re-parsed. The options object is one source with provenance `cli` (it plays the role of flags); `env` (default `process.env`, `{}` isolates), `configFile: false | string` (default: discovery per §3), `output: { stdout, stderr }` sinks, `logger` (external sink adapter), `hostMemory` (for tests) and `notificationChannels` (the `--notificationChannel` strings of §5.7) are the only extra fields. `createServer` resolves and validates eagerly (throws `ConfigError` with the same messages as the CLI), then returns `{ config, provenance, url, listen(), stop(deadline?) }`. ## 10. Scenario examples @@ -507,6 +531,7 @@ Rules: colour per `color`; the seed password and seeded agent token are printed | S7 start over | `browserhive purge` (`--all` to also drop saved logins and credentials) | | LAN exposure | `browserhive --host 0.0.0.0 --auth token --admin` | | Telemetry | `browserhive --admin --otel --otelEndpoint http://collector:4318` | +| Phone notifications | `BH_TG_TOKEN=… browserhive --admin --publicUrl https://bh.example.net --notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456"` (or add the channel in the dashboard) | | Env-only deployment | see below | ```sh diff --git a/specs/09-testing.md b/specs/09-testing.md index 2a13dda..923eeea 100644 --- a/specs/09-testing.md +++ b/specs/09-testing.md @@ -106,6 +106,8 @@ Domain and application (no I/O, fakes only): - Humanize (`infra/browsers/humanize/*.test.ts`): seeded RNG schedules with injected `sleep`; budget fallbacks to native; typo model; vault typer fallback. - Operator requests (`domain/operator-requests/*.test.ts`): attention and vault-confirm share the broker; timeout, cancel on heartbeat rejection, session-close settlement per reason, orphan recovery at startup, lease pause/resume, idempotent ids, bounded queue. - Notifications (`app/notifications/*.test.ts`): producer rules from the bus; persistence; read/dismiss; WS topic emission. The message contract (D-32): producers table-driven event → `NotificationMessage` (kind, category, severity, state, thread, blocks, actions, entities) validated against the contract schema; lifecycle revisions (attention and vault confirm resolved/rejected/timeout/cancelled, degradation recovered, tool-error group growth = new revision with `alert: false`); `restrictContent` per level; `degrade()` table-driven (tables → lists, images dropped or linked, `act` → `open`, button cap, truncation with the "Open in BrowserHive" footer, act buttons dropped outside `open`). The outbox (`outbox.test.ts`, fake clock, manual intervals, fixed jitter, a scripted fake channel): enqueue in the same transaction as the notification; zero channels → no rows, no timer; send → channel message with `last_revision`; retry with backoff and `retry_after`; dead after 8 attempts and after 24 h; non-retryable → dead; coalescing and supersede; the 3 s edit spacing; `message_gone` → re-send or supersede; breaker at 5 consecutive failures with the `channel.broken` notification delivered in-app only and **no degradation reported** (the loop test); backlog collapse; crash recovery of `sending`; the TTL sweep enqueues deletes, `delete_unsupported`, `too_old`, late deletes; routing rules (category, severity, session globs, harness, quiet hours across DST). The registry: startup projection, removal of undeclared startup rows, name clash → `CONFIG_INVALID`. **Invariant** (`redaction.property.test.ts`, seeded generator, 1 000 cases): a sentinel secret registered in the `SecretRegistry` and routed through every producer input never appears in `message_json`, the in-app row or payload, or any delivery row. +- Notification channels (N1, 03 §9.5): the renderers (`infra/notifications/-render.test.ts`) against **golden files** per platform × sample kind × revision (`packages/core/test/goldens/notifications//.json`, regenerated only with `UPDATE_GOLDENS=1`); escaping and length property tests (Telegram HTML never contains an unescaped `<`, `>` or `&` from message text, never exceeds 4096 characters, or 1024 as a caption; Discord embed limits; ntfy header-safe values); the startup-flag parser (`--notificationChannel`) table-driven with the exact exit-64 texts, including the inline-secret refusal that never echoes the value; the channel service (CRUD, read-only startup channels, `SecretEnvName` refusals, Telegram TTL cap, pause/resume, test send, preview is pure); the per-channel image rule (the masked/unmasked variant kept, the image dropped when `images[category]` is off or the level is below `full`); the `publicUrl` link builder and the host/origin trust; the `publicUrl` check outcomes against a `Bun.serve` fake (`ok`, `elsewhere`, `login` for a 302 or 401/403 or an HTML page, `unreachable`). **Adapters against fakes** (`packages/core/test/integration/notifications/*.test.ts`, no secrets): `Bun.serve` fakes of the Telegram Bot API, the Discord webhook API and ntfy (`packages/core/test/helpers/fake-platforms.ts`) script 429 with `retry_after`, 5xx, timeouts, "message to edit not found" and "message can't be deleted"; the full path event → outbox → send → edit (resolution) → TTL delete runs through each. The redaction sentinel suite renders through every adapter and asserts the sentinel is absent from every request path and body, the preview, the delivery log and the webhook body. +- Real ntfy (`packages/core/test/integration/notifications/ntfy-live.test.ts`): runs only when `BHDEV_NTFY_URL` points at a real ntfy server (CI starts `binwiederhier/ntfy` as a service container in the `ntfy` job); publishes, reads back with `GET //json?poll=1`, uploads an attachment, replaces by sequence id and deletes. - Event bus (`app/events/bus.test.ts`): every domain event has a versioned name and a zod payload in contracts; consumers are isolated (a throwing consumer does not stop others). ### 3.3 CLI unit (`packages/browserhive/src/cli/*.test.ts`) @@ -118,6 +120,8 @@ Domain and application (no I/O, fakes only): - Composition sandbox preflight (`test/composition/sandbox.test.ts`, fake probes): `sandbox=on` refuses with `SANDBOX_UNAVAILABLE`, the guidance lists a browser that was checked to work first, only the failing browser's alternatives are probed, and the data dir is released; a working browser boots after one probe; a missing configured browser refuses with the install command; `auto`/`off` probe nothing at boot. - `config show/validate/schema`: provenance table (`config-file via $VAR`, a secret key's reference named but its value never printed); JSON output validated against the schema, including `refs` and `template`; a config file with a reference on every key validates against the published JSON Schema. - `db status/backup/restore/migrate`: against a temp file DB. +- `channels list/test/preview`: against a fake REST server (the `admin tokens` remote pattern): table and `--json` output, exit 1 on a failed test send, `--sample` validation. +- `doctor` notification checks: a startup channel whose variable is unset fails naming the variable; the `publicUrl` outcomes map to ✓/!/✗. - Composition root: phase failure in `open-listeners` unwinds storage and closes the DB; `stop()` after failed `listen()` is a no-op; signal handling (first = graceful, second = 130); `createServer` typed options resolve with provenance `cli`. ### 3.4 Integration (real Chromium) @@ -164,6 +168,8 @@ All doubles live in `test/helpers/` of their package and implement a **port**, n | `FakeVaultBackend` | `VaultBackend` | in-memory entries with configurable `capabilities`; every call recorded | | `fake-bw` | executable on PATH | a Bun script emulating `bw status/list/get/sync` for integration and e2e; `BW_SESSION` equal to `FAKE_BW_TOKEN` means unlocked (there is no `unlock`, because BrowserHive never runs it) | | `FakeOtlpCollector` | HTTP server | accepts OTLP/HTTP, stores payloads for assertions | +| `FakeChannel` | `NotificationChannel` | scripted results per call (`ok`, a `ChannelSendError`); records send/edit/delete | +| Fake platforms (`fake-platforms.ts`) | the Telegram Bot API, Discord webhook API and ntfy over `Bun.serve` | record every request (method, path, headers, JSON or multipart body), script failures per route (429 with retry-after, 500, a hang, "message not found"), and answer with realistic bodies (message ids, sequence ids) | ## 5. Determinism rules @@ -196,6 +202,8 @@ All doubles live in `test/helpers/` of their package and implement a **port**, n | dashboard e2e | ✓ smoke subset (login → sessions → detail) on ubuntu | full incl. responsive goldens | | property tests | ✓ small case counts | large case counts | | package gate | ✓ | ✓ + release dry-run | +| real ntfy (`ntfy` job, service container `binwiederhier/ntfy`, not a required check) | ✓ when library code changed | ✓ | +| live notification platforms (`notify-live.yml`: real Telegram, a Discord webhook and ntfy.sh; send, read back where the platform allows, edit, delete) | only PRs labelled `live-notify`, never from forks; the `notify-live` environment needs the owner's approval | weekly and on dispatch; each platform skips with a note when its secrets are absent; a failure opens or updates a drift issue | | coverage ratchet | ✓ | ✓ | Playwright browsers are cached with a key derived from the resolved `playwright`/`patchright` versions. Windows runs use `--disable-dev-shm-usage`-equivalent flags only where the launcher already sets them; nothing is skipped silently: a suite that cannot run (no Chromium) fails with a clear reason instead of `skip`. diff --git a/specs/10-error-handling-and-telemetry.md b/specs/10-error-handling-and-telemetry.md index 6741228..9fdaee1 100644 --- a/specs/10-error-handling-and-telemetry.md +++ b/specs/10-error-handling-and-telemetry.md @@ -85,6 +85,13 @@ Decisions: D-07 (error model), D-08 (telemetry), D-20 (privacy). | ATTENTION_REQUIRES_HTTP | 400 | domain | never | `{tool}` | | ATTENTION_NOT_OPEN | 409 | domain | never | `{request_id, status}` | | CONFIRM_NOT_OPEN | 409 | domain | never | `{request_id, status}` | +| CHANNEL_NOT_FOUND | 404 | domain | never | `{channel_id}` | +| CHANNEL_NAME_TAKEN | 409 | domain | different_args | `{name}` | +| CHANNEL_READ_ONLY | 409 | domain | never | `{channel_id, name}` (a startup channel: edit its `--notificationChannel` flag) | +| CHANNEL_NOT_READY | 409 | domain | after_operator | `{channel_id?, missing[]}` (the environment variables that are unset; names only) | +| CHANNEL_KIND_UNAVAILABLE | 400 | domain | different_args | `{kind, mode?}` | +| CHANNEL_PLATFORM_ERROR | 502 | domain | backoff | `{kind, code, detail}` (the classified platform failure, scrubbed) | +| DELIVERY_NOT_FOUND | 404 | domain | never | `{seq}` | | INPUT_NOT_PERMITTED | 409 | domain | after_operator | `{session_id}` | | SCREENCAST_FAILED | 502 | domain | backoff | `{session_id, reason}` | | TOOL_NOT_AVAILABLE | 400 | domain | never | `{tool, requires}` | @@ -383,8 +390,8 @@ The same registry backs `/api/v1/system` figures; with `--otel` off, the in-proc - References in config files (D-29, 08 §3.1): the variable *name* is always shown (it is the operator's coordinate); the value and the file's text around a reference never are on a secret key (no `template`, problem messages name the variable only). The key-name heuristics above also apply to reference names: a value that came through `{env:GRAFANA_API_TOKEN}` renders `` on every surface even on a key that is not flagged secret, `GET /api/v1/system/config` marks that key `secret: true` for this run, and the values such references produced are registered as always-on entries in the `observability` phase. Over-redaction is the accepted failure direction. - Error projections run through `toWire` too, so an error message that echoes a typed value cannot carry a secret past the redaction window. - `--screenshotTrace` skips frames while a session's secret window is open; `vault_fill` is excluded from screenshot tracing. -- Notifications are a sink (D-32): producers pass every string they copy from an event (attention reason and message, tool name, error message, entry name, degradation message, URLs) through the `Redactor` and `sanitizeUrl` before it becomes part of the stored `NotificationMessage`, the in-app row or a delivery; adapters and the delivery log only ever see that result, and `last_error` of a delivery is scrubbed too. Channel secrets are never in the database (only environment variable names, D-33). -- Property test: for every sink (log line, DB row, WS frame, MCP result, problem+json, OTLP payload, export stream, notification message, in-app notification row, delivery log) inject a sentinel secret through every documented path and assert the sentinel never appears. +- Notifications are a sink (D-32): producers pass every string they copy from an event (attention reason and message, tool name, error message, entry name, degradation message, URLs) through the `Redactor` and `sanitizeUrl` before it becomes part of the stored `NotificationMessage`, the in-app row or a delivery; adapters and the delivery log only ever see that result, and `last_error` of a delivery is scrubbed too. Channel secrets are never in the database (only environment variable names, D-33). The adapter factories read the variables through `ctx.secret(name)`, which registers each value as an always-on `SecretRegistry` entry before it is used, so a token inside a platform URL (`/bot/…`, a Discord webhook path) is scrubbed from span attributes, log lines and `last_error`. Every renderer output (the platform request bodies and paths), the preview (`POST /channels/preview` replaces a secret in a path by its variable name) and the generic webhook body are sinks of the sentinel test. Screenshots (D-36) are never taken while the session's secret window is open, never when `recordToolResults=none`, and reach a channel only at content level `full` with `images[category]` on; masking uses Playwright's `mask` over form fields. +- Property test: for every sink (log line, DB row, WS frame, MCP result, problem+json, OTLP payload, export stream, notification message, in-app notification row, delivery log, each platform renderer's request and the generic webhook body) inject a sentinel secret through every documented path and assert the sentinel never appears. --- From 96ce2636160b63caeb11883d50bd6c1694e5dcee Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:21:02 -0400 Subject: [PATCH 02/22] feat(contracts): channel API, publicUrl and the platform table The channels REST DTOs (views that never carry a secret value, input with environment variable names, preview, delivery log, env check, Telegram connect), the channels WS topic and events, channels:read and channels:write scopes, the publicUrl key and grammar, GET /health instance_id, the public-url status DTO, per-platform requirements with one shared config check, preview samples and delivery reason texts, and the channel error codes. --- packages/contracts/src/config/index.ts | 1 + packages/contracts/src/config/keys-server.ts | 18 +- packages/contracts/src/config/parsers.ts | 18 + packages/contracts/src/enums/scope.ts | 2 + .../contracts/src/errors/codes-service.ts | 100 + packages/contracts/src/http/channels.ts | 319 ++++ packages/contracts/src/http/common.ts | 5 + packages/contracts/src/http/endpoints.ts | 16 + packages/contracts/src/http/index.ts | 30 + packages/contracts/src/http/system.ts | 30 + packages/contracts/src/index.ts | 54 + .../contracts/src/notifications/channel.ts | 5 + packages/contracts/src/notifications/index.ts | 22 + .../contracts/src/notifications/platforms.ts | 407 +++++ packages/contracts/src/ws/commands.ts | 4 +- packages/contracts/src/ws/feed-events.ts | 16 + packages/contracts/src/ws/index.ts | 3 + packages/contracts/src/ws/topics.ts | 1 + .../exports.snapshot.test.ts.snap | 48 + .../contracts/test/config.registry.test.ts | 1 + .../contracts/test/errors.registry.test.ts | 7 + .../test/goldens/ws/ws-protocol.json | 1628 +++++++++++++++-- .../test/notification-platforms.test.ts | 117 ++ packages/core/src/app/config/consumers.ts | 1 + packages/core/src/domain/auth/cookie.test.ts | 2 +- specs/10-error-handling-and-telemetry.md | 4 +- 26 files changed, 2720 insertions(+), 139 deletions(-) create mode 100644 packages/contracts/src/http/channels.ts create mode 100644 packages/contracts/src/notifications/platforms.ts create mode 100644 packages/contracts/test/notification-platforms.test.ts diff --git a/packages/contracts/src/config/index.ts b/packages/contracts/src/config/index.ts index ed3c752..9918822 100644 --- a/packages/contracts/src/config/index.ts +++ b/packages/contracts/src/config/index.ts @@ -41,6 +41,7 @@ export { zMaxSessions, zPath, zPort, + zPublicUrl, zRatio, zReservedEnum, zString, diff --git a/packages/contracts/src/config/keys-server.ts b/packages/contracts/src/config/keys-server.ts index cc2c45f..a2da167 100644 --- a/packages/contracts/src/config/keys-server.ts +++ b/packages/contracts/src/config/keys-server.ts @@ -3,7 +3,16 @@ import type { z } from 'zod'; import { AuthMode } from '../enums/auth-mode.ts'; import { isIPv4, isIPv6, zHost } from './host.ts'; import { derived, key } from './key.ts'; -import { zBool, zDuration, zEnumOf, zList, zPath, zPort, zReservedEnum } from './parsers.ts'; +import { + zBool, + zDuration, + zEnumOf, + zList, + zPath, + zPort, + zPublicUrl, + zReservedEnum, +} from './parsers.ts'; const AUTH_TOKEN_ITEM_RE = /^[^:\s]+:.{32,}$/; @@ -97,6 +106,13 @@ export const SERVER_KEYS = { 'Extra Host names to accept besides loopback and the bound host, such as the name a reverse proxy forwards. Ports are ignored.', examples: ['browserhive.example.com'], }), + publicUrl: key(zPublicUrl, { + optional: true, + group: 'server', + describe: + 'Address where you made the dashboard reachable (reverse proxy, tunnel, Tailscale name). Notification links use it, and its host is trusted like allowedHosts.', + examples: ['https://browserhive.example.net'], + }), admin: key(zBool, { default: false, group: 'server', diff --git a/packages/contracts/src/config/parsers.ts b/packages/contracts/src/config/parsers.ts index 971cb69..ba70ccd 100644 --- a/packages/contracts/src/config/parsers.ts +++ b/packages/contracts/src/config/parsers.ts @@ -175,6 +175,24 @@ export const zUrl = z }) .meta({ [GRAMMAR_META_KEY]: URL_GRAMMAR }); +const PUBLIC_URL_GRAMMAR = + "an absolute http: or https: URL without query or fragment, like 'https://browserhive.example.net'"; +const PUBLIC_URL_RE = /^https?:\/\/[^\s/?#@]+(?::\d{1,5})?(?:\/[^\s?#]*)?$/i; + +/** + * Public-address grammar (`publicUrl`, spec 08 §5.8): an absolute `http:`/`https:` URL without + * credentials, query or fragment. Output: the trimmed text without trailing slashes. + */ +export const zPublicUrl = z + .string() + .transform((value, ctx): string => { + const text = value.trim(); + return PUBLIC_URL_RE.test(text) + ? text.replace(/\/+$/, '') + : fail(ctx, PUBLIC_URL_GRAMMAR, value); + }) + .meta({ [GRAMMAR_META_KEY]: PUBLIC_URL_GRAMMAR }); + const STRING_GRAMMAR = 'a non-empty string'; /** String grammar: as-is, but never empty (an empty value is a usage error, spec 08 §1). */ diff --git a/packages/contracts/src/enums/scope.ts b/packages/contracts/src/enums/scope.ts index 089c492..7dbb82e 100644 --- a/packages/contracts/src/enums/scope.ts +++ b/packages/contracts/src/enums/scope.ts @@ -18,6 +18,8 @@ export const Scope = z.enum([ 'logs:read', 'notifications:read', 'notifications:write', + 'channels:read', + 'channels:write', 'preferences:write', 'mcp:tools', ]); diff --git a/packages/contracts/src/errors/codes-service.ts b/packages/contracts/src/errors/codes-service.ts index 3618c70..c138df8 100644 --- a/packages/contracts/src/errors/codes-service.ts +++ b/packages/contracts/src/errors/codes-service.ts @@ -250,6 +250,106 @@ export const SERVICE_ERRORS = { cause: 'The event produced no screenshot, or retention removed it.', resolution: 'Nothing to do; the artifact no longer exists.', }), + CHANNEL_NOT_FOUND: defineError({ + code: 'CHANNEL_NOT_FOUND', + httpStatus: 404, + category: 'domain', + retryable: 'never', + title: 'Notification channel not found', + message: "Notification channel '{channel_id}' does not exist.", + hint: 'List the channels with GET /api/v1/channels.', + details: z.object({ channel_id: z.string() }), + docs: true, + cause: 'The channel was deleted, or the id is wrong.', + resolution: 'Refresh the channel list.', + }), + CHANNEL_NAME_TAKEN: defineError({ + code: 'CHANNEL_NAME_TAKEN', + httpStatus: 409, + category: 'domain', + retryable: 'different_args', + title: 'Channel name in use', + message: "A notification channel named '{name}' already exists.", + hint: 'Pick another name.', + details: z.object({ name: z.string() }), + docs: true, + cause: 'Channel names are unique across dashboard and startup channels.', + resolution: 'Choose a different name, or edit the existing channel.', + }), + CHANNEL_READ_ONLY: defineError({ + code: 'CHANNEL_READ_ONLY', + httpStatus: 409, + category: 'domain', + retryable: 'never', + title: 'Startup channel is read-only', + message: + "Notification channel '{name}' comes from --notificationChannel and cannot be edited or deleted here.", + hint: 'Change or remove the --notificationChannel flag and restart; pausing is allowed.', + details: z.object({ channel_id: z.string(), name: z.string() }), + docs: true, + cause: 'Startup channels are declared by a command-line flag (D-39); the flag is their truth.', + resolution: 'Edit the flag and restart BrowserHive, or pause the channel from the dashboard.', + }), + CHANNEL_NOT_READY: defineError({ + code: 'CHANNEL_NOT_READY', + httpStatus: 409, + category: 'domain', + retryable: 'after_operator', + title: 'Channel is not ready', + message: 'The notification channel cannot send: {problem}', + hint: 'Set the missing environment variables and restart BrowserHive.', + details: z.object({ + channel_id: z.string().optional(), + problem: z.string(), + missing: z.array(z.string()), + }), + docs: true, + cause: + 'An environment variable the channel names is not set in the server’s environment (secrets are never stored, D-33).', + resolution: + 'Export the variable where BrowserHive runs (shell, systemd, Docker) and restart it.', + }), + CHANNEL_KIND_UNAVAILABLE: defineError({ + code: 'CHANNEL_KIND_UNAVAILABLE', + httpStatus: 400, + category: 'domain', + retryable: 'different_args', + title: 'Platform not available yet', + message: "Notification channels of kind '{kind}'{mode_text} are not available in this release.", + hint: 'Use telegram, discord (webhook mode), ntfy or webhook.', + details: z.object({ kind: z.string(), mode: z.string().optional(), mode_text: z.string() }), + docs: true, + cause: 'The platform (or Discord bot mode) is reserved for a later release.', + resolution: 'Pick an available platform, or Discord in webhook mode.', + }), + CHANNEL_PLATFORM_ERROR: defineError({ + code: 'CHANNEL_PLATFORM_ERROR', + httpStatus: 502, + category: 'domain', + retryable: 'backoff', + title: 'The platform refused the request', + message: '{kind} answered: {detail}', + hint: 'Check the credentials the channel names, then retry.', + details: z.object({ kind: z.string(), code: z.string(), detail: z.string() }), + docs: true, + cause: + 'The notification platform rejected the call (a wrong token, a network failure, a limit).', + resolution: + 'Read the detail; fix the token or URL in the environment and restart, or retry later.', + }), + DELIVERY_NOT_FOUND: defineError({ + code: 'DELIVERY_NOT_FOUND', + httpStatus: 404, + category: 'domain', + retryable: 'never', + title: 'Delivery not found', + message: 'Delivery {seq} does not exist.', + hint: 'Delivery rows are kept for 30 days.', + details: z.object({ seq: z.number() }), + docs: true, + cause: 'The row was pruned by retention, or deleted with its channel.', + resolution: 'Nothing to do.', + }), INTERNAL_ERROR: defineError({ code: 'INTERNAL_ERROR', httpStatus: 500, diff --git a/packages/contracts/src/http/channels.ts b/packages/contracts/src/http/channels.ts new file mode 100644 index 0000000..d739bab --- /dev/null +++ b/packages/contracts/src/http/channels.ts @@ -0,0 +1,319 @@ +/** @module contracts/http/channels — notification channels: CRUD, test send, preview, the delivery log, the environment check and the Telegram connect flow (spec 03 §4.8.1, D-33, D-37, D-38, D-39) */ +import { z } from 'zod'; +import { + NotificationChannelKind, + NotificationChannelSource, + NotificationChannelStatus, + NotificationDeliveryOp, + NotificationDeliveryStatus, + NotificationKind, +} from '../enums/index.ts'; +import { NotificationId } from '../ids/index.ts'; +import { + NotificationChannelName, + NotificationChannelRules, + NotificationChannelSecretRefs, + NotificationChannelTarget, + SecretEnvName, +} from '../notifications/channel.ts'; +import { NotificationMessage } from '../notifications/message.ts'; +import { AvailableChannelKind, PreviewSample } from '../notifications/platforms.ts'; +import { Count, Cursor, csv, DurationMs, EpochMs, limitQuery, page } from './common.ts'; + +/** Channel id (`nc-…`). */ +export const ChannelId = z.string().regex(/^nc-[A-Za-z0-9_-]{4,64}$/, 'a channel id (nc-…)'); +/** Channel id. */ +export type ChannelId = z.infer; + +/** What a platform adapter can render (spec 03 §9.3), in wire form. */ +export const ChannelCapabilitiesDto = z.object({ + rich_blocks: z.boolean(), + tables: z.boolean(), + images: z.boolean(), + act_buttons: z.boolean(), + open_links: z.boolean(), + edit: z.boolean(), + delete: z.boolean(), + replies: z.boolean(), + /** How long after sending a message may still be deleted; `null` = no limit. */ + delete_window_ms: DurationMs.nullable(), + max_title_chars: Count, + max_text_chars: Count, + max_buttons: Count, +}); +/** Adapter capabilities. */ +export type ChannelCapabilitiesDto = z.infer; + +/** Whether one secret variable a channel names is set in the server's environment (never its value). */ +export const ChannelSecretState = z.object({ + param: z.string(), + env: z.string(), + set: z.boolean(), +}); +/** Secret variable state. */ +export type ChannelSecretState = z.infer; + +/** Delivery counts of one channel. */ +export const ChannelStats = z.object({ + sent_24h: Count, + failed_24h: Count, + suppressed_24h: Count, + /** Jobs waiting (`pending`, `retrying`, `sending`). */ + pending: Count, + last_delivery_at: EpochMs.nullable(), + last_status: NotificationDeliveryStatus.nullable(), +}); +/** Delivery counts of one channel. */ +export type ChannelStats = z.infer; + +/** One configured channel as the API shows it. Never carries a secret value. */ +export const ChannelView = z.object({ + channel_id: ChannelId, + name: z.string(), + kind: NotificationChannelKind, + mode: z.string().nullable(), + /** `startup` channels come from `--notificationChannel` and are read-only (D-39). */ + source: NotificationChannelSource, + status: NotificationChannelStatus, + target: NotificationChannelTarget, + /** Short, lossy rendering of where it sends ("chat …3456", "ntfy.sh/bh-alerts"). */ + target_hint: z.string(), + secret_refs: NotificationChannelSecretRefs, + secrets: z.array(ChannelSecretState), + rules: NotificationChannelRules, + /** `null` when no adapter could be built. */ + capabilities: ChannelCapabilitiesDto.nullable(), + /** The adapter is built and every required variable is set. */ + ready: z.boolean(), + /** Why it is not ready, or a warning (a private webhook target); `null` when fine. */ + problem: z.string().nullable(), + failure_count: Count, + last_error: z.string().nullable(), + last_ok_at: EpochMs.nullable(), + last_failure_at: EpochMs.nullable(), + created_at: EpochMs, + updated_at: EpochMs, + stats: ChannelStats, +}); +/** One configured channel. */ +export type ChannelView = z.infer; + +/** Path params `{channel_id}`. */ +export const ChannelIdParams = z.strictObject({ channel_id: ChannelId }); +/** Path params `{channel_id}`. */ +export type ChannelIdParams = z.infer; + +/** `POST /channels` body. Secrets are environment variable NAMES (D-33). */ +export const ChannelInput = z.strictObject({ + name: NotificationChannelName, + kind: AvailableChannelKind, + /** Discord: `webhook` (default) or `bot` (not available yet, D-38). */ + mode: z.string().max(32).nullable().optional(), + target: NotificationChannelTarget.default({}), + secret_refs: z.record(z.string().max(64), z.string().max(256)).default({}), + rules: NotificationChannelRules.default({}), +}); +/** `POST /channels` body. */ +export type ChannelInput = z.infer; + +/** `PATCH /channels/{id}` body: any subset of the input except `kind`. */ +export const ChannelPatch = z.strictObject({ + name: NotificationChannelName.optional(), + mode: z.string().max(32).nullable().optional(), + target: NotificationChannelTarget.optional(), + secret_refs: z.record(z.string().max(64), z.string().max(256)).optional(), + rules: NotificationChannelRules.optional(), +}); +/** `PATCH /channels/{id}` body. */ +export type ChannelPatch = z.infer; + +/** `GET /channels` body. */ +export const ChannelsResponse = z.object({ data: z.array(ChannelView), now: EpochMs }); +/** `GET /channels` body. */ +export type ChannelsResponse = z.infer; + +/** One channel. */ +export const ChannelResponse = z.object({ channel: ChannelView }); +/** One channel. */ +export type ChannelResponse = z.infer; + +/** One outbox job / delivery log row (D-34). */ +export const DeliveryRow = z.object({ + seq: z.number().int().positive(), + channel_id: z.string(), + channel_name: z.string().nullable(), + channel_kind: z.string().nullable(), + notification_id: NotificationId, + notification_kind: NotificationKind.nullable(), + notification_title: z.string().nullable(), + revision: z.number().int().min(1), + op: NotificationDeliveryOp, + status: NotificationDeliveryStatus, + /** Suppression, supersede or failure reason (`filtered`, `quiet_hours`, `rate_limited`, …). */ + reason: z.string().nullable(), + attempts: Count, + next_attempt_at: EpochMs.nullable(), + last_error: z.string().nullable(), + duration_ms: DurationMs.nullable(), + /** The platform coordinates of the message (message id, chat id, sequence id). */ + message_ref: z.record(z.string(), z.union([z.string(), z.number()])).nullable(), + created_at: EpochMs, + updated_at: EpochMs, +}); +/** One delivery log row. */ +export type DeliveryRow = z.infer; + +/** `GET /channels/deliveries` query (keyset on `seq`, newest first). */ +export const DeliveriesQuery = z.strictObject({ + cursor: Cursor.optional(), + limit: limitQuery(200, 50), + channel_id: ChannelId.optional(), + notification_id: NotificationId.optional(), + status: csv(NotificationDeliveryStatus), + op: csv(NotificationDeliveryOp), + kind: csv(NotificationKind), +}); +/** `GET /channels/deliveries` query. */ +export type DeliveriesQuery = z.infer; + +/** `GET /channels/deliveries` body. */ +export const DeliveriesPage = page(DeliveryRow); +/** `GET /channels/deliveries` body. */ +export type DeliveriesPage = z.infer; + +/** Path params `{seq}`. */ +export const DeliverySeqParams = z.strictObject({ seq: z.coerce.number().int().positive() }); + +/** `GET /channels/deliveries/{seq}` body. */ +export const DeliveryDetailResponse = z.object({ + delivery: DeliveryRow, + /** The notification's current message as this channel is shown it; `null` when unavailable. */ + message: NotificationMessage.nullable(), +}); +/** `GET /channels/deliveries/{seq}` body. */ +export type DeliveryDetailResponse = z.infer; + +/** `POST /channels/{id}/test` body. */ +export const ChannelTestResponse = z.object({ + ok: z.boolean(), + delivery: DeliveryRow.nullable(), + error: z.object({ code: z.string(), message: z.string() }).nullable(), +}); +/** `POST /channels/{id}/test` body. */ +export type ChannelTestResponse = z.infer; + +/** `POST /channels/preview` body: a saved channel, or a draft. */ +export const ChannelPreviewRequest = z + .strictObject({ + channel_id: ChannelId.optional(), + kind: AvailableChannelKind.optional(), + mode: z.string().max(32).nullable().optional(), + target: NotificationChannelTarget.optional(), + rules: NotificationChannelRules.optional(), + sample: PreviewSample.default('attention'), + }) + .refine((b) => (b.channel_id === undefined) !== (b.kind === undefined), { + message: 'give channel_id or kind, not both', + }); +/** `POST /channels/preview` body. */ +export type ChannelPreviewRequest = z.infer; + +/** One platform request a renderer produced; secrets in the path are replaced by variable names. */ +export const PlatformRequest = z.object({ + /** `POST`, `PUT`, `PATCH`, `DELETE`. */ + method: z.string(), + /** Platform method or path (`sendPhoto`, `/webhooks/{BH_DISCORD_WEBHOOK}`, `/bh-alerts`). */ + path: z.string(), + /** `json`, `multipart` (a file part plus fields) or `binary` (a file body, fields as query). */ + encoding: z.enum(['json', 'multipart', 'binary']), + /** JSON body, or the non-file fields of a multipart/binary request. */ + body: z.record(z.string(), z.unknown()), + /** Headers that carry content (ntfy `X-*`); never an Authorization header. */ + headers: z.record(z.string(), z.string()), + /** The attached file, if any (never its bytes). */ + file: z.object({ name: z.string(), content_type: z.string() }).nullable(), +}); +/** One platform request. */ +export type PlatformRequest = z.infer; + +/** `POST /channels/preview` response. Pure: nothing was sent. */ +export const ChannelPreview = z.object({ + kind: AvailableChannelKind, + mode: z.string().nullable(), + sample: PreviewSample, + capabilities: ChannelCapabilitiesDto, + /** The message as the channel receives it (content level, image rule, degrade applied). */ + message: NotificationMessage, + /** The request(s) a send makes, in order. */ + requests: z.array(PlatformRequest), + /** Links point at this computer (no `publicUrl`). */ + local_links: z.boolean(), + notes: z.array(z.string()), +}); +/** `POST /channels/preview` response. */ +export type ChannelPreview = z.infer; + +/** `GET /channels/env` query. */ +export const ChannelEnvQuery = z.strictObject({ + names: z.preprocess( + (v) => + typeof v === 'string' + ? v + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : v, + z.array(SecretEnvName).min(1).max(16), + ), +}); +/** `GET /channels/env` query. */ +export type ChannelEnvQuery = z.infer; + +/** `GET /channels/env` body. */ +export const ChannelEnvResponse = z.object({ + vars: z.array(z.object({ name: z.string(), set: z.boolean() })), +}); +/** `GET /channels/env` body. */ +export type ChannelEnvResponse = z.infer; + +/** `POST /channels/telegram/connect` body. */ +export const TelegramConnectRequest = z.strictObject({ token_env: SecretEnvName }); +/** `POST /channels/telegram/connect` body. */ +export type TelegramConnectRequest = z.infer; + +/** `POST /channels/telegram/connect` response. */ +export const TelegramConnectResponse = z.object({ + connect_id: z.string(), + bot_username: z.string(), + /** Opens a private chat with the bot and sends `/start `. */ + link: z.string(), + /** Adds the bot to a group and sends `/start ` there. */ + group_link: z.string(), + expires_at: EpochMs, +}); +/** `POST /channels/telegram/connect` response. */ +export type TelegramConnectResponse = z.infer; + +/** Path params `{connect_id}`. */ +export const TelegramConnectParams = z.strictObject({ + connect_id: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/), +}); + +/** `GET /channels/telegram/connect/{connect_id}` response. */ +export const TelegramConnectStatus = z.object({ + status: z.enum(['waiting', 'connected', 'expired', 'failed']), + chat: z + .object({ + id: z.string(), + title: z.string(), + type: z.string(), + thread_id: z.string().nullable(), + }) + .nullable(), + /** Who sent `/start` (the first allow-list entry for act buttons, N2). */ + user: z.object({ id: z.string(), name: z.string() }).nullable(), + error: z.string().nullable(), + expires_at: EpochMs, +}); +/** `GET /channels/telegram/connect/{connect_id}` response. */ +export type TelegramConnectStatus = z.infer; diff --git a/packages/contracts/src/http/common.ts b/packages/contracts/src/http/common.ts index 4e5d788..7716db0 100644 --- a/packages/contracts/src/http/common.ts +++ b/packages/contracts/src/http/common.ts @@ -226,6 +226,11 @@ export const HealthResponse = z.object({ phase: BootPhase, version: z.string(), uptime_ms: DurationMs, + /** + * Random per start: the `publicUrl` check compares it to tell this BrowserHive from another + * server behind the same address (spec 08 §5.8). Absent from servers older than the field. + */ + instance_id: z.string().optional(), checks: z.object({ db: HealthCheckState, browser: HealthCheckState, diff --git a/packages/contracts/src/http/endpoints.ts b/packages/contracts/src/http/endpoints.ts index e6fee03..a0b71b5 100644 --- a/packages/contracts/src/http/endpoints.ts +++ b/packages/contracts/src/http/endpoints.ts @@ -116,6 +116,7 @@ export const HTTP_ENDPOINTS: readonly HttpEndpoint[] = [ ep('getSystemConfig', 'get', '/system/config', 'system:read'), ep('getSystemRealtime', 'get', '/system/realtime', 'system:read'), ep('listMcpConnections', 'get', '/system/mcp/connections', 'system:read'), + ep('getPublicUrlStatus', 'get', '/system/public-url', 'system:read'), ep('setLogLevel', 'patch', '/system/log-level', 'system:write'), ep('listSystemEvents', 'get', '/system/events', 'system:read'), ep('listLogs', 'get', '/logs', 'logs:read'), @@ -133,6 +134,21 @@ export const HTTP_ENDPOINTS: readonly HttpEndpoint[] = [ ep('markAllNotificationsRead', 'post', '/notifications/read-all', 'notifications:write'), ep('dismissNotification', 'delete', '/notifications/{notification_id}', 'notifications:write'), ep('dismissAllNotifications', 'post', '/notifications/dismiss-all', 'notifications:write'), + // §4.8.1 notification channels + ep('listChannels', 'get', '/channels', 'channels:read'), + ep('createChannel', 'post', '/channels', 'channels:write'), + ep('previewChannel', 'post', '/channels/preview', 'channels:read'), + ep('listDeliveries', 'get', '/channels/deliveries', 'channels:read'), + ep('getDelivery', 'get', '/channels/deliveries/{seq}', 'channels:read'), + ep('checkChannelEnv', 'get', '/channels/env', 'channels:read'), + ep('startTelegramConnect', 'post', '/channels/telegram/connect', 'channels:write'), + ep('getTelegramConnect', 'get', '/channels/telegram/connect/{connect_id}', 'channels:read'), + ep('getChannel', 'get', '/channels/{channel_id}', 'channels:read'), + ep('updateChannel', 'patch', '/channels/{channel_id}', 'channels:write'), + ep('deleteChannel', 'delete', '/channels/{channel_id}', 'channels:write'), + ep('pauseChannel', 'post', '/channels/{channel_id}/pause', 'channels:write'), + ep('resumeChannel', 'post', '/channels/{channel_id}/resume', 'channels:write'), + ep('testChannel', 'post', '/channels/{channel_id}/test', 'channels:write'), ep('getPreferences', 'get', '/me/preferences', null), ep('putPreferences', 'put', '/me/preferences', 'preferences:write'), ep('search', 'get', '/search', 'sessions:read'), diff --git a/packages/contracts/src/http/index.ts b/packages/contracts/src/http/index.ts index b26613c..80b7f89 100644 --- a/packages/contracts/src/http/index.ts +++ b/packages/contracts/src/http/index.ts @@ -70,6 +70,33 @@ export { BlocklistStats, ReloadBlocklistResponse, } from './blocklist.ts'; +export { + ChannelCapabilitiesDto, + ChannelEnvQuery, + ChannelEnvResponse, + ChannelId, + ChannelIdParams, + ChannelInput, + ChannelPatch, + ChannelPreview, + ChannelPreviewRequest, + ChannelResponse, + ChannelSecretState, + ChannelStats, + ChannelsResponse, + ChannelTestResponse, + ChannelView, + DeliveriesPage, + DeliveriesQuery, + DeliveryDetailResponse, + DeliveryRow, + DeliverySeqParams, + PlatformRequest, + TelegramConnectParams, + TelegramConnectRequest, + TelegramConnectResponse, + TelegramConnectStatus, +} from './channels.ts'; export type { Page } from './common.ts'; export { AppliedQuery, @@ -223,6 +250,9 @@ export { McpConnectionsQuery, McpConnectionsResponse, MigrationRow, + PublicUrlOutcome, + PublicUrlQuery, + PublicUrlStatus, REDACTED, RealtimeConnection, RetentionStatus, diff --git a/packages/contracts/src/http/system.ts b/packages/contracts/src/http/system.ts index a0bc5c3..4012f36 100644 --- a/packages/contracts/src/http/system.ts +++ b/packages/contracts/src/http/system.ts @@ -18,6 +18,7 @@ import { EpochMs, listQuery, page, + QueryBool as QueryBoolFlag, QueryInt, sortable, } from './common.ts'; @@ -170,6 +171,35 @@ export const SystemInfo = z.object({ /** `GET /system` body. */ export type SystemInfo = z.infer; +/** Outcome of the `publicUrl` check (spec 08 §5.8). */ +export const PublicUrlOutcome = z.enum(['ok', 'elsewhere', 'login', 'unreachable', 'unset']); +/** Outcome of the `publicUrl` check. */ +export type PublicUrlOutcome = z.infer; + +/** `GET /system/public-url` query. */ +export const PublicUrlQuery = z.strictObject({ refresh: QueryBoolFlag.optional() }); + +/** `GET /system/public-url` body (D-37). */ +export const PublicUrlStatus = z.object({ + configured: z.boolean(), + /** The `publicUrl` value, or `null` when unset. */ + url: z.string().nullable(), + /** Where links point without `publicUrl`: the local listener. */ + local_url: z.string(), + /** The `publicUrl` host is in the `Host` allow-list and its origin passes the origin guard. */ + host_trusted: z.boolean(), + outcome: PublicUrlOutcome, + /** One sentence about the outcome. */ + detail: z.string(), + /** HTTP status of `/health`, when there was an answer. */ + status_code: z.number().int().nullable(), + checked_at: EpochMs.nullable(), + /** `http:` on a host that is not loopback. */ + insecure: z.boolean(), +}); +/** `GET /system/public-url` body. */ +export type PublicUrlStatus = z.infer; + /** Redaction marker used for secret config values. */ export const REDACTED = '[REDACTED]'; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ed79fb0..b9de800 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -105,6 +105,7 @@ export { zMaxSessions, zPath, zPort, + zPublicUrl, zRatio, zReservedEnum, zString, @@ -280,6 +281,21 @@ export { Bytes, ChangePasswordRequest, ChangePasswordResponse, + ChannelCapabilitiesDto, + ChannelEnvQuery, + ChannelEnvResponse, + ChannelId, + ChannelIdParams, + ChannelInput, + ChannelPatch, + ChannelPreview, + ChannelPreviewRequest, + ChannelResponse, + ChannelSecretState, + ChannelStats, + ChannelsResponse, + ChannelTestResponse, + ChannelView, ClientErrorReport, Count, CreateGrantRequest, @@ -292,6 +308,11 @@ export { csv, DeleteSessionResponse, DeleteVaultBindingResponse, + DeliveriesPage, + DeliveriesQuery, + DeliveryDetailResponse, + DeliveryRow, + DeliverySeqParams, DomainCount, DurationMs, Engine, @@ -365,9 +386,13 @@ export { PageSortKey, PagesPage, PagesQuery, + PlatformRequest, PREFERENCES_MAX_BYTES, Preferences, PreferencesResponse, + PublicUrlOutcome, + PublicUrlQuery, + PublicUrlStatus, PutGroupPolicyRequest, PutGroupPolicyResponse, PutPreferencesRequest, @@ -446,6 +471,10 @@ export { SystemInfo, SystemRealtimeResponse, sortable, + TelegramConnectParams, + TelegramConnectRequest, + TelegramConnectResponse, + TelegramConnectStatus, TerminateSessionResponse, TimelineItem, TimelineKind, @@ -583,6 +612,28 @@ export { TableBlock, TextBlock, } from './notifications/message.ts'; +export { + AVAILABLE_CHANNEL_KINDS, + AVAILABLE_DISCORD_MODES, + AvailableChannelKind, + CHANNEL_KIND_SPECS, + CHANNEL_PRESETS, + type ChannelConfigProblem, + type ChannelKindSpec, + checkChannelConfig, + DELIVERY_REASON_TEXT, + DISCORD_MODES, + deliveryReasonText, + looksLikeSecretValue, + NTFY_DEFAULT_SERVER, + PREVIEW_SAMPLE_LABEL, + PREVIEW_SAMPLES, + PreviewSample, + type SecretParamSpec, + type TargetKeySpec, + TELEGRAM_DELETE_WINDOW_MS, + TELEGRAM_TTL_MAX_MS, +} from './notifications/platforms.ts'; export { classifyLegacy, IN_APP_ONLY_KINDS, @@ -643,6 +694,9 @@ export { AttentionResolvedEvent, BlocklistHitEvent, BlocklistReloadedEvent, + ChannelChangedEvent, + ChannelRemovedEvent, + DeliveryUpdatedEvent, HelloReply, InputCommand, InputModifiers, diff --git a/packages/contracts/src/notifications/channel.ts b/packages/contracts/src/notifications/channel.ts index b759a83..72f6f51 100644 --- a/packages/contracts/src/notifications/channel.ts +++ b/packages/contracts/src/notifications/channel.ts @@ -64,6 +64,11 @@ export const NotificationChannelRules = z.object({ content: NotificationContentLevel.optional(), /** Screenshots per category (D-36); absent = off. */ images: PerCategory(z.boolean()).optional(), + /** + * Screenshots for this channel have their form fields masked (Playwright `mask`); absent = off. + * A stored frame (a crash's last screenshot) cannot be masked, so such a channel gets none. + */ + mask_images: z.boolean().optional(), /** Message TTL per category in milliseconds (D-35); absent = never. */ ttl_ms: PerCategory(z.number().int().positive()).optional(), /** Delete the message once its notification is resolved, per category; absent = off. */ diff --git a/packages/contracts/src/notifications/index.ts b/packages/contracts/src/notifications/index.ts index 175e487..17d581b 100644 --- a/packages/contracts/src/notifications/index.ts +++ b/packages/contracts/src/notifications/index.ts @@ -52,6 +52,28 @@ export { TableBlock, TextBlock, } from './message.ts'; +export { + AVAILABLE_CHANNEL_KINDS, + AVAILABLE_DISCORD_MODES, + AvailableChannelKind, + CHANNEL_KIND_SPECS, + CHANNEL_PRESETS, + type ChannelConfigProblem, + type ChannelKindSpec, + checkChannelConfig, + DELIVERY_REASON_TEXT, + DISCORD_MODES, + deliveryReasonText, + looksLikeSecretValue, + NTFY_DEFAULT_SERVER, + PREVIEW_SAMPLE_LABEL, + PREVIEW_SAMPLES, + PreviewSample, + type SecretParamSpec, + type TargetKeySpec, + TELEGRAM_DELETE_WINDOW_MS, + TELEGRAM_TTL_MAX_MS, +} from './platforms.ts'; export { classifyLegacy, IN_APP_ONLY_KINDS, diff --git a/packages/contracts/src/notifications/platforms.ts b/packages/contracts/src/notifications/platforms.ts new file mode 100644 index 0000000..58a2d9c --- /dev/null +++ b/packages/contracts/src/notifications/platforms.ts @@ -0,0 +1,407 @@ +/** @module contracts/notifications/platforms — what each notification platform needs (target keys, secret parameters, modes), the shared config check used by the API, the startup flag parser and the dashboard, the preview samples and the delivery-log reason texts (spec 03 §9.5, spec 08 §5.7, D-33, D-38, D-39). Platform-neutral. */ + +import { z } from 'zod'; +import type { NotificationCategory } from '../enums/notification-category.ts'; +import { RESERVED_ENV_PREFIX } from './channel.ts'; + +/** Platforms that have an adapter (N1). The other `NotificationChannelKind` members are reserved. */ +export const AVAILABLE_CHANNEL_KINDS = ['telegram', 'discord', 'ntfy', 'webhook'] as const; +/** A platform with an adapter. */ +export const AvailableChannelKind = z.enum(AVAILABLE_CHANNEL_KINDS); +/** A platform with an adapter. */ +export type AvailableChannelKind = z.infer; + +/** Discord channel modes (D-38). `bot` is reserved until act buttons ship (N2). */ +export const DISCORD_MODES = ['webhook', 'bot'] as const; +/** Discord modes a channel may be saved with today. */ +export const AVAILABLE_DISCORD_MODES: readonly string[] = ['webhook']; + +/** Telegram lets a bot delete its own messages for 48 hours; setups cap TTLs one hour below (D-35). */ +export const TELEGRAM_TTL_MAX_MS = 47 * 60 * 60_000; +/** Telegram's own delete window (the adapter's `deleteWindowMs`). */ +export const TELEGRAM_DELETE_WINDOW_MS = 48 * 60 * 60_000; + +/** Default ntfy server. */ +export const NTFY_DEFAULT_SERVER = 'https://ntfy.sh'; + +/** One non-secret target key of a platform (`target_json`). */ +export interface TargetKeySpec { + readonly key: string; + /** Startup flag parameter that fills it (`chat` → `chat_id`). */ + readonly param: string; + readonly required: boolean; + readonly describe: string; +} + +/** One secret parameter of a platform: stored as an environment variable name (D-33). */ +export interface SecretParamSpec { + readonly param: string; + readonly required: boolean; + /** Variable name the setup suggests (never `BROWSERHIVE_*`). */ + readonly suggestedEnv: string; + readonly describe: string; +} + +/** What a platform needs. */ +export interface ChannelKindSpec { + readonly kind: AvailableChannelKind; + readonly label: string; + /** Modes (Discord only). */ + readonly modes: readonly string[] | null; + readonly defaultMode: string | null; + readonly target: readonly TargetKeySpec[]; + readonly secrets: readonly SecretParamSpec[]; + /** + * Keys where exactly one of the target key or the secret parameter of the same name must be + * set (an ntfy topic, a webhook URL: literal, or from a variable). + */ + readonly eitherTargetOrSecret: readonly string[]; +} + +/** Every available platform. */ +export const CHANNEL_KIND_SPECS: { readonly [K in AvailableChannelKind]: ChannelKindSpec } = { + telegram: { + kind: 'telegram', + label: 'Telegram', + modes: null, + defaultMode: null, + target: [ + { + key: 'chat_id', + param: 'chat', + required: true, + describe: 'Chat id (a person, a group, or a channel; groups start with -100).', + }, + { + key: 'thread_id', + param: 'thread', + required: false, + describe: 'Forum topic id inside a group.', + }, + { key: 'chat_title', param: 'title', required: false, describe: 'Name of the chat.' }, + { key: 'bot_username', param: 'bot', required: false, describe: "The bot's username." }, + ], + secrets: [ + { + param: 'token', + required: true, + suggestedEnv: 'BH_TELEGRAM_TOKEN', + describe: 'The bot token from @BotFather.', + }, + ], + eitherTargetOrSecret: [], + }, + discord: { + kind: 'discord', + label: 'Discord', + modes: DISCORD_MODES, + defaultMode: 'webhook', + target: [], + secrets: [ + { + param: 'webhook', + required: true, + suggestedEnv: 'BH_DISCORD_WEBHOOK', + describe: 'The webhook URL (Channel settings → Integrations → Webhooks).', + }, + ], + eitherTargetOrSecret: [], + }, + ntfy: { + kind: 'ntfy', + label: 'ntfy', + modes: null, + defaultMode: null, + target: [ + { + key: 'server', + param: 'server', + required: false, + describe: `ntfy server (default ${NTFY_DEFAULT_SERVER}).`, + }, + { + key: 'topic', + param: 'topic', + required: false, + describe: 'Topic name. On a public server the topic acts as a password.', + }, + ], + secrets: [ + { + param: 'token', + required: false, + suggestedEnv: 'BH_NTFY_TOKEN', + describe: 'Access token for a protected server or topic.', + }, + { + param: 'topic', + required: false, + suggestedEnv: 'BH_NTFY_TOPIC', + describe: 'Topic name kept in a variable instead of the database.', + }, + ], + eitherTargetOrSecret: ['topic'], + }, + webhook: { + kind: 'webhook', + label: 'Webhook', + modes: null, + defaultMode: null, + target: [{ key: 'url', param: 'url', required: false, describe: 'Absolute http(s) URL.' }], + secrets: [ + { + param: 'url', + required: false, + suggestedEnv: 'BH_WEBHOOK_URL', + describe: 'The URL kept in a variable (when it contains a key).', + }, + { + param: 'secret', + required: false, + suggestedEnv: 'BH_WEBHOOK_SECRET', + describe: 'Key of the X-BrowserHive-Signature HMAC.', + }, + ], + eitherTargetOrSecret: ['url'], + }, +}; + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const HTTP_RE = /^https?:\/\/[^\s/?#@]+(?::\d{1,5})?(?:\/[^\s?#]*)?$/i; +const TOPIC_RE = /^[A-Za-z0-9_-]{1,64}$/; +const CHAT_RE = /^-?\d{1,20}$|^@[A-Za-z0-9_]{5,32}$/; +const THREAD_RE = /^\d{1,12}$/; + +/** + * Whether `value` could be a secret value typed where a variable name belongs: anything that is + * not a plain variable name. Used to refuse, without echoing, a token pasted into a name field. + * + * @returns True when it is not a valid environment variable name. + */ +export function looksLikeSecretValue(value: string): boolean { + return !ENV_NAME_RE.test(value); +} + +/** A problem found in a channel configuration: which field, and a sentence that never echoes a secret. */ +export interface ChannelConfigProblem { + readonly field: string; + readonly message: string; +} + +/** + * Checks a channel's platform configuration (not its rules). Shared by the API, the startup flag + * parser and the dashboard, so the three refuse the same things with the same words. + * + * @returns Every problem (empty when valid). + */ +export function checkChannelConfig(input: { + readonly kind: string; + readonly mode: string | null; + readonly target: Readonly>; + readonly secretRefs: Readonly>; +}): ChannelConfigProblem[] { + const problems: ChannelConfigProblem[] = []; + const parsed = AvailableChannelKind.safeParse(input.kind); + if (!parsed.success) { + return [{ field: 'kind', message: `'${input.kind}' has no adapter yet.` }]; + } + const spec = CHANNEL_KIND_SPECS[parsed.data]; + if (spec.modes === null) { + if (input.mode !== null) + problems.push({ field: 'mode', message: `${spec.label} has no modes.` }); + } else if (input.mode !== null && !spec.modes.includes(input.mode)) { + problems.push({ field: 'mode', message: `mode must be one of: ${spec.modes.join(', ')}.` }); + } + const targetKeys = new Set(spec.target.map((t) => t.key)); + for (const key of Object.keys(input.target)) { + if (!targetKeys.has(key)) { + problems.push({ field: `target.${key}`, message: `unknown ${spec.label} setting '${key}'.` }); + } + } + const secretParams = new Set(spec.secrets.map((s) => s.param)); + for (const [param, env] of Object.entries(input.secretRefs)) { + if (!secretParams.has(param)) { + problems.push({ + field: `secret_refs.${param}`, + message: `unknown ${spec.label} secret '${param}'.`, + }); + continue; + } + if (looksLikeSecretValue(env)) { + problems.push({ + field: `secret_refs.${param}`, + message: `${param} must name an environment variable (letters, digits and _), never contain the secret.`, + }); + } else if (env.startsWith(RESERVED_ENV_PREFIX)) { + problems.push({ + field: `secret_refs.${param}`, + message: `${param}: names starting with ${RESERVED_ENV_PREFIX} are reserved for configuration.`, + }); + } + } + for (const t of spec.target) { + if (t.required && (input.target[t.key] ?? '') === '') { + problems.push({ field: `target.${t.key}`, message: `${t.key} is required.` }); + } + } + for (const s of spec.secrets) { + if (s.required && input.secretRefs[s.param] === undefined) { + problems.push({ + field: `secret_refs.${s.param}`, + message: `${s.param} is required (the name of the variable that holds it).`, + }); + } + } + for (const key of spec.eitherTargetOrSecret) { + const literal = (input.target[key] ?? '') !== ''; + const fromEnv = input.secretRefs[key] !== undefined; + if (literal === fromEnv) { + problems.push({ + field: `target.${key}`, + message: literal + ? `${key} is given both literally and as a variable; keep one.` + : `${key} is required (literally, or as a variable).`, + }); + } + } + const t = input.target; + if (parsed.data === 'telegram') { + if (t['chat_id'] !== undefined && t['chat_id'] !== '' && !CHAT_RE.test(t['chat_id'])) { + problems.push({ + field: 'target.chat_id', + message: 'chat_id must be a number like -1001234567890.', + }); + } + if (t['thread_id'] !== undefined && !THREAD_RE.test(t['thread_id'])) { + problems.push({ field: 'target.thread_id', message: 'thread_id must be a number.' }); + } + } + if (parsed.data === 'ntfy') { + if (t['server'] !== undefined && !HTTP_RE.test(t['server'])) { + problems.push({ field: 'target.server', message: 'server must be an absolute http(s) URL.' }); + } + if (t['topic'] !== undefined && t['topic'] !== '' && !TOPIC_RE.test(t['topic'])) { + problems.push({ + field: 'target.topic', + message: 'topic may contain letters, digits, _ and - (up to 64).', + }); + } + } + if (parsed.data === 'webhook' && t['url'] !== undefined && t['url'] !== '') { + if (!/^https?:\/\//i.test(t['url'])) { + problems.push({ field: 'target.url', message: 'url must use http: or https:.' }); + } else if (!HTTP_RE.test(t['url'].replace(/\?.*$/, ''))) { + problems.push({ field: 'target.url', message: 'url must be an absolute http(s) URL.' }); + } + } + return problems; +} + +/** Sample notifications the preview renders (spec 03 §4.8.1). */ +export const PREVIEW_SAMPLES = [ + 'attention', + 'attention-resolved', + 'vault-confirm', + 'tool-errors', + 'crash', + 'degraded', + 'test', +] as const; +/** A preview sample. */ +export const PreviewSample = z.enum(PREVIEW_SAMPLES); +/** A preview sample. */ +export type PreviewSample = z.infer; + +/** Human labels of the preview samples. */ +export const PREVIEW_SAMPLE_LABEL: { readonly [S in PreviewSample]: string } = { + attention: 'Attention requested', + 'attention-resolved': 'Attention resolved', + 'vault-confirm': 'Vault fill to confirm', + 'tool-errors': 'Tool errors', + crash: 'Session crashed', + degraded: 'System degraded', + test: 'Test message', +}; + +/** Presets of the setup wizard (spec 04 §12.11.1). */ +export const CHANNEL_PRESETS: readonly { + readonly id: string; + readonly label: string; + readonly describe: string; + readonly categories: readonly NotificationCategory[] | null; +}[] = [ + { + id: 'needs-me', + label: 'Needs me now', + describe: 'Attention requests and vault fills waiting for you.', + categories: ['needs-you'], + }, + { + id: 'problems', + label: 'Problems', + describe: 'Everything that needs you, plus crashes, reaped sessions and tool errors.', + categories: ['needs-you', 'problems'], + }, + { + id: 'wrap-ups', + label: 'Wrap-ups', + describe: 'Finished sessions and completed fills (quiet, informational).', + categories: ['wrap-ups'], + }, + { + id: 'everything', + label: 'Everything', + describe: 'Every notification BrowserHive produces.', + categories: null, + }, +]; + +/** + * What a delivery-log reason means, in one sentence (the "why wasn't this sent?" view). Dynamic + * reasons (`backlog:N`, an error code on `dead`) are handled by {@link deliveryReasonText}. + */ +export const DELIVERY_REASON_TEXT: Readonly> = { + filtered: + "The channel's rules (category, severity, session or harness) exclude this notification.", + quiet_hours: 'It arrived during the quiet hours of the channel and was not urgent.', + throttled: 'Too many messages in a short time; it was held back.', + channel_paused: 'The channel was paused (or broken) when this was due.', + content_blocked: "The channel's content level does not allow this notification.", + image_blocked: 'The screenshot was not allowed on this channel; the text was sent without it.', + edit_unsupported: 'The platform cannot edit messages, and this change was silent.', + delete_unsupported: 'The platform cannot delete messages.', + no_adapter: 'The channel could not be started (for example, a variable it needs is not set).', + covered: 'A newer version of the same notification was already delivered.', + not_sent: 'The first message was never sent, so there was nothing to edit.', + message_deleted: 'The message had already been deleted.', + message_gone: 'The message was deleted in the chat, so it could not be edited.', + collapsed: 'Too many messages were waiting; they were folded into one "you missed N" message.', + channel_gone: 'The channel was deleted.', + no_message: 'The notification has no message to send (it predates channels).', + max_attempts: 'Every retry failed (8 attempts).', + expired: 'It could not be delivered within 24 hours.', + 'could_not_delete: too_old': + 'Telegram lets a bot delete messages for 48 hours only; this one was older.', + rate_limited: 'The platform asked BrowserHive to slow down; it will retry.', + unavailable: 'The platform could not be reached; it will retry.', + timeout: 'The platform did not answer in time; it will retry.', + auth: 'The platform refused the credentials (a wrong or revoked token or URL).', + rejected: 'The platform refused the message.', + test: 'A test message sent from the dashboard or the CLI.', +}; + +/** + * One sentence for a delivery row's reason. + * + * @returns The explanation, or `null` without a reason. + */ +export function deliveryReasonText(reason: string | null): string | null { + if (reason === null || reason === '') return null; + const known = DELIVERY_REASON_TEXT[reason]; + if (known !== undefined) return known; + if (reason.startsWith('backlog:')) { + const n = reason.slice('backlog:'.length); + return `Sent with a note that ${n} earlier notifications were folded into it.`; + } + return reason; +} diff --git a/packages/contracts/src/ws/commands.ts b/packages/contracts/src/ws/commands.ts index a0d5b64..5e4e688 100644 --- a/packages/contracts/src/ws/commands.ts +++ b/packages/contracts/src/ws/commands.ts @@ -124,7 +124,8 @@ export const WS_TOPIC_SCOPES: { | 'blocklist' | 'system' | 'logs' - | 'notifications']: Scope; + | 'notifications' + | 'channels']: Scope; } = { sessions: 'sessions:read', session: 'sessions:read', @@ -138,4 +139,5 @@ export const WS_TOPIC_SCOPES: { system: 'system:read', logs: 'logs:read', notifications: 'notifications:read', + channels: 'channels:read', }; diff --git a/packages/contracts/src/ws/feed-events.ts b/packages/contracts/src/ws/feed-events.ts index 0c10366..f183057 100644 --- a/packages/contracts/src/ws/feed-events.ts +++ b/packages/contracts/src/ws/feed-events.ts @@ -4,6 +4,7 @@ import { ClosedReason, DegradationSeverity } from '../enums/index.ts'; import { ScreenshotRow } from '../http/artifacts.ts'; import { OperatorRequestRow } from '../http/attention.ts'; import { BlockedRequestRow } from '../http/blocklist.ts'; +import { ChannelView, DeliveryRow } from '../http/channels.ts'; import { Count, EpochMs } from '../http/common.ts'; import { LogRecord } from '../http/logs.ts'; import { Notification } from '../http/notifications.ts'; @@ -144,6 +145,17 @@ export const NotificationUpdatedEvent = z.object({ notification: Notification, }); +// channels --------------------------------------------------------------------------------------- +/** A notification channel was created, edited, paused, resumed or broken, or its stats moved. */ +export const ChannelChangedEvent = z.object({ ...ev('channel.changed'), channel: ChannelView }); +/** A notification channel was deleted. */ +export const ChannelRemovedEvent = z.object({ + ...ev('channel.removed'), + channel_id: z.string(), +}); +/** A delivery job was enqueued or changed status (the live delivery log). */ +export const DeliveryUpdatedEvent = z.object({ ...ev('delivery.updated'), delivery: DeliveryRow }); + // logs ------------------------------------------------------------------------------------------- /** One log record from the ring buffer (droppable under backpressure). */ export const LogRecordEvent = z.object({ ...ev('log.record'), record: LogRecord }); @@ -175,6 +187,9 @@ export const WsFeedEvent = z.discriminatedUnion('type', [ RetentionCompletedEvent, NotificationCreatedEvent, NotificationUpdatedEvent, + ChannelChangedEvent, + ChannelRemovedEvent, + DeliveryUpdatedEvent, LogRecordEvent, ]); /** Every feed event payload. */ @@ -200,6 +215,7 @@ export const WS_TOPIC_EVENTS: { readonly [T in WsStaticTopic]: readonly WsFeedEv ], logs: ['log.record'], notifications: ['notification.created', 'notification.updated'], + channels: ['channel.changed', 'channel.removed', 'delivery.updated'], }; /** Event types published on `session:` topics. */ diff --git a/packages/contracts/src/ws/index.ts b/packages/contracts/src/ws/index.ts index d3cfb8c..e99d87a 100644 --- a/packages/contracts/src/ws/index.ts +++ b/packages/contracts/src/ws/index.ts @@ -35,6 +35,9 @@ export { AttentionResolvedEvent, BlocklistHitEvent, BlocklistReloadedEvent, + ChannelChangedEvent, + ChannelRemovedEvent, + DeliveryUpdatedEvent, LogRecordEvent, NotificationCreatedEvent, NotificationUpdatedEvent, diff --git a/packages/contracts/src/ws/topics.ts b/packages/contracts/src/ws/topics.ts index 6b56ac5..3a3f9cf 100644 --- a/packages/contracts/src/ws/topics.ts +++ b/packages/contracts/src/ws/topics.ts @@ -14,6 +14,7 @@ export const WS_STATIC_TOPICS = [ 'system', 'logs', 'notifications', + 'channels', ] as const; /** Static topic name. */ export type WsStaticTopic = (typeof WS_STATIC_TOPICS)[number]; diff --git a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap index 982cb91..59462c6 100644 --- a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap +++ b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap @@ -11,6 +11,8 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "AUDIT_ERRORS", "AUTH_ERRORS", "AUTH_NAME_RE", + "AVAILABLE_CHANNEL_KINDS", + "AVAILABLE_DISCORD_MODES", "ActAction", "ActionStyle", "ActivityBucket", @@ -41,6 +43,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "AuthSessionInfo", "AuthSessionList", "AuthSessionSummary", + "AvailableChannelKind", "AvailableVaultEntry", "BASE_LAUNCH_DEFAULTS", "BEARER_TOKEN_RE", @@ -72,6 +75,8 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "BulkSessionsResponse", "BulkVaultConfirmRequest", "Bytes", + "CHANNEL_KIND_SPECS", + "CHANNEL_PRESETS", "CLIENT_NAME_ALIASES", "CONFIG_FILE_SCHEMA_ID", "CONFIG_GROUPS", @@ -85,6 +90,23 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "ChangePasswordRequest", "ChangePasswordResponse", "Channel", + "ChannelCapabilitiesDto", + "ChannelChangedEvent", + "ChannelEnvQuery", + "ChannelEnvResponse", + "ChannelId", + "ChannelIdParams", + "ChannelInput", + "ChannelPatch", + "ChannelPreview", + "ChannelPreviewRequest", + "ChannelRemovedEvent", + "ChannelResponse", + "ChannelSecretState", + "ChannelStats", + "ChannelTestResponse", + "ChannelView", + "ChannelsResponse", "ClientErrorReport", "ClosedReason", "CodeBlock", @@ -102,11 +124,19 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "Cursor", "DECLARED_SOURCES", "DEFAULT_CONTENT_LEVEL", + "DELIVERY_REASON_TEXT", "DERIVED_SOURCES", + "DISCORD_MODES", "DashboardPath", "DegradationSeverity", "DeleteSessionResponse", "DeleteVaultBindingResponse", + "DeliveriesPage", + "DeliveriesQuery", + "DeliveryDetailResponse", + "DeliveryRow", + "DeliverySeqParams", + "DeliveryUpdatedEvent", "DividerBlock", "DomainCount", "DurationMs", @@ -229,6 +259,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "NOTIFICATION_TEXT_MAX", "NOTIFICATION_TITLE_MAX", "NO_EXPLICIT_KEYS", + "NTFY_DEFAULT_SERVER", "Notification", "NotificationAckResponse", "NotificationAction", @@ -277,6 +308,8 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "PASSWORD_MAX_LENGTH", "PASSWORD_MIN_LENGTH", "PREFERENCES_MAX_BYTES", + "PREVIEW_SAMPLES", + "PREVIEW_SAMPLE_LABEL", "PRINCIPAL_ID_RE", "PageDomainsQuery", "PageDomainsResponse", @@ -289,13 +322,18 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "PagesQuery", "PersistenceMode", "PingCommand", + "PlatformRequest", "PongReply", "Preferences", "PreferencesResponse", + "PreviewSample", "PrincipalId", "PrincipalKind", "ProblemDetails", "ProvenanceSource", + "PublicUrlOutcome", + "PublicUrlQuery", + "PublicUrlStatus", "PutGroupPolicyRequest", "PutGroupPolicyResponse", "PutPreferencesRequest", @@ -442,6 +480,8 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "SystemTickEvent", "TAB_ID_RE", "TAB_ID_SUFFIX_LENGTH", + "TELEGRAM_DELETE_WINDOW_MS", + "TELEGRAM_TTL_MAX_MS", "TELEMETRY_KEYS", "TOOL_CONTRACTS", "TOOL_PACKS", @@ -449,6 +489,10 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "TabId", "TabSummary", "TableBlock", + "TelegramConnectParams", + "TelegramConnectRequest", + "TelegramConnectResponse", + "TelegramConnectStatus", "TerminateSessionResponse", "TextBlock", "TimeWindow", @@ -556,6 +600,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "WsStreamMessage", "WsTopic", "capMetaBag", + "checkChannelConfig", "classifyLegacy", "codesInCategory", "configFileJsonSchema", @@ -563,6 +608,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "csv", "defineError", "defineTool", + "deliveryReasonText", "derived", "envNameOf", "errorDocsUrl", @@ -601,6 +647,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "launchSessionInput", "limitQuery", "listQuery", + "looksLikeSecretValue", "lookupKey", "metricHarness", "namesFor", @@ -647,6 +694,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "zMaxSessions", "zPath", "zPort", + "zPublicUrl", "zRatio", "zReservedEnum", "zString", diff --git a/packages/contracts/test/config.registry.test.ts b/packages/contracts/test/config.registry.test.ts index 51049e9..527753f 100644 --- a/packages/contracts/test/config.registry.test.ts +++ b/packages/contracts/test/config.registry.test.ts @@ -24,6 +24,7 @@ const SPEC_KEYS = [ 'allowInsecureBind', 'trustedProxies', 'allowedHosts', + 'publicUrl', 'admin', 'dataDir', 'shutdownTimeout', diff --git a/packages/contracts/test/errors.registry.test.ts b/packages/contracts/test/errors.registry.test.ts index 08b19d7..8581ffd 100644 --- a/packages/contracts/test/errors.registry.test.ts +++ b/packages/contracts/test/errors.registry.test.ts @@ -50,6 +50,13 @@ const CORE_CODES = [ /** The remaining codes named by spec 10 §1.1. */ const SPEC_CODES = [ + 'CHANNEL_NOT_FOUND', + 'CHANNEL_NAME_TAKEN', + 'CHANNEL_READ_ONLY', + 'CHANNEL_NOT_READY', + 'CHANNEL_KIND_UNAVAILABLE', + 'CHANNEL_PLATFORM_ERROR', + 'DELIVERY_NOT_FOUND', 'SESSION_NOT_AVAILABLE', 'ELEMENT_NOT_FOUND', 'NAVIGATION_TIMEOUT', diff --git a/packages/contracts/test/goldens/ws/ws-protocol.json b/packages/contracts/test/goldens/ws/ws-protocol.json index 555b54f..5b59bd7 100644 --- a/packages/contracts/test/goldens/ws/ws-protocol.json +++ b/packages/contracts/test/goldens/ws/ws-protocol.json @@ -3333,6 +3333,686 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "channel.changed" + }, + "channel": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ], + "additionalProperties": false + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "harness": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + } + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "boolean" + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "delete_when_resolved": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "boolean" + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "additionalProperties": false + }, + "capabilities": { + "anyOf": [ + { + "type": "object", + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "max_title_chars": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_buttons": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "rich_blocks", + "tables", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "last_failure_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failed_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pending": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_delivery_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "last_status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ], + "additionalProperties": false + } + }, + "required": [ + "channel_id", + "name", + "kind", + "mode", + "source", + "status", + "target", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", + "created_at", + "updated_at", + "stats" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "channel" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "channel.removed" + }, + "channel_id": { + "type": "string" + } + }, + "required": [ + "type", + "channel_id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery.updated" + }, + "delivery": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + { + "type": "null" + } + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "next_attempt_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "message_ref": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "number" + ] + } + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "delivery" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -6314,9 +6994,202 @@ }, "required": [ "type", - "at", - "pruned_rows", - "result" + "at", + "pruned_rows", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "notification.created" + }, + "notification": { + "type": "object", + "properties": { + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "principal_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + { + "type": "null" + } + ] + }, + "session_slug": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "source_event_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "read_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "dismissed_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" + } + }, + "required": [ + "notification_id", + "principal_id", + "type", + "title", + "body", + "session_id", + "session_slug", + "target", + "source_event_id", + "created_at", + "updated_at", + "count", + "read_at", + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "notification" ], "additionalProperties": false }, @@ -6325,7 +7198,7 @@ "properties": { "type": { "type": "string", - "const": "notification.created" + "const": "notification.updated" }, "notification": { "type": "object", @@ -6473,43 +7346,517 @@ "final" ] }, - "revision": { + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" + } + }, + "required": [ + "notification_id", + "principal_id", + "type", + "title", + "body", + "session_id", + "session_slug", + "target", + "source_event_id", + "created_at", + "updated_at", + "count", + "read_at", + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "notification" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "channel.changed" + }, + "channel": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ], + "additionalProperties": false + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "harness": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + } + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "boolean" + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "delete_when_resolved": { + "type": "object", + "propertyNames": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "additionalProperties": { + "type": "boolean" + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "additionalProperties": false + }, + "capabilities": { + "anyOf": [ + { + "type": "object", + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "max_title_chars": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "max_buttons": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "rich_blocks", + "tables", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "last_failure_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "created_at": { "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 9007199254740991 }, - "thread": { - "type": "string" + "updated_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failed_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pending": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_delivery_at": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "last_status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ], + "additionalProperties": false } }, "required": [ - "notification_id", - "principal_id", - "type", - "title", - "body", - "session_id", - "session_slug", + "channel_id", + "name", + "kind", + "mode", + "source", + "status", "target", - "source_event_id", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", "created_at", "updated_at", - "count", - "read_at", - "dismissed_at", - "kind", - "category", - "severity", - "state", - "revision", - "thread" + "stats" ], "additionalProperties": false } }, "required": [ "type", - "notification" + "channel" ], "additionalProperties": false }, @@ -6518,85 +7865,119 @@ "properties": { "type": { "type": "string", - "const": "notification.updated" + "const": "channel.removed" }, - "notification": { + "channel_id": { + "type": "string" + } + }, + "required": [ + "type", + "channel_id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery.updated" + }, + "delivery": { "type": "object", "properties": { - "notification_id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" + "seq": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, - "principal_id": { + "channel_id": { + "type": "string" + }, + "channel_name": { "type": [ "string", "null" ] }, - "type": { - "type": "string", - "enum": [ - "attention", - "error", - "vault", - "lifecycle", - "system" - ] - }, - "title": { - "type": "string" - }, - "body": { + "channel_kind": { "type": [ "string", "null" ] }, - "session_id": { + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { "anyOf": [ { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] }, { "type": "null" } ] }, - "session_slug": { + "notification_title": { "type": [ "string", "null" ] }, - "target": { - "type": [ - "string", - "null" + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" ] }, - "source_event_id": { + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { "type": [ "string", "null" ] }, - "created_at": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated_at": { + "attempts": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, - "count": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "read_at": { + "next_attempt_at": { "anyOf": [ { "type": "integer", @@ -6608,7 +7989,13 @@ } ] }, - "dismissed_at": { + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { "anyOf": [ { "type": "integer", @@ -6620,89 +8007,62 @@ } ] }, - "kind": { - "type": "string", - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test" - ] - }, - "category": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - }, - "severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "state": { - "type": "string", - "enum": [ - "open", - "acted", - "resolved", - "expired", - "final" + "message_ref": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": [ + "string", + "number" + ] + } + }, + { + "type": "null" + } ] }, - "revision": { + "created_at": { "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 9007199254740991 }, - "thread": { - "type": "string" + "updated_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", "notification_id", - "principal_id", - "type", - "title", - "body", - "session_id", - "session_slug", - "target", - "source_event_id", - "created_at", - "updated_at", - "count", - "read_at", - "dismissed_at", - "kind", - "category", - "severity", - "state", + "notification_kind", + "notification_title", "revision", - "thread" + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at" ], "additionalProperties": false } }, "required": [ "type", - "notification" + "delivery" ], "additionalProperties": false }, diff --git a/packages/contracts/test/notification-platforms.test.ts b/packages/contracts/test/notification-platforms.test.ts new file mode 100644 index 0000000..c114731 --- /dev/null +++ b/packages/contracts/test/notification-platforms.test.ts @@ -0,0 +1,117 @@ +/** @module contracts/test/notification-platforms.test — the per-platform channel check shared by the API, the startup flag and the dashboard (spec 03 §9.5, D-33), the reason texts and the public-URL grammar */ +import { describe, expect, it } from 'bun:test'; +import { zPublicUrl } from '../src/config/index.ts'; +import { ChannelInput, ChannelPreviewRequest } from '../src/http/index.ts'; +import { + CHANNEL_KIND_SPECS, + checkChannelConfig, + deliveryReasonText, + looksLikeSecretValue, + SUPPRESSION_REASONS, +} from '../src/notifications/index.ts'; + +const check = ( + kind: string, + target: Record, + secretRefs: Record, + mode: string | null = null, +) => checkChannelConfig({ kind, mode, target, secretRefs }).map((p) => p.field); + +describe('checkChannelConfig', () => { + it('accepts a minimal channel of every platform', () => { + expect(check('telegram', { chat_id: '-1001234567890' }, { token: 'BH_TG_TOKEN' })).toEqual([]); + expect(check('discord', {}, { webhook: 'BH_DISCORD_WEBHOOK' }, 'webhook')).toEqual([]); + expect(check('ntfy', { topic: 'bh-alerts' }, {})).toEqual([]); + expect( + check('ntfy', { server: 'https://ntfy.example.net' }, { topic: 'BH_NTFY_TOPIC' }), + ).toEqual([]); + expect(check('webhook', { url: 'https://hooks.example.net/bh?x=1' }, {})).toEqual([]); + }); + + it('requires the required keys and exactly one of a literal or a variable', () => { + expect(check('telegram', {}, {})).toEqual(['target.chat_id', 'secret_refs.token']); + expect(check('ntfy', {}, {})).toEqual(['target.topic']); + expect(check('ntfy', { topic: 'a' }, { topic: 'B' })).toEqual(['target.topic']); + expect(check('webhook', {}, {})).toEqual(['target.url']); + }); + + it('refuses a secret value where a variable name belongs, without echoing it', () => { + const problems = checkChannelConfig({ + kind: 'telegram', + mode: null, + target: { chat_id: '1' }, + secretRefs: { token: '123:abc-def' }, + }); + expect(problems.map((p) => p.field)).toEqual(['secret_refs.token']); + expect(problems[0]?.message).not.toContain('123:abc'); + expect(check('telegram', { chat_id: '1' }, { token: 'BROWSERHIVE_X' })).toEqual([ + 'secret_refs.token', + ]); + }); + + it('refuses unknown keys, unknown kinds, bad modes and bad values', () => { + expect(check('telegram', { chat_id: '1', nope: 'x' }, { token: 'T' })).toEqual(['target.nope']); + expect(check('slack', {}, {})).toEqual(['kind']); + expect(check('discord', {}, { webhook: 'W' }, 'selfbot')).toEqual(['mode']); + expect(check('telegram', { chat_id: 'abc' }, { token: 'T' })).toEqual(['target.chat_id']); + expect(check('ntfy', { topic: 'has space' }, {})).toEqual(['target.topic']); + expect(check('webhook', { url: 'file:///etc/passwd' }, {})).toEqual(['target.url']); + }); + + it('every platform suggests variable names outside the reserved prefix', () => { + for (const spec of Object.values(CHANNEL_KIND_SPECS)) { + for (const secret of spec.secrets) { + expect(secret.suggestedEnv.startsWith('BROWSERHIVE_')).toBe(false); + expect(looksLikeSecretValue(secret.suggestedEnv)).toBe(false); + } + } + }); +}); + +describe('deliveryReasonText', () => { + it('explains every suppression reason and the dynamic ones', () => { + for (const reason of SUPPRESSION_REASONS) { + expect(deliveryReasonText(reason)).not.toBe(reason); + } + expect(deliveryReasonText('backlog:12')).toContain('12'); + expect(deliveryReasonText(null)).toBeNull(); + expect(deliveryReasonText('something-new')).toBe('something-new'); + }); +}); + +describe('zPublicUrl', () => { + it('accepts http(s) with a path prefix and drops trailing slashes', () => { + expect(zPublicUrl.parse('https://bh.example.net/')).toBe('https://bh.example.net'); + expect(zPublicUrl.parse(' http://my-box.tail1234.ts.net:9876/bh// ')).toBe( + 'http://my-box.tail1234.ts.net:9876/bh', + ); + }); + + it('refuses queries, fragments, credentials and other schemes', () => { + for (const bad of [ + 'https://x.net/?a=1', + 'https://x.net/#f', + 'https://user:pw@x.net', + 'ftp://x.net', + 'x.net', + ]) { + expect(zPublicUrl.safeParse(bad).success).toBe(false); + } + }); +}); + +describe('channel DTOs', () => { + it('defaults target, secret refs and rules on input', () => { + const parsed = ChannelInput.parse({ name: 'phone', kind: 'ntfy' }); + expect(parsed).toEqual({ name: 'phone', kind: 'ntfy', target: {}, secret_refs: {}, rules: {} }); + }); + + it('previews either a saved channel or a draft', () => { + expect(ChannelPreviewRequest.safeParse({ kind: 'telegram' }).success).toBe(true); + expect(ChannelPreviewRequest.safeParse({ channel_id: 'nc-abcdef' }).success).toBe(true); + expect(ChannelPreviewRequest.safeParse({}).success).toBe(false); + expect( + ChannelPreviewRequest.safeParse({ channel_id: 'nc-abcdef', kind: 'telegram' }).success, + ).toBe(false); + }); +}); diff --git a/packages/core/src/app/config/consumers.ts b/packages/core/src/app/config/consumers.ts index a91ad1e..9fab61c 100644 --- a/packages/core/src/app/config/consumers.ts +++ b/packages/core/src/app/config/consumers.ts @@ -18,6 +18,7 @@ export const CONSUMED_KEYS: Readonly> = { allowInsecureBind: 'app/config/resolve', trustedProxies: 'interface/http/middleware/forwarded', allowedHosts: 'interface/http/middleware/host-guard', + publicUrl: 'app/notifications/links', admin: 'browserhive/composition', dataDir: 'browserhive/composition/phases/open-storage', shutdownTimeout: 'browserhive/composition', diff --git a/packages/core/src/domain/auth/cookie.test.ts b/packages/core/src/domain/auth/cookie.test.ts index e780485..04fecb3 100644 --- a/packages/core/src/domain/auth/cookie.test.ts +++ b/packages/core/src/domain/auth/cookie.test.ts @@ -51,7 +51,7 @@ describe('scopes', () => { expect(scopesForKind('operator')).toBe(OPERATOR_SCOPES); expect(scopesForKind('agent')).toBe(AGENT_SCOPES); expect(scopesForKind('service')).toEqual([]); - expect(OPERATOR_SCOPES).toHaveLength(17); + expect(OPERATOR_SCOPES).toHaveLength(19); }); it('parseScopes drops unknown values and keeps registry order', () => { diff --git a/specs/10-error-handling-and-telemetry.md b/specs/10-error-handling-and-telemetry.md index 9fdaee1..ba3250e 100644 --- a/specs/10-error-handling-and-telemetry.md +++ b/specs/10-error-handling-and-telemetry.md @@ -88,8 +88,8 @@ Decisions: D-07 (error model), D-08 (telemetry), D-20 (privacy). | CHANNEL_NOT_FOUND | 404 | domain | never | `{channel_id}` | | CHANNEL_NAME_TAKEN | 409 | domain | different_args | `{name}` | | CHANNEL_READ_ONLY | 409 | domain | never | `{channel_id, name}` (a startup channel: edit its `--notificationChannel` flag) | -| CHANNEL_NOT_READY | 409 | domain | after_operator | `{channel_id?, missing[]}` (the environment variables that are unset; names only) | -| CHANNEL_KIND_UNAVAILABLE | 400 | domain | different_args | `{kind, mode?}` | +| CHANNEL_NOT_READY | 409 | domain | after_operator | `{channel_id?, problem, missing[]}` (the environment variables that are unset; names only) | +| CHANNEL_KIND_UNAVAILABLE | 400 | domain | different_args | `{kind, mode?, mode_text}` | | CHANNEL_PLATFORM_ERROR | 502 | domain | backoff | `{kind, code, detail}` (the classified platform failure, scrubbed) | | DELIVERY_NOT_FOUND | 404 | domain | never | `{seq}` | | INPUT_NOT_PERMITTED | 409 | domain | after_operator | `{session_id}` | From 91958ba3fa1531432dbb1bbabfd31f512a893622 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:21:56 -0400 Subject: [PATCH 03/22] feat(core): renderer, screenshot store, Telegram setup and URL probe ports The pure renderer half of a platform adapter (shared by sends and the preview), the notification image store and reader, the setup-only Telegram connect calls and the one-shot URL probe of the publicUrl check. --- .../core/src/ports/notification-channel.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/core/src/ports/notification-channel.ts b/packages/core/src/ports/notification-channel.ts index 71bee41..adbd7c2 100644 --- a/packages/core/src/ports/notification-channel.ts +++ b/packages/core/src/ports/notification-channel.ts @@ -119,3 +119,125 @@ export interface NotificationChannel { /** Deletes a sent message. Required when `capabilities.delete`. */ delete?(ref: PlatformMessageRef): Promise; } + +/** + * One platform request a renderer produced (spec 03 §9.5). `path` never holds a secret: a secret + * parameter appears as `{secret:}` (`{secret:webhook}/messages/123`), which the transport + * substitutes with the value and the preview with the variable's name. Shaped like the contract's + * `PlatformRequest`, so the preview returns it as-is. + */ +export interface RenderedRequest { + /** `POST`, `PUT`, `PATCH`, `DELETE`. */ + readonly method: string; + /** Platform method (`sendPhoto`) or path relative to the platform base (`/bh-alerts`). */ + readonly path: string; + readonly encoding: 'json' | 'multipart' | 'binary'; + /** JSON body, or the non-file fields of a multipart/binary request. */ + readonly body: Readonly>; + /** Content headers (ntfy `X-*`); never credentials. */ + readonly headers: Readonly>; + /** The attached image, if any: the `image` block's `ref` and how it is named on the wire. */ + readonly file: { + readonly ref: string; + readonly name: string; + readonly content_type: string; + } | null; +} + +/** What a renderer knows about the channel and the call beyond the delivery. */ +export interface RenderContext { + /** Discord `webhook`/`bot`; `null` elsewhere. */ + readonly mode: string | null; + /** The channel's non-secret coordinates (chat id, topic, server). */ + readonly target: Readonly>; + readonly op: 'send' | 'edit'; + /** The message being edited (`op = edit`). */ + readonly ref: PlatformMessageRef | null; + /** + * The payload an act button carries (`bh1:`, N2) where the capabilities allow act + * buttons; the preview passes a placeholder. + */ + readonly actToken: (actionId: string) => string; +} + +/** + * The pure half of a platform adapter: the contract in, the platform request(s) out (spec 03 §9.5). + * The same renderer serves the transport and `POST /channels/preview`, so a preview is exactly + * what a send makes. + */ +export interface ChannelRenderer { + readonly kind: string; + /** What this platform renders in `mode`. */ + capabilities(mode: string | null): ChannelCapabilities; + /** The request(s) for one send or edit, in order. Throws only on a programming error. */ + render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[]; +} + +/** A stored notification screenshot (D-36). */ +export interface NotificationImage { + readonly bytes: Uint8Array; + readonly contentType: string; + /** Wire file name (`screenshot.jpg`). */ + readonly filename: string; +} + +/** Resolves an `image` block's `ref` to bytes; adapters never read the database or the disk. */ +export interface NotificationImageReader { + /** The image, or `null` when it is gone (pruned, or never stored). */ + read(ref: string): Promise; +} + +/** Stores notification screenshots (`/notifications/images/`, 0600) and prunes them. */ +export interface NotificationImageStore extends NotificationImageReader { + /** Stores bytes and returns their opaque ref. */ + put(image: NotificationImage): Promise; + /** Deletes images older than `olderThan` (epoch ms). */ + prune(olderThan: number): Promise; +} + +/** Who pressed `/start ` and where (the Telegram connect flow, spec 03 §4.8.1). */ +export interface TelegramStart { + readonly chat: { + readonly id: string; + readonly title: string; + readonly type: string; + readonly threadId: string | null; + }; + readonly user: { readonly id: string; readonly name: string } | null; +} + +/** + * The Telegram setup calls: the bot's identity and the one-time `/start ` wait. Setup-only + * long polling; the persistent callback loop of act buttons is N2's. + */ +export interface TelegramSetup { + /** `getMe`: the bot's username. Throws a `ChannelSendError` (`auth` for a refused token). */ + botUsername(token: string): Promise; + /** + * Long-polls `getUpdates` until a message `/start ` arrives (private chat or group), the + * signal aborts, or `deadline` (epoch ms) passes. Updates it reads are acknowledged. + * + * @returns The chat and the sender, or `null` on timeout/abort. + */ + waitForStart( + token: string, + code: string, + options: { readonly signal: AbortSignal; readonly deadline: number }, + ): Promise; +} + +/** Outcome of one HTTP probe of `/health` (spec 08 §5.8). */ +export type UrlProbeResult = + | { + readonly kind: 'response'; + readonly status: number; + readonly contentType: string | null; + /** Where a 3xx pointed. */ + readonly location: string | null; + /** At most 64 KiB of the body. */ + readonly body: string; + } + | { readonly kind: 'error'; readonly detail: string }; + +/** Fetches a URL once without following redirects (the `publicUrl` check). */ +export type UrlProbe = (url: string, timeoutMs: number) => Promise; From 174e490f4e260cf42a2e2412b59024d105e76b28 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:29:26 -0400 Subject: [PATCH 04/22] feat(notifications): startup channel flag, screenshots and per-channel image rule Parses --notificationChannel into startup channels with secrets as variable names only (an inline secret is refused without echoing it), answers the env and config-file spellings with a hint naming the flag, captures attention, vault-confirm (before the fill) and crash screenshots when a channel wants them, keeps per channel only the image variant it may see, adds the public link builder, the delivery log filters and per-channel stats. --- packages/core/src/app/config/index.ts | 12 +- packages/core/src/app/config/layers.ts | 25 +- .../config/notification-channel-flag.test.ts | 154 +++++++ .../app/config/notification-channel-flag.ts | 422 ++++++++++++++++++ .../src/app/notifications/channel-registry.ts | 14 +- packages/core/src/app/notifications/images.ts | 68 +++ packages/core/src/app/notifications/links.ts | 35 +- .../app/notifications/notification-service.ts | 127 +++++- packages/core/src/app/notifications/outbox.ts | 30 +- .../core/src/app/notifications/producers.ts | 36 ++ .../repositories/notification-outbox.ts | 60 +++ .../core/src/ports/notification-channel.ts | 19 + .../ports/persistence/notification-outbox.ts | 3 + .../persistence/records-notifications.ts | 19 + .../core/src/ports/persistence/records.ts | 1 + .../helpers/in-memory-notification-repos.ts | 32 ++ 16 files changed, 1041 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/app/config/notification-channel-flag.test.ts create mode 100644 packages/core/src/app/config/notification-channel-flag.ts create mode 100644 packages/core/src/app/notifications/images.ts diff --git a/packages/core/src/app/config/index.ts b/packages/core/src/app/config/index.ts index c5bf264..b34cbd3 100644 --- a/packages/core/src/app/config/index.ts +++ b/packages/core/src/app/config/index.ts @@ -36,7 +36,17 @@ export { } from './failure.ts'; export { isJsonObject, type JsonParseError, type JsonValue, parseJson } from './json-parse.ts'; export { type KeyKind, keyKind, PATH_KEYS, quoteRaw, REDACTED_TEXT, renderValue } from './kinds.ts'; -export { type ConfigOverrides, OTEL_ENV_KEYS, UNSUPPORTED_HINT } from './layers.ts'; +export { + type ConfigOverrides, + FLAG_ONLY_HINT, + OTEL_ENV_KEYS, + UNSUPPORTED_HINT, +} from './layers.ts'; +export { + NOTIFICATION_CHANNEL_FLAG, + type NotificationChannelFlagResult, + parseNotificationChannelFlags, +} from './notification-channel-flag.ts'; export { type ConfigShowRow, type ConfigView, diff --git a/packages/core/src/app/config/layers.ts b/packages/core/src/app/config/layers.ts index d38bfa8..b2d0bdf 100644 --- a/packages/core/src/app/config/layers.ts +++ b/packages/core/src/app/config/layers.ts @@ -78,7 +78,11 @@ export function collectEnvLayer(env: Readonly code: 'CONFIG_UNKNOWN_KEY', source: 'env', location: name, - message: removed ? `${base} ${UNSUPPORTED_HINT}` : withSuggestion(base, suggestions), + message: FLAG_ONLY_SPELLINGS.has(name) + ? `${base} ${FLAG_ONLY_HINT}` + : removed + ? `${base} ${UNSUPPORTED_HINT}` + : withSuggestion(base, suggestions), suggestions, }); continue; @@ -118,6 +122,19 @@ export function collectEnvLayer(env: Readonly export const UNSUPPORTED_HINT = 'This option is not supported: the dashboard shares --host and --port.'; +/** + * Spellings of the flag-only `--notificationChannel` (spec 08 §5.7, D-39) in the environment and + * the config file: unknown there, answered with a hint naming the flag. + */ +export const FLAG_ONLY_SPELLINGS: ReadonlySet = new Set([ + 'BROWSERHIVE_NOTIFICATION_CHANNEL', + 'notificationChannel', +]); + +/** Hint for {@link FLAG_ONLY_SPELLINGS}. */ +export const FLAG_ONLY_HINT = + 'Notification channels are declared with the --notificationChannel flag or in the dashboard, never in the environment or the config file.'; + /** Standard OTEL variables read as the `env(otel)` sub-source (spec 08 §5.3). */ export const OTEL_ENV_KEYS: Readonly> = { OTEL_EXPORTER_OTLP_ENDPOINT: 'otelEndpoint', @@ -247,7 +264,11 @@ export function collectFileLayer( code: 'CONFIG_UNKNOWN_KEY', source: 'file', location, - message: removed ? `${base} ${UNSUPPORTED_HINT}` : withSuggestion(base, suggestions), + message: FLAG_ONLY_SPELLINGS.has(name) + ? `${base} ${FLAG_ONLY_HINT}` + : removed + ? `${base} ${UNSUPPORTED_HINT}` + : withSuggestion(base, suggestions), suggestions, }); continue; diff --git a/packages/core/src/app/config/notification-channel-flag.test.ts b/packages/core/src/app/config/notification-channel-flag.test.ts new file mode 100644 index 0000000..9e5bf19 --- /dev/null +++ b/packages/core/src/app/config/notification-channel-flag.test.ts @@ -0,0 +1,154 @@ +/** @module app/config/notification-channel-flag.test — the `--notificationChannel` grammar (spec 08 §5.7, D-39): per-kind parameters, rules, env-name secrets, the exact exit-64 texts, and that a secret is never echoed. */ +import { describe, expect, it } from 'bun:test'; +import { parseNotificationChannelFlags } from './notification-channel-flag.ts'; + +const ENV: Record = { + BH_TG_TOKEN: `1234:${'a'.repeat(35)}`, + BH_DISCORD_WEBHOOK: `https://discord.test/api/webhooks/1/${'b'.repeat(20)}`, + BH_NTFY_TOKEN: `tk_${'c'.repeat(29)}`, + BH_HOOK_SECRET: 'd'.repeat(32), + BH_EMPTY: '', +}; +const env = (name: string) => ENV[name]; +const parse = (...values: string[]) => parseNotificationChannelFlags(values, env); + +describe('parseNotificationChannelFlags', () => { + it('parses the four platforms of spec 08 §5.7', () => { + const r = parse( + 'telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456', + 'discord:name=team,webhook=env:BH_DISCORD_WEBHOOK,categories=needs-you+problems', + 'ntfy:name=pager,server=https://ntfy.example.net,topic=bh-alerts,token=env:BH_NTFY_TOKEN,min=error', + 'webhook:name=ops,url=https://hooks.example.net/bh,secret=env:BH_HOOK_SECRET', + ); + expect(r.problems).toEqual([]); + expect(r.channels).toEqual([ + { + name: 'phone', + kind: 'telegram', + mode: null, + target: { chat_id: '123456' }, + secret_refs: { token: 'BH_TG_TOKEN' }, + rules: {}, + }, + { + name: 'team', + kind: 'discord', + mode: 'webhook', + target: {}, + secret_refs: { webhook: 'BH_DISCORD_WEBHOOK' }, + rules: { categories: ['needs-you', 'problems'] }, + }, + { + name: 'pager', + kind: 'ntfy', + mode: null, + target: { server: 'https://ntfy.example.net', topic: 'bh-alerts' }, + secret_refs: { token: 'BH_NTFY_TOKEN' }, + rules: { min_severity: 'error' }, + }, + { + name: 'ops', + kind: 'webhook', + mode: null, + target: { url: 'https://hooks.example.net/bh' }, + secret_refs: { secret: 'BH_HOOK_SECRET' }, + rules: {}, + }, + ]); + expect(r.warnings).toEqual([]); + }); + + it('parses every rule parameter', () => { + const r = parse( + 'telegram:name=phone,token=env:BH_TG_TOKEN,chat=-1001234567890,thread=42,sessions=shop-*+scrape-*,harness=claude-code,content=full,quiet=22:00-07:30,tz=Europe/Berlin,ttl.needs-you=2h,ttl.problems=1d,deleteWhenResolved=needs-you,images=needs-you,maskImages=true', + ); + expect(r.problems).toEqual([]); + expect(r.channels[0]).toEqual({ + name: 'phone', + kind: 'telegram', + mode: null, + target: { chat_id: '-1001234567890', thread_id: '42' }, + secret_refs: { token: 'BH_TG_TOKEN' }, + rules: { + sessions: ['shop-*', 'scrape-*'], + harness: ['claude-code'], + content: 'full', + quiet_hours: { start: '22:00', end: '07:30', time_zone: 'Europe/Berlin' }, + ttl_ms: { 'needs-you': 7_200_000, problems: 86_400_000 }, + delete_when_resolved: { 'needs-you': true }, + images: { 'needs-you': true }, + mask_images: true, + }, + }); + }); + + it('refuses an inline secret with the exact message and never echoes it', () => { + const secret = `9876:${'z'.repeat(35)}`; + const r = parse(`telegram:name=phone,token=${secret},chat=1`); + expect(r.channels).toEqual([]); + expect(r.problems).toEqual([ + "--notificationChannel 'phone': token must name an environment variable (token=env:NAME), never contain the secret: other users of this machine can read process arguments.", + ]); + expect(JSON.stringify(r)).not.toContain('zzzz'); + }); + + it('names unset or empty variables and reserved prefixes', () => { + expect(parse('telegram:name=a,token=env:BH_MISSING,chat=1').problems).toEqual([ + "--notificationChannel 'a': BH_MISSING is not set (token=env:BH_MISSING). Set it in the environment that starts BrowserHive.", + ]); + expect(parse('telegram:name=a,token=env:BH_EMPTY,chat=1').problems[0]).toContain( + 'BH_EMPTY is not set', + ); + expect(parse('telegram:name=a,token=env:BROWSERHIVE_TG,chat=1').problems[0]).toContain( + 'reserved for configuration', + ); + }); + + it('reports unknown platforms and parameters with suggestions, and missing ones', () => { + expect(parse('telegarm:name=a').problems[0]).toContain("Did you mean 'telegram:'?"); + expect(parse('telegram:name=a,token=env:BH_TG_TOKEN,chta=1').problems).toEqual([ + "--notificationChannel #1: unknown parameter 'chta' for Telegram. Did you mean 'chat'?", + "--notificationChannel 'a': chat is required.", + ]); + expect(parse('ntfy:name=a').problems).toEqual([ + "--notificationChannel 'a': topic is required.", + ]); + expect(parse('telegram:token=env:BH_TG_TOKEN,chat=1').problems).toEqual([ + '--notificationChannel #1: name is required (name=phone).', + ]); + }); + + it('caps Telegram TTLs at 47 h and validates rule values', () => { + expect( + parse('telegram:name=a,token=env:BH_TG_TOKEN,chat=1,ttl.needs-you=48h').problems[0], + ).toContain('longer than 47h'); + expect(parse('ntfy:name=a,topic=t1,ttl.needs-you=3d').problems).toEqual([]); + const bad = parse( + 'ntfy:name=a,topic=t1,min=loud,quiet=25:00-01:00,content=all,images=needs-you', + ); + expect(bad.problems).toHaveLength(4); + expect(parse('ntfy:name=a,topic=t1,images=needs-you').problems).toEqual([ + "--notificationChannel 'a': images needs content=full (screenshots are full content).", + ]); + }); + + it('refuses duplicate names, repeated parameters and Discord bot mode', () => { + expect(parse('ntfy:name=a,topic=t1', 'ntfy:name=a,topic=t2').problems[0]).toContain( + "the name 'a' is used by two channels", + ); + expect(parse('ntfy:name=a,topic=t1,topic=t2').problems[0]).toContain('given twice'); + expect(parse('discord:name=a,webhook=env:BH_DISCORD_WEBHOOK,mode=bot').problems[0]).toContain( + 'bot mode is not available', + ); + }); + + it('warns about a literal topic on ntfy.sh and decodes percent-encoding', () => { + const r = parse('ntfy:name=a,topic=bh-x', 'webhook:name=b,url=https://h.example.net/a%2Cb'); + expect(r.problems).toEqual([]); + expect(r.warnings).toHaveLength(1); + expect(r.channels[1]?.target['url']).toBe('https://h.example.net/a,b'); + expect(parse('ntfy:name=a,topic=env:BH_NTFY_TOKEN').channels[0]?.secret_refs).toEqual({ + topic: 'BH_NTFY_TOKEN', + }); + }); +}); diff --git a/packages/core/src/app/config/notification-channel-flag.ts b/packages/core/src/app/config/notification-channel-flag.ts new file mode 100644 index 0000000..912b2cd --- /dev/null +++ b/packages/core/src/app/config/notification-channel-flag.ts @@ -0,0 +1,422 @@ +/** @module app/config/notification-channel-flag — the flag-only `--notificationChannel` grammar (spec 08 §5.7, D-33, D-39): `:=,…` → `StartupNotificationChannel`, with secrets only as environment variable names (an inline secret is a usage error that never echoes it). Pure. */ + +import { zDuration } from '@browserhive/contracts/config'; +import { + NotificationCategory, + NotificationContentLevel, + NotificationSeverity, +} from '@browserhive/contracts/enums'; +import { + AVAILABLE_CHANNEL_KINDS, + AvailableChannelKind, + CHANNEL_KIND_SPECS, + type ChannelKindSpec, + checkChannelConfig, + NotificationChannelName, + type NotificationChannelRules, + NTFY_DEFAULT_SERVER, + RESERVED_ENV_PREFIX, + StartupNotificationChannel, + TELEGRAM_TTL_MAX_MS, +} from '@browserhive/contracts/notifications'; +import { withSuggestion } from './failure.ts'; +import { suggest } from './suggest.ts'; + +/** The flag's name. */ +export const NOTIFICATION_CHANNEL_FLAG = '--notificationChannel'; + +/** Result of parsing every `--notificationChannel` value. */ +export interface NotificationChannelFlagResult { + readonly channels: readonly StartupNotificationChannel[]; + /** Usage errors (exit 64), without the `browserhive: ` prefix. Never contain a secret. */ + readonly problems: readonly string[]; + /** Warnings (a literal ntfy topic on the public server). */ + readonly warnings: readonly string[]; +} + +const RULE_PARAMS = [ + 'name', + 'categories', + 'min', + 'sessions', + 'harness', + 'content', + 'quiet', + 'tz', + 'deleteWhenResolved', + 'images', + 'maskImages', +] as const; + +/** Secret parameters that must be `env:NAME` (topic and url may also be literal). */ +const ALWAYS_SECRET: ReadonlySet = new Set(['token', 'webhook', 'secret', 'password']); + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const QUIET_RE = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/; + +function decode(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function paramsOf(spec: ChannelKindSpec): readonly string[] { + return [ + ...RULE_PARAMS, + ...spec.target.map((t) => t.param), + ...spec.secrets.map((s) => s.param), + ...(spec.modes === null ? [] : ['mode']), + ]; +} + +function isCategory(value: string): value is NotificationCategory { + return NotificationCategory.safeParse(value).success; +} + +function validZone(zone: string): boolean { + try { + new Intl.DateTimeFormat('en-GB', { timeZone: zone }); + return true; + } catch { + return false; + } +} + +function parseBool(value: string): boolean | null { + const v = value.toLowerCase(); + if (v === 'true' || v === '1' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'no') return false; + return null; +} + +/** + * Parses every `--notificationChannel` value (spec 08 §5.7). Every problem of every value is + * reported; a problem never contains the text of a secret parameter. + * + * @param values The flag values, in order. + * @param env Reads the server's environment (a referenced variable must be set and non-empty). + * @returns The parsed channels, usage problems and warnings. + */ +export function parseNotificationChannelFlags( + values: readonly string[], + env: (name: string) => string | undefined, +): NotificationChannelFlagResult { + const channels: StartupNotificationChannel[] = []; + const problems: string[] = []; + const warnings: string[] = []; + const names = new Set(); + values.forEach((raw, index) => { + const parsed = parseOne(raw, index, env, warnings); + if (parsed.problems.length > 0) { + problems.push(...parsed.problems); + return; + } + const channel = parsed.channel; + if (channel === null) return; + if (names.has(channel.name)) { + problems.push( + `${NOTIFICATION_CHANNEL_FLAG}: the name '${channel.name}' is used by two channels. Names must be unique.`, + ); + return; + } + names.add(channel.name); + channels.push(channel); + }); + return { channels, problems, warnings }; +} + +function parseOne( + raw: string, + index: number, + env: (name: string) => string | undefined, + warnings: string[], +): { readonly channel: StartupNotificationChannel | null; readonly problems: string[] } { + const problems: string[] = []; + const colon = raw.indexOf(':'); + const kindText = colon < 0 ? raw.trim() : raw.slice(0, colon).trim(); + const kind = AvailableChannelKind.safeParse(kindText); + const nth = `${NOTIFICATION_CHANNEL_FLAG} #${index + 1}`; + if (!kind.success) { + const hint = suggest(kindText, AVAILABLE_CHANNEL_KINDS); + problems.push( + withSuggestion( + `${nth}: unknown platform '${kindText}'. Expected ${AVAILABLE_CHANNEL_KINDS.join(', ')} followed by ':' and parameters, like "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456".`, + hint.map((h) => `${h}:`), + ), + ); + return { channel: null, problems }; + } + const spec = CHANNEL_KIND_SPECS[kind.data]; + const params = new Map(); + const body = colon < 0 ? '' : raw.slice(colon + 1); + const allowed = paramsOf(spec); + for (const part of body.split(',')) { + if (part.trim() === '') continue; + const eq = part.indexOf('='); + const key = (eq < 0 ? part : part.slice(0, eq)).trim(); + const value = eq < 0 ? '' : part.slice(eq + 1).trim(); + const known = allowed.includes(key) || /^ttl\.[a-z-]+$/.test(key); + if (!known) { + problems.push( + withSuggestion( + `${nth}: unknown parameter '${key}' for ${spec.label}.`, + suggest(key, allowed), + ), + ); + continue; + } + if (params.has(key)) { + problems.push(`${nth}: parameter '${key}' is given twice.`); + continue; + } + if (eq < 0 || value === '') { + problems.push(`${nth}: parameter '${key}' has no value.`); + continue; + } + const decoded = decode(value); + if (decoded === null) { + // Never echo: the value might be a secret. + problems.push(`${nth}: parameter '${key}' has a malformed percent-encoding.`); + continue; + } + params.set(key, decoded); + } + const name = params.get('name'); + const label = name === undefined ? nth : `${NOTIFICATION_CHANNEL_FLAG} '${name}'`; + if (name === undefined) problems.push(`${nth}: name is required (name=phone).`); + else if (!NotificationChannelName.safeParse(name).success) { + problems.push( + `${label}: name must be lowercase letters, digits and dashes (up to 32), like 'phone'.`, + ); + } + const target: Record = {}; + const secretRefs: Record = {}; + // Secrets and the literal-or-variable parameters. + const secretParams = new Set(spec.secrets.map((s) => s.param)); + for (const [key, value] of params) { + if (!secretParams.has(key)) continue; + const fromEnv = value.startsWith('env:'); + if (!fromEnv) { + if (ALWAYS_SECRET.has(key)) { + problems.push( + `${label}: ${key} must name an environment variable (${key}=env:NAME), never contain the secret: other users of this machine can read process arguments.`, + ); + continue; + } + target[key] = value; + if (kind.data === 'ntfy' && key === 'topic') { + const server = params.get('server') ?? NTFY_DEFAULT_SERVER; + if (/^https?:\/\/ntfy\.sh\/?$/i.test(server)) { + warnings.push( + `${label}: the topic is written in the flag; on ntfy.sh the topic acts as a password. Prefer topic=env:NAME.`, + ); + } + } + continue; + } + const envName = value.slice('env:'.length); + if (!ENV_NAME_RE.test(envName)) { + problems.push(`${label}: ${key}=env:NAME needs a variable name ([A-Za-z_][A-Za-z0-9_]*).`); + continue; + } + if (envName.startsWith(RESERVED_ENV_PREFIX)) { + problems.push( + `${label}: ${key}: variables starting with ${RESERVED_ENV_PREFIX} are reserved for configuration; use another name.`, + ); + continue; + } + const current = env(envName); + if (current === undefined || current === '') { + problems.push( + `${label}: ${envName} is not set (${key}=env:${envName}). Set it in the environment that starts BrowserHive.`, + ); + continue; + } + secretRefs[key] = envName; + } + for (const t of spec.target) { + const value = params.get(t.param); + if (value !== undefined && !secretParams.has(t.param)) target[t.key] = value; + } + let mode: string | null = spec.defaultMode; + const modeParam = params.get('mode'); + if (modeParam !== undefined) { + if (modeParam === 'bot') { + problems.push( + `${label}: Discord bot mode is not available in this release; use mode=webhook (the default).`, + ); + } else mode = modeParam; + } + const rules = parseRules(params, label, kind.data, problems); + for (const t of spec.target) { + if (t.required && !params.has(t.param)) problems.push(`${label}: ${t.param} is required.`); + } + for (const secret of spec.secrets) { + if (secret.required && !params.has(secret.param)) { + problems.push(`${label}: ${secret.param} is required (${secret.param}=env:NAME).`); + } + } + for (const key of spec.eitherTargetOrSecret) { + if (!params.has(key)) problems.push(`${label}: ${key} is required.`); + } + if (problems.length === 0) { + for (const problem of checkChannelConfig({ kind: kind.data, mode, target, secretRefs })) { + problems.push(`${label}: ${paramForField(spec, problem.field)}: ${problem.message}`); + } + } + if (problems.length > 0 || name === undefined) return { channel: null, problems }; + const channel = StartupNotificationChannel.safeParse({ + name, + kind: kind.data, + mode, + target, + secret_refs: secretRefs, + rules, + }); + if (!channel.success) { + return { channel: null, problems: [`${label}: the channel is not valid.`] }; + } + return { channel: channel.data, problems }; +} + +function paramForField(spec: ChannelKindSpec, field: string): string { + const key = field.replace(/^(target|secret_refs)\./, ''); + return spec.target.find((t) => t.key === key)?.param ?? key; +} + +function list(value: string): string[] { + return value + .split('+') + .map((v) => v.trim()) + .filter((v) => v !== ''); +} + +function categoriesOf( + value: string, + label: string, + param: string, + problems: string[], +): NotificationCategory[] | null { + const items = list(value); + const bad = items.filter((c) => !isCategory(c)); + if (bad.length > 0 || items.length === 0) { + problems.push( + `${label}: ${param} must be a + list of ${NotificationCategory.options.join(', ')}.`, + ); + return null; + } + return items.filter(isCategory); +} + +function parseRules( + params: ReadonlyMap, + label: string, + kind: AvailableChannelKind, + problems: string[], +): NotificationChannelRules { + const rules: { + -readonly [K in keyof NotificationChannelRules]: NotificationChannelRules[K]; + } = {}; + const categories = params.get('categories'); + if (categories !== undefined) { + const parsed = categoriesOf(categories, label, 'categories', problems); + if (parsed !== null) rules.categories = parsed; + } + const min = params.get('min'); + if (min !== undefined) { + const parsed = NotificationSeverity.safeParse(min); + if (parsed.success) rules.min_severity = parsed.data; + else problems.push(`${label}: min must be one of ${NotificationSeverity.options.join(', ')}.`); + } + const sessions = params.get('sessions'); + if (sessions !== undefined) rules.sessions = list(sessions); + const harness = params.get('harness'); + if (harness !== undefined) rules.harness = list(harness); + const content = params.get('content'); + if (content !== undefined) { + const parsed = NotificationContentLevel.safeParse(content); + if (parsed.success) rules.content = parsed.data; + else + problems.push( + `${label}: content must be one of ${NotificationContentLevel.options.join(', ')}.`, + ); + } + const quiet = params.get('quiet'); + const tz = params.get('tz'); + if (quiet !== undefined) { + const m = QUIET_RE.exec(quiet); + if (m === null) problems.push(`${label}: quiet must be HH:MM-HH:MM, like 22:00-07:30.`); + else { + rules.quiet_hours = { + start: `${m[1]}:${m[2]}`, + end: `${m[3]}:${m[4]}`, + ...(tz !== undefined && { time_zone: tz }), + }; + } + } + if (tz !== undefined) { + if (quiet === undefined) problems.push(`${label}: tz applies to quiet hours; set quiet too.`); + else if (!validZone(tz)) problems.push(`${label}: tz '${tz}' is not an IANA time zone.`); + } + const ttl: Partial> = {}; + for (const [key, value] of params) { + if (!key.startsWith('ttl.')) continue; + const category = key.slice('ttl.'.length); + if (!isCategory(category)) { + problems.push( + withSuggestion( + `${label}: unknown category '${category}' in ${key}.`, + suggest(category, NotificationCategory.options).map((c) => `ttl.${c}`), + ), + ); + continue; + } + const parsed = zDuration.safeParse(value); + if (!parsed.success || parsed.data <= 0) { + problems.push(`${label}: ${key} must be a duration like 2h, 30m or 1d.`); + continue; + } + if (kind === 'telegram' && parsed.data > TELEGRAM_TTL_MAX_MS) { + problems.push( + `${label}: ${key} is longer than 47h; Telegram lets a bot delete its messages for 48 hours only.`, + ); + continue; + } + ttl[category] = parsed.data; + } + if (Object.keys(ttl).length > 0) rules.ttl_ms = ttl; + const dwr = params.get('deleteWhenResolved'); + if (dwr !== undefined) { + const flag = parseBool(dwr); + if (flag !== null) { + if (flag) { + rules.delete_when_resolved = Object.fromEntries( + NotificationCategory.options.map((c) => [c, true]), + ); + } + } else { + const parsed = categoriesOf(dwr, label, 'deleteWhenResolved', problems); + if (parsed !== null) + rules.delete_when_resolved = Object.fromEntries(parsed.map((c) => [c, true])); + } + } + const images = params.get('images'); + if (images !== undefined) { + const parsed = categoriesOf(images, label, 'images', problems); + if (parsed !== null) { + rules.images = Object.fromEntries(parsed.map((c) => [c, true])); + if (rules.content !== 'full') { + problems.push(`${label}: images needs content=full (screenshots are full content).`); + } + } + } + const mask = params.get('maskImages'); + if (mask !== undefined) { + const flag = parseBool(mask); + if (flag === null) problems.push(`${label}: maskImages must be true or false.`); + else rules.mask_images = flag; + } + return rules; +} diff --git a/packages/core/src/app/notifications/channel-registry.ts b/packages/core/src/app/notifications/channel-registry.ts index 1c670c2..b307753 100644 --- a/packages/core/src/app/notifications/channel-registry.ts +++ b/packages/core/src/app/notifications/channel-registry.ts @@ -31,6 +31,11 @@ export type ChannelAdapterFactory = ( /** A channel with its adapter (`null` when no factory exists for its kind or the factory failed). */ export interface RegisteredChannel extends RoutableChannel { readonly adapter: NotificationChannel | null; + /** + * Why there is no adapter (no factory for the kind, or the factory's error such as an unset + * variable); `null` with an adapter. Never contains a secret value (factories name variables). + */ + readonly problem: string | null; } /** Dependencies of {@link ChannelRegistry}. */ @@ -158,7 +163,9 @@ export class ChannelRegistry { private build(record: NotificationChannelRecord): Omit { const factory = this.deps.factories?.get(record.kind); - if (factory === undefined) return { adapter: null, capabilities: null }; + if (factory === undefined) { + return { adapter: null, capabilities: null, problem: `no adapter for '${record.kind}'` }; + } const context: ChannelFactoryContext = { secret: (envName) => { const value = this.deps.env?.(envName); @@ -169,10 +176,11 @@ export class ChannelRegistry { }; try { const adapter = factory(record, context); - return { adapter, capabilities: adapter.capabilities }; + return { adapter, capabilities: adapter.capabilities, problem: null }; } catch (err) { + const problem = serializeError(err).message; this.log.warn('channel adapter failed', { channel: record.name, err: serializeError(err) }); - return { adapter: null, capabilities: null }; + return { adapter: null, capabilities: null, problem }; } } } diff --git a/packages/core/src/app/notifications/images.ts b/packages/core/src/app/notifications/images.ts new file mode 100644 index 0000000..c26d837 --- /dev/null +++ b/packages/core/src/app/notifications/images.ts @@ -0,0 +1,68 @@ +/** @module app/notifications/images — the per-channel screenshot rule (D-36, spec 03 §9.5): which image variant, if any, a channel may see, and which variants a notification should be captured with. Pure. */ + +import type { NotificationCategory } from '@browserhive/contracts/enums'; +import type { + Block, + NotificationChannelRules, + NotificationMessage, +} from '@browserhive/contracts/notifications'; +import { contentLevelOf } from './routing.ts'; + +type ImageBlock = Extract; + +/** Whether a channel wants screenshots for a category at all (and is allowed them: level `full`). */ +export function wantsImages( + rules: NotificationChannelRules, + category: NotificationCategory, +): boolean { + return rules.images?.[category] === true && contentLevelOf(rules) === 'full'; +} + +/** + * Keeps, per channel, only the image the channel may see (D-36): none when `images[category]` is + * off or the content level is below `full`; with `mask_images`, only a masked image; otherwise the + * unmasked image, or the masked one when that is all there is. At most one image survives. + * + * @returns The message for this channel (unchanged when it has no image). + */ +export function applyImageRule( + message: NotificationMessage, + rules: NotificationChannelRules, +): NotificationMessage { + const images = message.blocks.filter((b): b is ImageBlock => b.type === 'image'); + if (images.length === 0) return message; + let keep: ImageBlock | undefined; + if (wantsImages(rules, message.category)) { + const masked = images.find((b) => b.masked); + const unmasked = images.find((b) => !b.masked); + keep = rules.mask_images === true ? masked : (unmasked ?? masked); + } + const blocks = message.blocks.filter((b) => b.type !== 'image' || b === keep); + return { ...message, blocks, privacy: { ...message.privacy, has_image: keep !== undefined } }; +} + +/** Which variants a notification of `category` should be captured with. */ +export interface ImageVariants { + readonly masked: boolean; + readonly unmasked: boolean; +} + +/** + * The variants the active channels want for a category: a masked capture when any wanting channel + * masks, an unmasked one when any does not. Neither when no active channel wants screenshots. + * + * @returns The variants. + */ +export function imageVariants( + channels: readonly { readonly status: string; readonly rules: NotificationChannelRules }[], + category: NotificationCategory, +): ImageVariants { + let masked = false; + let unmasked = false; + for (const channel of channels) { + if (channel.status !== 'active' || !wantsImages(channel.rules, category)) continue; + if (channel.rules.mask_images === true) masked = true; + else unmasked = true; + } + return { masked, unmasked }; +} diff --git a/packages/core/src/app/notifications/links.ts b/packages/core/src/app/notifications/links.ts index 4e0e56c..b1dd6f8 100644 --- a/packages/core/src/app/notifications/links.ts +++ b/packages/core/src/app/notifications/links.ts @@ -1,7 +1,11 @@ -/** @module app/notifications/links — the default `LinkBuilder`: links to this computer's dashboard until `publicUrl` exists (D-37). */ +/** @module app/notifications/links — the `LinkBuilder`s (D-37): links to the public address (`publicUrl`) or, without one, to this computer's dashboard. */ import type { LinkBuilder } from '../../ports/notification-channel.ts'; +function join(base: string, path: string): string { + return `${base.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +} + /** * Links to the local dashboard (`http://127.0.0.1:9876/sessions/…`). `local` is true, so * renderers label them "Open on this computer" (D-37). `baseUrl` is read per call because the @@ -12,9 +16,30 @@ import type { LinkBuilder } from '../../ports/notification-channel.ts'; export function createLocalLinkBuilder(baseUrl: () => string): LinkBuilder { return { local: true, - url(path) { - const base = baseUrl().replace(/\/+$/, ''); - return `${base}${path.startsWith('/') ? path : `/${path}`}`; - }, + url: (path) => join(baseUrl(), path), + }; +} + +/** + * Links to the address where the operator made the dashboard reachable (`publicUrl`, spec 08 + * §5.8): `publicUrl + path`, a path prefix of `publicUrl` kept. Links never carry a token. + * + * @returns A link builder with `local: false`. + */ +export function createPublicLinkBuilder(publicUrl: string): LinkBuilder { + return { + local: false, + url: (path) => join(publicUrl, path), }; } + +/** + * The link builder for a configuration: public when `publicUrl` is set, local otherwise. + * + * @returns The builder. + */ +export function linkBuilderFor(publicUrl: string | undefined, localUrl: () => string): LinkBuilder { + return publicUrl === undefined + ? createLocalLinkBuilder(localUrl) + : createPublicLinkBuilder(publicUrl); +} diff --git a/packages/core/src/app/notifications/notification-service.ts b/packages/core/src/app/notifications/notification-service.ts index 2d323d7..972eefb 100644 --- a/packages/core/src/app/notifications/notification-service.ts +++ b/packages/core/src/app/notifications/notification-service.ts @@ -1,16 +1,26 @@ /** @module app/notifications/notification-service — server-side notification producer + inbox API (D-16, D-32, D-34, spec 03 §4.8/§9): bus rules → rows with their contract message → in-app channel inline and external channels through the outbox; lifecycle revisions; read/dismiss state with `notification.*` events. */ +import type { NotificationCategory } from '@browserhive/contracts/enums'; import type { Notification } from '@browserhive/contracts/http'; import { Notification as NotificationSchema } from '@browserhive/contracts/http'; import { parseSessionId } from '@browserhive/contracts/ids'; -import type { NotificationMessage } from '@browserhive/contracts/notifications'; +import { + type Block, + KIND_CATEGORY, + type NotificationMessage, +} from '@browserhive/contracts/notifications'; import { serializeError } from '../../kernel/errors/serialize-error.ts'; import { createRedactor, type Redactor } from '../../kernel/redact.ts'; import type { Clock } from '../../ports/clock.ts'; import type { EventBus } from '../../ports/event-bus.ts'; import type { IdGenerator } from '../../ports/id-generator.ts'; import type { Logger } from '../../ports/logger.ts'; -import type { LinkBuilder, NotificationChannel } from '../../ports/notification-channel.ts'; +import type { + CapturedImage, + LinkBuilder, + NotificationChannel, + NotificationSnapshots, +} from '../../ports/notification-channel.ts'; import type { NotificationRepository } from '../../ports/persistence/notifications.ts'; import type { NotificationListQuery, Page } from '../../ports/persistence/queries.ts'; import type { @@ -19,6 +29,7 @@ import type { } from '../../ports/persistence/records.ts'; import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts'; import type { DomainEvents } from '../events/catalog.ts'; +import type { ImageVariants } from './images.ts'; import { createInAppChannel } from './in-app-channel.ts'; import { buildMessage, @@ -30,6 +41,7 @@ import { import type { NotificationOutbox } from './outbox.ts'; import { draftFor, + type ImageRequest, NOTIFICATION_GROUP_IDLE_MS, NOTIFICATION_GROUP_MAX_AGE_MS, type NotificationDraft, @@ -79,8 +91,28 @@ export interface NotificationServiceDeps { readonly groupIdleMs?: number; /** Age after which a group row stops growing; default {@link NOTIFICATION_GROUP_MAX_AGE_MS}. */ readonly groupMaxAgeMs?: number; + /** + * Screenshots for notifications (D-36). Absent, or with `enabled: false` (`recordToolResults` + * is `none`), nothing is ever captured. + */ + readonly screenshots?: NotificationScreenshots; + /** Called after jobs were enqueued for a notification (the live delivery log). */ + readonly onDeliveryChange?: (notificationId: string) => void; } +/** How the service takes screenshots (spec 03 §9.5). */ +export interface NotificationScreenshots { + readonly enabled: boolean; + readonly snapshots: NotificationSnapshots; + /** The variants the active channels want for a category (from the channel registry). */ + readonly variants: (category: NotificationCategory) => ImageVariants; + /** Longest wait for one capture; default 4 s. */ + readonly timeoutMs?: number; +} + +/** Default capture budget. */ +const CAPTURE_TIMEOUT_MS = 4_000; + /** * Wire projection of a row (`Notification` DTO), validated so branded ids are honest. * @@ -224,8 +256,10 @@ export class NotificationService { principalId: string | null, primary = true, ): Promise { + const images = primary && draft.image !== undefined ? await this.capture(draft) : []; const now = this.deps.clock.now(); const notificationId = `n-${this.deps.ids.opaque(12)}`; + const content = draft.content(1); const message = this.seal( buildMessage({ id: notificationId, @@ -239,7 +273,8 @@ export class NotificationService { updatedAt: now, title: draft.title, summary: draft.body ?? '', - ...draft.content(1), + ...content, + blocks: withImages(content.blocks, images), }), ); const record: NotificationRecord = { @@ -588,6 +623,92 @@ export class NotificationService { /** Wakes the outbox when work was enqueued. */ private kick(jobs: readonly NewNotificationDelivery[]): void { + const first = jobs[0]; + if (first !== undefined) { + try { + this.deps.onDeliveryChange?.(first.notificationId); + } catch (err) { + this.log.warn('delivery feed failed', { err: serializeError(err) }); + } + } if (jobs.some((j) => j.status === 'pending')) this.deps.outbox?.kick(); } + + /** + * The screenshots a new notification carries (D-36): nothing unless screenshots are enabled and + * an active channel wants this category; a masked and/or unmasked capture of the live page, or + * the session's last stored frame for a crash (never masked). Failures and timeouts yield none. + */ + private async capture(draft: NotificationDraft): Promise { + const shots = this.deps.screenshots; + const request: ImageRequest | undefined = draft.image; + if (shots === undefined || !shots.enabled || request === undefined) return []; + const variants = shots.variants(KIND_CATEGORY[draft.kind]); + if (!variants.masked && !variants.unmasked) return []; + const timeout = shots.timeoutMs ?? CAPTURE_TIMEOUT_MS; + const out: ImageBlockInput[] = []; + const take = async (masked: boolean, run: () => Promise) => { + try { + const shot = await withTimeout(run(), timeout); + if (shot !== null) out.push({ shot, masked, request }); + } catch (err) { + this.log.warn('screenshot failed', { kind: draft.kind, err: serializeError(err) }); + } + }; + if (request.source === 'last') { + await take(false, () => shots.snapshots.lastFrame(request.sessionId)); + return out; + } + if (variants.unmasked) { + await take(false, () => shots.snapshots.capture(request.sessionId, { masked: false })); + } + if (variants.masked) { + await take(true, () => shots.snapshots.capture(request.sessionId, { masked: true })); + } + return out; + } +} + +/** A captured screenshot on its way into a message. */ +interface ImageBlockInput { + readonly shot: CapturedImage; + readonly masked: boolean; + readonly request: ImageRequest; +} + +/** Adds image blocks before the footer (or at the end). */ +function withImages( + blocks: readonly Block[], + images: readonly ImageBlockInput[], +): readonly Block[] { + if (images.length === 0) return blocks; + const imageBlocks: Block[] = images.map((i) => ({ + type: 'image', + ref: i.shot.ref, + alt: i.request.alt, + captured_at: i.shot.capturedAt, + masked: i.masked, + path: i.request.path, + })); + const footer = blocks.findIndex((b) => b.type === 'footer'); + return footer < 0 + ? [...blocks, ...imageBlocks] + : [...blocks.slice(0, footer), ...imageBlocks, ...blocks.slice(footer)]; +} + +/** Resolves with `null` when `promise` takes longer than `ms`. */ +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err); + }, + ); + }); } diff --git a/packages/core/src/app/notifications/outbox.ts b/packages/core/src/app/notifications/outbox.ts index e51069c..65281ab 100644 --- a/packages/core/src/app/notifications/outbox.ts +++ b/packages/core/src/app/notifications/outbox.ts @@ -28,6 +28,7 @@ import { type IntervalScheduler, realIntervalScheduler } from '../maintenance/ti import type { ChannelRegistry, RegisteredChannel } from './channel-registry.ts'; import { restrictContent } from './content-level.ts'; import { degrade } from './degrade.ts'; +import { applyImageRule } from './images.ts'; import { clip, decodeMessage, text } from './message.ts'; import { contentLevelOf, deleteWhenResolved, expiryFor, planDeliveries } from './routing.ts'; @@ -104,6 +105,11 @@ export interface NotificationOutboxDeps { readonly tracer?: Tracer; readonly counter?: DeliveryCounter; readonly options?: Partial; + /** + * Called after a job of (channel, notification) was written (a status change, a new delete job), + * so the live delivery log can refresh those rows. Must not throw. + */ + readonly onDeliveryChange?: (channelId: string, notificationId: string) => void; } /** Summary of one pass (tests, logs). */ @@ -271,7 +277,9 @@ export class NotificationOutbox { createdAt: now, })); if (rows.length === 0) return 0; - return this.deps.uow.transaction((r) => r.notificationDeliveries.enqueue(rows)); + const n = await this.deps.uow.transaction((r) => r.notificationDeliveries.enqueue(rows)); + for (const row of rows) this.changed(row.channelId, row.notificationId); + return n; } /** More than `backlogThreshold` pending `info` sends on a channel collapse into the newest. */ @@ -311,6 +319,14 @@ export class NotificationOutbox { this.deps.counter?.add(1, { channel_kind: kind, status }); } + private changed(channelId: string, notificationId: string): void { + try { + this.deps.onDeliveryChange?.(channelId, notificationId); + } catch (err) { + this.report(err); + } + } + /** Writes a decision made without a platform call. */ private async settle( job: NotificationDeliveryRecord, @@ -324,6 +340,7 @@ export class NotificationOutbox { updatedAt: this.deps.clock.now(), }); this.count(entry?.record.kind ?? 'unknown', status); + this.changed(job.channelId, job.notificationId); } private async process(job: NotificationDeliveryRecord): Promise { @@ -360,6 +377,7 @@ export class NotificationOutbox { return this.settle(job, entry, 'superseded', 'covered'); } if (!(await this.deps.repos.notificationDeliveries.claim(job.seq, now))) return; + this.changed(job.channelId, job.notificationId); await this.deps.repos.notificationDeliveries.supersede( job.channelId, job.notificationId, @@ -388,7 +406,10 @@ export class NotificationOutbox { entry: RegisteredChannel, message: NotificationMessage, ): Promise { - let shown = restrictContent(message, contentLevelOf(entry.record.rules)); + let shown = applyImageRule( + restrictContent(message, contentLevelOf(entry.record.rules)), + entry.record.rules, + ); const missed = job.reason?.startsWith(BACKLOG_PREFIX) ? Number(job.reason.slice(BACKLOG_PREFIX.length)) : 0; @@ -496,6 +517,7 @@ export class NotificationOutbox { }); this.deps.registry.setCachedStatus(job.channelId, entry.record.status, 0); this.count(entry.record.kind, 'sent'); + this.changed(job.channelId, job.notificationId); } private classify(err: unknown): Classified { @@ -556,6 +578,7 @@ export class NotificationOutbox { await r.notificationChannelMessages.markDeleted(job.channelId, job.notificationId, now); }); this.count(entry.record.kind, 'superseded'); + this.changed(job.channelId, job.notificationId); return; } const attempts = job.attempts + 1; @@ -581,6 +604,7 @@ export class NotificationOutbox { return health ? r.notificationChannels.recordFailure(job.channelId, now, detail) : 0; }); this.count(entry.record.kind, patch.status); + this.changed(job.channelId, job.notificationId); this.log.warn('delivery failed', { channel: entry.record.name, seq: job.seq, @@ -646,6 +670,7 @@ export class NotificationOutbox { return this.settle(job, entry, 'dead', 'could_not_delete: too_old'); } if (!(await this.deps.repos.notificationDeliveries.claim(job.seq, now))) return; + this.changed(job.channelId, job.notificationId); const remove = adapter.delete.bind(adapter); const started = this.deps.clock.now(); try { @@ -670,6 +695,7 @@ export class NotificationOutbox { }); } this.count(entry.record.kind, 'sent'); + this.changed(job.channelId, job.notificationId); } catch (err) { await this.failed(job, entry, err, started, cm); } diff --git a/packages/core/src/app/notifications/producers.ts b/packages/core/src/app/notifications/producers.ts index 12a7024..f7accfd 100644 --- a/packages/core/src/app/notifications/producers.ts +++ b/packages/core/src/app/notifications/producers.ts @@ -52,6 +52,22 @@ export interface NotificationDraft { readonly group?: NotificationGroup; /** Blocks, actions and entities of the message for a row holding `count` occurrences (1 unless grouped). */ readonly content: (count: number) => MessageContent; + /** + * A screenshot this notification may carry (D-36): `live` captures the session's page now + * (attention, vault confirm: before the fill starts), `last` uses its last stored screenshot (a + * crash). Taken only when a channel wants it (spec 03 §9.5). + */ + readonly image?: ImageRequest; +} + +/** What screenshot a draft asks for. */ +export interface ImageRequest { + readonly sessionId: string; + readonly source: 'live' | 'last'; + /** Alt text of the image block. */ + readonly alt: string; + /** Dashboard page that shows the context (used where a channel cannot carry images). */ + readonly path: string; } /** How a draft folds into an existing row. */ @@ -280,6 +296,12 @@ function attentionCreated(payload: DomainEvents['attention.created']): Notificat sourceEventId: d.request_id, dedupKey: `att:${d.request_id}`, content: () => ({ blocks, actions, entities: requestEntities(d) }), + image: { + sessionId: d.session_id, + source: 'live', + alt: 'The page when the agent asked for attention', + path: live, + }, }; } @@ -334,6 +356,14 @@ function vaultConfirmCreated(payload: DomainEvents['vault.confirm.created']): No sourceEventId: d.request_id, dedupKey: `vault:${d.request_id}`, content: () => ({ blocks, actions, entities: requestEntities(d) }), + // Captured when the confirmation is created: the fill waits for it, so this is before the + // fill sequence starts (D-36); the capture refuses while a secret window is open. + image: { + sessionId: d.session_id, + source: 'live', + alt: 'The login page before the fill', + path: `/sessions/${d.session_id}`, + }, }; } @@ -469,6 +499,12 @@ function sessionClosed(d: DomainEvents['session.closed']): NotificationDraft | n sourceEventId: null, dedupKey: `closed:${d.session_id}:${d.closed_at}`, content, + image: { + sessionId: d.session_id, + source: 'last', + alt: 'The last screenshot before the crash', + path: `/sessions/${d.session_id}`, + }, }; } diff --git a/packages/core/src/infra/persistence/repositories/notification-outbox.ts b/packages/core/src/infra/persistence/repositories/notification-outbox.ts index d07f281..00aef84 100644 --- a/packages/core/src/infra/persistence/repositories/notification-outbox.ts +++ b/packages/core/src/infra/persistence/repositories/notification-outbox.ts @@ -11,6 +11,7 @@ import type { NotificationDeliveryRepository, } from '../../../ports/persistence/notification-outbox.ts'; import type { + ChannelDeliveryStats, DeliveryFinishPatch, NewNotificationDelivery, NotificationChannelMessageRecord, @@ -294,6 +295,13 @@ export class SqliteNotificationDeliveryRepository implements NotificationDeliver qb = qb.where('notification_id', '=', query.notificationId); if (query.statuses !== undefined && query.statuses.length > 0) qb = qb.where('status', 'in', [...query.statuses]); + if (query.ops !== undefined && query.ops.length > 0) qb = qb.where('op', 'in', [...query.ops]); + if (query.kinds !== undefined && query.kinds.length > 0) { + const kinds = [...query.kinds]; + qb = qb.where('notification_id', 'in', (eb) => + eb.selectFrom('notifications').select('notification_id').where('kind', 'in', kinds), + ); + } if (query.beforeSeq !== undefined) qb = qb.where('seq', '<', query.beforeSeq); const rows = await qb .orderBy('seq', 'desc') @@ -301,6 +309,58 @@ export class SqliteNotificationDeliveryRepository implements NotificationDeliver .execute(); return rows.map(deliveryFromRow); } + + async stats(since: number): Promise { + const rows = await this.#db + .selectFrom('notification_deliveries') + .select([ + 'channel_id', + sql`SUM(CASE WHEN status = 'sent' AND updated_at >= ${since} THEN 1 ELSE 0 END)`.as( + 'sent', + ), + sql`SUM(CASE WHEN status = 'dead' AND updated_at >= ${since} THEN 1 ELSE 0 END)`.as( + 'failed', + ), + sql`SUM(CASE WHEN status = 'suppressed' AND updated_at >= ${since} THEN 1 ELSE 0 END)`.as( + 'suppressed', + ), + sql`SUM(CASE WHEN status IN ('pending', 'retrying', 'sending') THEN 1 ELSE 0 END)`.as( + 'pending', + ), + sql`MAX(CASE WHEN status IN ('sent', 'dead') THEN updated_at END)`.as( + 'last_at', + ), + ]) + .groupBy('channel_id') + .execute(); + const out: ChannelDeliveryStats[] = []; + for (const row of rows) { + const lastAt = row.last_at === null ? null : asNumber(row.last_at); + let lastStatus: NotificationDeliveryStatus | null = null; + if (lastAt !== null) { + const last = await this.#db + .selectFrom('notification_deliveries') + .select('status') + .where('channel_id', '=', row.channel_id) + .where('status', 'in', ['sent', 'dead']) + .orderBy('updated_at', 'desc') + .orderBy('seq', 'desc') + .limit(1) + .executeTakeFirst(); + lastStatus = last === undefined ? null : (last.status as NotificationDeliveryStatus); + } + out.push({ + channelId: row.channel_id, + sent: asNumber(row.sent), + failed: asNumber(row.failed), + suppressed: asNumber(row.suppressed), + pending: asNumber(row.pending), + lastAt, + lastStatus, + }); + } + return out; + } } /** SQLite implementation of {@link NotificationChannelMessageRepository}. */ diff --git a/packages/core/src/ports/notification-channel.ts b/packages/core/src/ports/notification-channel.ts index adbd7c2..ca53840 100644 --- a/packages/core/src/ports/notification-channel.ts +++ b/packages/core/src/ports/notification-channel.ts @@ -241,3 +241,22 @@ export type UrlProbeResult = /** Fetches a URL once without following redirects (the `publicUrl` check). */ export type UrlProbe = (url: string, timeoutMs: number) => Promise; + +/** One captured or stored screenshot, as the notification stores it. */ +export interface CapturedImage { + /** Image store ref (`nimg-…`). */ + readonly ref: string; + readonly capturedAt: number; +} + +/** + * Takes the screenshots of D-36 for notifications. Implementations refuse (return `null`) while + * the session's secret window is open, when it has no page, or when the capture fails; they never + * throw. + */ +export interface NotificationSnapshots { + /** A JPEG of the session's active page now, form fields masked when `masked`. */ + capture(sessionId: string, options: { readonly masked: boolean }): Promise; + /** The session's last stored screenshot (a crashed session has no page to capture). */ + lastFrame(sessionId: string): Promise; +} diff --git a/packages/core/src/ports/persistence/notification-outbox.ts b/packages/core/src/ports/persistence/notification-outbox.ts index 9a1e5c2..60980ba 100644 --- a/packages/core/src/ports/persistence/notification-outbox.ts +++ b/packages/core/src/ports/persistence/notification-outbox.ts @@ -2,6 +2,7 @@ import type { NotificationChannelStatus, NotificationDeliveryStatus } from './enums.ts'; import type { + ChannelDeliveryStats, DeliveryFinishPatch, NewNotificationDelivery, NotificationChannelMessageRecord, @@ -92,6 +93,8 @@ export interface NotificationDeliveryRepository { count(statuses: readonly NotificationDeliveryStatus[]): Promise; /** The delivery log, newest first. */ list(query: NotificationDeliveryListQuery): Promise; + /** Per-channel counts: finished jobs since `since`, open jobs now, and the last finished job. */ + stats(since: number): Promise; } /** Repository over `notification_channel_messages`. */ diff --git a/packages/core/src/ports/persistence/records-notifications.ts b/packages/core/src/ports/persistence/records-notifications.ts index cfeb858..cc70214 100644 --- a/packages/core/src/ports/persistence/records-notifications.ts +++ b/packages/core/src/ports/persistence/records-notifications.ts @@ -91,11 +91,30 @@ export interface NotificationDeliveryListQuery { readonly channelId?: string; readonly notificationId?: string; readonly statuses?: readonly NotificationDeliveryStatus[]; + readonly ops?: readonly NotificationDeliveryOp[]; + /** Notification kinds (`attention.requested`, …). */ + readonly kinds?: readonly string[]; /** Only rows with `seq` below this (the next page). */ readonly beforeSeq?: number; readonly limit?: number; } +/** Delivery counts of one channel (the channel cards, spec 03 §4.8.1). */ +export interface ChannelDeliveryStats { + readonly channelId: string; + /** `sent` jobs updated since the window start. */ + readonly sent: number; + /** `dead` jobs updated since the window start. */ + readonly failed: number; + /** `suppressed` jobs updated since the window start. */ + readonly suppressed: number; + /** `pending`, `retrying` and `sending` jobs now. */ + readonly pending: number; + /** When the last `sent` or `dead` job finished. */ + readonly lastAt: number | null; + readonly lastStatus: NotificationDeliveryStatus | null; +} + /** The platform message a notification became on a channel (`notification_channel_messages`). */ export interface NotificationChannelMessageRecord { readonly channelId: string; diff --git a/packages/core/src/ports/persistence/records.ts b/packages/core/src/ports/persistence/records.ts index 07082fe..a0db50c 100644 --- a/packages/core/src/ports/persistence/records.ts +++ b/packages/core/src/ports/persistence/records.ts @@ -28,6 +28,7 @@ export type { PrincipalRecord, } from './records-identity.ts'; export type { + ChannelDeliveryStats, DeliveryFinishPatch, NewNotificationDelivery, NotificationChannelMessageRecord, diff --git a/packages/core/test/helpers/in-memory-notification-repos.ts b/packages/core/test/helpers/in-memory-notification-repos.ts index 43ecacf..3a9232b 100644 --- a/packages/core/test/helpers/in-memory-notification-repos.ts +++ b/packages/core/test/helpers/in-memory-notification-repos.ts @@ -10,6 +10,7 @@ import type { NotificationDeliveryRepository, } from '../../src/ports/persistence/notification-outbox.ts'; import type { + ChannelDeliveryStats, DeliveryFinishPatch, NewNotificationDelivery, NotificationChannelMessageRecord, @@ -193,11 +194,42 @@ export class InMemoryNotificationDeliveryRepository implements NotificationDeliv query.statuses.length === 0 || query.statuses.includes(r.status), ) + .filter((r) => query.ops === undefined || query.ops.length === 0 || query.ops.includes(r.op)) + .filter((r) => { + if (query.kinds === undefined || query.kinds.length === 0) return true; + const kind = this.notificationOf(r.notificationId)?.kind; + return kind !== undefined && query.kinds.includes(kind); + }) .filter((r) => query.beforeSeq === undefined || r.seq < query.beforeSeq) .sort((a, b) => b.seq - a.seq) .slice(0, Math.min(Math.max(1, query.limit ?? 100), 1000)); } + async stats(since: number): Promise { + const byChannel = new Map(); + for (const r of this.rows) + byChannel.set(r.channelId, [...(byChannel.get(r.channelId) ?? []), r]); + return [...byChannel.entries()].map(([channelId, rows]) => { + const recent = (status: NotificationDeliveryStatus) => + rows.filter((r) => r.status === status && r.updatedAt >= since).length; + const finished = rows + .filter((r) => r.status === 'sent' || r.status === 'dead') + .sort((a, b) => b.updatedAt - a.updatedAt || b.seq - a.seq); + const last = finished[0]; + return { + channelId, + sent: recent('sent'), + failed: recent('dead'), + suppressed: recent('suppressed'), + pending: rows.filter( + (r) => r.status === 'pending' || r.status === 'retrying' || r.status === 'sending', + ).length, + lastAt: last?.updatedAt ?? null, + lastStatus: last?.status ?? null, + }; + }); + } + /** Drops every job of a channel (the FK cascade). */ removeChannel(channelId: string): void { for (let i = this.rows.length - 1; i >= 0; i--) { From ea07953441d6c8e6a87f63d331b9a34e09165f2b Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:33:23 -0400 Subject: [PATCH 05/22] feat(notifications): sample notifications for previews and tests Realistic messages for every preview sample (attention, its resolution, vault confirm, tool errors, crash, degraded, test), built through the real producers with fixed ids and times, optionally with a masked or unmasked screenshot block. --- packages/core/src/app/notifications/index.ts | 8 + .../src/app/notifications/samples.test.ts | 41 +++ .../core/src/app/notifications/samples.ts | 301 ++++++++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 packages/core/src/app/notifications/samples.test.ts create mode 100644 packages/core/src/app/notifications/samples.ts diff --git a/packages/core/src/app/notifications/index.ts b/packages/core/src/app/notifications/index.ts index 3efa94c..602b6bb 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -72,3 +72,11 @@ export { type RouteDecision, route, } from './routing.ts'; +export { + SAMPLE_IMAGE_REF, + SAMPLE_NOTIFICATION_ID, + SAMPLE_NOW, + SAMPLE_SESSION_ID, + type SampleOptions, + sampleMessage, +} from './samples.ts'; diff --git a/packages/core/src/app/notifications/samples.test.ts b/packages/core/src/app/notifications/samples.test.ts new file mode 100644 index 0000000..078ab9c --- /dev/null +++ b/packages/core/src/app/notifications/samples.test.ts @@ -0,0 +1,41 @@ +/** @module app/notifications/samples.test — every preview sample is a valid contract message built by the real producers (spec 03 §4.8.1). */ +import { describe, expect, it } from 'bun:test'; +import { NotificationMessage, PREVIEW_SAMPLES } from '@browserhive/contracts/notifications'; +import { SAMPLE_IMAGE_REF, sampleMessage } from './samples.ts'; + +describe('sampleMessage', () => { + it('builds a valid message for every sample, with and without a screenshot', () => { + for (const sample of PREVIEW_SAMPLES) { + for (const image of ['none', 'masked', 'unmasked'] as const) { + const message = sampleMessage(sample, { image }); + expect(NotificationMessage.safeParse(message).success).toBe(true); + } + } + }); + + it('carries the image only where a trigger exists, masked as asked', () => { + const masked = sampleMessage('attention', { image: 'masked' }); + const block = masked.blocks.find((b) => b.type === 'image'); + expect(block).toMatchObject({ type: 'image', ref: SAMPLE_IMAGE_REF, masked: true }); + expect(masked.privacy.has_image).toBe(true); + expect(sampleMessage('tool-errors', { image: 'masked' }).privacy.has_image).toBe(false); + expect(sampleMessage('attention').privacy.has_image).toBe(false); + }); + + it('models lifecycle and grouping like real notifications', () => { + const open = sampleMessage('attention'); + expect(open).toMatchObject({ state: 'open', alert: true, revision: 1 }); + expect(open.actions.some((a) => a.kind === 'act')).toBe(true); + const resolved = sampleMessage('attention-resolved'); + expect(resolved).toMatchObject({ state: 'resolved', alert: false, revision: 2 }); + expect(resolved.actions).toEqual([]); + expect(sampleMessage('tool-errors').title).toContain('3 tool errors'); + expect(sampleMessage('test')).toMatchObject({ kind: 'test', category: 'system' }); + expect(sampleMessage('test').actions[0]).toMatchObject({ id: 'open-dashboard' }); + }); + + it('is deterministic', () => { + expect(sampleMessage('crash')).toEqual(sampleMessage('crash')); + expect(sampleMessage('crash', { now: 5 }).at.created).toBe(5); + }); +}); diff --git a/packages/core/src/app/notifications/samples.ts b/packages/core/src/app/notifications/samples.ts new file mode 100644 index 0000000..fd74ac1 --- /dev/null +++ b/packages/core/src/app/notifications/samples.ts @@ -0,0 +1,301 @@ +/** @module app/notifications/samples — realistic sample notifications built through the real producers, for the channel preview, the test send and the renderer goldens (spec 03 §4.8.1). Pure: fixed ids and times. */ + +import type { Block } from '@browserhive/contracts/notifications'; +import { NotificationMessage, type PreviewSample } from '@browserhive/contracts/notifications'; +import { + AttentionCreatedEvent, + AttentionResolvedEvent, + SessionClosedEvent, + SystemDegradedEvent, + ToolCalledEvent, + VaultConfirmCreatedEvent, +} from '@browserhive/contracts/ws'; +import type { DomainEvents } from '../events/catalog.ts'; +import { buildMessage, reviseMessage, time } from './message.ts'; +import { draftFor, type ProducedEvent, revisionFor } from './producers.ts'; + +/** Session id of every sample. */ +export const SAMPLE_SESSION_ID = 'checkout-a1b2c3d4'; +/** Notification id of every sample. */ +export const SAMPLE_NOTIFICATION_ID = 'n-sample000001'; +/** Image ref of a sample screenshot (never resolves to bytes). */ +export const SAMPLE_IMAGE_REF = 'nimg-sample'; +/** Default time of the samples (2026-09-21, a fixed instant so goldens are stable). */ +export const SAMPLE_NOW = 1_790_000_000_000; + +const REQUEST_ID = 'a-sample000001'; + +/** Options of {@link sampleMessage}. */ +export interface SampleOptions { + /** Creation time of the sample; later revisions are a few seconds after. */ + readonly now?: number; + /** Add a screenshot block (attention, vault confirm, crash); default none. */ + readonly image?: 'none' | 'masked' | 'unmasked'; +} + +function request(kind: 'attention' | 'vault_confirm', now: number, extra: object) { + return { + request_id: REQUEST_ID, + kind, + session_id: SAMPLE_SESSION_ID, + session_slug: 'checkout', + owner: 'local', + reason: 'CAPTCHA on the checkout page: please solve it, then resume', + mode: 'takeover', + options: null, + status: 'pending', + message: null, + resolved_by: null, + resolution_reason: null, + created_at: now, + resolved_at: null, + deadline_at: null, + waited_ms: null, + page_url: 'https://shop.example.com/checkout/payment?step=2', + tool: 'click', + event_id: null, + entry_name: null, + ...extra, + }; +} + +function attentionEvent(now: number): ProducedEvent { + const payload: DomainEvents['attention.created'] = AttentionCreatedEvent.parse({ + type: 'attention.created', + request: request('attention', now, {}), + }); + return { name: 'attention.created', at: now, payload }; +} + +function attentionResolvedEvent(now: number, at: number): ProducedEvent { + const payload: DomainEvents['attention.resolved'] = AttentionResolvedEvent.parse({ + type: 'attention.resolved', + request: request('attention', now, { + status: 'resolved', + resolved_by: 'admin', + resolved_at: at, + waited_ms: at - now, + }), + }); + return { name: 'attention.resolved', at, payload }; +} + +function vaultEvent(now: number): ProducedEvent { + const payload: DomainEvents['vault.confirm.created'] = VaultConfirmCreatedEvent.parse({ + type: 'vault.confirm.created', + request: request('vault_confirm', now, { + mode: null, + reason: 'vault_fill', + entry_name: 'github', + page_url: 'https://github.com/login', + tool: 'vault_fill', + }), + }); + return { name: 'vault.confirm.created', at: now, payload }; +} + +function toolEvent(now: number): ProducedEvent { + const base = ToolCalledEvent.parse({ + type: 'tool.called', + has_detail: false, + row: { + event_id: 'e-00000000000000000000000003', + session_id: SAMPLE_SESSION_ID, + tool: 'navigate', + tab_id: null, + ok: false, + error_code: 'NAVIGATION_TIMEOUT', + error_message: null, + duration_ms: 30_000, + result_size_bytes: 0, + ts: now, + trace_id: null, + has_screenshot: false, + }, + }); + const payload: DomainEvents['tool.called'] = { + ...base, + observation: { + eventId: base.row.event_id, + sessionId: SAMPLE_SESSION_ID, + connectionId: null, + harness: 'claude-code', + tool: 'navigate', + tabId: null, + args: {}, + ok: false, + errorCode: 'NAVIGATION_TIMEOUT', + errorMessage: null, + resultText: null, + resultSizeBytes: 0, + durationMs: 30_000, + ts: now, + principal: 'local', + traceId: null, + spanId: null, + seq: 3, + }, + }; + return { name: 'tool.called', at: now, payload }; +} + +function crashEvent(now: number): ProducedEvent { + const payload: DomainEvents['session.closed'] = SessionClosedEvent.parse({ + type: 'session.closed', + session_id: SAMPLE_SESSION_ID, + closed_at: now, + reason: 'crash', + }); + return { name: 'session.closed', at: now, payload }; +} + +function degradedEvent(now: number): ProducedEvent { + const payload: DomainEvents['system.degraded'] = SystemDegradedEvent.parse({ + type: 'system.degraded', + event: { + event_id: 'e-00000000000000000000000009', + code: 'RETENTION_FAILED', + severity: 'error', + message: 'The retention sweep failed: database is locked', + details: null, + first_seen_at: now, + last_seen_at: now, + count: 1, + resolved_at: null, + }, + }); + return { name: 'system.degraded', at: now, payload }; +} + +/** The first revision of a producer's draft for `event`, with `count` occurrences. */ +function fromEvent(event: ProducedEvent, now: number, count = 1): NotificationMessage { + const draft = draftFor(event); + if (draft === null) throw new TypeError(`no draft for ${event.name}`); + return buildMessage({ + id: SAMPLE_NOTIFICATION_ID, + revision: count, + thread: draft.thread, + kind: draft.kind, + severity: draft.severity, + state: draft.state, + alert: count === 1, + createdAt: now, + updatedAt: now + (count - 1) * 20_000, + title: draft.group === undefined ? draft.title : draft.group.title(count), + summary: draft.body ?? '', + ...draft.content(count), + }); +} + +/** Inserts a screenshot block after the first quote (or first) block. */ +function withImage( + message: NotificationMessage, + masked: boolean, + now: number, + path: string, +): NotificationMessage { + const image: Block = { + type: 'image', + ref: SAMPLE_IMAGE_REF, + alt: `Screenshot of session ${SAMPLE_SESSION_ID.replace(/-[0-9a-z]{8}$/, '')}`, + captured_at: now, + masked, + path, + }; + const quote = message.blocks.findIndex((b) => b.type === 'quote'); + const at = quote >= 0 ? quote + 1 : 0; + const blocks = [...message.blocks.slice(0, at), image, ...message.blocks.slice(at)]; + return { ...message, blocks, privacy: { ...message.privacy, has_image: true } }; +} + +function testMessage(now: number): NotificationMessage { + return buildMessage({ + id: SAMPLE_NOTIFICATION_ID, + revision: 1, + thread: 'test:sample', + kind: 'test', + severity: 'info', + state: 'final', + alert: true, + createdAt: now, + updatedAt: now, + title: 'BrowserHive test message', + summary: + 'This channel works. Tap "Open dashboard" on your phone to check that links reach BrowserHive.', + blocks: [ + { + type: 'fields', + items: [ + { label: 'Sent', value: [time(now)] }, + { label: 'Kind', value: [{ type: 'code', text: 'test' }] }, + ], + }, + ], + actions: [ + { + kind: 'open', + id: 'open-dashboard', + label: 'Open dashboard', + style: 'primary', + path: '/notifications/channels', + }, + ], + entities: {}, + }); +} + +/** + * A realistic notification of the given sample kind, built through the real producers so a + * preview or a golden shows exactly what a real notification would carry. + * + * @returns The message (validated against the contract). + */ +export function sampleMessage( + sample: PreviewSample, + options: SampleOptions = {}, +): NotificationMessage { + const now = options.now ?? SAMPLE_NOW; + const image = options.image ?? 'none'; + const live = `/sessions/${SAMPLE_SESSION_ID}?live=1`; + let message: NotificationMessage; + let imagePath: string | null = null; + switch (sample) { + case 'attention': + message = fromEvent(attentionEvent(now), now); + imagePath = live; + break; + case 'attention-resolved': { + const first = fromEvent(attentionEvent(now), now); + const at = now + 130_000; + const revision = revisionFor(attentionResolvedEvent(now, at)); + if (revision === null) throw new TypeError('no revision for attention.resolved'); + message = reviseMessage( + image === 'none' ? first : withImage(first, image === 'masked', now, live), + revision.change, + at, + ); + return NotificationMessage.parse(message); + } + case 'vault-confirm': + message = fromEvent(vaultEvent(now), now); + imagePath = live; + break; + case 'tool-errors': + message = fromEvent(toolEvent(now), now, 3); + break; + case 'crash': + message = fromEvent(crashEvent(now), now); + imagePath = `/sessions/${SAMPLE_SESSION_ID}`; + break; + case 'degraded': + message = fromEvent(degradedEvent(now), now); + break; + case 'test': + message = testMessage(now); + break; + } + if (image !== 'none' && imagePath !== null) { + message = withImage(message, image === 'masked', now, imagePath); + } + return NotificationMessage.parse(message); +} From 13675c8b2e67670a9a9e6a6c5b95469f80189338 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:33:23 -0400 Subject: [PATCH 06/22] feat(notifications): Telegram, Discord webhook, ntfy and webhook adapters Pure renderers shared with the preview and one transport per platform: Telegram HTML messages and photos with inline keyboards, edits of text or caption and deletes; Discord webhook embeds with link buttons and a kept screenshot on edit (bot-mode buttons rendered for the preview); ntfy JSON publishes or uploads replaced by sequence id and deleted; the generic webhook posting the signed contract with same-origin redirects only. One HTTP helper classifies every failure and never echoes a secret. Also the Telegram connect calls, the screenshot store and the publicUrl probe. --- packages/core/package.json | 1 + .../core/src/infra/notifications/discord.ts | 528 +++++++++++++++++ packages/core/src/infra/notifications/http.ts | 272 +++++++++ .../src/infra/notifications/image-store.ts | 118 ++++ .../core/src/infra/notifications/index.ts | 155 +++++ packages/core/src/infra/notifications/ntfy.ts | 353 +++++++++++ .../src/infra/notifications/render-common.ts | 102 ++++ .../src/infra/notifications/telegram-setup.ts | 146 +++++ .../core/src/infra/notifications/telegram.ts | 555 ++++++++++++++++++ .../core/src/infra/notifications/url-probe.ts | 69 +++ .../core/src/infra/notifications/webhook.ts | 179 ++++++ 11 files changed, 2478 insertions(+) create mode 100644 packages/core/src/infra/notifications/discord.ts create mode 100644 packages/core/src/infra/notifications/http.ts create mode 100644 packages/core/src/infra/notifications/image-store.ts create mode 100644 packages/core/src/infra/notifications/index.ts create mode 100644 packages/core/src/infra/notifications/ntfy.ts create mode 100644 packages/core/src/infra/notifications/render-common.ts create mode 100644 packages/core/src/infra/notifications/telegram-setup.ts create mode 100644 packages/core/src/infra/notifications/telegram.ts create mode 100644 packages/core/src/infra/notifications/url-probe.ts create mode 100644 packages/core/src/infra/notifications/webhook.ts diff --git a/packages/core/package.json b/packages/core/package.json index de9c3cd..f7478eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ "./config": "./src/app/config/index.ts", "./ports/*": "./src/ports/*.ts", "./persistence": "./src/infra/persistence/index.ts", + "./notifications": "./src/infra/notifications/index.ts", "./maintenance": "./src/app/maintenance/index.ts", "./runtime": "./src/public/runtime.ts", "./server": "./src/public/server.ts" diff --git a/packages/core/src/infra/notifications/discord.ts b/packages/core/src/infra/notifications/discord.ts new file mode 100644 index 0000000..b27d858 --- /dev/null +++ b/packages/core/src/infra/notifications/discord.ts @@ -0,0 +1,528 @@ +/** @module infra/notifications/discord — the Discord adapter, webhook mode (spec 03 §9.5, D-38, D-40): a pure renderer to one embed plus link buttons (bot mode's interactive buttons are drawn for the preview only), and the webhook transport (send with `?wait=true`, edit keeping the screenshot, delete). */ + +import type { Block, Inline, NotificationMessage } from '@browserhive/contracts/notifications'; +import { + type ChannelCapabilities, + type ChannelDelivery, + type ChannelRenderer, + ChannelSendError, + type ChannelSendResult, + type LinkBuilder, + type NotificationChannel, + type NotificationImageReader, + type PlatformMessageRef, + type RenderContext, + type RenderedRequest, +} from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { + callPlatform, + type FailureRefiner, + type FetchFn, + multipart, + type PlatformAnswer, + substituteSecrets, +} from './http.ts'; +import { + bodyBlocks, + clipText, + firstImage, + LOCAL_LINKS_LABEL, + openLinks, + SCREENSHOT_FILENAME, + severityMark, +} from './render-common.ts'; + +/** Discord's embed limits. */ +export const DISCORD_LIMITS = { + title: 256, + description: 4096, + fields: 25, + fieldName: 256, + fieldValue: 1024, + footer: 2048, + total: 6000, + buttonsPerRow: 5, + buttonLabel: 80, +} as const; + +/** Webhook mode: link buttons only (D-38). */ +export const DISCORD_WEBHOOK_CAPABILITIES: ChannelCapabilities = { + richBlocks: true, + tables: false, + images: true, + actButtons: false, + openLinks: true, + edit: true, + delete: true, + replies: false, + deleteWindowMs: null, + maxTitleChars: 120, + maxTextChars: 3500, + maxButtons: 5, +}; + +/** Bot mode (N2): interactive act buttons; drawn by the preview's "What's the difference?" panel. */ +export const DISCORD_BOT_CAPABILITIES: ChannelCapabilities = { + ...DISCORD_WEBHOOK_CAPABILITIES, + actButtons: true, +}; + +/** Embed colour per severity, and for settled states. */ +export function discordColor(message: Pick): number { + if (message.state === 'resolved') return 0x22c55e; + if (message.state === 'expired') return 0x6b7280; + switch (message.severity) { + case 'info': + return 0x3b82f6; + case 'warn': + return 0xf59e0b; + case 'error': + return 0xef4444; + case 'critical': + return 0xd946ef; + } +} + +/** Escapes Discord markdown (and mention/timestamp syntax) in user text. */ +export function escapeMarkdown(text: string): string { + return text.replace(/([\\*_~`|<>[\]()])/g, '\\$1').replace(/^(\s*)([#+-]|\d+\.)/gm, '$1\\$2'); +} + +function inlineNode(node: Inline, links: LinkBuilder): string { + switch (node.type) { + case 'text': + return escapeMarkdown(node.text); + case 'bold': + return `**${escapeMarkdown(node.text)}**`; + case 'italic': + return `*${escapeMarkdown(node.text)}*`; + case 'code': + return `\`${node.text.replace(/`/g, 'ʼ')}\``; + case 'link': + return links.local + ? escapeMarkdown(node.text) + : `[${escapeMarkdown(node.text)}](${links.url(node.path).replace(/\)/g, '%29')})`; + case 'time': + return ``; + } +} + +function inline(run: readonly Inline[], links: LinkBuilder): string { + return run.map((node) => inlineNode(node, links)).join(''); +} + +function block(b: Block, links: LinkBuilder): string { + switch (b.type) { + case 'text': + return inline(b.content, links); + case 'heading': + return `**${escapeMarkdown(b.text)}**`; + case 'fields': + return b.items + .map((i) => `**${escapeMarkdown(i.label)}:** ${inline(i.value, links)}`) + .join('\n'); + case 'quote': + return inline(b.content, links) + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + case 'list': + return b.items + .map((item, n) => `${b.ordered ? `${n + 1}.` : '-'} ${inline(item, links)}`) + .join('\n'); + case 'code': { + const lang = + b.language !== null && /^[A-Za-z0-9_+-]{1,32}$/.test(b.language) ? b.language : ''; + return `\`\`\`${lang}\n${b.text.replace(/```/g, "'''")}\n\`\`\``; + } + case 'footer': + return `-# ${inline(b.content, links)}`; + case 'table': + case 'image': + case 'divider': + return ''; + } +} + +interface Embed { + title: string; + description?: string; + url?: string; + color: number; + fields?: { name: string; value: string; inline: boolean }[]; + image?: { url: string }; + footer: { text: string }; + timestamp: string; +} + +/** The embed of a message (limits enforced). */ +export function discordEmbed( + message: NotificationMessage, + links: LinkBuilder, + imageName: string | null, +): Embed { + const title = clipText(`${severityMark(message)} ${message.title}`, DISCORD_LIMITS.title); + const fields: { name: string; value: string; inline: boolean }[] = []; + const paragraphs: string[] = []; + if (message.summary.trim() !== '' && message.summary !== message.title) { + paragraphs.push(escapeMarkdown(message.summary)); + } + for (const b of bodyBlocks(message)) { + if (b.type === 'fields') { + for (const item of b.items) { + const value = clipText(inline(item.value, links) || '—', DISCORD_LIMITS.fieldValue); + if (fields.length < DISCORD_LIMITS.fields) { + fields.push({ + name: clipText(item.label, DISCORD_LIMITS.fieldName), + value, + inline: true, + }); + } else { + paragraphs.push(`**${escapeMarkdown(item.label)}:** ${value}`); + } + } + continue; + } + const text = block(b, links); + if (text !== '') paragraphs.push(text); + } + if (links.local) { + const resolved = openLinks(message, links); + if (resolved.length > 0) { + paragraphs.push( + [ + `**🖥 ${LOCAL_LINKS_LABEL}**`, + ...resolved.map((l) => `${escapeMarkdown(l.label)}: \`${l.url}\``), + ].join('\n'), + ); + } + } + const footer = 'BrowserHive'; + const first = openLinks(message, links)[0]; + // Keep the whole embed under the 6000-character total: fields go first, then the description. + let budget = DISCORD_LIMITS.total - title.length - footer.length; + const kept: typeof fields = []; + for (const f of fields) { + const cost = f.name.length + f.value.length; + if (cost > budget - 200) break; + kept.push(f); + budget -= cost; + } + const description = clipText( + paragraphs.join('\n\n'), + Math.min(DISCORD_LIMITS.description, Math.max(0, budget)), + ); + return { + title, + ...(description !== '' && { description }), + ...(!links.local && first !== undefined && { url: first.url }), + color: discordColor(message), + ...(kept.length > 0 && { fields: kept }), + ...(imageName !== null && { image: { url: `attachment://${imageName}` } }), + footer: { text: footer }, + timestamp: new Date(message.at.updated).toISOString(), + }; +} + +type Button = + | { type: 2; style: 5; label: string; url: string } + | { type: 2; style: 1 | 2 | 4; label: string; custom_id: string }; + +/** Action rows: link buttons, and in bot mode interactive act buttons. */ +export function discordComponents( + message: NotificationMessage, + links: LinkBuilder, + capabilities: ChannelCapabilities, + context: RenderContext, +): { type: 1; components: Button[] }[] { + const buttons: Button[] = []; + for (const action of message.actions) { + const label = clipText(action.label, DISCORD_LIMITS.buttonLabel); + if (action.kind === 'open') { + if (!links.local) buttons.push({ type: 2, style: 5, label, url: links.url(action.path) }); + } else if (capabilities.actButtons) { + const style = action.style === 'primary' ? 1 : action.style === 'danger' ? 4 : 2; + buttons.push({ type: 2, style, label, custom_id: context.actToken(action.id) }); + } + } + const rows: { type: 1; components: Button[] }[] = []; + for (let i = 0; i < buttons.length && rows.length < 5; i += DISCORD_LIMITS.buttonsPerRow) { + rows.push({ type: 1, components: buttons.slice(i, i + DISCORD_LIMITS.buttonsPerRow) }); + } + return rows; +} + +function capabilitiesOf(mode: string | null): ChannelCapabilities { + return mode === 'bot' ? DISCORD_BOT_CAPABILITIES : DISCORD_WEBHOOK_CAPABILITIES; +} + +/** + * The Discord renderer. Webhook sends go to `{secret:webhook}?wait=true&with_components=true` + * (the path placeholder is the secret webhook URL); a screenshot makes the request multipart + * (`payload_json` + `files[0]`, shown as the embed image). Edits `PATCH …/messages/{id}` list the + * attachment to keep. + */ +export const discordRenderer: ChannelRenderer = { + kind: 'discord', + capabilities: capabilitiesOf, + render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { + const { message, links } = delivery; + const capabilities = capabilitiesOf(context.mode); + const image = firstImage(message); + const components = discordComponents(message, links, capabilities, context); + const base = { + content: null, + allowed_mentions: { parse: [] as string[] }, + components, + }; + if (context.op === 'edit' && context.ref !== null) { + const kept = context.ref['attachment_id']; + const keptName = context.ref['attachment_name']; + const path = `{secret:webhook}/messages/${context.ref['message_id']}?with_components=true`; + if (image !== null && kept !== undefined) { + const name = String(keptName ?? SCREENSHOT_FILENAME); + return [ + { + method: 'PATCH', + path, + encoding: 'json', + body: { + ...base, + embeds: [discordEmbed(message, links, name)], + attachments: [{ id: String(kept) }], + }, + headers: {}, + file: null, + }, + ]; + } + if (image !== null) { + return [ + { + method: 'PATCH', + path, + encoding: 'multipart', + body: { + payload_json: { + ...base, + embeds: [discordEmbed(message, links, SCREENSHOT_FILENAME)], + attachments: [{ id: 0, filename: SCREENSHOT_FILENAME }], + }, + }, + headers: {}, + file: { ref: image.ref, name: SCREENSHOT_FILENAME, content_type: 'image/jpeg' }, + }, + ]; + } + return [ + { + method: 'PATCH', + path, + encoding: 'json', + body: { ...base, embeds: [discordEmbed(message, links, null)], attachments: [] }, + headers: {}, + file: null, + }, + ]; + } + const path = '{secret:webhook}?wait=true&with_components=true'; + if (image !== null) { + return [ + { + method: 'POST', + path, + encoding: 'multipart', + body: { + payload_json: { + ...base, + embeds: [discordEmbed(message, links, SCREENSHOT_FILENAME)], + attachments: [{ id: 0, filename: SCREENSHOT_FILENAME }], + }, + }, + headers: {}, + file: { ref: image.ref, name: SCREENSHOT_FILENAME, content_type: 'image/jpeg' }, + }, + ]; + } + return [ + { + method: 'POST', + path, + encoding: 'json', + body: { ...base, embeds: [discordEmbed(message, links, null)] }, + headers: {}, + file: null, + }, + ]; + }, +}; + +/** A deleted webhook (`10015 Unknown Webhook`) is a credential problem, not a gone message. */ +export const refineDiscord: FailureRefiner = (answer) => { + const code = + answer.json !== null && typeof answer.json === 'object' + ? Reflect.get(answer.json, 'code') + : null; + if (code === 10015) return new ChannelSendError('auth', 'Discord: the webhook no longer exists'); + if (code === 10008) return new ChannelSendError('message_gone', `Discord: ${answer.detail}`); + return null; +}; + +/** What the Discord transport needs besides the channel row. */ +export interface DiscordChannelDeps { + /** The webhook URL (resolved from the channel's `webhook` variable). */ + readonly webhookUrl: string; + readonly images: NotificationImageReader; + readonly fetch?: FetchFn; +} + +/** + * Joins the webhook URL with a rendered path suffix (`/messages/1?with_components=true`), merging + * query parameters (a webhook URL may carry `?thread_id=`). + * + * @returns The absolute URL. + */ +export function webhookUrlFor(webhook: string, rendered: string): string { + const suffix = rendered.replace(/^\{secret:webhook\}/, ''); + const [path = '', query = ''] = suffix.split('?'); + const url = new URL(webhook); + url.pathname = `${url.pathname.replace(/\/+$/, '')}${path}`; + for (const [k, v] of new URLSearchParams(query)) url.searchParams.set(k, v); + return url.toString(); +} + +function refOf(answer: PlatformAnswer, previous: PlatformMessageRef | null): PlatformMessageRef { + const json = answer.json as Record | null; + const id = json?.['id']; + if (typeof id !== 'string') { + if (previous !== null) return previous; + throw new ChannelSendError('rejected', 'Discord answered without a message id'); + } + const attachments = Array.isArray(json?.['attachments']) + ? (json?.['attachments'] as unknown[]) + : []; + const first = attachments[0] as Record | undefined; + return { + message_id: id, + ...(typeof json?.['channel_id'] === 'string' && { channel_id: json['channel_id'] }), + ...(typeof first?.['id'] === 'string' && { attachment_id: first['id'] }), + ...(typeof first?.['filename'] === 'string' && { attachment_name: first['filename'] }), + }; +} + +/** + * A Discord channel in webhook mode. Bot mode is refused (it arrives with act buttons, N2). + * + * @returns The adapter. + */ +export function createDiscordChannel( + record: NotificationChannelRecord, + deps: DiscordChannelDeps, +): NotificationChannel { + if (record.mode === 'bot') { + throw new Error('Discord bot mode arrives with act buttons; use webhook mode'); + } + try { + const parsed = new URL(deps.webhookUrl); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('scheme'); + } catch { + throw new Error(`the webhook variable of channel '${record.name}' does not hold a URL`); + } + const options = { + fetch: deps.fetch ?? fetch, + secrets: [deps.webhookUrl, new URL(deps.webhookUrl).pathname], + platform: 'Discord', + refine: refineDiscord, + }; + const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({ + mode: 'webhook', + target: record.target, + op, + ref, + actToken: () => { + throw new ChannelSendError('rejected', 'act buttons need Discord bot mode'); + }, + }); + + async function perform( + request: RenderedRequest, + addressesMessage: boolean, + ): Promise { + const url = webhookUrlFor(deps.webhookUrl, substituteSecrets(request.path, {})); + if (request.encoding === 'multipart') { + const payload = request.body['payload_json'] as Record; + const image = request.file === null ? null : await deps.images.read(request.file.ref); + if (image === null || request.file === null) { + // The screenshot is gone (pruned): send the embed without it. + const embeds = (payload['embeds'] as Record[]).map( + ({ image: _dropped, ...rest }) => rest, + ); + return callPlatform( + { + url, + method: request.method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...payload, embeds, attachments: [] }), + addressesMessage, + }, + options, + ); + } + return callPlatform( + { + url, + method: request.method, + body: multipart( + { payload_json: payload }, + { + field: 'files[0]', + bytes: image.bytes, + name: request.file.name, + type: image.contentType, + }, + ), + addressesMessage, + }, + options, + ); + } + return callPlatform( + { + url, + method: request.method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request.body), + addressesMessage, + }, + options, + ); + } + + return { + id: record.channelId, + name: record.name, + kind: 'discord', + capabilities: DISCORD_WEBHOOK_CAPABILITIES, + async send(delivery: ChannelDelivery): Promise { + const [request] = discordRenderer.render(delivery, context('send', null)); + if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); + return { ref: refOf(await perform(request, false), null) }; + }, + async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { + const [request] = discordRenderer.render(delivery, context('edit', ref)); + if (request === undefined) return { ref }; + return { ref: refOf(await perform(request, true), ref) }; + }, + async delete(ref: PlatformMessageRef): Promise { + await callPlatform( + { + url: webhookUrlFor(deps.webhookUrl, `/messages/${ref['message_id']}`), + method: 'DELETE', + addressesMessage: true, + }, + options, + ); + }, + }; +} diff --git a/packages/core/src/infra/notifications/http.ts b/packages/core/src/infra/notifications/http.ts new file mode 100644 index 0000000..c86b119 --- /dev/null +++ b/packages/core/src/infra/notifications/http.ts @@ -0,0 +1,272 @@ +/** @module infra/notifications/http — the one HTTP helper of the platform adapters (spec 03 §9.5): timeouts, JSON/multipart/binary bodies, manual redirects for operator-supplied URLs, and the classification of every failure into a `ChannelSendError` that never carries a secret. */ + +import { ChannelSendError } from '../../ports/notification-channel.ts'; + +/** The `fetch` the adapters call; injectable for fakes. */ +export type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; + +/** Default timeout of one platform call. */ +export const PLATFORM_TIMEOUT_MS = 10_000; +/** Longest error detail kept (the outbox clips again). */ +const DETAIL_MAX = 300; +/** Redirects followed on a same-origin hop before giving up. */ +const MAX_REDIRECTS = 3; + +/** One platform call. */ +export interface PlatformCall { + readonly url: string; + readonly method: string; + readonly headers?: Readonly>; + readonly body?: string | FormData | Blob | null; + readonly timeoutMs?: number; + /** + * `follow` for the known platforms; `same-origin` follows a redirect only when it keeps the + * scheme and host (the generic webhook, spec 03 §9.5), anything else is `rejected: redirect`. + */ + readonly redirects?: 'follow' | 'same-origin'; + /** Whether a 404 means the addressed message is gone (edits and deletes). */ + readonly addressesMessage?: boolean; + /** Cancels the call (the Telegram connect wait). */ + readonly signal?: AbortSignal; +} + +/** A successful answer. */ +export interface PlatformAnswer { + readonly status: number; + readonly headers: Headers; + readonly text: string; + /** The parsed JSON body, or `null` when the body is not JSON. */ + readonly json: unknown; +} + +/** Everything the classifier knows about a failed answer. */ +export interface FailedAnswer extends PlatformAnswer { + /** The platform's own error sentence (`description`, `message`, `error`), scrubbed. */ + readonly detail: string; +} + +/** + * Platform-specific refinement of a failed answer (Telegram's 400 descriptions): return an error + * to throw instead of the generic classification, or `null` for the default, or `'ok'` when the + * failure is harmless ("message is not modified"). + */ +export type FailureRefiner = (answer: FailedAnswer) => ChannelSendError | 'ok' | null; + +/** Options of {@link callPlatform}. */ +export interface CallOptions { + readonly fetch: FetchFn; + /** Literal secrets that must never appear in an error detail (tokens, webhook URLs). */ + readonly secrets: readonly string[]; + /** Platform name for messages (`Telegram`). */ + readonly platform: string; + readonly refine?: FailureRefiner; +} + +/** + * Replaces every secret literal in `text` (and any bot-token-shaped path segment) with + * `[redacted]`, then clips it. + * + * @returns The safe text. + */ +export function scrubDetail(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + if (secret.length >= 4) out = out.split(secret).join('[redacted]'); + } + out = out.replace(/\/bot\d+:[A-Za-z0-9_-]+/g, '/bot[redacted]'); + out = out.replace(/\/api\/webhooks\/\d+\/[A-Za-z0-9_.-]+/g, '/api/webhooks/[redacted]'); + return out.length > DETAIL_MAX ? `${out.slice(0, DETAIL_MAX - 1)}…` : out; +} + +function parseJson(text: string): unknown { + if (text === '') return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function field(json: unknown, key: string): unknown { + return json !== null && typeof json === 'object' ? Reflect.get(json, key) : undefined; +} + +/** + * The wait a 429 (or 503) asks for, in ms: Telegram `parameters.retry_after` (seconds), Discord + * `retry_after` (float seconds), or the `Retry-After` header (seconds or an HTTP date). + * + * @returns Milliseconds, or `null` when the platform said nothing. + */ +export function retryAfterMs(answer: PlatformAnswer, now: () => number = Date.now): number | null { + const parameters = field(answer.json, 'parameters'); + const fromParameters = field(parameters, 'retry_after'); + if (typeof fromParameters === 'number' && Number.isFinite(fromParameters)) { + return Math.max(0, Math.ceil(fromParameters * 1000)); + } + const fromBody = field(answer.json, 'retry_after'); + if (typeof fromBody === 'number' && Number.isFinite(fromBody)) { + return Math.max(0, Math.ceil(fromBody * 1000)); + } + const header = answer.headers.get('retry-after'); + if (header !== null && header.trim() !== '') { + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, Math.ceil(seconds * 1000)); + const at = Date.parse(header); + if (Number.isFinite(at)) return Math.max(0, at - now()); + } + return null; +} + +function detailOf(answer: PlatformAnswer, secrets: readonly string[]): string { + const json = answer.json; + for (const key of ['description', 'message', 'error']) { + const value = field(json, key); + if (typeof value === 'string' && value !== '') return scrubDetail(value, secrets); + } + const text = answer.text.replace(/\s+/g, ' ').trim(); + return scrubDetail(text === '' ? `HTTP ${answer.status}` : text, secrets); +} + +/** + * The default classification of a failed answer (N0 handoff §6.1, spec 03 §9.5). + * + * @returns The error to throw. + */ +export function classifyFailure( + answer: FailedAnswer, + platform: string, + addressesMessage: boolean, +): ChannelSendError { + const { status, detail } = answer; + const message = `${platform} ${status}: ${detail}`; + if (status === 429) { + return new ChannelSendError('rate_limited', message, { retryAfterMs: retryAfterMs(answer) }); + } + if (status === 401 || status === 403) return new ChannelSendError('auth', message); + if (status === 404 && addressesMessage) return new ChannelSendError('message_gone', message); + if (status === 408) return new ChannelSendError('timeout', message); + if (status >= 500) { + return new ChannelSendError('unavailable', message, { retryAfterMs: retryAfterMs(answer) }); + } + return new ChannelSendError('rejected', message); +} + +function combinedSignal(timeoutMs: number, external?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + return external === undefined ? timeout : AbortSignal.any([timeout, external]); +} + +function isAbort(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === 'AbortError' || + err.name === 'TimeoutError' || + /abort|timed? ?out/i.test(err.message)) + ); +} + +async function once(call: PlatformCall, url: string, options: CallOptions): Promise { + try { + return await options.fetch(url, { + method: call.method, + headers: { 'user-agent': 'BrowserHive', ...call.headers }, + ...(call.body !== undefined && call.body !== null && { body: call.body }), + redirect: call.redirects === 'same-origin' ? 'manual' : 'follow', + signal: combinedSignal(call.timeoutMs ?? PLATFORM_TIMEOUT_MS, call.signal), + }); + } catch (err) { + if (isAbort(err)) { + throw new ChannelSendError('timeout', `${options.platform} did not answer in time`); + } + const raw = err instanceof Error ? err.message : String(err); + throw new ChannelSendError( + 'unavailable', + `${options.platform} unreachable: ${scrubDetail(raw.replace(/https?:\/\/\S+/g, ''), options.secrets)}`, + ); + } +} + +/** + * Makes one platform call and returns the answer, or throws a classified `ChannelSendError` + * whose message never contains a secret. + * + * @returns The 2xx answer. + */ +export async function callPlatform( + call: PlatformCall, + options: CallOptions, +): Promise { + let url = call.url; + let response = await once(call, url, options); + for (let hop = 0; call.redirects === 'same-origin' && hop < MAX_REDIRECTS; hop++) { + if (response.status < 300 || response.status >= 400) break; + const location = response.headers.get('location'); + if (location === null) break; + const from = new URL(url); + let next: URL; + try { + next = new URL(location, from); + } catch { + throw new ChannelSendError('rejected', `${options.platform} redirect: invalid location`); + } + if (next.protocol !== from.protocol || next.host !== from.host) { + throw new ChannelSendError( + 'rejected', + `${options.platform} redirect: refused a redirect to another scheme or host`, + ); + } + url = next.toString(); + response = await once(call, url, options); + } + const text = await response.text().catch(() => ''); + const answer: PlatformAnswer = { + status: response.status, + headers: response.headers, + text, + json: parseJson(text), + }; + if (response.status >= 200 && response.status < 300) return answer; + if (response.status >= 300 && response.status < 400) { + throw new ChannelSendError('rejected', `${options.platform} redirect: not followed`); + } + const failed: FailedAnswer = { ...answer, detail: detailOf(answer, options.secrets) }; + const refined = options.refine?.(failed) ?? null; + if (refined === 'ok') return answer; + if (refined !== null) throw refined; + throw classifyFailure(failed, options.platform, call.addressesMessage === true); +} + +/** + * Builds a multipart body: every string field as is, every other value as JSON, and the file + * under `fileField`. + * + * @returns The form data. + */ +export function multipart( + fields: Readonly>, + file: { + readonly field: string; + readonly bytes: Uint8Array; + readonly name: string; + readonly type: string; + } | null, +): FormData { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null) continue; + form.append(key, typeof value === 'string' ? value : JSON.stringify(value)); + } + if (file !== null) { + form.append(file.field, new Blob([new Uint8Array(file.bytes)], { type: file.type }), file.name); + } + return form; +} + +/** + * Replaces `{secret:}` placeholders in a string with the resolved values. + * + * @returns The substituted text. + */ +export function substituteSecrets(text: string, secrets: Readonly>): string { + return text.replace(/\{secret:([a-z_]+)\}/g, (whole, param: string) => secrets[param] ?? whole); +} diff --git a/packages/core/src/infra/notifications/image-store.ts b/packages/core/src/infra/notifications/image-store.ts new file mode 100644 index 0000000..e804831 --- /dev/null +++ b/packages/core/src/infra/notifications/image-store.ts @@ -0,0 +1,118 @@ +/** @module infra/notifications/image-store — notification screenshots on disk (D-36, spec 03 §9.5): `/notifications/images/`, directory 0700, files 0600, named by an opaque ref that is validated before any path is built. */ + +import { randomBytes } from 'node:crypto'; +import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + NotificationImage, + NotificationImageStore, +} from '../../ports/notification-channel.ts'; + +/** Shape of an image ref: `nimg-` + 16 URL-safe characters. Anything else never reaches the disk. */ +export const IMAGE_REF_RE = /^nimg-[A-Za-z0-9_-]{16}$/; + +const EXTENSIONS: Readonly> = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; +const TYPES: Readonly> = { + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +}; + +/** Options of {@link createNotificationImageStore}. */ +export interface ImageStoreOptions { + /** Opaque id source (16 URL-safe characters); defaults to crypto random bytes. */ + readonly ids?: { opaque(size: number): string }; +} + +function randomId(size: number): string { + return randomBytes(size).toString('base64url').slice(0, size); +} + +/** + * The filesystem image store. `read` returns `null` for an unknown or malformed ref, so a pruned + * screenshot makes the adapter send the text alone. + * + * @returns The store. + */ +export function createNotificationImageStore( + dir: string, + options: ImageStoreOptions = {}, +): NotificationImageStore { + const opaque = (size: number) => options.ids?.opaque(size) ?? randomId(size); + let ready: Promise | undefined; + const ensure = () => { + ready ??= mkdir(dir, { recursive: true, mode: 0o700 }).then(() => undefined); + return ready; + }; + + async function find(ref: string): Promise { + if (!IMAGE_REF_RE.test(ref)) return null; + for (const ext of Object.keys(TYPES)) { + const path = join(dir, `${ref}.${ext}`); + try { + await stat(path); + return path; + } catch { + // try the next extension + } + } + return null; + } + + return { + async put(image: NotificationImage): Promise { + await ensure(); + const ref = `nimg-${opaque(16)}`; + if (!IMAGE_REF_RE.test(ref)) + throw new TypeError('image ref generator produced an invalid ref'); + const ext = EXTENSIONS[image.contentType] ?? 'jpg'; + await writeFile(join(dir, `${ref}.${ext}`), image.bytes, { mode: 0o600 }); + return ref; + }, + + async read(ref: string): Promise { + const path = await find(ref); + if (path === null) return null; + try { + const bytes = new Uint8Array(await readFile(path)); + const ext = path.slice(path.lastIndexOf('.') + 1); + return { + bytes, + contentType: TYPES[ext] ?? 'image/jpeg', + filename: `screenshot.${ext}`, + }; + } catch { + return null; + } + }, + + async prune(olderThan: number): Promise { + let names: string[]; + try { + names = await readdir(dir); + } catch { + return 0; + } + let removed = 0; + for (const name of names) { + const ref = name.replace(/\.[a-z]+$/, ''); + if (!IMAGE_REF_RE.test(ref)) continue; + const path = join(dir, name); + try { + const info = await stat(path); + if (info.mtimeMs < olderThan) { + await unlink(path); + removed++; + } + } catch { + // already gone + } + } + return removed; + }, + }; +} diff --git a/packages/core/src/infra/notifications/index.ts b/packages/core/src/infra/notifications/index.ts new file mode 100644 index 0000000..7edb51e --- /dev/null +++ b/packages/core/src/infra/notifications/index.ts @@ -0,0 +1,155 @@ +/** @module infra/notifications — the platform adapters of the notification channels (spec 03 §9.5, D-40): the renderers (shared with the preview), the transports as registry factories, the Telegram setup calls, the screenshot store and the `publicUrl` probe. The only code that calls a platform. */ + +import type { + ChannelRenderer, + NotificationChannel, + NotificationImageReader, +} from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { createDiscordChannel, discordRenderer } from './discord.ts'; +import type { FetchFn } from './http.ts'; +import { createNtfyChannel, ntfyRenderer } from './ntfy.ts'; +import { createTelegramChannel, telegramRenderer } from './telegram.ts'; +import { createWebhookChannel, webhookRenderer } from './webhook.ts'; + +export { + createDiscordChannel, + DISCORD_BOT_CAPABILITIES, + DISCORD_LIMITS, + DISCORD_WEBHOOK_CAPABILITIES, + type DiscordChannelDeps, + discordRenderer, +} from './discord.ts'; +export { callPlatform, classifyFailure, type FetchFn, retryAfterMs, scrubDetail } from './http.ts'; +export { + createNotificationImageStore, + IMAGE_REF_RE, + type ImageStoreOptions, +} from './image-store.ts'; +export { + createNtfyChannel, + NTFY_CAPABILITIES, + type NtfyChannelDeps, + ntfyRenderer, +} from './ntfy.ts'; +export { LOCAL_LINKS_LABEL } from './render-common.ts'; +export { + createTelegramChannel, + escapeHtml, + TELEGRAM_API_BASE, + TELEGRAM_CAPABILITIES, + TELEGRAM_CAPTION_MAX, + TELEGRAM_TEXT_MAX, + type TelegramChannelDeps, + telegramRenderer, +} from './telegram.ts'; +export { createTelegramSetup, type TelegramSetupOptions } from './telegram-setup.ts'; +export { createUrlProbe, type UrlProbeOptions } from './url-probe.ts'; +export { + createWebhookChannel, + SIGNATURE_HEADER, + signBody, + TIMESTAMP_HEADER, + WEBHOOK_CAPABILITIES, + type WebhookChannelDeps, + webhookRenderer, +} from './webhook.ts'; + +/** The renderer of every platform with an adapter, by kind (the preview uses the same ones). */ +export const CHANNEL_RENDERERS: ReadonlyMap = new Map([ + ['telegram', telegramRenderer], + ['discord', discordRenderer], + ['ntfy', ntfyRenderer], + ['webhook', webhookRenderer], +]); + +/** Reads a channel secret by variable name (`ChannelFactoryContext` of the registry). */ +export interface SecretContext { + secret(envName: string): string | null; +} + +/** Builds one channel's adapter; structurally the registry's `ChannelAdapterFactory`. */ +export type PlatformAdapterFactory = ( + channel: NotificationChannelRecord, + context: SecretContext, +) => NotificationChannel; + +/** Dependencies shared by the factories. */ +export interface ChannelFactoriesDeps { + readonly images: NotificationImageReader; + readonly fetch?: FetchFn; + /** Base URLs of the platforms (the fakes pass their own). */ + readonly apiBases?: { readonly telegram?: string }; +} + +/** + * Reads a secret parameter of a channel: the variable it names, or an error naming what is missing + * (the registry records the adapter as absent and the API shows the message as the problem). + */ +function secretOf( + channel: NotificationChannelRecord, + context: SecretContext, + param: string, + required: boolean, +): string | null { + const name = channel.secretRefs[param]; + if (name === undefined) { + if (required) throw new Error(`channel '${channel.name}' names no variable for ${param}`); + return null; + } + const value = context.secret(name); + if (value === null && required) throw new Error(`${name} is not set`); + if (value === null) throw new Error(`${name} is not set`); + return value; +} + +/** + * The adapter factories per kind, for `ChannelRegistry({ factories })`. + * + * @returns telegram, discord, ntfy and webhook factories. + */ +export function channelFactories( + deps: ChannelFactoriesDeps, +): ReadonlyMap { + const fetchFn = deps.fetch; + return new Map([ + [ + 'telegram', + (channel, context) => + createTelegramChannel(channel, { + token: secretOf(channel, context, 'token', true) ?? '', + images: deps.images, + ...(fetchFn !== undefined && { fetch: fetchFn }), + ...(deps.apiBases?.telegram !== undefined && { apiBase: deps.apiBases.telegram }), + }), + ], + [ + 'discord', + (channel, context) => + createDiscordChannel(channel, { + webhookUrl: secretOf(channel, context, 'webhook', true) ?? '', + images: deps.images, + ...(fetchFn !== undefined && { fetch: fetchFn }), + }), + ], + [ + 'ntfy', + (channel, context) => + createNtfyChannel(channel, { + token: secretOf(channel, context, 'token', false), + topic: secretOf(channel, context, 'topic', false), + images: deps.images, + ...(fetchFn !== undefined && { fetch: fetchFn }), + }), + ], + [ + 'webhook', + (channel, context) => + createWebhookChannel(channel, { + url: secretOf(channel, context, 'url', false), + secret: secretOf(channel, context, 'secret', false), + ...(fetchFn !== undefined && { fetch: fetchFn }), + }), + ], + ]); +} diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts new file mode 100644 index 0000000..cb49435 --- /dev/null +++ b/packages/core/src/infra/notifications/ntfy.ts @@ -0,0 +1,353 @@ +/** @module infra/notifications/ntfy — the ntfy adapter (spec 03 §9.5): a pure renderer to a JSON publish (or a `PUT` upload when a screenshot is attached) with priority, tags, click and `view` actions, and the transport (send, replace by sequence id, delete). */ + +import type { Block, NotificationMessage } from '@browserhive/contracts/notifications'; +import { NTFY_DEFAULT_SERVER } from '@browserhive/contracts/notifications'; +import { + type ChannelCapabilities, + type ChannelDelivery, + type ChannelRenderer, + ChannelSendError, + type ChannelSendResult, + type NotificationChannel, + type NotificationImageReader, + type PlatformMessageRef, + type RenderContext, + type RenderedRequest, +} from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { callPlatform, type FetchFn, type PlatformAnswer, substituteSecrets } from './http.ts'; +import { + bodyBlocks, + clipText, + firstImage, + LOCAL_LINKS_LABEL, + openLinks, + plainRun, + SCREENSHOT_FILENAME, +} from './render-common.ts'; + +/** ntfy turns a message longer than 4096 bytes into an attachment; stay well below. */ +export const NTFY_MESSAGE_MAX_BYTES = 4000; +/** ntfy allows three action buttons. */ +export const NTFY_ACTIONS_MAX = 3; + +/** What the ntfy renderer supports: plain text, a screenshot, three `view` actions, replace and delete. */ +export const NTFY_CAPABILITIES: ChannelCapabilities = { + richBlocks: false, + tables: false, + images: true, + actButtons: false, + openLinks: true, + edit: true, + delete: true, + replies: false, + deleteWindowMs: null, + maxTitleChars: 250, + maxTextChars: 3500, + maxButtons: NTFY_ACTIONS_MAX, +}; + +/** ntfy priority: info 3, warn and error 4, critical 5; silent revisions 2 (no sound). */ +export function ntfyPriority(message: Pick): number { + if (!message.alert) return 2; + switch (message.severity) { + case 'info': + return 3; + case 'warn': + case 'error': + return 4; + case 'critical': + return 5; + } +} + +/** ntfy tags (emoji short codes): the outcome once settled, else the severity. */ +export function ntfyTags(message: Pick): string[] { + if (message.state === 'resolved') return ['white_check_mark']; + if (message.state === 'expired') return ['hourglass']; + switch (message.severity) { + case 'info': + return ['information_source']; + case 'warn': + return ['warning']; + case 'error': + return ['rotating_light']; + case 'critical': + return ['sos']; + } +} + +function blockText(b: Block): string { + switch (b.type) { + case 'text': + case 'footer': + return plainRun(b.content); + case 'heading': + return b.text; + case 'fields': + return b.items.map((i) => `${i.label}: ${plainRun(i.value)}`).join('\n'); + case 'quote': + return `“${plainRun(b.content)}”`; + case 'list': + return b.items + .map((item, n) => `${b.ordered ? `${n + 1}.` : '•'} ${plainRun(item)}`) + .join('\n'); + case 'code': + return b.text; + case 'table': + case 'image': + case 'divider': + return ''; + } +} + +/** + * Cuts `text` to at most `maxBytes` of UTF-8, on a character boundary, with an ellipsis. + * + * @returns The clipped text. + */ +export function clipBytes(text: string, maxBytes: number): string { + const encoder = new TextEncoder(); + if (encoder.encode(text).length <= maxBytes) return text; + let out = ''; + let used = 0; + const budget = maxBytes - 3; + for (const ch of text) { + const size = encoder.encode(ch).length; + if (used + size > budget) break; + out += ch; + used += size; + } + return `${out}…`; +} + +/** The plain-text body of a message: summary, then the blocks. */ +export function ntfyText(message: NotificationMessage): string { + const parts: string[] = []; + if (message.summary.trim() !== '' && message.summary !== message.title) + parts.push(message.summary); + for (const b of bodyBlocks(message)) { + const text = blockText(b); + if (text !== '') parts.push(text); + } + return clipBytes(parts.join('\n\n'), NTFY_MESSAGE_MAX_BYTES); +} + +interface ViewAction { + action: 'view'; + label: string; + url: string; + clear: boolean; +} + +/** + * The topic of a channel as rendered: the literal topic, or `{secret:topic}` when it lives in a + * variable (the transport substitutes it; the preview shows the variable's name). + */ +function topicOf(target: Readonly>): string { + const literal = target['topic']; + return literal !== undefined && literal !== '' ? literal : '{secret:topic}'; +} + +/** + * The ntfy renderer. Without a screenshot: `POST /` JSON `{topic, title, message, priority, tags, + * click, actions, markdown: false, sequence_id}`. With one: `PUT //` with the + * JPEG as the body and the fields as query parameters (`actions` as JSON). The sequence id is the + * notification id, so a revision replaces the phone's notification. + */ +export const ntfyRenderer: ChannelRenderer = { + kind: 'ntfy', + capabilities: () => NTFY_CAPABILITIES, + render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] { + const { message, links } = delivery; + const topic = topicOf(context.target); + const sequence = + context.op === 'edit' && context.ref !== null && context.ref['sequence_id'] !== undefined + ? String(context.ref['sequence_id']) + : message.id; + const resolved = openLinks(message, links).slice(0, NTFY_ACTIONS_MAX); + const actions: ViewAction[] = resolved.map((l, i) => ({ + action: 'view', + label: links.local && i === 0 ? LOCAL_LINKS_LABEL : clipText(l.label, 40), + url: l.url, + clear: false, + })); + const fields = { + title: clipText(message.title, NTFY_CAPABILITIES.maxTitleChars), + message: ntfyText(message), + priority: ntfyPriority(message), + tags: ntfyTags(message), + ...(resolved[0] !== undefined && { click: resolved[0].url }), + ...(actions.length > 0 && { actions }), + }; + const image = firstImage(message); + if (image !== null) { + return [ + { + method: 'PUT', + path: `/${topic}/${sequence}`, + encoding: 'binary', + body: { ...fields, filename: SCREENSHOT_FILENAME }, + headers: {}, + file: { ref: image.ref, name: SCREENSHOT_FILENAME, content_type: 'image/jpeg' }, + }, + ]; + } + return [ + { + method: 'POST', + path: '/', + encoding: 'json', + body: { topic, ...fields, markdown: false, sequence_id: sequence }, + headers: {}, + file: null, + }, + ]; + }, +}; + +/** + * The query string of a binary (upload) publish: every field as text, lists comma-joined, the + * actions as JSON. + * + * @returns `?title=…&message=…`. + */ +export function ntfyQuery(fields: Readonly>): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null) continue; + if (key === 'actions') params.set(key, JSON.stringify(value)); + else if (Array.isArray(value)) params.set(key, value.join(',')); + else params.set(key, String(value)); + } + const text = params.toString(); + return text === '' ? '' : `?${text}`; +} + +/** What the ntfy transport needs besides the channel row. */ +export interface NtfyChannelDeps { + /** Access token, when the server or topic is protected. */ + readonly token: string | null; + /** The topic from a variable (when `target.topic` is empty). */ + readonly topic: string | null; + readonly images: NotificationImageReader; + readonly fetch?: FetchFn; +} + +function refOf(answer: PlatformAnswer, sequence: string): PlatformMessageRef { + const json = answer.json as Record | null; + const id = json?.['id']; + return { + ...(typeof id === 'string' && { id }), + sequence_id: typeof json?.['sequence_id'] === 'string' ? json['sequence_id'] : sequence, + }; +} + +/** + * An ntfy channel. The ref stores the sequence id (never the topic, which may be a secret); the + * transport knows the topic. + * + * @returns The adapter. + */ +export function createNtfyChannel( + record: NotificationChannelRecord, + deps: NtfyChannelDeps, +): NotificationChannel { + const server = (record.target['server'] ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, ''); + const literal = record.target['topic']; + const topic = literal !== undefined && literal !== '' ? literal : deps.topic; + if (topic === null || topic === '') throw new Error(`channel '${record.name}' has no ntfy topic`); + const resolvedTopic: string = topic; + const secrets = [ + ...(deps.token === null ? [] : [deps.token]), + ...(deps.topic === null ? [] : [deps.topic]), + ]; + const options = { fetch: deps.fetch ?? fetch, secrets, platform: 'ntfy' }; + const auth: Record = + deps.token === null ? {} : { authorization: `Bearer ${deps.token}` }; + const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({ + mode: null, + target: record.target, + op, + ref, + actToken: () => { + throw new ChannelSendError('rejected', 'act buttons are not available on ntfy yet'); + }, + }); + + async function publish(request: RenderedRequest): Promise { + const path = substituteSecrets(request.path, { topic: resolvedTopic }); + if (request.encoding === 'binary' && request.file !== null) { + const image = await deps.images.read(request.file.ref); + if (image !== null) { + return callPlatform( + { + url: `${server}${path}${ntfyQuery(request.body)}`, + method: 'PUT', + headers: { ...auth, 'content-type': image.contentType }, + body: new Blob([new Uint8Array(image.bytes)], { type: image.contentType }), + }, + options, + ); + } + // The screenshot is gone (pruned): publish the text alone, keeping the sequence id. + const [, , sequence = ''] = path.split('/'); + const { filename: _dropped, ...fields } = request.body; + return callPlatform( + { + url: `${server}/`, + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ + topic: resolvedTopic, + ...fields, + markdown: false, + sequence_id: sequence, + }), + }, + options, + ); + } + const body = { ...request.body, topic: resolvedTopic }; + return callPlatform( + { + url: `${server}${path}`, + method: request.method, + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + options, + ); + } + + return { + id: record.channelId, + name: record.name, + kind: 'ntfy', + capabilities: NTFY_CAPABILITIES, + async send(delivery: ChannelDelivery): Promise { + const [request] = ntfyRenderer.render(delivery, context('send', null)); + if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send'); + return { ref: refOf(await publish(request), delivery.message.id) }; + }, + async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { + const [request] = ntfyRenderer.render(delivery, context('edit', ref)); + if (request === undefined) return { ref }; + const sequence = String(ref['sequence_id'] ?? delivery.message.id); + return { ref: refOf(await publish(request), sequence) }; + }, + async delete(ref: PlatformMessageRef): Promise { + const sequence = String(ref['sequence_id'] ?? ''); + if (sequence === '') throw new ChannelSendError('message_gone', 'ntfy: no sequence id'); + await callPlatform( + { + url: `${server}/${encodeURIComponent(resolvedTopic)}/${encodeURIComponent(sequence)}`, + method: 'DELETE', + headers: auth, + addressesMessage: true, + }, + options, + ); + }, + }; +} diff --git a/packages/core/src/infra/notifications/render-common.ts b/packages/core/src/infra/notifications/render-common.ts new file mode 100644 index 0000000..5a1b12f --- /dev/null +++ b/packages/core/src/infra/notifications/render-common.ts @@ -0,0 +1,102 @@ +/** @module infra/notifications/render-common — pure helpers the platform renderers share (spec 03 §9.5): severity marks, UTC time text, absolute links, the screenshot block and the quote that only repeats the summary. */ + +import type { Block, Inline, NotificationMessage } from '@browserhive/contracts/notifications'; +import type { LinkBuilder } from '../../ports/notification-channel.ts'; + +/** Label that introduces links which only open on the computer running BrowserHive (D-37). */ +export const LOCAL_LINKS_LABEL = 'Open on this computer'; +/** Wire name of an attached screenshot. */ +export const SCREENSHOT_FILENAME = 'screenshot.jpg'; + +/** The leading mark of a message: its outcome once settled, else its severity. */ +export function severityMark(message: Pick): string { + if (message.state === 'resolved') return '✅'; + if (message.state === 'expired') return '⌛'; + if (message.state === 'acted') return '👤'; + switch (message.severity) { + case 'info': + return 'ℹ️'; + case 'warn': + return '⚠️'; + case 'error': + return '🔴'; + case 'critical': + return '🚨'; + } +} + +/** + * `HH:MM UTC` of an instant, or `YYYY-MM-DD HH:MM UTC` when `withDate` (the fallback text when a + * platform cannot localise a time). + * + * @returns The text. + */ +export function utcTime(at: number, withDate = false): string { + const iso = new Date(at).toISOString(); + const hm = iso.slice(11, 16); + return withDate ? `${iso.slice(0, 10)} ${hm} UTC` : `${hm} UTC`; +} + +/** One open link, absolute. */ +export interface ResolvedLink { + readonly id: string; + readonly label: string; + readonly url: string; + readonly style: 'primary' | 'danger' | 'default'; +} + +/** The open actions of a (degraded) message as absolute links. */ +export function openLinks(message: NotificationMessage, links: LinkBuilder): ResolvedLink[] { + const out: ResolvedLink[] = []; + for (const action of message.actions) { + if (action.kind !== 'open') continue; + out.push({ + id: action.id, + label: action.label, + url: links.url(action.path), + style: action.style, + }); + } + return out; +} + +/** The first screenshot block, if any. */ +export function firstImage(message: NotificationMessage): Extract | null { + for (const block of message.blocks) if (block.type === 'image') return block; + return null; +} + +/** Plain text of an inline run (times as UTC text, links as their label). */ +export function plainRun(run: readonly Inline[]): string { + return run.map((node) => (node.type === 'time' ? utcTime(node.at) : node.text)).join(''); +} + +/** + * The blocks worth rendering: screenshots are carried separately, and a collapsible quote whose + * text the summary already starts with is dropped (attention requests quote their reason, which + * is also the start of the summary). + * + * @returns The blocks, in order. + */ +export function bodyBlocks(message: NotificationMessage): Block[] { + const summary = message.summary.trim(); + return message.blocks.filter((block) => { + if (block.type === 'image') return false; + if (block.type === 'quote' && block.collapsible) { + const quoted = plainRun(block.content).trim(); + return quoted === '' || !summary.startsWith(quoted); + } + return true; + }); +} + +/** + * Clips `text` to `max` characters with a trailing ellipsis. + * + * @returns The clipped text. + */ +export function clipText(text: string, max: number): string { + if (text.length <= max) return text; + if (max <= 1) return text.slice(0, Math.max(0, max)); + return `${text.slice(0, max - 1)}…`; +} diff --git a/packages/core/src/infra/notifications/telegram-setup.ts b/packages/core/src/infra/notifications/telegram-setup.ts new file mode 100644 index 0000000..2eab44c --- /dev/null +++ b/packages/core/src/infra/notifications/telegram-setup.ts @@ -0,0 +1,146 @@ +/** @module infra/notifications/telegram-setup — the setup-only Telegram calls of the connect flow (spec 03 §4.8.1): the bot's username and the wait for `/start ` over `getUpdates` long polling. The persistent callback loop of act buttons is N2's. */ + +import { + ChannelSendError, + type TelegramSetup, + type TelegramStart, +} from '../../ports/notification-channel.ts'; +import { callPlatform, type FetchFn } from './http.ts'; +import { refineTelegram, TELEGRAM_API_BASE } from './telegram.ts'; + +/** Longest single `getUpdates` wait (seconds). */ +const LONG_POLL_S = 25; + +/** Options of {@link createTelegramSetup}. */ +export interface TelegramSetupOptions { + readonly fetch?: FetchFn; + readonly apiBase?: string; + /** Wall clock (epoch ms); injectable for tests. */ + readonly now?: () => number; +} + +function obj(value: unknown): Record | null { + return value !== null && typeof value === 'object' ? (value as Record) : null; +} + +function nameOf(user: Record | null): string { + if (user === null) return ''; + const first = typeof user['first_name'] === 'string' ? user['first_name'] : ''; + const last = typeof user['last_name'] === 'string' ? user['last_name'] : ''; + const full = `${first} ${last}`.trim(); + if (full !== '') return full; + return typeof user['username'] === 'string' ? `@${user['username']}` : ''; +} + +/** + * Whether a message text is `/start ` (or `/start@ `, as groups send it). + * + * @returns True for this code. + */ +export function isStartCommand(text: string, code: string): boolean { + const match = /^\/start(?:@[A-Za-z0-9_]+)?\s+(\S+)\s*$/.exec(text.trim()); + return match?.[1] === code; +} + +/** + * The Telegram setup calls over the Bot API. + * + * @returns The setup port. + */ +export function createTelegramSetup(options: TelegramSetupOptions = {}): TelegramSetup { + const base = (options.apiBase ?? TELEGRAM_API_BASE).replace(/\/+$/, ''); + const fetchFn = options.fetch ?? fetch; + const now = options.now ?? Date.now; + const call = ( + token: string, + method: string, + body: unknown, + timeoutMs = 10_000, + signal?: AbortSignal, + ) => + callPlatform( + { + url: `${base}/bot${token}/${method}`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + timeoutMs, + ...(signal !== undefined && { signal }), + }, + { fetch: fetchFn, secrets: [token], platform: 'Telegram', refine: refineTelegram }, + ); + + return { + async botUsername(token: string): Promise { + const answer = await call(token, 'getMe', {}); + const result = obj(obj(answer.json)?.['result']); + const username = result?.['username']; + if (typeof username !== 'string' || username === '') { + throw new ChannelSendError('rejected', 'Telegram getMe returned no username'); + } + return username; + }, + + async waitForStart(token, code, { signal, deadline }): Promise { + let offset: number | undefined; + while (!signal.aborted && now() < deadline) { + const wait = Math.max(0, Math.min(LONG_POLL_S, Math.floor((deadline - now()) / 1000))); + let answer: Awaited>; + try { + answer = await call( + token, + 'getUpdates', + { + ...(offset !== undefined && { offset }), + timeout: wait, + allowed_updates: ['message', 'my_chat_member'], + }, + (wait + 10) * 1000, + signal, + ); + } catch (err) { + if (signal.aborted) return null; + throw err; + } + if (signal.aborted) return null; + const updates = obj(answer.json)?.['result']; + if (!Array.isArray(updates)) continue; + for (const raw of updates) { + const update = obj(raw); + const id = update?.['update_id']; + if (typeof id === 'number') offset = id + 1; + const message = obj(update?.['message']); + const text = message?.['text']; + if (message === null || typeof text !== 'string' || !isStartCommand(text, code)) continue; + const chat = obj(message['chat']); + const from = obj(message['from']); + const chatId = chat?.['id']; + if (typeof chatId !== 'number' && typeof chatId !== 'string') continue; + const type = typeof chat?.['type'] === 'string' ? chat['type'] : 'private'; + const title = + typeof chat?.['title'] === 'string' ? chat['title'] : nameOf(chat) || String(chatId); + const thread = message['message_thread_id']; + // Acknowledge what was read, so the next connect does not see this /start again. + await call(token, 'getUpdates', { offset, timeout: 0 }).catch(() => undefined); + const userId = from?.['id']; + return { + chat: { + id: String(chatId), + title, + type, + threadId: + typeof thread === 'number' && message['is_topic_message'] === true + ? String(thread) + : null, + }, + user: + typeof userId === 'number' || typeof userId === 'string' + ? { id: String(userId), name: nameOf(from) || String(userId) } + : null, + }; + } + } + return null; + }, + }; +} diff --git a/packages/core/src/infra/notifications/telegram.ts b/packages/core/src/infra/notifications/telegram.ts new file mode 100644 index 0000000..feaaebe --- /dev/null +++ b/packages/core/src/infra/notifications/telegram.ts @@ -0,0 +1,555 @@ +/** @module infra/notifications/telegram — the Telegram Bot API adapter (spec 03 §9.5, D-40): a pure renderer to classic `sendMessage`/`sendPhoto` with `parse_mode: HTML` and an inline keyboard, plus the transport (send, edit text or caption, delete within 48 h). */ + +import type { Block, Inline, NotificationMessage } from '@browserhive/contracts/notifications'; +import { TELEGRAM_DELETE_WINDOW_MS } from '@browserhive/contracts/notifications'; +import { + type ChannelCapabilities, + type ChannelDelivery, + type ChannelRenderer, + ChannelSendError, + type ChannelSendResult, + type LinkBuilder, + type NotificationChannel, + type NotificationImageReader, + type PlatformMessageRef, + type RenderContext, + type RenderedRequest, +} from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import { + callPlatform, + type FailureRefiner, + type FetchFn, + multipart, + type PlatformAnswer, +} from './http.ts'; +import { + bodyBlocks, + clipText, + firstImage, + LOCAL_LINKS_LABEL, + openLinks, + SCREENSHOT_FILENAME, + severityMark, + utcTime, +} from './render-common.ts'; + +/** Public Bot API base. */ +export const TELEGRAM_API_BASE = 'https://api.telegram.org'; +/** Visible characters of a text message (after entity parsing). */ +export const TELEGRAM_TEXT_MAX = 4096; +/** Visible characters of a photo caption. */ +export const TELEGRAM_CAPTION_MAX = 1024; +/** Buttons per keyboard row: two keep labels like "Open in BrowserHive" readable on a phone. */ +const BUTTONS_PER_ROW = 2; + +/** + * What the Telegram renderer supports. Rich blocks render natively (bold headings and labels, + * expandable quotes, `
`); tables become lists through `degrade`. The text budget leaves
+ * room for the title line and the keyboard-less link section; a caption is clipped to 1024 by the
+ * renderer itself.
+ */
+export const TELEGRAM_CAPABILITIES: ChannelCapabilities = {
+  richBlocks: true,
+  tables: false,
+  images: true,
+  actButtons: false,
+  openLinks: true,
+  edit: true,
+  delete: true,
+  replies: true,
+  deleteWindowMs: TELEGRAM_DELETE_WINDOW_MS,
+  maxTitleChars: 120,
+  maxTextChars: 3500,
+  maxButtons: 6,
+};
+
+/** Escapes text for Telegram HTML: only `<`, `>` and `&` (spec 03 §9.5). */
+export function escapeHtml(text: string): string {
+  return text.replace(/&/g, '&').replace(//g, '>');
+}
+
+function escapeAttr(text: string): string {
+  return escapeHtml(text).replace(/"/g, '"');
+}
+
+/** A rendered fragment and its visible length. */
+interface Frag {
+  readonly html: string;
+  readonly visible: number;
+}
+
+const EMPTY: Frag = { html: '', visible: 0 };
+
+function frag(html: string, visible: number): Frag {
+  return { html, visible };
+}
+
+function concat(parts: readonly Frag[], separator = ''): Frag {
+  const kept = parts.filter((p) => p.visible > 0 || p.html !== '');
+  return {
+    html: kept.map((p) => p.html).join(separator),
+    visible:
+      kept.reduce((n, p) => n + p.visible, 0) + Math.max(0, kept.length - 1) * separator.length,
+  };
+}
+
+/** Plain text, clipped to `budget` visible characters. */
+function plain(text: string, budget: number): Frag {
+  const t = clipText(text, Math.max(0, budget));
+  return frag(escapeHtml(t), t.length);
+}
+
+function wrap(tag: string, inner: Frag, attrs = ''): Frag {
+  if (inner.visible === 0) return EMPTY;
+  return frag(`<${tag}${attrs}>${inner.html}`, inner.visible);
+}
+
+/** An inline run with a visible budget; text leaves are clipped, never tags. */
+function inlineRun(run: readonly Inline[], links: LinkBuilder, budget: number): Frag {
+  const out: Frag[] = [];
+  let left = budget;
+  for (const node of run) {
+    if (left <= 0) break;
+    let piece: Frag;
+    switch (node.type) {
+      case 'text':
+        piece = plain(node.text, left);
+        break;
+      case 'bold':
+        piece = wrap('b', plain(node.text, left));
+        break;
+      case 'italic':
+        piece = wrap('i', plain(node.text, left));
+        break;
+      case 'code':
+        piece = wrap('code', plain(node.text, left));
+        break;
+      case 'link': {
+        const label = plain(node.text, left);
+        piece = links.local
+          ? label
+          : wrap('a', label, ` href="${escapeAttr(links.url(node.path))}"`);
+        break;
+      }
+      case 'time': {
+        const fallback = utcTime(node.at);
+        if (fallback.length > left) {
+          piece = EMPTY;
+          left = 0;
+          break;
+        }
+        const format = node.style === 'relative' ? 'r' : 't';
+        piece = frag(
+          `${escapeHtml(fallback)}`,
+          fallback.length,
+        );
+        break;
+      }
+    }
+    out.push(piece);
+    left -= piece.visible;
+  }
+  return concat(out);
+}
+
+function lines(items: readonly Frag[]): Frag {
+  return concat(items, '\n');
+}
+
+/** One block with a visible budget (`null` when it has nothing to show). */
+function renderBlock(block: Block, links: LinkBuilder, budget: number): Frag {
+  switch (block.type) {
+    case 'text':
+      return inlineRun(block.content, links, budget);
+    case 'heading':
+      return wrap('b', plain(block.text, budget));
+    case 'fields': {
+      const out: Frag[] = [];
+      let left = budget;
+      for (const item of block.items) {
+        const label = `${item.label}:`;
+        if (left <= label.length + 2) break;
+        const line = concat([
+          wrap('b', plain(label, left)),
+          plain(' ', 1),
+          inlineRun(item.value, links, left - label.length - 1),
+        ]);
+        out.push(line);
+        left -= line.visible + 1;
+      }
+      return lines(out);
+    }
+    case 'quote': {
+      const inner = inlineRun(block.content, links, budget);
+      return wrap('blockquote', inner, block.collapsible ? ' expandable' : '');
+    }
+    case 'list': {
+      const out: Frag[] = [];
+      let left = budget;
+      block.items.forEach((item, i) => {
+        const bullet = block.ordered ? `${i + 1}. ` : '• ';
+        if (left <= bullet.length + 1) return;
+        const line = concat([plain(bullet, left), inlineRun(item, links, left - bullet.length)]);
+        out.push(line);
+        left -= line.visible + 1;
+      });
+      return lines(out);
+    }
+    case 'table':
+      // `degrade` turns tables into lists for this renderer (tables: false).
+      return EMPTY;
+    case 'code': {
+      const inner = plain(block.text, budget);
+      if (block.language !== null && /^[A-Za-z0-9_+-]{1,32}$/.test(block.language)) {
+        return wrap('pre', wrap('code', inner, ` class="language-${block.language}"`));
+      }
+      return wrap('pre', inner);
+    }
+    case 'footer':
+      return wrap('i', inlineRun(block.content, links, budget));
+    case 'image':
+    case 'divider':
+      return EMPTY;
+  }
+}
+
+/** The HTML text of a message within `limit` visible characters. */
+export function telegramHtml(
+  message: NotificationMessage,
+  links: LinkBuilder,
+  limit: number,
+): string {
+  const header = concat([
+    plain(`${severityMark(message)} `, 4),
+    wrap('b', plain(message.title, 120)),
+  ]);
+  const local = links.local ? localLinks(message, links) : EMPTY;
+  const reserve = local.visible > 0 ? local.visible + 2 : 0;
+  const parts: Frag[] = [];
+  let used = header.visible;
+  const room = () => limit - used - reserve;
+  const summaryText = message.summary.trim();
+  let cut = false;
+  const head: Frag[] = [header];
+  if (summaryText !== '' && summaryText !== message.title) {
+    const summary = plain(summaryText, room() - 1);
+    head.push(summary);
+    used += summary.visible + 1;
+    if (summary.visible < summaryText.length) cut = true;
+  }
+  parts.push(lines(head));
+  for (const block of bodyBlocks(message)) {
+    if (cut || room() < 24) {
+      cut = true;
+      break;
+    }
+    const rendered = renderBlock(block, links, room() - 2);
+    if (rendered.visible === 0) continue;
+    parts.push(rendered);
+    used += rendered.visible + 2;
+  }
+  if (cut && room() >= 3) parts.push(plain('…', 1));
+  if (local.visible > 0) parts.push(local);
+  return concat(parts, '\n\n').html;
+}
+
+function localLinks(message: NotificationMessage, links: LinkBuilder): Frag {
+  const resolved = openLinks(message, links);
+  if (resolved.length === 0) return EMPTY;
+  return lines([
+    wrap('b', plain(`🖥 ${LOCAL_LINKS_LABEL}`, 64)),
+    ...resolved.map((l) => concat([plain(`${l.label}: `, 64), wrap('code', plain(l.url, 2048))])),
+  ]);
+}
+
+/** The inline keyboard of a message (URL buttons; callback buttons where act buttons are on). */
+function keyboard(
+  message: NotificationMessage,
+  links: LinkBuilder,
+  capabilities: ChannelCapabilities,
+  context: RenderContext,
+): { inline_keyboard: { text: string; url?: string; callback_data?: string }[][] } {
+  const buttons: { text: string; url?: string; callback_data?: string }[] = [];
+  for (const action of message.actions) {
+    if (action.kind === 'open') {
+      if (!links.local) buttons.push({ text: action.label, url: links.url(action.path) });
+    } else if (capabilities.actButtons) {
+      buttons.push({ text: action.label, callback_data: context.actToken(action.id) });
+    }
+  }
+  const rows: { text: string; url?: string; callback_data?: string }[][] = [];
+  for (let i = 0; i < buttons.length; i += BUTTONS_PER_ROW) {
+    rows.push(buttons.slice(i, i + BUTTONS_PER_ROW));
+  }
+  return { inline_keyboard: rows };
+}
+
+function isPhotoRef(ref: PlatformMessageRef | null): boolean {
+  return ref !== null && (ref['photo'] === 1 || ref['photo'] === '1');
+}
+
+/**
+ * The Telegram renderer: one request per send or edit.
+ * - send: `sendPhoto` (multipart, caption ≤ 1024) when the message carries a screenshot, else
+ *   `sendMessage` (≤ 4096);
+ * - edit: `editMessageCaption` for a photo message, else `editMessageText`, always with the
+ *   keyboard (an empty one removes the buttons).
+ */
+export const telegramRenderer: ChannelRenderer = {
+  kind: 'telegram',
+  capabilities: () => TELEGRAM_CAPABILITIES,
+  render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] {
+    const { message, links } = delivery;
+    const chat = context.target['chat_id'] ?? '';
+    const thread = context.target['thread_id'];
+    const markup = keyboard(message, links, TELEGRAM_CAPABILITIES, context);
+    if (context.op === 'edit' && context.ref !== null) {
+      const photo = isPhotoRef(context.ref);
+      const html = telegramHtml(message, links, photo ? TELEGRAM_CAPTION_MAX : TELEGRAM_TEXT_MAX);
+      const target = {
+        chat_id: context.ref['chat_id'] ?? chat,
+        message_id: context.ref['message_id'],
+      };
+      return [
+        {
+          method: 'POST',
+          path: photo ? 'editMessageCaption' : 'editMessageText',
+          encoding: 'json',
+          body: photo
+            ? { ...target, caption: html, parse_mode: 'HTML', reply_markup: markup }
+            : {
+                ...target,
+                text: html,
+                parse_mode: 'HTML',
+                link_preview_options: { is_disabled: true },
+                reply_markup: markup,
+              },
+          headers: {},
+          file: null,
+        },
+      ];
+    }
+    const image = firstImage(message);
+    const common: Record = {
+      chat_id: chat,
+      ...(thread !== undefined && thread !== '' && { message_thread_id: Number(thread) }),
+      parse_mode: 'HTML',
+      disable_notification: !message.alert,
+      ...(markup.inline_keyboard.length > 0 && { reply_markup: markup }),
+      ...(delivery.replyTo !== null &&
+        delivery.replyTo['message_id'] !== undefined && {
+          reply_parameters: {
+            message_id: Number(delivery.replyTo['message_id']),
+            allow_sending_without_reply: true,
+          },
+        }),
+    };
+    if (image !== null) {
+      return [
+        {
+          method: 'POST',
+          path: 'sendPhoto',
+          encoding: 'multipart',
+          body: { ...common, caption: telegramHtml(message, links, TELEGRAM_CAPTION_MAX) },
+          headers: {},
+          file: { ref: image.ref, name: SCREENSHOT_FILENAME, content_type: 'image/jpeg' },
+        },
+      ];
+    }
+    return [
+      {
+        method: 'POST',
+        path: 'sendMessage',
+        encoding: 'json',
+        body: {
+          ...common,
+          text: telegramHtml(message, links, TELEGRAM_TEXT_MAX),
+          link_preview_options: { is_disabled: true },
+        },
+        headers: {},
+        file: null,
+      },
+    ];
+  },
+};
+
+/**
+ * Telegram's 400 descriptions: a vanished message, one too old to delete, an unchanged edit
+ * (harmless), a bot removed from the chat (auth), and a bot with a webhook set (rejected with a
+ * sentence the operator can act on).
+ */
+export const refineTelegram: FailureRefiner = (answer) => {
+  const d = answer.detail.toLowerCase();
+  if (d.includes('message is not modified')) return 'ok';
+  if (
+    d.includes('message to edit not found') ||
+    d.includes('message to delete not found') ||
+    d.includes('message_id_invalid')
+  ) {
+    return new ChannelSendError('message_gone', `Telegram: ${answer.detail}`);
+  }
+  if (d.includes("message can't be deleted") || d.includes('message can not be deleted')) {
+    return new ChannelSendError('too_old', `Telegram: ${answer.detail}`);
+  }
+  if (
+    d.includes('bot was blocked') ||
+    d.includes('bot was kicked') ||
+    d.includes('not enough rights')
+  ) {
+    return new ChannelSendError('auth', `Telegram: ${answer.detail}`);
+  }
+  if (answer.status === 409 && d.includes('webhook')) {
+    return new ChannelSendError(
+      'rejected',
+      'Telegram: this bot has a webhook set, so it cannot be polled; remove it with deleteWebhook',
+    );
+  }
+  return null;
+};
+
+/** What the Telegram transport needs besides the channel row. */
+export interface TelegramChannelDeps {
+  /** The bot token (resolved from the channel's `token` variable). */
+  readonly token: string;
+  readonly images: NotificationImageReader;
+  readonly fetch?: FetchFn;
+  /** Bot API base; the fakes pass their own. */
+  readonly apiBase?: string;
+}
+
+function messageOf(answer: PlatformAnswer): Record | null {
+  const result =
+    answer.json !== null && typeof answer.json === 'object'
+      ? Reflect.get(answer.json, 'result')
+      : null;
+  return result !== null && typeof result === 'object' ? (result as Record) : null;
+}
+
+/**
+ * A Telegram channel: sends, edits (text or caption) and deletes through the Bot API. A missing
+ * screenshot (pruned) degrades to a text message instead of failing.
+ *
+ * @returns The adapter.
+ */
+export function createTelegramChannel(
+  record: NotificationChannelRecord,
+  deps: TelegramChannelDeps,
+): NotificationChannel {
+  const base = (deps.apiBase ?? TELEGRAM_API_BASE).replace(/\/+$/, '');
+  const fetchFn = deps.fetch ?? fetch;
+  const options = {
+    fetch: fetchFn,
+    secrets: [deps.token],
+    platform: 'Telegram',
+    refine: refineTelegram,
+  };
+  const url = (method: string) => `${base}/bot${deps.token}/${method}`;
+  const context = (op: 'send' | 'edit', ref: PlatformMessageRef | null): RenderContext => ({
+    mode: record.mode,
+    target: record.target,
+    op,
+    ref,
+    actToken: () => {
+      throw new ChannelSendError('rejected', 'act buttons are not available on Telegram yet');
+    },
+  });
+
+  async function perform(
+    request: RenderedRequest,
+    addressesMessage: boolean,
+  ): Promise {
+    if (request.file !== null) {
+      const image = await deps.images.read(request.file.ref);
+      if (image !== null) {
+        return callPlatform(
+          {
+            url: url(request.path),
+            method: request.method,
+            body: multipart(request.body, {
+              field: 'photo',
+              bytes: image.bytes,
+              name: request.file.name,
+              type: image.contentType,
+            }),
+            addressesMessage,
+          },
+          options,
+        );
+      }
+      const { caption, ...rest } = request.body;
+      return callPlatform(
+        {
+          url: url('sendMessage'),
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({
+            ...rest,
+            text: caption,
+            link_preview_options: { is_disabled: true },
+          }),
+          addressesMessage,
+        },
+        options,
+      );
+    }
+    return callPlatform(
+      {
+        url: url(request.path),
+        method: request.method,
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify(request.body),
+        addressesMessage,
+      },
+      options,
+    );
+  }
+
+  return {
+    id: record.channelId,
+    name: record.name,
+    kind: 'telegram',
+    capabilities: TELEGRAM_CAPABILITIES,
+    async send(delivery: ChannelDelivery): Promise {
+      const [request] = telegramRenderer.render(delivery, context('send', null));
+      if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send');
+      const answer = await perform(request, false);
+      const sent = messageOf(answer);
+      const chat = sent !== null ? Reflect.get(sent, 'chat') : null;
+      const chatId =
+        chat !== null && typeof chat === 'object' ? Reflect.get(chat, 'id') : undefined;
+      const messageId = sent?.['message_id'];
+      if (typeof messageId !== 'number') {
+        throw new ChannelSendError('rejected', 'Telegram answered without a message id');
+      }
+      return {
+        ref: {
+          chat_id:
+            typeof chatId === 'number' || typeof chatId === 'string'
+              ? chatId
+              : String(record.target['chat_id'] ?? ''),
+          message_id: messageId,
+          photo: Array.isArray(sent?.['photo']) ? 1 : 0,
+        },
+      };
+    },
+    async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise {
+      const [request] = telegramRenderer.render(delivery, context('edit', ref));
+      if (request === undefined) return { ref };
+      await perform(request, true);
+      return { ref };
+    },
+    async delete(ref: PlatformMessageRef): Promise {
+      await callPlatform(
+        {
+          url: url('deleteMessage'),
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ chat_id: ref['chat_id'], message_id: ref['message_id'] }),
+          addressesMessage: true,
+        },
+        options,
+      );
+    },
+  };
+}
diff --git a/packages/core/src/infra/notifications/url-probe.ts b/packages/core/src/infra/notifications/url-probe.ts
new file mode 100644
index 0000000..566cb0a
--- /dev/null
+++ b/packages/core/src/infra/notifications/url-probe.ts
@@ -0,0 +1,69 @@
+/** @module infra/notifications/url-probe — one GET without following redirects, for the `publicUrl` check (spec 08 §5.8). */
+
+import type { UrlProbe, UrlProbeResult } from '../../ports/notification-channel.ts';
+import type { FetchFn } from './http.ts';
+
+/** Most body bytes kept. */
+const BODY_MAX = 64 * 1024;
+
+/** Options of {@link createUrlProbe}. */
+export interface UrlProbeOptions {
+  readonly fetch?: FetchFn;
+}
+
+async function readCapped(response: Response): Promise {
+  const reader = response.body?.getReader();
+  if (reader === undefined) return '';
+  const chunks: Uint8Array[] = [];
+  let size = 0;
+  while (size < BODY_MAX) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    chunks.push(value);
+    size += value.length;
+  }
+  await reader.cancel().catch(() => undefined);
+  const all = new Uint8Array(Math.min(size, BODY_MAX));
+  let at = 0;
+  for (const chunk of chunks) {
+    const part = chunk.subarray(0, Math.max(0, all.length - at));
+    all.set(part, at);
+    at += part.length;
+  }
+  return new TextDecoder().decode(all);
+}
+
+/**
+ * The URL probe: GET with `redirect: 'manual'`, a timeout, and at most 64 KiB of body.
+ *
+ * @returns The probe.
+ */
+export function createUrlProbe(options: UrlProbeOptions = {}): UrlProbe {
+  const fetchFn = options.fetch ?? fetch;
+  return async (url: string, timeoutMs: number): Promise => {
+    try {
+      const response = await fetchFn(url, {
+        method: 'GET',
+        redirect: 'manual',
+        headers: { accept: 'application/json', 'user-agent': 'BrowserHive publicUrl check' },
+        signal: AbortSignal.timeout(timeoutMs),
+      });
+      return {
+        kind: 'response',
+        status: response.status,
+        contentType: response.headers.get('content-type'),
+        location: response.headers.get('location'),
+        body: await readCapped(response),
+      };
+    } catch (err) {
+      const name = err instanceof Error ? err.name : '';
+      const detail =
+        name === 'TimeoutError' || name === 'AbortError'
+          ? `no answer within ${Math.round(timeoutMs / 1000)} s`
+          : err instanceof Error
+            ? err.message.replace(/https?:\/\/\S+/g, '')
+            : String(err);
+      return { kind: 'error', detail };
+    }
+  };
+}
diff --git a/packages/core/src/infra/notifications/webhook.ts b/packages/core/src/infra/notifications/webhook.ts
new file mode 100644
index 0000000..1c7a93a
--- /dev/null
+++ b/packages/core/src/infra/notifications/webhook.ts
@@ -0,0 +1,179 @@
+/** @module infra/notifications/webhook — the generic webhook adapter (spec 03 §9.5, D-32): posts the `NotificationMessage` contract itself in a small envelope, signed with HMAC-SHA256 when a secret is set; operator-supplied URLs get no scheme or host changing redirects. */
+
+import { createHmac } from 'node:crypto';
+import { NOTIFICATION_SCHEMA_VERSION } from '@browserhive/contracts/notifications';
+import {
+  type ChannelCapabilities,
+  type ChannelDelivery,
+  type ChannelRenderer,
+  ChannelSendError,
+  type ChannelSendResult,
+  type NotificationChannel,
+  type PlatformMessageRef,
+  type RenderContext,
+  type RenderedRequest,
+} from '../../ports/notification-channel.ts';
+import type { NotificationChannelRecord } from '../../ports/persistence/records.ts';
+import { callPlatform, type FetchFn } from './http.ts';
+
+/** Header carrying `sha256=`. */
+export const SIGNATURE_HEADER = 'X-BrowserHive-Signature';
+/** Header carrying the send time (epoch ms), so a receiver can refuse replays. */
+export const TIMESTAMP_HEADER = 'X-BrowserHive-Timestamp';
+
+/** What the generic webhook supports: the whole contract, no images, no delete. */
+export const WEBHOOK_CAPABILITIES: ChannelCapabilities = {
+  richBlocks: true,
+  tables: true,
+  images: false,
+  actButtons: false,
+  openLinks: true,
+  edit: true,
+  delete: false,
+  replies: false,
+  deleteWindowMs: null,
+  maxTitleChars: 120,
+  maxTextChars: 100_000,
+  maxButtons: 5,
+};
+
+/** The URL of the channel as rendered: the literal URL, or `{secret:url}` from a variable. */
+function urlOf(target: Readonly>): string {
+  const literal = target['url'];
+  return literal !== undefined && literal !== '' ? literal : '{secret:url}';
+}
+
+/**
+ * The webhook renderer: `POST ` with `{schema, event: 'notification', op, delivered_at,
+ * channel, links, local_links, message}`. `channel` and `delivered_at` are filled by the transport
+ * at send time (the renderer is pure, so the preview shows `null` and the message's update time).
+ */
+export const webhookRenderer: ChannelRenderer = {
+  kind: 'webhook',
+  capabilities: () => WEBHOOK_CAPABILITIES,
+  render(delivery: ChannelDelivery, context: RenderContext): readonly RenderedRequest[] {
+    const { message, links } = delivery;
+    const linkMap: Record = {};
+    for (const action of message.actions) {
+      if (action.kind === 'open') linkMap[action.id] = links.url(action.path);
+      else linkMap[action.id] = links.url(action.fallback.path);
+    }
+    return [
+      {
+        method: 'POST',
+        path: urlOf(context.target),
+        encoding: 'json',
+        body: {
+          schema: NOTIFICATION_SCHEMA_VERSION,
+          event: 'notification',
+          op: context.op,
+          delivered_at: message.at.updated,
+          channel: null,
+          links: linkMap,
+          local_links: links.local,
+          message,
+        },
+        headers: {},
+        file: null,
+      },
+    ];
+  },
+};
+
+/**
+ * The signature of a raw body: `sha256=`.
+ *
+ * @returns The header value.
+ */
+export function signBody(secret: string, body: string): string {
+  return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
+}
+
+/** What the webhook transport needs besides the channel row. */
+export interface WebhookChannelDeps {
+  /** The URL from a variable (when `target.url` is empty). */
+  readonly url: string | null;
+  /** The HMAC key, or `null` for unsigned posts. */
+  readonly secret: string | null;
+  readonly fetch?: FetchFn;
+  /** Send time (epoch ms); defaults to the wall clock. */
+  readonly now?: () => number;
+}
+
+/**
+ * A generic webhook channel. Only `http:`/`https:` URLs are accepted; a redirect is followed only
+ * when it keeps the scheme and host. Private addresses are allowed (the caller warns).
+ *
+ * @returns The adapter.
+ */
+export function createWebhookChannel(
+  record: NotificationChannelRecord,
+  deps: WebhookChannelDeps,
+): NotificationChannel {
+  const literal = record.target['url'];
+  const target = literal !== undefined && literal !== '' ? literal : deps.url;
+  if (target === null || target === '') throw new Error(`channel '${record.name}' has no URL`);
+  let parsed: URL;
+  try {
+    parsed = new URL(target);
+  } catch {
+    throw new Error(`channel '${record.name}' has an invalid URL`);
+  }
+  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+    throw new Error(`channel '${record.name}': only http and https URLs are allowed`);
+  }
+  const secrets = [
+    ...(deps.url === null ? [] : [deps.url]),
+    ...(deps.secret === null ? [] : [deps.secret]),
+  ];
+  const options = { fetch: deps.fetch ?? fetch, secrets, platform: 'Webhook' };
+  const now = deps.now ?? Date.now;
+
+  async function post(
+    delivery: ChannelDelivery,
+    op: 'send' | 'edit',
+    ref: PlatformMessageRef | null,
+  ) {
+    const context: RenderContext = {
+      mode: null,
+      target: record.target,
+      op,
+      ref,
+      actToken: () => {
+        throw new ChannelSendError('rejected', 'webhooks carry no act buttons');
+      },
+    };
+    const [request] = webhookRenderer.render(delivery, context);
+    if (request === undefined) throw new ChannelSendError('rejected', 'nothing to send');
+    const sentAt = now();
+    const body = JSON.stringify({
+      ...request.body,
+      delivered_at: sentAt,
+      channel: { id: record.channelId, name: record.name },
+    });
+    const headers: Record = { 'content-type': 'application/json' };
+    if (deps.secret !== null) {
+      headers[TIMESTAMP_HEADER] = String(sentAt);
+      headers[SIGNATURE_HEADER] = signBody(deps.secret, body);
+    }
+    await callPlatform(
+      { url: parsed.toString(), method: 'POST', headers, body, redirects: 'same-origin' },
+      options,
+    );
+  }
+
+  return {
+    id: record.channelId,
+    name: record.name,
+    kind: 'webhook',
+    capabilities: WEBHOOK_CAPABILITIES,
+    async send(delivery: ChannelDelivery): Promise {
+      await post(delivery, 'send', null);
+      return { ref: { notification_id: delivery.message.id, revision: delivery.message.revision } };
+    },
+    async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise {
+      await post(delivery, 'edit', ref);
+      return { ref: { notification_id: delivery.message.id, revision: delivery.message.revision } };
+    },
+  };
+}

From bf518fb7d1e83046d4a77390f1e6ee33d7a51f43 Mon Sep 17 00:00:00 2001
From: Amir Ghorbani 
Date: Mon, 28 Sep 2026 20:38:06 -0400
Subject: [PATCH 07/22] feat(notifications): channel service, routes, publicUrl
 check and channels CLI

The channels API (views without secret values, CRUD with read-only
startup channels, pause and resume, the test send, the pure preview,
the delivery log, the environment check and the Telegram connect
flow), the channels WS events, GET /system/public-url with the health
instance id, publicUrl host and origin trust, the --notificationChannel
flag on serve and doctor, browserhive channels list|test|preview, and
the doctor publicUrl and channel checks.
---
 .../src/cli/commands/admin-remote.ts          |   13 +-
 .../browserhive/src/cli/commands/channels.ts  |  192 +++
 .../src/cli/commands/doctor-checks.ts         |  105 ++
 .../browserhive/src/cli/commands/doctor.ts    |    4 +
 .../browserhive/src/cli/commands/serve.ts     |   11 +-
 packages/browserhive/src/cli/deps.ts          |   24 +
 packages/browserhive/src/cli/invocation.ts    |   33 +-
 packages/browserhive/src/cli/plan.ts          |   55 +-
 .../browserhive/src/cli/production/probes.ts  |   28 +
 .../browserhive/src/cli/production/storage.ts |   14 +
 packages/browserhive/src/cli/registry.ts      |   47 +-
 packages/browserhive/src/cli/run.ts           |   15 +-
 packages/browserhive/src/composition/types.ts |    5 +
 packages/browserhive/src/index.ts             |   25 +
 packages/browserhive/test/cli/helpers.ts      |   10 +
 packages/core/src/app/events/catalog.ts       |    7 +
 .../src/app/notifications/channel-service.ts  | 1052 +++++++++++++++++
 .../core/src/app/notifications/public-url.ts  |  232 ++++
 packages/core/src/interface/http/app.ts       |   10 +-
 packages/core/src/interface/http/env.ts       |    5 +
 packages/core/src/interface/http/health.ts    |    3 +-
 .../interface/http/middleware/origin-guard.ts |    8 +-
 .../src/interface/http/routes/channels.ts     |  208 ++++
 .../core/src/interface/http/routes/index.ts   |    2 +
 .../core/src/interface/http/routes/system.ts  |   12 +
 packages/core/src/interface/http/services.ts  |   30 +
 packages/core/src/public/runtime.ts           |    6 +
 27 files changed, 2138 insertions(+), 18 deletions(-)
 create mode 100644 packages/browserhive/src/cli/commands/channels.ts
 create mode 100644 packages/core/src/app/notifications/channel-service.ts
 create mode 100644 packages/core/src/app/notifications/public-url.ts
 create mode 100644 packages/core/src/interface/http/routes/channels.ts

diff --git a/packages/browserhive/src/cli/commands/admin-remote.ts b/packages/browserhive/src/cli/commands/admin-remote.ts
index 16d53c8..1b1fb0d 100644
--- a/packages/browserhive/src/cli/commands/admin-remote.ts
+++ b/packages/browserhive/src/cli/commands/admin-remote.ts
@@ -20,7 +20,12 @@ function headers(remote: RemoteTarget, withBody: boolean): Record {
-  const result = await call(context, remote, 'GET', '/auth/tokens');
+  const result = await remoteCall(context, remote, 'GET', '/auth/tokens');
   if (!result.ok) return result.code;
   const parsed = ApiTokenList.safeParse(result.json);
   if (!parsed.success) {
@@ -114,7 +119,7 @@ export async function remoteCreateToken(
   principal: string,
   expiresInMs: number | null,
 ): Promise {
-  const result = await call(context, remote, 'POST', '/auth/tokens', {
+  const result = await remoteCall(context, remote, 'POST', '/auth/tokens', {
     owner_kind: 'agent',
     display: principal,
     ...(expiresInMs !== null && { expires_in_ms: expiresInMs }),
@@ -149,7 +154,7 @@ export async function remoteRevokeToken(
   remote: RemoteTarget,
   credentialId: string,
 ): Promise {
-  const result = await call(
+  const result = await remoteCall(
     context,
     remote,
     'DELETE',
diff --git a/packages/browserhive/src/cli/commands/channels.ts b/packages/browserhive/src/cli/commands/channels.ts
new file mode 100644
index 0000000..538eba0
--- /dev/null
+++ b/packages/browserhive/src/cli/commands/channels.ts
@@ -0,0 +1,192 @@
+/** @module cli/commands/channels — `browserhive channels list | test  | preview `: notification channels over a running server's REST API (spec 08 §7.1, spec 03 §4.8.1) */
+import {
+  ChannelPreview,
+  ChannelsResponse,
+  ChannelTestResponse,
+  type ChannelView,
+} from '@browserhive/contracts/http';
+import { deliveryReasonText, type PreviewSample } from '@browserhive/contracts/notifications';
+import type { CommandContext } from '../deps.ts';
+import { EXIT, type ExitCode, type RemoteTarget } from '../invocation.ts';
+import { remoteCall } from './admin-remote.ts';
+import { formatTimestamp } from './common.ts';
+
+async function listChannels(
+  context: CommandContext,
+  remote: RemoteTarget,
+): Promise {
+  const result = await remoteCall(context, remote, 'GET', '/channels');
+  if (!result.ok) return result.code;
+  const parsed = ChannelsResponse.safeParse(result.json);
+  if (!parsed.success) {
+    context.out.diagnostic('browserhive: the server answered with an unexpected channel list.');
+    return EXIT.fatal;
+  }
+  return parsed.data.data;
+}
+
+async function channelByName(
+  context: CommandContext,
+  remote: RemoteTarget,
+  name: string,
+): Promise {
+  const channels = await listChannels(context, remote);
+  if (typeof channels === 'number') return channels;
+  const found = channels.find((c) => c.name === name);
+  if (found !== undefined) return found;
+  const names = channels.map((c) => c.name);
+  context.out.diagnostic(
+    `browserhive: no notification channel named '${name}'.${names.length === 0 ? ' No channels are configured.' : ` Channels: ${names.join(', ')}.`}`,
+  );
+  return EXIT.fatal;
+}
+
+function statusText(channel: ChannelView): string {
+  const base = channel.status === 'active' && !channel.ready ? 'not ready' : channel.status;
+  return channel.source === 'startup' ? `${base} (from startup)` : base;
+}
+
+function secretsText(channel: ChannelView): string {
+  if (channel.secrets.length === 0) return '—';
+  return channel.secrets.map((s) => `${s.env} ${s.set ? '✓' : '✗'}`).join(', ');
+}
+
+/**
+ * `channels list`.
+ *
+ * @returns The exit code.
+ */
+export async function runChannelsList(
+  context: CommandContext,
+  remote: RemoteTarget,
+  json: boolean,
+): Promise {
+  const { out } = context;
+  const channels = await listChannels(context, remote);
+  if (typeof channels === 'number') return channels;
+  if (json) {
+    out.json(channels);
+    return EXIT.ok;
+  }
+  if (channels.length === 0) {
+    out.line(
+      'No notification channels. Add one in the dashboard (Notifications → Channels) or start with --notificationChannel.',
+    );
+    return EXIT.ok;
+  }
+  out.table(
+    [
+      { header: 'NAME' },
+      { header: 'PLATFORM' },
+      { header: 'STATUS' },
+      { header: 'SENDS TO' },
+      { header: 'SECRETS' },
+      { header: 'LAST DELIVERY' },
+      { header: '24H SENT/FAILED/SUPPRESSED' },
+    ],
+    channels.map((c) => [
+      out.style.bold(c.name),
+      c.mode === null ? c.kind : `${c.kind} (${c.mode})`,
+      statusText(c),
+      c.target_hint,
+      secretsText(c),
+      c.stats.last_delivery_at === null
+        ? 'never'
+        : `${formatTimestamp(c.stats.last_delivery_at)} ${c.stats.last_status ?? ''}`.trim(),
+      `${c.stats.sent_24h}/${c.stats.failed_24h}/${c.stats.suppressed_24h}`,
+    ]),
+  );
+  for (const c of channels) {
+    if (c.problem !== null) out.line(`  ${out.style.dim(`${c.name}:`)} ${c.problem}`);
+  }
+  return EXIT.ok;
+}
+
+/**
+ * `channels test `: a real test message; exit 0 when the platform accepted it.
+ *
+ * @returns The exit code.
+ */
+export async function runChannelsTest(
+  context: CommandContext,
+  remote: RemoteTarget,
+  name: string,
+  json: boolean,
+): Promise {
+  const { out } = context;
+  const channel = await channelByName(context, remote, name);
+  if (typeof channel === 'number') return channel;
+  const result = await remoteCall(context, remote, 'POST', `/channels/${channel.channel_id}/test`);
+  if (!result.ok) return result.code;
+  const parsed = ChannelTestResponse.safeParse(result.json);
+  if (!parsed.success) {
+    out.diagnostic('browserhive: the server answered with an unexpected test result.');
+    return EXIT.fatal;
+  }
+  if (json) out.json(parsed.data);
+  else if (parsed.data.ok) {
+    const ms = parsed.data.delivery?.duration_ms;
+    out.status(
+      'ok',
+      `test message sent to ${name}`,
+      ms === null || ms === undefined ? undefined : `${ms} ms`,
+    );
+  } else {
+    const error = parsed.data.error;
+    out.status('fail', `test message to ${name} failed`, error?.code);
+    if (error !== null) {
+      out.line(`  ${error.message}`);
+      const why = deliveryReasonText(error.code);
+      if (why !== null && why !== error.code) out.line(`  ${why}`);
+    }
+  }
+  return parsed.data.ok ? EXIT.ok : EXIT.fatal;
+}
+
+/**
+ * `channels preview  [--sample]`: the platform request a send would make; sends nothing.
+ *
+ * @returns The exit code.
+ */
+export async function runChannelsPreview(
+  context: CommandContext,
+  remote: RemoteTarget,
+  name: string,
+  sample: PreviewSample,
+  json: boolean,
+): Promise {
+  const { out } = context;
+  const channel = await channelByName(context, remote, name);
+  if (typeof channel === 'number') return channel;
+  const result = await remoteCall(context, remote, 'POST', '/channels/preview', {
+    channel_id: channel.channel_id,
+    sample,
+  });
+  if (!result.ok) return result.code;
+  const parsed = ChannelPreview.safeParse(result.json);
+  if (!parsed.success) {
+    out.diagnostic('browserhive: the server answered with an unexpected preview.');
+    return EXIT.fatal;
+  }
+  const preview = parsed.data;
+  if (json) {
+    out.json(preview);
+    return EXIT.ok;
+  }
+  out.line(
+    out.style.bold(
+      `${name} · ${preview.kind}${preview.mode === null ? '' : ` (${preview.mode})`} · sample ${sample}`,
+    ),
+  );
+  out.line(out.style.dim('Nothing was sent. The request a send would make:'));
+  for (const request of preview.requests) {
+    out.line();
+    out.line(`${request.method} ${request.path}  ${out.style.dim(request.encoding)}`);
+    for (const [header, value] of Object.entries(request.headers)) out.line(`${header}: ${value}`);
+    if (request.file !== null)
+      out.line(out.style.dim(`[file ${request.file.name} ${request.file.content_type}]`));
+    out.line(JSON.stringify(request.body, null, 2));
+  }
+  for (const note of preview.notes) out.line(`${out.style.dim('note:')} ${note}`);
+  return EXIT.ok;
+}
diff --git a/packages/browserhive/src/cli/commands/doctor-checks.ts b/packages/browserhive/src/cli/commands/doctor-checks.ts
index f6722c7..0c1f1ff 100644
--- a/packages/browserhive/src/cli/commands/doctor-checks.ts
+++ b/packages/browserhive/src/cli/commands/doctor-checks.ts
@@ -12,8 +12,10 @@ import {
   deriveMaxSessions,
   isJsonObject,
   parseJson,
+  parseNotificationChannelFlags,
   type ResolvedConfigBundle,
 } from '@browserhive/core/config';
+import { classifyPublicUrlProbe, isInsecurePublicUrl } from '@browserhive/core/runtime';
 import type { CliDeps } from '../deps.ts';
 import { databasePath, formatTimestamp, size } from './common.ts';
 
@@ -451,3 +453,106 @@ export function checkSecretsFile(deps: CliDeps, bundle: ResolvedConfigBundle): C
   }
   return result('secrets', 'ok', `authTokens in ${path} (owner-only)`);
 }
+
+/** Timeout of the `publicUrl` probes. */
+export const PUBLIC_URL_TIMEOUT_MS = 5000;
+
+/**
+ * The `publicUrl` check (spec 08 §5.8): `/health` compared with the running local
+ * server's `instance_id` when one answers. ✓ `ok`; ! `login`, `unreachable` and plain `http` on a
+ * public host; ✗ `elsewhere`.
+ */
+export async function checkPublicUrl(deps: CliDeps, config: ServerConfig): Promise {
+  const url = config.publicUrl;
+  if (url === undefined) {
+    return result('publicUrl', 'ok', 'not set (notification links open on this computer only)');
+  }
+  const localHost =
+    config.host === '0.0.0.0' || config.host === '::' || config.host === ''
+      ? '127.0.0.1'
+      : config.host;
+  const local = await deps.probes.fetchOnce(
+    `http://${localHost.includes(':') ? `[${localHost}]` : localHost}:${config.port}/health`,
+    PUBLIC_URL_TIMEOUT_MS,
+  );
+  let instanceId: string | null = null;
+  if (local.kind === 'response') {
+    try {
+      const body: unknown = JSON.parse(local.body);
+      const id =
+        typeof body === 'object' && body !== null
+          ? (body as { instance_id?: unknown }).instance_id
+          : undefined;
+      instanceId = typeof id === 'string' ? id : null;
+    } catch {
+      instanceId = null;
+    }
+  }
+  const verdict = classifyPublicUrlProbe(
+    await deps.probes.fetchOnce(`${url}/health`, PUBLIC_URL_TIMEOUT_MS),
+    instanceId,
+  );
+  const insecure = isInsecurePublicUrl(url)
+    ? ' Plain http on a public host: links travel without TLS.'
+    : '';
+  const status: CheckStatus =
+    verdict.outcome === 'elsewhere'
+      ? 'fail'
+      : verdict.outcome === 'ok' && insecure === ''
+        ? 'ok'
+        : 'warn';
+  return result('publicUrl', status, `${url}: ${verdict.detail}${insecure}`);
+}
+
+/**
+ * Notification channels (spec 08 §7.1): every `--notificationChannel` parses, and every variable
+ * a startup or dashboard channel names is set (never showing a value).
+ */
+export async function checkNotificationChannels(
+  deps: CliDeps,
+  dataDir: string,
+  flags: readonly string[],
+): Promise {
+  const parsed = parseNotificationChannelFlags(flags, (name) => deps.env[name]);
+  if (parsed.problems.length > 0) {
+    return result('notification channels', 'fail', parsed.problems.join(' '));
+  }
+  const missing: string[] = [];
+  let dashboard: readonly {
+    readonly name: string;
+    readonly secretRefs: Readonly>;
+    readonly source: string;
+  }[] = [];
+  if ((await deps.fs.stat(databasePath(dataDir))) !== null) {
+    try {
+      const storage = await deps.openStorage({
+        dataDir,
+        readOnly: true,
+        migrate: false,
+        owner: 'doctor',
+      });
+      try {
+        dashboard = (await storage.notificationChannels()).filter((c) => c.source === 'db');
+      } finally {
+        await storage.close();
+      }
+    } catch {
+      dashboard = [];
+    }
+  }
+  for (const channel of dashboard) {
+    for (const name of Object.values(channel.secretRefs)) {
+      const value = deps.env[name];
+      if (value === undefined || value === '') missing.push(`${channel.name}: ${name} is not set`);
+    }
+  }
+  const total = parsed.channels.length + dashboard.length;
+  if (missing.length > 0) return result('notification channels', 'fail', missing.join('; '));
+  if (total === 0) return result('notification channels', 'ok', 'none configured');
+  const warn = parsed.warnings.length > 0;
+  return result(
+    'notification channels',
+    warn ? 'warn' : 'ok',
+    `${parsed.channels.length} from flags, ${dashboard.length} from the dashboard; every variable is set${warn ? `. ${parsed.warnings.join(' ')}` : ''}`,
+  );
+}
diff --git a/packages/browserhive/src/cli/commands/doctor.ts b/packages/browserhive/src/cli/commands/doctor.ts
index b2f17c5..112f370 100644
--- a/packages/browserhive/src/cli/commands/doctor.ts
+++ b/packages/browserhive/src/cli/commands/doctor.ts
@@ -19,8 +19,10 @@ import {
   checkConfig,
   checkDatabase,
   checkDataDir,
+  checkNotificationChannels,
   checkOtel,
   checkPort,
+  checkPublicUrl,
   checkReferenceDefaults,
   checkSecretsFile,
   checkUnrecognisedDataFiles,
@@ -95,7 +97,9 @@ export async function runChecks(
     results.push(await checkOtel(deps, config));
     results.push(checkCapacity(deps, config));
     results.push(checkSecretsFile(deps, resolution.value));
+    results.push(await checkPublicUrl(deps, config));
   }
+  results.push(await checkNotificationChannels(deps, dataDir, invocation.notificationChannels));
   return { results, browsers };
 }
 
diff --git a/packages/browserhive/src/cli/commands/serve.ts b/packages/browserhive/src/cli/commands/serve.ts
index f42ce0f..1e9d6c2 100644
--- a/packages/browserhive/src/cli/commands/serve.ts
+++ b/packages/browserhive/src/cli/commands/serve.ts
@@ -1,4 +1,5 @@
 /** @module cli/commands/serve — `browserhive [serve]`: hands the resolved configuration to the composition root and waits for it to stop (spec 08 §7.1, §7.4) */
+import type { StartupNotificationChannel } from '@browserhive/contracts/notifications';
 import type { ResolvedConfigBundle } from '@browserhive/core/config';
 import type { CommandContext } from '../deps.ts';
 
@@ -13,11 +14,17 @@ import type { CommandContext } from '../deps.ts';
  */
 export async function runServe(
   context: CommandContext,
-  resolved: ResolvedConfigBundle,
+  invocation: {
+    readonly resolved: ResolvedConfigBundle;
+    readonly startupChannels: readonly StartupNotificationChannel[];
+    readonly channelWarnings: readonly string[];
+  },
 ): Promise {
   const { deps, out } = context;
   const server = await deps.bootServer({
-    resolved,
+    resolved: invocation.resolved,
+    startupChannels: invocation.startupChannels,
+    startupChannelWarnings: invocation.channelWarnings,
     host: deps.host,
     output: out.sinks(),
     env: deps.env,
diff --git a/packages/browserhive/src/cli/deps.ts b/packages/browserhive/src/cli/deps.ts
index d209d89..18ab08f 100644
--- a/packages/browserhive/src/cli/deps.ts
+++ b/packages/browserhive/src/cli/deps.ts
@@ -1,9 +1,12 @@
 /** @module cli/deps — `CliDeps`: every effect the CLI performs, injected (process facts, filesystem, child processes, storage, server boot, probes) so the suites never touch the real host */
+
 import type { Channel } from '@browserhive/contracts/enums';
+import type { StartupNotificationChannel } from '@browserhive/contracts/notifications';
 import type { ConfigFs, ResolvedConfigBundle } from '@browserhive/core/config';
 import type { FileSystem } from '@browserhive/core/ports/file-system';
 import type { HostEnvironment } from '@browserhive/core/ports/host-environment';
 import type { ProcessRunner } from '@browserhive/core/ports/process-runner';
+import type { UrlProbeResult } from '@browserhive/core/runtime';
 import type {
   DetectedBrowser,
   SandboxEnvironment,
@@ -20,6 +23,10 @@ export interface ServeBootInput {
   readonly env: Readonly>;
   readonly appVersion: string;
   readonly installProcessHandlers: boolean;
+  /** Parsed `--notificationChannel` values (spec 08 §5.7). */
+  readonly startupChannels?: readonly StartupNotificationChannel[];
+  /** Warnings of those flags, logged at boot. */
+  readonly startupChannelWarnings?: readonly string[];
 }
 
 /** Structural `RunningServer` of the composition seam. */
@@ -111,6 +118,18 @@ export interface CliStorage {
   }): Promise;
   /** Revokes one token (credential id) and returns nothing. */
   revokeToken(credentialId: string): Promise;
+  /**
+   * The configured notification channels (name, kind, source and the variables they name); empty
+   * for a database from before the channels table.
+   */
+  notificationChannels(): Promise<
+    readonly {
+      readonly name: string;
+      readonly kind: string;
+      readonly source: string;
+      readonly secretRefs: Readonly>;
+    }[]
+  >;
   close(): Promise;
 }
 
@@ -176,6 +195,11 @@ export interface HostProbes {
   sandboxEnvironment(): Promise;
   /** Whether an installed AppArmor profile names `path` (`null` off Linux or when unreadable). */
   apparmorCovers(path: string): Promise;
+  /**
+   * One GET without following redirects (the `publicUrl` check): status, content type, location
+   * and at most 64 KiB of the body, or the network error.
+   */
+  fetchOnce(url: string, timeoutMs: number): Promise;
   /** HEAD request with a timeout; `ok` means any HTTP response arrived. */
   httpReachable(
     url: string,
diff --git a/packages/browserhive/src/cli/invocation.ts b/packages/browserhive/src/cli/invocation.ts
index 48e4689..8722591 100644
--- a/packages/browserhive/src/cli/invocation.ts
+++ b/packages/browserhive/src/cli/invocation.ts
@@ -1,5 +1,9 @@
 /** @module cli/invocation — `CliPlan`: the pure decision `planCli` reaches from argv before any side effect (spec 09 §3.3) */
 import type { Channel } from '@browserhive/contracts/enums';
+import type {
+  PreviewSample,
+  StartupNotificationChannel,
+} from '@browserhive/contracts/notifications';
 import type { ConfigFailure, ResolvedConfigBundle } from '@browserhive/core/config';
 import type { ColorMode } from './output/style.ts';
 import type { CommandName } from './registry.ts';
@@ -31,7 +35,14 @@ export interface DataDirTarget {
 
 /** What to run once planning succeeded. */
 export type Invocation =
-  | { readonly command: 'serve'; readonly resolved: ResolvedConfigBundle }
+  | {
+      readonly command: 'serve';
+      readonly resolved: ResolvedConfigBundle;
+      /** `--notificationChannel` values, parsed (spec 08 §5.7). */
+      readonly startupChannels: readonly StartupNotificationChannel[];
+      /** Warnings of the channel flags (a literal ntfy.sh topic), logged at boot. */
+      readonly channelWarnings: readonly string[];
+    }
   | {
       readonly command: 'init';
       readonly resolved: ResolvedConfigBundle;
@@ -53,6 +64,8 @@ export type Invocation =
         | { readonly ok: false; readonly error: ConfigFailure };
       /** The data directory used for disk and database checks (resolved even when config is invalid). */
       readonly dataDir: string;
+      /** Raw `--notificationChannel` values (checked by the doctor, never a usage error there). */
+      readonly notificationChannels: readonly string[];
     }
   | ({
       readonly command: 'purge';
@@ -98,6 +111,24 @@ export type Invocation =
       readonly json: boolean;
       readonly remote: RemoteTarget | null;
     } & DataDirTarget)
+  | {
+      readonly command: 'channels-list';
+      readonly json: boolean;
+      readonly remote: RemoteTarget;
+    }
+  | {
+      readonly command: 'channels-test';
+      readonly name: string;
+      readonly json: boolean;
+      readonly remote: RemoteTarget;
+    }
+  | {
+      readonly command: 'channels-preview';
+      readonly name: string;
+      readonly sample: PreviewSample;
+      readonly json: boolean;
+      readonly remote: RemoteTarget;
+    }
   | { readonly command: 'version'; readonly json: boolean };
 
 /** Help topic: the whole CLI, one command, or one subcommand. */
diff --git a/packages/browserhive/src/cli/plan.ts b/packages/browserhive/src/cli/plan.ts
index 60736f3..724d01e 100644
--- a/packages/browserhive/src/cli/plan.ts
+++ b/packages/browserhive/src/cli/plan.ts
@@ -2,8 +2,9 @@
 import { isAbsolute, resolve as resolvePath } from 'node:path';
 import { lookupKey } from '@browserhive/contracts/config';
 import { Channel } from '@browserhive/contracts/enums';
+import { PREVIEW_SAMPLES, PreviewSample } from '@browserhive/contracts/notifications';
 import type { ConfigFailure, ConfigFs, ResolvedConfigBundle } from '@browserhive/core/config';
-import { keyKind, resolveConfig } from '@browserhive/core/config';
+import { keyKind, parseNotificationChannelFlags, resolveConfig } from '@browserhive/core/config';
 import type { HostEnvironment } from '@browserhive/core/ports/host-environment';
 import { type CliPlan, EXIT, type Invocation, type RemoteTarget } from './invocation.ts';
 import type { ColorMode } from './output/style.ts';
@@ -232,8 +233,18 @@ function planInvocation(name: string, input: PlanInput): CliPlan {
       const resolved = resolveFull(input);
       if (!resolved.ok) return failurePlan(resolved.error);
       const bundle = resolved.value;
+      const flags = parseNotificationChannelFlags(
+        input.tokens.flags.get('notificationChannel') ?? [],
+        (name) => input.env[name],
+      );
+      if (flags.problems.length > 0) return usage(flags.problems);
       return run(
-        { command: 'serve', resolved: bundle },
+        {
+          command: 'serve',
+          resolved: bundle,
+          startupChannels: flags.channels,
+          channelWarnings: flags.warnings,
+        },
         colorOf(bundle, color),
         bundle.config.transport === 'stdio',
       );
@@ -271,7 +282,14 @@ function planInvocation(name: string, input: PlanInput): CliPlan {
       const scoped = resolution.ok ? resolution : resolveDataDir(input);
       const dir = scoped.ok ? scoped.value.config.dataDir : fallbackDataDir(input);
       return run(
-        { command: 'doctor', json, printApparmorProfile, resolution, dataDir: dir },
+        {
+          command: 'doctor',
+          json,
+          printApparmorProfile,
+          resolution,
+          dataDir: dir,
+          notificationChannels: input.tokens.flags.get('notificationChannel') ?? [],
+        },
         color,
       );
     }
@@ -291,6 +309,10 @@ function planInvocation(name: string, input: PlanInput): CliPlan {
     }
     case 'config schema':
       return run({ command: 'config-schema' }, color);
+    case 'channels list':
+    case 'channels test':
+    case 'channels preview':
+      return planChannels(name, input);
     case 'version': {
       const json = reader.bool('json');
       if (reader.problems.length > 0) return usage(reader.problems);
@@ -301,6 +323,33 @@ function planInvocation(name: string, input: PlanInput): CliPlan {
   }
 }
 
+function planChannels(name: string, input: PlanInput): CliPlan {
+  const { reader, color, args } = input;
+  const json = reader.bool('json');
+  const sampleText = reader.oneOf('sample', PREVIEW_SAMPLES) ?? 'attention';
+  const explicit = remoteTarget(reader);
+  if (reader.problems.length > 0) return usage(reader.problems);
+  let remote: RemoteTarget;
+  if (explicit !== null) remote = explicit;
+  else {
+    const resolved = resolveFull(input);
+    const host = resolved.ok ? resolved.value.config.host : '127.0.0.1';
+    const port = resolved.ok ? resolved.value.config.port : 9876;
+    const reachable = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
+    remote = { url: `http://${reachable.includes(':') ? `[${reachable}]` : reachable}:${port}` };
+  }
+  const sample = PreviewSample.parse(sampleText);
+  const channel = args[0] ?? '';
+  switch (name) {
+    case 'channels list':
+      return run({ command: 'channels-list', json, remote }, color);
+    case 'channels test':
+      return run({ command: 'channels-test', name: channel, json, remote }, color);
+    default:
+      return run({ command: 'channels-preview', name: channel, sample, json, remote }, color);
+  }
+}
+
 function fallbackDataDir(input: PlanInput): string {
   const resolved = resolveConfig({
     argv: [],
diff --git a/packages/browserhive/src/cli/production/probes.ts b/packages/browserhive/src/cli/production/probes.ts
index b2ec405..eeffb0d 100644
--- a/packages/browserhive/src/cli/production/probes.ts
+++ b/packages/browserhive/src/cli/production/probes.ts
@@ -193,6 +193,34 @@ export function createHostProbes(options: {
         readFile: readText,
       });
     },
+    fetchOnce: async (url, timeoutMs) => {
+      try {
+        const response = await fetch(url, {
+          method: 'GET',
+          redirect: 'manual',
+          signal: AbortSignal.timeout(timeoutMs),
+        });
+        const text = await response.text();
+        return {
+          kind: 'response',
+          status: response.status,
+          contentType: response.headers.get('content-type'),
+          location: response.headers.get('location'),
+          body: text.slice(0, 64 * 1024),
+        };
+      } catch (err) {
+        const name = err instanceof Error ? err.name : '';
+        return {
+          kind: 'error',
+          detail:
+            name === 'TimeoutError'
+              ? `no answer within ${timeoutMs} ms`
+              : err instanceof Error
+                ? err.message
+                : 'request failed',
+        };
+      }
+    },
     httpReachable: async (url, timeoutMs) => {
       try {
         const response = await fetch(url, {
diff --git a/packages/browserhive/src/cli/production/storage.ts b/packages/browserhive/src/cli/production/storage.ts
index 79b41a9..d73f423 100644
--- a/packages/browserhive/src/cli/production/storage.ts
+++ b/packages/browserhive/src/cli/production/storage.ts
@@ -158,6 +158,20 @@ export async function openCliStorage(
       return { password: result.password.reveal(), credentialsPath: result.credentialsPath };
     },
     listTokens: async () => (await storage.auth.listTokens()).map(tokenRow),
+    notificationChannels: async () => {
+      try {
+        const rows = await storage.repos.notificationChannels.list();
+        return rows.map((r) => ({
+          name: r.name,
+          kind: r.kind,
+          source: r.source,
+          secretRefs: r.secretRefs,
+        }));
+      } catch {
+        // A database from before schema v5 has no channels table.
+        return [];
+      }
+    },
     createToken: async ({ principal, expiresInMs }) => {
       const created = await storage.auth.createToken(CLI_ISSUER, {
         ownerKind: 'agent',
diff --git a/packages/browserhive/src/cli/registry.ts b/packages/browserhive/src/cli/registry.ts
index c0a0a1f..6404aab 100644
--- a/packages/browserhive/src/cli/registry.ts
+++ b/packages/browserhive/src/cli/registry.ts
@@ -10,6 +10,7 @@ export const COMMAND_NAMES = [
   'config',
   'db',
   'admin',
+  'channels',
   'version',
   'help',
 ] as const;
@@ -79,6 +80,13 @@ const json: FlagDescriptor = {
   describe: 'Print machine-readable JSON instead of text.',
 };
 const dataDirKeys: readonly ConfigKey[] = ['dataDir', 'config', 'color'];
+const notificationChannelFlag: FlagDescriptor = {
+  name: 'notificationChannel',
+  kind: 'value',
+  placeholder: '',
+  describe:
+    'Declare a notification channel for this run (repeatable; secrets as env:NAME), e.g. "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456".',
+};
 const remoteFlags: readonly FlagDescriptor[] = [
   {
     name: 'url',
@@ -125,8 +133,8 @@ export const COMMANDS: readonly CommandDescriptor[] = [
     name: 'serve',
     summary: 'Start the MCP server (default)',
     description:
-      'Resolves the configuration (defaults < environment < browserhive.config.json < flags), starts the server and waits for a signal. Every configuration key is a flag.',
-    flags: [],
+      'Resolves the configuration (defaults < environment < browserhive.config.json < flags), starts the server and waits for a signal. Every configuration key is a flag; --notificationChannel is a flag only (never an environment variable or a config-file key).',
+    flags: [notificationChannelFlag],
     configKeys: CONFIG_KEYS,
     subcommands: [],
     defaultSubcommand: null,
@@ -187,6 +195,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
       'Runs every host check and prints a table. Exit 0 when all checks pass, 1 when any fails, 2 for warnings only. Accepts the server flags so the checks see the configuration serve would use. Checks launch each installed browser once to test the sandbox.',
     flags: [
       json,
+      notificationChannelFlag,
       {
         name: 'printApparmorProfile',
         kind: 'boolean',
@@ -327,6 +336,40 @@ export const COMMANDS: readonly CommandDescriptor[] = [
     defaultSubcommand: null,
     args: [],
   },
+  {
+    name: 'channels',
+    summary: 'List, test and preview notification channels',
+    description:
+      'Talks to a running server over its REST API (--url, default the configured host and port) with an operator bearer (--token) or the dashboard cookie (--cookie). test sends a real message and exits 1 when the platform refused it; preview sends nothing.',
+    flags: [],
+    configKeys: ['host', 'port', 'config', 'color'],
+    subcommands: [
+      sub(['list'], 'List channels with status, target, secret variables and 24 h counts', {
+        flags: [json, ...remoteFlags],
+      }),
+      sub(['test'], 'Send a real test message through a channel', {
+        args: [{ name: 'name', required: true, describe: 'Channel name.' }],
+        flags: [json, ...remoteFlags],
+      }),
+      sub(['preview'], 'Print the platform request a channel would send (sends nothing)', {
+        args: [{ name: 'name', required: true, describe: 'Channel name.' }],
+        flags: [
+          json,
+          {
+            name: 'sample',
+            kind: 'value',
+            placeholder: '',
+            describe:
+              'Sample notification: attention, attention-resolved, vault-confirm, tool-errors, crash, degraded or test.',
+            defaultText: 'attention',
+          },
+          ...remoteFlags,
+        ],
+      }),
+    ],
+    defaultSubcommand: ['list'],
+    args: [],
+  },
   {
     name: 'version',
     summary: 'Print version information',
diff --git a/packages/browserhive/src/cli/run.ts b/packages/browserhive/src/cli/run.ts
index dc3eb5e..ecc2138 100644
--- a/packages/browserhive/src/cli/run.ts
+++ b/packages/browserhive/src/cli/run.ts
@@ -6,6 +6,7 @@ import {
   runTokensList,
   runTokensRevoke,
 } from './commands/admin.ts';
+import { runChannelsList, runChannelsPreview, runChannelsTest } from './commands/channels.ts';
 import { appErrorFacts } from './commands/common.ts';
 import { runConfigSchema, runConfigShow, runConfigValidate } from './commands/config.ts';
 import { runDbBackup, runDbMigrate, runDbRestore, runDbStatus } from './commands/db.ts';
@@ -46,7 +47,7 @@ export async function runInvocation(
 ): Promise {
   switch (invocation.command) {
     case 'serve':
-      return runServe(context, invocation.resolved);
+      return runServe(context, invocation);
     case 'init':
       return runInit(context, invocation);
     case 'doctor':
@@ -75,6 +76,18 @@ export async function runInvocation(
       return runTokensCreate(context, invocation);
     case 'admin-tokens-revoke':
       return runTokensRevoke(context, invocation);
+    case 'channels-list':
+      return runChannelsList(context, invocation.remote, invocation.json);
+    case 'channels-test':
+      return runChannelsTest(context, invocation.remote, invocation.name, invocation.json);
+    case 'channels-preview':
+      return runChannelsPreview(
+        context,
+        invocation.remote,
+        invocation.name,
+        invocation.sample,
+        invocation.json,
+      );
     case 'version':
       return runVersion(context, invocation.json);
   }
diff --git a/packages/browserhive/src/composition/types.ts b/packages/browserhive/src/composition/types.ts
index 769d2c2..fbff70e 100644
--- a/packages/browserhive/src/composition/types.ts
+++ b/packages/browserhive/src/composition/types.ts
@@ -1,6 +1,7 @@
 /** @module composition/types — the seam shared with the CLI and the programmatic API: `BootInput`, `RunningServer`, `OutputSinks`. */
 
 import type { Readable, Writable } from 'node:stream';
+import type { StartupNotificationChannel } from '@browserhive/contracts/notifications';
 import type { ResolvedConfigBundle } from '@browserhive/core/config';
 import type { HostEnvironment, LogSink } from '@browserhive/core/runtime';
 import type { SandboxHost } from './sandbox.ts';
@@ -30,6 +31,10 @@ export interface BootInput {
   readonly stdio?: { readonly stdin: Readable; readonly stdout: Writable };
   /** Test seam: the sandbox probes and browser detection (no real launches). */
   readonly sandboxHost?: SandboxHost;
+  /** Startup notification channels (`--notificationChannel`, spec 08 §5.7, D-39). */
+  readonly startupChannels?: readonly StartupNotificationChannel[];
+  /** Warnings of those flags (a literal ntfy.sh topic), logged at boot. */
+  readonly startupChannelWarnings?: readonly string[];
 }
 
 /** A running server. */
diff --git a/packages/browserhive/src/index.ts b/packages/browserhive/src/index.ts
index d943d9f..968872e 100644
--- a/packages/browserhive/src/index.ts
+++ b/packages/browserhive/src/index.ts
@@ -12,6 +12,7 @@ import {
   type ConfigFailure,
   type ConfigOverrides,
   configFailure,
+  parseNotificationChannelFlags,
   resolveConfig,
   suggestKey,
   withSuggestion,
@@ -80,6 +81,11 @@ export interface CreateServerOptions extends ServerOptions {
   readonly logger?: BrowserHiveLogSink;
   /** Host RAM in bytes for the `maxSessions` derivation (tests). */
   readonly hostMemory?: number;
+  /**
+   * Startup notification channels in the `--notificationChannel` grammar (spec 08 §5.7); secrets
+   * as `env:NAME`, read from `env`.
+   */
+  readonly notificationChannels?: readonly string[];
 }
 
 /** A created (not yet listening) server. */
@@ -117,6 +123,7 @@ const EXTRA_OPTIONS: ReadonlySet = new Set([
   'output',
   'logger',
   'hostMemory',
+  'notificationChannels',
 ]);
 
 function isConfigKey(name: string): name is ConfigKey {
@@ -191,6 +198,22 @@ export async function createServer(options: CreateServerOptions = {}): Promise env[name],
+  );
+  if (channelFlags.problems.length > 0) {
+    throw new ConfigError(
+      configFailure(
+        channelFlags.problems.map((message) => ({
+          code: 'CONFIG_INVALID' as const,
+          source: 'cli' as const,
+          location: 'options.notificationChannels',
+          message,
+        })),
+      ),
+    );
+  }
 
   let running: RunningServer | undefined;
   let listening: Promise | undefined;
@@ -206,6 +229,8 @@ export async function createServer(options: CreateServerOptions = {}): Promise }[];
 }
 
 /** A default storage state (schema v1, no pending migrations). */
@@ -159,6 +163,7 @@ export function storageState(overrides: Partial = {}): StorageStat
     userVersion: 1,
     minReaderVersion: 1,
     applicationId: APPLICATION_ID,
+    channels: [],
     pending: [],
     tables: [
       { table: 'sessions', rows: 3 },
@@ -251,6 +256,7 @@ function fakeStorage(
     revokeToken: async (credentialId) => {
       state.tokens = state.tokens.filter((row) => row.credentialId !== credentialId);
     },
+    notificationChannels: async () => state.channels,
     close: async () => {
       state.closed += 1;
     },
@@ -268,6 +274,8 @@ export interface ProbeState {
   diskFree: number | null;
   modes: Record;
   otlp: { ok: boolean; detail: string };
+  /** Answers of `fetchOnce` by URL (default: a network error). */
+  fetches: Record;
   /** Detected channels (bundled Chromium first). */
   browsers: DetectedBrowser[];
   /** Sandbox probe verdict per channel; missing channels answer `not-installed`. */
@@ -343,6 +351,7 @@ export function probeState(overrides: Partial = {}): ProbeState {
     diskFree: 50 * 1000 ** 3,
     modes: {},
     otlp: { ok: true, detail: 'HTTP 405' },
+    fetches: {},
     browsers: [detected('chromium'), detected('chrome'), detected('edge')],
     sandbox: { chromium: { state: 'works', version: '153.0.8010.12' } },
     environment: sandboxEnvironment(),
@@ -364,6 +373,7 @@ function fakeProbes(state: ProbeState): HostProbes {
     pathMode: async (path) => state.modes[path] ?? 0o700,
     installCommand: (driver) => ({ command: '/usr/bin/bun', args: [`/pkg/${driver}/cli.js`] }),
     httpReachable: async () => state.otlp,
+    fetchOnce: async (url) => state.fetches[url] ?? { kind: 'error', detail: 'connection refused' },
     browsers: async () => state.browsers,
     sandbox: async (channel) => {
       state.probed.push(channel);
diff --git a/packages/core/src/app/events/catalog.ts b/packages/core/src/app/events/catalog.ts
index b9a0ebd..c5cf8e5 100644
--- a/packages/core/src/app/events/catalog.ts
+++ b/packages/core/src/app/events/catalog.ts
@@ -6,6 +6,9 @@ import type {
   AttentionResolvedEvent,
   BlocklistHitEvent,
   BlocklistReloadedEvent,
+  ChannelChangedEvent,
+  ChannelRemovedEvent,
+  DeliveryUpdatedEvent,
   LogRecordEvent,
   NotificationCreatedEvent,
   NotificationUpdatedEvent,
@@ -158,6 +161,10 @@ export type DomainEvents = {
   };
   /** A notification channel's status changed (the breaker opened, D-34). Internal; not on the feed. */
   readonly 'notification.channel.changed': NotificationChannelChangedEvent;
+  // channels (the `channels` topic, spec 03 §6.6)
+  readonly 'channel.changed': z.infer;
+  readonly 'channel.removed': z.infer;
+  readonly 'delivery.updated': z.infer;
   // logs
   readonly 'log.record': z.infer;
 } & AuthEvents; // auth (audit; never on the public feed): `auth.`
diff --git a/packages/core/src/app/notifications/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts
new file mode 100644
index 0000000..cfff802
--- /dev/null
+++ b/packages/core/src/app/notifications/channel-service.ts
@@ -0,0 +1,1052 @@
+/** @module app/notifications/channel-service — the notification channels API (spec 03 §4.8.1, D-33, D-35, D-38, D-39): views that never carry a secret value, CRUD of dashboard channels (startup channels read-only), pause/resume, the test send, the pure preview, the delivery log, the environment check, the Telegram connect flow, and the `channels` feed. */
+
+import type { NotificationCategory } from '@browserhive/contracts/enums';
+import {
+  type ChannelCapabilitiesDto,
+  type ChannelInput,
+  type ChannelPatch,
+  type ChannelPreview,
+  type ChannelPreviewRequest,
+  type ChannelTestResponse,
+  type ChannelView,
+  type DeliveryRow,
+  DeliveryRow as DeliveryRowSchema,
+  type PlatformRequest,
+  type TelegramConnectResponse,
+  type TelegramConnectStatus,
+} from '@browserhive/contracts/http';
+import {
+  AvailableChannelKind,
+  CHANNEL_KIND_SPECS,
+  checkChannelConfig,
+  type NotificationChannelRules,
+  type NotificationMessage,
+  NTFY_DEFAULT_SERVER,
+  type PreviewSample,
+  TELEGRAM_TTL_MAX_MS,
+} from '@browserhive/contracts/notifications';
+import { AppError } from '../../kernel/errors/app-error.ts';
+import { serializeError } from '../../kernel/errors/serialize-error.ts';
+import type { Redactor } from '../../kernel/redact.ts';
+import { isLoopbackHost, isPrivateNetworkHost } from '../../kernel/url.ts';
+import type { Clock } from '../../ports/clock.ts';
+import type { EventPublisher } from '../../ports/event-bus.ts';
+import type { IdGenerator } from '../../ports/id-generator.ts';
+import type { Logger } from '../../ports/logger.ts';
+import {
+  type ChannelCapabilities,
+  type ChannelDelivery,
+  type ChannelRenderer,
+  ChannelSendError,
+  type LinkBuilder,
+  type TelegramSetup,
+  type TelegramStart,
+} from '../../ports/notification-channel.ts';
+import type {
+  ChannelDeliveryStats,
+  NotificationChannelRecord,
+  NotificationDeliveryRecord,
+  NotificationRecord,
+} from '../../ports/persistence/records.ts';
+import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts';
+import type { DomainEvents } from '../events/catalog.ts';
+import type { ChannelRegistry, RegisteredChannel } from './channel-registry.ts';
+import { restrictContent } from './content-level.ts';
+import { degrade } from './degrade.ts';
+import { applyImageRule, wantsImages } from './images.ts';
+import { clip, decodeMessage, encodeMessage } from './message.ts';
+import { contentLevelOf, deleteWhenResolved, expiryFor } from './routing.ts';
+import { sampleMessage } from './samples.ts';
+
+/** Window of the per-channel counts on the cards. */
+const STATS_WINDOW_MS = 24 * 60 * 60_000;
+/** How long the Telegram connect flow waits for `/start `. */
+export const TELEGRAM_CONNECT_MS = 2 * 60_000;
+/** Connect sessions are forgotten this long after they end. */
+const CONNECT_KEEP_MS = 10 * 60_000;
+/** Debounce of `channel.changed` after deliveries moved. */
+const CHANNEL_FEED_DEBOUNCE_MS = 750;
+/** Rows re-published per notification after a delivery change. */
+const FEED_ROWS = 20;
+/** Longest `last_error` stored. */
+const ERROR_MAX = 500;
+
+/** Dependencies of {@link ChannelService}. */
+export interface ChannelServiceDeps {
+  readonly repos: Pick<
+    Repositories,
+    | 'notificationChannels'
+    | 'notificationDeliveries'
+    | 'notificationChannelMessages'
+    | 'notifications'
+  >;
+  readonly uow: UnitOfWork;
+  readonly registry: ChannelRegistry;
+  /** The pure renderers per kind (the same the adapters use). */
+  readonly renderers: ReadonlyMap;
+  readonly links: LinkBuilder;
+  readonly clock: Clock;
+  readonly ids: IdGenerator;
+  readonly logger: Logger;
+  readonly bus: EventPublisher;
+  /** Reads the server's environment (whether a named variable is set; never exposed). */
+  readonly env: (name: string) => string | undefined;
+  /** Registers a secret value with the redactor before it is used. */
+  readonly registerSecret?: (value: string) => void;
+  readonly telegram?: TelegramSetup;
+  readonly redactor?: Redactor;
+  /** Timer for debounced feed events (defaults to `setTimeout`). */
+  readonly schedule?: (fn: () => void, ms: number) => void;
+}
+
+/** A page of the delivery log. */
+export interface DeliveryPage {
+  readonly items: readonly DeliveryRow[];
+  readonly nextCursor: string | null;
+}
+
+/** Filters of the delivery log. */
+export interface DeliveryListInput {
+  readonly cursor?: string;
+  readonly limit: number;
+  readonly channelId?: string;
+  readonly notificationId?: string;
+  readonly statuses?: readonly NotificationDeliveryRecord['status'][];
+  readonly ops?: readonly NotificationDeliveryRecord['op'][];
+  readonly kinds?: readonly string[];
+}
+
+interface ConnectSession {
+  readonly id: string;
+  readonly tokenEnv: string;
+  readonly botUsername: string;
+  readonly expiresAt: number;
+  readonly abort: AbortController;
+  status: TelegramConnectStatus['status'];
+  start: TelegramStart | null;
+  error: string | null;
+  endedAt: number | null;
+}
+
+/** Capabilities in wire form. */
+export function capabilitiesDto(c: ChannelCapabilities): ChannelCapabilitiesDto {
+  return {
+    rich_blocks: c.richBlocks,
+    tables: c.tables,
+    images: c.images,
+    act_buttons: c.actButtons,
+    open_links: c.openLinks,
+    edit: c.edit,
+    delete: c.delete,
+    replies: c.replies,
+    delete_window_ms: c.deleteWindowMs,
+    max_title_chars: c.maxTitleChars,
+    max_text_chars: c.maxTextChars,
+    max_buttons: c.maxButtons,
+  };
+}
+
+function last(value: string, n: number): string {
+  return value.length <= n ? value : `…${value.slice(-n)}`;
+}
+
+function hostPath(url: string): string {
+  try {
+    const u = new URL(url);
+    return `${u.host}${u.pathname === '/' ? '' : u.pathname}`;
+  } catch {
+    return url;
+  }
+}
+
+/**
+ * A short, lossy rendering of where a channel sends (never a secret: variables are named).
+ *
+ * @returns The hint.
+ */
+export function targetHint(
+  record: Pick,
+): string {
+  const t = record.target;
+  const s = record.secretRefs;
+  switch (record.kind) {
+    case 'telegram': {
+      const chat = t['chat_id'] ?? '';
+      const base =
+        t['chat_title'] !== undefined
+          ? `${t['chat_title']} (${last(chat, 4)})`
+          : `chat ${last(chat, 4)}`;
+      return t['thread_id'] === undefined ? base : `${base} · topic ${t['thread_id']}`;
+    }
+    case 'discord':
+      return `${record.mode ?? 'webhook'} from $${s['webhook'] ?? '?'}`;
+    case 'ntfy': {
+      const server = hostPath(t['server'] ?? NTFY_DEFAULT_SERVER);
+      const topic = t['topic'] ?? (s['topic'] === undefined ? '?' : `$${s['topic']}`);
+      return `${server}/${topic}`;
+    }
+    case 'webhook':
+      return t['url'] !== undefined ? hostPath(t['url']) : `$${s['url'] ?? '?'}`;
+    default:
+      return record.kind;
+  }
+}
+
+function webhookWarning(record: NotificationChannelRecord): string | null {
+  if (record.kind !== 'webhook') return null;
+  const url = record.target['url'];
+  if (url === undefined) return null;
+  try {
+    const host = new URL(url).hostname.replace(/^\[|\]$/g, '');
+    if (isLoopbackHost(host) || isPrivateNetworkHost(host)) {
+      return `The webhook targets a private address (${host}): BrowserHive makes this request from inside your network.`;
+    }
+  } catch {
+    return null;
+  }
+  return null;
+}
+
+function encodeCursor(seq: number): string {
+  return Buffer.from(JSON.stringify({ seq })).toString('base64url');
+}
+
+function decodeCursor(cursor: string): number {
+  try {
+    const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
+    const seq =
+      typeof parsed === 'object' && parsed !== null ? (parsed as { seq?: unknown }).seq : null;
+    if (typeof seq === 'number' && Number.isInteger(seq) && seq > 0) return seq;
+  } catch {
+    // fall through
+  }
+  throw new AppError('VALIDATION_FAILED', {
+    issues: [{ path: 'cursor', message: 'not a delivery cursor', code: 'custom' }],
+  });
+}
+
+/**
+ * The notification channels API (spec 03 §4.8.1). Every write goes to `notification_channels`
+ * and then reloads the registry (the planner and the outbox read only its cache).
+ */
+export class ChannelService {
+  private readonly log: Logger;
+  private readonly connects = new Map();
+  private readonly pendingChannels = new Set();
+  private channelTimer = false;
+
+  constructor(private readonly deps: ChannelServiceDeps) {
+    this.log = deps.logger.child({ module: 'notifications' });
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Views
+  // ---------------------------------------------------------------------------------------------
+
+  /** Every channel (dashboard and startup), by name. */
+  async list(): Promise {
+    const stats = await this.stats();
+    return this.deps.registry.channels().map((entry) => this.view(entry, stats));
+  }
+
+  /**
+   * One channel.
+   *
+   * @throws AppError `CHANNEL_NOT_FOUND`.
+   */
+  async get(channelId: string): Promise {
+    const entry = this.entry(channelId);
+    return this.view(entry, await this.stats());
+  }
+
+  private async stats(): Promise> {
+    const rows = await this.deps.repos.notificationDeliveries.stats(
+      this.deps.clock.now() - STATS_WINDOW_MS,
+    );
+    return new Map(rows.map((r) => [r.channelId, r]));
+  }
+
+  private entry(channelId: string): RegisteredChannel {
+    const entry = this.deps.registry.get(channelId);
+    if (entry === undefined) throw new AppError('CHANNEL_NOT_FOUND', { channel_id: channelId });
+    return entry;
+  }
+
+  private isSet(name: string): boolean {
+    const value = this.deps.env(name);
+    return value !== undefined && value !== '';
+  }
+
+  private missingOf(record: NotificationChannelRecord): string[] {
+    return Object.values(record.secretRefs).filter((name) => !this.isSet(name));
+  }
+
+  private view(
+    entry: RegisteredChannel,
+    stats: ReadonlyMap,
+  ): ChannelView {
+    const r = entry.record;
+    const s = stats.get(r.channelId);
+    const missing = this.missingOf(r);
+    const problem =
+      missing.length > 0
+        ? `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set in the environment BrowserHive runs in.`
+        : (entry.problem ?? webhookWarning(r));
+    return {
+      channel_id: r.channelId,
+      name: r.name,
+      kind: AvailableChannelKind.safeParse(r.kind).success
+        ? (r.kind as ChannelView['kind'])
+        : 'webhook',
+      mode: r.mode,
+      source: r.source,
+      status: r.status,
+      target: { ...r.target },
+      target_hint: targetHint(r),
+      secret_refs: { ...r.secretRefs },
+      secrets: Object.entries(r.secretRefs).map(([param, env]) => ({
+        param,
+        env,
+        set: this.isSet(env),
+      })),
+      rules: r.rules,
+      capabilities: entry.capabilities === null ? null : capabilitiesDto(entry.capabilities),
+      ready: entry.adapter !== null && missing.length === 0,
+      problem,
+      failure_count: r.failureCount,
+      last_error: r.lastError,
+      last_ok_at: r.lastOkAt,
+      last_failure_at: r.lastFailureAt,
+      created_at: r.createdAt,
+      updated_at: r.updatedAt,
+      stats: {
+        sent_24h: s?.sent ?? 0,
+        failed_24h: s?.failed ?? 0,
+        suppressed_24h: s?.suppressed ?? 0,
+        pending: s?.pending ?? 0,
+        last_delivery_at: s?.lastAt ?? null,
+        last_status: s?.lastStatus ?? null,
+      },
+    };
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Writes
+  // ---------------------------------------------------------------------------------------------
+
+  private validate(input: {
+    readonly kind: string;
+    readonly mode: string | null;
+    readonly target: Readonly>;
+    readonly secretRefs: Readonly>;
+    readonly rules: NotificationChannelRules;
+  }): void {
+    if (input.kind === 'discord' && input.mode === 'bot') {
+      throw new AppError('CHANNEL_KIND_UNAVAILABLE', {
+        kind: 'discord',
+        mode: 'bot',
+        mode_text: ' in bot mode',
+      });
+    }
+    const issues = checkChannelConfig({
+      kind: input.kind,
+      mode: input.mode,
+      target: input.target,
+      secretRefs: input.secretRefs,
+    }).map((p) => ({ path: p.field, message: p.message, code: 'custom' }));
+    if (input.kind === 'telegram') {
+      for (const [category, ms] of Object.entries(input.rules.ttl_ms ?? {})) {
+        if (ms !== undefined && ms > TELEGRAM_TTL_MAX_MS) {
+          issues.push({
+            path: `rules.ttl_ms.${category}`,
+            message:
+              'Telegram lets a bot delete its messages for 48 hours only; choose 47 h or less.',
+            code: 'custom',
+          });
+        }
+      }
+    }
+    const images = Object.entries(input.rules.images ?? {}).some(([, on]) => on === true);
+    if (images && contentLevelOf(input.rules) !== 'full') {
+      issues.push({
+        path: 'rules.images',
+        message: 'Screenshots need the content level "full".',
+        code: 'custom',
+      });
+    }
+    if (issues.length > 0) throw new AppError('VALIDATION_FAILED', { issues });
+  }
+
+  private async assertNameFree(name: string, except: string | null): Promise {
+    const clash = await this.deps.repos.notificationChannels.getByName(name);
+    if (clash !== null && clash.channelId !== except) {
+      throw new AppError('CHANNEL_NAME_TAKEN', { name });
+    }
+  }
+
+  /**
+   * Creates a dashboard channel.
+   *
+   * @throws AppError `VALIDATION_FAILED`, `CHANNEL_NAME_TAKEN`, `CHANNEL_KIND_UNAVAILABLE`.
+   */
+  async create(input: ChannelInput): Promise {
+    const spec = CHANNEL_KIND_SPECS[input.kind];
+    const mode = input.mode ?? spec.defaultMode;
+    this.validate({
+      kind: input.kind,
+      mode,
+      target: input.target,
+      secretRefs: input.secret_refs,
+      rules: input.rules,
+    });
+    await this.assertNameFree(input.name, null);
+    const now = this.deps.clock.now();
+    const channelId = `nc-${this.deps.ids.opaque(12)}`;
+    await this.deps.repos.notificationChannels.upsert({
+      channelId,
+      name: input.name,
+      kind: input.kind,
+      mode,
+      source: 'db',
+      status: 'active',
+      target: input.target,
+      secretRefs: input.secret_refs,
+      rules: input.rules,
+      failureCount: 0,
+      lastError: null,
+      lastOkAt: null,
+      lastFailureAt: null,
+      createdAt: now,
+      updatedAt: now,
+    });
+    await this.deps.registry.reload();
+    this.log.info('channel created', { channel: input.name, kind: input.kind });
+    const view = await this.get(channelId);
+    this.publishChannelNow(view);
+    return view;
+  }
+
+  /**
+   * Edits a dashboard channel (not its kind).
+   *
+   * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_READ_ONLY`, `VALIDATION_FAILED`, `CHANNEL_NAME_TAKEN`.
+   */
+  async update(channelId: string, patch: ChannelPatch): Promise {
+    const current = this.entry(channelId).record;
+    if (current.source === 'startup') {
+      throw new AppError('CHANNEL_READ_ONLY', { channel_id: channelId, name: current.name });
+    }
+    const next: NotificationChannelRecord = {
+      ...current,
+      name: patch.name ?? current.name,
+      mode: patch.mode === undefined ? current.mode : patch.mode,
+      target: patch.target ?? current.target,
+      secretRefs: patch.secret_refs ?? current.secretRefs,
+      rules: patch.rules ?? current.rules,
+      updatedAt: this.deps.clock.now(),
+    };
+    this.validate({
+      kind: next.kind,
+      mode: next.mode,
+      target: next.target,
+      secretRefs: next.secretRefs,
+      rules: next.rules,
+    });
+    if (next.name !== current.name) await this.assertNameFree(next.name, channelId);
+    await this.deps.repos.notificationChannels.upsert(next);
+    await this.deps.registry.reload();
+    this.log.info('channel updated', { channel: next.name });
+    const view = await this.get(channelId);
+    this.publishChannelNow(view);
+    return view;
+  }
+
+  /**
+   * Deletes a dashboard channel with its delivery log.
+   *
+   * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_READ_ONLY`.
+   */
+  async remove(channelId: string): Promise {
+    const current = this.entry(channelId).record;
+    if (current.source === 'startup') {
+      throw new AppError('CHANNEL_READ_ONLY', { channel_id: channelId, name: current.name });
+    }
+    await this.deps.repos.notificationChannels.remove(channelId);
+    await this.deps.registry.reload();
+    this.log.info('channel removed', { channel: current.name });
+    this.deps.bus.publish('channel.removed', { type: 'channel.removed', channel_id: channelId });
+  }
+
+  /**
+   * Pauses a channel (startup channels too; the pause survives restarts): its pending jobs are
+   * suppressed with `channel_paused`.
+   */
+  async pause(channelId: string): Promise {
+    const entry = this.entry(channelId);
+    const now = this.deps.clock.now();
+    await this.deps.uow.transaction(async (r) => {
+      await r.notificationChannels.setStatus(channelId, 'paused', now);
+      await r.notificationDeliveries.suppressChannel(channelId, 'channel_paused', now);
+    });
+    await this.deps.registry.reload();
+    this.statusChanged(entry, 'paused', now);
+    const view = await this.get(channelId);
+    this.publishChannelNow(view);
+    return view;
+  }
+
+  /** Resumes a paused or broken channel (consecutive failures reset). */
+  async resume(channelId: string): Promise {
+    const entry = this.entry(channelId);
+    const now = this.deps.clock.now();
+    await this.deps.repos.notificationChannels.setStatus(channelId, 'active', now);
+    await this.deps.registry.reload();
+    this.statusChanged(entry, 'active', now);
+    const view = await this.get(channelId);
+    this.publishChannelNow(view);
+    return view;
+  }
+
+  private statusChanged(entry: RegisteredChannel, status: 'active' | 'paused', at: number): void {
+    if (entry.record.status === status) return;
+    this.log.info('channel status changed', { channel: entry.record.name, status });
+    this.deps.bus.publish('notification.channel.changed', {
+      type: 'notification.channel.changed',
+      channel_id: entry.record.channelId,
+      name: entry.record.name,
+      kind: entry.record.kind,
+      status,
+      previous_status: entry.record.status,
+      failure_count: 0,
+      last_error: null,
+      at,
+    });
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Test send and preview
+  // ---------------------------------------------------------------------------------------------
+
+  /** The message as a channel receives it: content level, image rule, degrade. */
+  private shape(
+    message: NotificationMessage,
+    rules: NotificationChannelRules,
+    capabilities: ChannelCapabilities,
+  ): NotificationMessage {
+    return degrade(
+      applyImageRule(restrictContent(message, contentLevelOf(rules)), rules),
+      capabilities,
+    );
+  }
+
+  /**
+   * Sends a `test` notification through the channel now (outside the outbox queue) and records it
+   * in the delivery log (spec 03 §4.8.1).
+   *
+   * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_NOT_READY`.
+   */
+  async test(channelId: string): Promise {
+    const entry = this.entry(channelId);
+    const adapter = entry.adapter;
+    const capabilities = entry.capabilities;
+    const missing = this.missingOf(entry.record);
+    if (adapter === null || capabilities === null || missing.length > 0) {
+      throw new AppError('CHANNEL_NOT_READY', {
+        channel_id: channelId,
+        problem:
+          missing.length > 0
+            ? `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set.`
+            : (entry.problem ?? 'the channel has no adapter.'),
+        missing,
+      });
+    }
+    const now = this.deps.clock.now();
+    const notificationId = `n-${this.deps.ids.opaque(12)}`;
+    const sample = sampleMessage('test', { now });
+    const message: NotificationMessage = {
+      ...sample,
+      id: notificationId,
+      thread: `test:${channelId}`,
+    };
+    const record: NotificationRecord = {
+      notificationId,
+      principalId: null,
+      type: 'system',
+      title: message.title,
+      body: message.summary,
+      sessionId: null,
+      target: '/notifications/channels',
+      sourceEventId: null,
+      createdAt: now,
+      updatedAt: now,
+      count: 1,
+      groupKey: null,
+      readAt: now,
+      dismissedAt: now,
+      kind: message.kind,
+      category: message.category,
+      severity: message.severity,
+      state: message.state,
+      revision: 1,
+      thread: message.thread,
+      messageJson: encodeMessage(message),
+    };
+    await this.deps.uow.transaction(async (r) => {
+      await r.notifications.insert(record);
+      await r.notificationDeliveries.enqueue([
+        {
+          channelId,
+          notificationId,
+          revision: 1,
+          op: 'send',
+          status: 'pending',
+          reason: 'test',
+          nextAttemptAt: null,
+          createdAt: now,
+        },
+      ]);
+    });
+    const job = (
+      await this.deps.repos.notificationDeliveries.list({ channelId, notificationId, limit: 1 })
+    )[0];
+    if (job === undefined) throw new Error('test delivery was not recorded');
+    await this.deps.repos.notificationDeliveries.claim(job.seq, now);
+    const delivery: ChannelDelivery = {
+      message: this.shape(message, entry.record.rules, capabilities),
+      links: this.deps.links,
+      replyTo: null,
+    };
+    const started = this.deps.clock.now();
+    let error: { code: string; message: string } | null = null;
+    try {
+      const result = await adapter.send(delivery);
+      const done = this.deps.clock.now();
+      const rules = entry.record.rules;
+      let expiresAt = expiryFor(rules, message, done);
+      if (deleteWhenResolved(rules, message)) expiresAt = done;
+      await this.deps.uow.transaction(async (r) => {
+        await r.notificationDeliveries.finish(job.seq, {
+          status: 'sent',
+          reason: 'test',
+          lastError: null,
+          durationMs: Math.max(0, done - started),
+          messageRef: result.ref,
+          updatedAt: done,
+        });
+        await r.notificationChannelMessages.upsert({
+          channelId,
+          notificationId,
+          thread: message.thread,
+          messageRef: result.ref,
+          lastRevision: 1,
+          sentAt: done,
+          updatedAt: done,
+          expiresAt,
+          deletedAt: null,
+        });
+      });
+      this.log.info('channel test sent', { channel: entry.record.name });
+    } catch (err) {
+      const done = this.deps.clock.now();
+      const code = err instanceof ChannelSendError ? err.code : 'unavailable';
+      const text = this.scrub(
+        err instanceof ChannelSendError ? err.message : serializeError(err).message,
+      );
+      error = { code, message: text };
+      await this.deps.repos.notificationDeliveries.finish(job.seq, {
+        status: 'dead',
+        reason: code,
+        lastError: `${code}: ${text}`,
+        durationMs: Math.max(0, done - started),
+        updatedAt: done,
+      });
+      this.log.warn('channel test failed', { channel: entry.record.name, code });
+    }
+    const row = await this.deps.repos.notificationDeliveries.get(job.seq);
+    const dto = row === null ? null : await this.deliveryRow(row, new Map());
+    if (dto !== null)
+      this.deps.bus.publish('delivery.updated', { type: 'delivery.updated', delivery: dto });
+    this.scheduleChannel(channelId);
+    return { ok: error === null, delivery: dto, error };
+  }
+
+  private scrub(text: string): string {
+    return clip(this.deps.redactor?.scrubText(text) ?? text, ERROR_MAX);
+  }
+
+  /**
+   * Renders a sample notification exactly as the channel (saved, or a draft) would send it.
+   * Pure: nothing is sent and nothing is stored.
+   *
+   * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_KIND_UNAVAILABLE`.
+   */
+  preview(request: ChannelPreviewRequest): ChannelPreview {
+    let kind: string;
+    let mode: string | null;
+    let target: Readonly>;
+    let rules: NotificationChannelRules;
+    let secretRefs: Readonly> = {};
+    if (request.channel_id !== undefined) {
+      const r = this.entry(request.channel_id).record;
+      kind = r.kind;
+      mode = r.mode;
+      target = r.target;
+      rules = r.rules;
+      secretRefs = r.secretRefs;
+    } else {
+      kind = request.kind ?? 'webhook';
+      const spec = CHANNEL_KIND_SPECS[request.kind ?? 'webhook'];
+      mode = request.mode ?? spec.defaultMode;
+      target = request.target ?? {};
+      rules = request.rules ?? {};
+    }
+    const renderer = this.deps.renderers.get(kind);
+    const parsedKind = AvailableChannelKind.safeParse(kind);
+    if (renderer === undefined || !parsedKind.success) {
+      throw new AppError('CHANNEL_KIND_UNAVAILABLE', { kind, mode_text: '' });
+    }
+    const capabilities = renderer.capabilities(mode);
+    const now = this.deps.clock.now();
+    const plain = sampleMessage(request.sample, { now });
+    const withImage = wantsImages(rules, plain.category)
+      ? sampleMessage(request.sample, {
+          now,
+          image: rules.mask_images === true ? 'masked' : 'unmasked',
+        })
+      : plain;
+    const shown = this.shape(withImage, rules, capabilities);
+    const rendered = renderer.render(
+      { message: shown, links: this.deps.links, replyTo: null },
+      { mode, target, op: 'send', ref: null, actToken: (id) => `bh1:preview-${id}` },
+    );
+    const spec = CHANNEL_KIND_SPECS[parsedKind.data];
+    const envOf = (param: string) =>
+      secretRefs[param] ??
+      spec.secrets.find((s) => s.param === param)?.suggestedEnv ??
+      param.toUpperCase();
+    const requests: PlatformRequest[] = rendered.map((r) => ({
+      method: r.method,
+      path: r.path.replace(/\{secret:([a-z_]+)\}/g, (_m, param: string) => `{${envOf(param)}}`),
+      encoding: r.encoding,
+      body: { ...r.body },
+      headers: { ...r.headers },
+      file: r.file === null ? null : { name: r.file.name, content_type: r.file.content_type },
+    }));
+    return {
+      kind: parsedKind.data,
+      mode,
+      sample: request.sample,
+      capabilities: capabilitiesDto(capabilities),
+      message: shown,
+      requests,
+      local_links: this.deps.links.local,
+      notes: this.notes(parsedKind.data, rules, capabilities, plain.category, request.sample),
+    };
+  }
+
+  private notes(
+    kind: string,
+    rules: NotificationChannelRules,
+    caps: ChannelCapabilities,
+    category: NotificationCategory,
+    sample: PreviewSample,
+  ): string[] {
+    const notes: string[] = [];
+    if (this.deps.links.local) {
+      notes.push(
+        'publicUrl is not set: links point at this computer and will not open on a phone.',
+      );
+    }
+    if (!caps.actButtons && (sample === 'attention' || sample === 'vault-confirm')) {
+      notes.push(
+        'Approve/Reject buttons arrive with act buttons; until then they open BrowserHive.',
+      );
+    }
+    if (rules.images?.[category] === true && !wantsImages(rules, category)) {
+      notes.push('Screenshots are on, but they need the content level "full".');
+    }
+    if (kind === 'ntfy' && wantsImages(rules, category)) {
+      const server = NTFY_DEFAULT_SERVER;
+      notes.push(
+        `On ${server.replace('https://', '')} attachments are stored on the public server for 3 hours; a self-hosted ntfy keeps screenshots private.`,
+      );
+    }
+    return notes;
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Delivery log
+  // ---------------------------------------------------------------------------------------------
+
+  /** A page of the delivery log, newest first. */
+  async deliveries(input: DeliveryListInput): Promise {
+    const beforeSeq = input.cursor === undefined ? undefined : decodeCursor(input.cursor);
+    const rows = await this.deps.repos.notificationDeliveries.list({
+      ...(input.channelId !== undefined && { channelId: input.channelId }),
+      ...(input.notificationId !== undefined && { notificationId: input.notificationId }),
+      ...(input.statuses !== undefined && { statuses: input.statuses }),
+      ...(input.ops !== undefined && { ops: input.ops }),
+      ...(input.kinds !== undefined && { kinds: input.kinds }),
+      ...(beforeSeq !== undefined && { beforeSeq }),
+      limit: input.limit + 1,
+    });
+    const page = rows.slice(0, input.limit);
+    const cache = new Map();
+    const items: DeliveryRow[] = [];
+    for (const row of page) items.push(await this.deliveryRow(row, cache));
+    const lastRow = page[page.length - 1];
+    return {
+      items,
+      nextCursor:
+        rows.length > input.limit && lastRow !== undefined ? encodeCursor(lastRow.seq) : null,
+    };
+  }
+
+  /**
+   * One delivery with the notification's current message as that channel is shown it.
+   *
+   * @throws AppError `DELIVERY_NOT_FOUND`.
+   */
+  async delivery(
+    seq: number,
+  ): Promise<{ delivery: DeliveryRow; message: NotificationMessage | null }> {
+    const row = await this.deps.repos.notificationDeliveries.get(seq);
+    if (row === null) throw new AppError('DELIVERY_NOT_FOUND', { seq });
+    const cache = new Map();
+    const dto = await this.deliveryRow(row, cache);
+    const record = cache.get(row.notificationId) ?? null;
+    const message = record === null ? null : decodeMessage(record.messageJson);
+    const entry = this.deps.registry.get(row.channelId);
+    let shown: NotificationMessage | null = message;
+    if (message !== null && entry !== undefined) {
+      shown =
+        entry.capabilities === null
+          ? restrictContent(message, contentLevelOf(entry.record.rules))
+          : this.shape(message, entry.record.rules, entry.capabilities);
+    }
+    return { delivery: dto, message: shown };
+  }
+
+  private async deliveryRow(
+    row: NotificationDeliveryRecord,
+    cache: Map,
+  ): Promise {
+    let n = cache.get(row.notificationId);
+    if (n === undefined) {
+      n = await this.deps.repos.notifications.get(row.notificationId);
+      cache.set(row.notificationId, n);
+    }
+    const channel = this.deps.registry.get(row.channelId)?.record;
+    return DeliveryRowSchema.parse({
+      seq: row.seq,
+      channel_id: row.channelId,
+      channel_name: channel?.name ?? null,
+      channel_kind: channel?.kind ?? null,
+      notification_id: row.notificationId,
+      notification_kind: n?.kind ?? null,
+      notification_title: n?.title ?? null,
+      revision: row.revision,
+      op: row.op,
+      status: row.status,
+      reason: row.reason,
+      attempts: row.attempts,
+      next_attempt_at: row.nextAttemptAt,
+      last_error: row.lastError,
+      duration_ms: row.durationMs,
+      message_ref: row.messageRef === null ? null : { ...row.messageRef },
+      created_at: row.createdAt,
+      updated_at: row.updatedAt,
+    });
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Environment check and Telegram connect
+  // ---------------------------------------------------------------------------------------------
+
+  /** Whether each named variable is set and non-empty (never its value). */
+  env(names: readonly string[]): { name: string; set: boolean }[] {
+    return names.map((name) => ({ name, set: this.isSet(name) }));
+  }
+
+  /**
+   * Starts the Telegram connect flow: checks the token with `getMe`, then waits up to two minutes
+   * for `/start ` (setup-only long polling). A new connect for the same variable cancels the
+   * previous one.
+   *
+   * @throws AppError `CHANNEL_NOT_READY` (unset variable), `CHANNEL_PLATFORM_ERROR`.
+   */
+  async telegramConnect(tokenEnv: string): Promise {
+    const telegram = this.deps.telegram;
+    if (telegram === undefined) {
+      throw new AppError('CHANNEL_KIND_UNAVAILABLE', { kind: 'telegram', mode_text: '' });
+    }
+    const token = this.deps.env(tokenEnv);
+    if (token === undefined || token === '') {
+      throw new AppError('CHANNEL_NOT_READY', {
+        problem: `${tokenEnv} is not set.`,
+        missing: [tokenEnv],
+      });
+    }
+    this.deps.registerSecret?.(token);
+    let username: string;
+    try {
+      username = await telegram.botUsername(token);
+    } catch (err) {
+      const code = err instanceof ChannelSendError ? err.code : 'unavailable';
+      throw new AppError('CHANNEL_PLATFORM_ERROR', {
+        kind: 'telegram',
+        code,
+        detail: this.scrub(serializeError(err).message),
+      });
+    }
+    this.pruneConnects();
+    for (const session of this.connects.values()) {
+      if (session.tokenEnv === tokenEnv && session.status === 'waiting') {
+        session.abort.abort();
+        session.status = 'expired';
+        session.endedAt = this.deps.clock.now();
+      }
+    }
+    const id = this.deps.ids.opaque(16);
+    const code = this.deps.ids.opaque(16);
+    const expiresAt = this.deps.clock.now() + TELEGRAM_CONNECT_MS;
+    const session: ConnectSession = {
+      id,
+      tokenEnv,
+      botUsername: username,
+      expiresAt,
+      abort: new AbortController(),
+      status: 'waiting',
+      start: null,
+      error: null,
+      endedAt: null,
+    };
+    this.connects.set(id, session);
+    void telegram
+      .waitForStart(token, code, { signal: session.abort.signal, deadline: expiresAt })
+      .then((start) => {
+        if (session.status !== 'waiting') return;
+        session.start = start;
+        session.status = start === null ? 'expired' : 'connected';
+        session.endedAt = this.deps.clock.now();
+        if (start !== null) this.log.info('telegram chat connected', { type: start.chat.type });
+      })
+      .catch((err: unknown) => {
+        if (session.status !== 'waiting') return;
+        session.status = 'failed';
+        session.error = this.scrub(serializeError(err).message);
+        session.endedAt = this.deps.clock.now();
+      });
+    return {
+      connect_id: id,
+      bot_username: username,
+      link: `https://t.me/${username}?start=${code}`,
+      group_link: `https://t.me/${username}?startgroup=${code}`,
+      expires_at: expiresAt,
+    };
+  }
+
+  /**
+   * The state of one connect flow.
+   *
+   * @throws AppError `NOT_FOUND` for an unknown or forgotten id.
+   */
+  telegramConnectStatus(connectId: string): TelegramConnectStatus {
+    this.pruneConnects();
+    const session = this.connects.get(connectId);
+    if (session === undefined) throw new AppError('NOT_FOUND', {});
+    if (session.status === 'waiting' && this.deps.clock.now() > session.expiresAt + 5_000) {
+      session.status = 'expired';
+      session.endedAt = this.deps.clock.now();
+    }
+    const start = session.start;
+    return {
+      status: session.status,
+      chat:
+        start === null
+          ? null
+          : {
+              id: start.chat.id,
+              title: start.chat.title,
+              type: start.chat.type,
+              thread_id: start.chat.threadId,
+            },
+      user: start?.user ?? null,
+      error: session.error,
+      expires_at: session.expiresAt,
+    };
+  }
+
+  /** Cancels every connect flow (shutdown). */
+  stop(): void {
+    for (const session of this.connects.values()) session.abort.abort();
+    this.connects.clear();
+  }
+
+  private pruneConnects(): void {
+    const now = this.deps.clock.now();
+    for (const [id, session] of this.connects) {
+      if (session.endedAt !== null && now - session.endedAt > CONNECT_KEEP_MS)
+        this.connects.delete(id);
+    }
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // Feed
+  // ---------------------------------------------------------------------------------------------
+
+  /**
+   * Re-publishes the delivery rows of one notification (optionally on one channel) on the
+   * `channels` topic, and schedules the affected channels' `channel.changed`. Never throws.
+   */
+  onDeliveryChange(notificationId: string, channelId?: string): void {
+    void (async () => {
+      const rows = await this.deps.repos.notificationDeliveries.list({
+        notificationId,
+        ...(channelId !== undefined && { channelId }),
+        limit: FEED_ROWS,
+      });
+      const cache = new Map();
+      const latest = new Map();
+      for (const row of rows) {
+        const key = `${row.channelId}:${row.op}`;
+        if (!latest.has(key)) latest.set(key, row);
+      }
+      for (const row of latest.values()) {
+        const dto = await this.deliveryRow(row, cache);
+        this.deps.bus.publish('delivery.updated', { type: 'delivery.updated', delivery: dto });
+        this.scheduleChannel(row.channelId);
+      }
+    })().catch((err: unknown) =>
+      this.log.warn('delivery feed failed', { err: serializeError(err) }),
+    );
+  }
+
+  private publishChannelNow(view: ChannelView): void {
+    this.deps.bus.publish('channel.changed', { type: 'channel.changed', channel: view });
+  }
+
+  /** Debounced `channel.changed` (stats moved). */
+  scheduleChannel(channelId: string): void {
+    this.pendingChannels.add(channelId);
+    if (this.channelTimer) return;
+    this.channelTimer = true;
+    const schedule =
+      this.deps.schedule ?? ((fn: () => void, ms: number) => void setTimeout(fn, ms));
+    schedule(() => {
+      this.channelTimer = false;
+      const ids = [...this.pendingChannels];
+      this.pendingChannels.clear();
+      void this.stats()
+        .then((stats) => {
+          for (const id of ids) {
+            const entry = this.deps.registry.get(id);
+            if (entry !== undefined) this.publishChannelNow(this.view(entry, stats));
+          }
+        })
+        .catch((err: unknown) =>
+          this.log.warn('channel feed failed', { err: serializeError(err) }),
+        );
+    }, CHANNEL_FEED_DEBOUNCE_MS);
+  }
+}
diff --git a/packages/core/src/app/notifications/public-url.ts b/packages/core/src/app/notifications/public-url.ts
new file mode 100644
index 0000000..2da7b9f
--- /dev/null
+++ b/packages/core/src/app/notifications/public-url.ts
@@ -0,0 +1,232 @@
+/** @module app/notifications/public-url — the `publicUrl` check (spec 08 §5.8, D-37): fetch `/health` and tell whether it reaches this BrowserHive, another server, a login in front, or nothing; plus the host/origin trust helpers. */
+
+import type { PublicUrlOutcome, PublicUrlStatus } from '@browserhive/contracts/http';
+import { serializeError } from '../../kernel/errors/serialize-error.ts';
+import { isLoopbackHost } from '../../kernel/url.ts';
+import type { Clock } from '../../ports/clock.ts';
+import type { UrlProbe, UrlProbeResult } from '../../ports/notification-channel.ts';
+
+/** Timeout of one probe. */
+export const PUBLIC_URL_PROBE_TIMEOUT_MS = 5_000;
+/** How long a check result is reused. */
+export const PUBLIC_URL_CACHE_MS = 60_000;
+
+/** Outcome of classifying one probe. */
+export interface PublicUrlVerdict {
+  readonly outcome: Exclude;
+  readonly detail: string;
+  readonly statusCode: number | null;
+  /** A BrowserHive answered, whether or not it could be confirmed as this one. */
+  readonly browserhive: boolean;
+}
+
+function hostOf(url: string): string | null {
+  try {
+    return new URL(url).hostname.replace(/^\[|\]$/g, '').toLowerCase();
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * The host of `publicUrl` (the name the `Host` guard and `/mcp` must accept), or `null` when unset.
+ *
+ * @returns The lower-cased host name without brackets.
+ */
+export function publicUrlHost(publicUrl: string | undefined): string | null {
+  return publicUrl === undefined ? null : hostOf(publicUrl);
+}
+
+/**
+ * The origin of `publicUrl` (`https://bh.example.net`, the origin guard's extra same origin).
+ *
+ * @returns The origin, or `null` when unset.
+ */
+export function publicUrlOrigin(publicUrl: string | undefined): string | null {
+  if (publicUrl === undefined) return null;
+  try {
+    return new URL(publicUrl).origin;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Whether `publicUrl` is plain `http:` on a host that is not loopback (links would travel without
+ * TLS, spec 08 §5.8).
+ */
+export function isInsecurePublicUrl(publicUrl: string | undefined): boolean {
+  if (publicUrl === undefined || !publicUrl.toLowerCase().startsWith('http:')) return false;
+  const host = hostOf(publicUrl);
+  return host !== null && !isLoopbackHost(host);
+}
+
+function parseHealth(body: string): { readonly instanceId: string | null; readonly isBh: boolean } {
+  try {
+    const json: unknown = JSON.parse(body);
+    if (typeof json !== 'object' || json === null) return { instanceId: null, isBh: false };
+    const record = json as Record;
+    const isBh = typeof record['version'] === 'string' && typeof record['status'] === 'string';
+    const id = record['instance_id'];
+    return { instanceId: typeof id === 'string' ? id : null, isBh };
+  } catch {
+    return { instanceId: null, isBh: false };
+  }
+}
+
+/**
+ * Classifies one probe of `/health` against this start's `instance_id` (`null` when
+ * there is no running server to compare with, as in `doctor` without a server).
+ *
+ * @returns The verdict.
+ */
+export function classifyPublicUrlProbe(
+  result: UrlProbeResult,
+  instanceId: string | null,
+): PublicUrlVerdict {
+  if (result.kind === 'error') {
+    return {
+      outcome: 'unreachable',
+      detail: `No answer from this machine (${result.detail}). It may still work from outside, for example behind a router without hairpin NAT.`,
+      statusCode: null,
+      browserhive: false,
+    };
+  }
+  const status = result.status;
+  if (status >= 300 && status < 400) {
+    const to = result.location === null ? null : hostOf(result.location);
+    return {
+      outcome: 'login',
+      detail: `It redirects${to === null ? '' : ` to ${to}`}: probably a login or an access proxy in front (for example Cloudflare Access), so it cannot be confirmed from here.`,
+      statusCode: status,
+      browserhive: false,
+    };
+  }
+  if (status === 401 || status === 403 || status === 407) {
+    return {
+      outcome: 'login',
+      detail: `It answers HTTP ${status}: a login or an access proxy is in front, so it cannot be confirmed from here.`,
+      statusCode: status,
+      browserhive: false,
+    };
+  }
+  const health = parseHealth(result.body);
+  if (health.isBh) {
+    if (instanceId === null) {
+      return {
+        outcome: 'ok',
+        detail: 'A BrowserHive answered (start the server to confirm it is this one).',
+        statusCode: status,
+        browserhive: true,
+      };
+    }
+    if (health.instanceId === instanceId) {
+      return {
+        outcome: 'ok',
+        detail: 'It points to this BrowserHive.',
+        statusCode: status,
+        browserhive: true,
+      };
+    }
+    return {
+      outcome: 'elsewhere',
+      detail: 'Another BrowserHive answered (a different instance, or an older version).',
+      statusCode: status,
+      browserhive: true,
+    };
+  }
+  if ((result.contentType ?? '').includes('text/html')) {
+    return {
+      outcome: 'login',
+      detail:
+        'A web page answered instead of BrowserHive: probably a login or an access proxy in front.',
+      statusCode: status,
+      browserhive: false,
+    };
+  }
+  return {
+    outcome: 'elsewhere',
+    detail: `Something answered with HTTP ${status}, but it is not BrowserHive.`,
+    statusCode: status,
+    browserhive: false,
+  };
+}
+
+/** Dependencies of {@link PublicUrlChecker}. */
+export interface PublicUrlCheckerDeps {
+  readonly publicUrl: string | undefined;
+  /** Where links point without `publicUrl` (the local listener). */
+  readonly localUrl: () => string;
+  readonly instanceId: string;
+  readonly probe: UrlProbe;
+  readonly clock: Clock;
+  readonly cacheMs?: number;
+}
+
+/** Runs and caches the `publicUrl` check for `GET /system/public-url` and the System page. */
+export class PublicUrlChecker {
+  private cached: { readonly at: number; readonly verdict: PublicUrlVerdict } | undefined;
+  private running: Promise | undefined;
+
+  constructor(private readonly deps: PublicUrlCheckerDeps) {}
+
+  /**
+   * The current status; runs the probe when `refresh` is set or the cached result is older than a
+   * minute. Never throws.
+   *
+   * @returns The status DTO.
+   */
+  async status(refresh = false): Promise {
+    const url = this.deps.publicUrl;
+    const base = {
+      configured: url !== undefined,
+      url: url ?? null,
+      local_url: this.deps.localUrl(),
+      host_trusted: url !== undefined,
+      insecure: isInsecurePublicUrl(url),
+    };
+    if (url === undefined) {
+      return {
+        ...base,
+        outcome: 'unset',
+        detail:
+          'Not set: links in notifications open on this computer only. Set publicUrl to open them on your phone.',
+        status_code: null,
+        checked_at: null,
+      };
+    }
+    const now = this.deps.clock.now();
+    const fresh =
+      !refresh &&
+      this.cached !== undefined &&
+      now - this.cached.at < (this.deps.cacheMs ?? PUBLIC_URL_CACHE_MS);
+    if (!fresh) {
+      this.running ??= this.check(url).finally(() => {
+        this.running = undefined;
+      });
+      const verdict = await this.running;
+      this.cached = { at: this.deps.clock.now(), verdict };
+    }
+    const cached = this.cached;
+    if (cached === undefined) throw new Error('public url check produced no result');
+    return {
+      ...base,
+      outcome: cached.verdict.outcome,
+      detail: cached.verdict.detail,
+      status_code: cached.verdict.statusCode,
+      checked_at: cached.at,
+    };
+  }
+
+  private async check(url: string): Promise {
+    try {
+      const result = await this.deps.probe(`${url}/health`, PUBLIC_URL_PROBE_TIMEOUT_MS);
+      return classifyPublicUrlProbe(result, this.deps.instanceId);
+    } catch (err) {
+      return classifyPublicUrlProbe(
+        { kind: 'error', detail: serializeError(err).message },
+        this.deps.instanceId,
+      );
+    }
+  }
+}
diff --git a/packages/core/src/interface/http/app.ts b/packages/core/src/interface/http/app.ts
index bfc8227..6ca34f8 100644
--- a/packages/core/src/interface/http/app.ts
+++ b/packages/core/src/interface/http/app.ts
@@ -4,6 +4,7 @@ import { API_PREFIX } from '@browserhive/contracts/http';
 import { WS_PATH } from '@browserhive/contracts/ws';
 import { Hono } from 'hono';
 import type { Authenticator } from '../../app/auth/authenticate.ts';
+import { publicUrlHost, publicUrlOrigin } from '../../app/notifications/public-url.ts';
 import { AppError } from '../../kernel/errors/app-error.ts';
 import type { Clock } from '../../ports/clock.ts';
 import type { IdGenerator } from '../../ports/id-generator.ts';
@@ -90,14 +91,19 @@ export function createHttpApp(deps: HttpAppDeps): HttpApp {
   );
   app.use('*', accessLog({ clock: deps.clock, logger: deps.logger }));
   app.use('*', secureHeaders());
+  const publicHost = publicUrlHost(config.publicUrl);
+  const publicOrigin = publicUrlOrigin(config.publicUrl);
   app.use(
     '*',
     hostGuard({
       host: config.host,
-      ...(config.allowedHosts !== undefined && { allowedHosts: config.allowedHosts }),
+      allowedHosts: [...(config.allowedHosts ?? []), ...(publicHost === null ? [] : [publicHost])],
     }),
   );
-  app.use(`${API_PREFIX}/*`, originGuard());
+  app.use(
+    `${API_PREFIX}/*`,
+    originGuard({ trustedOrigins: publicOrigin === null ? [] : [publicOrigin] }),
+  );
   app.onError(errorHandler(deps.logger));
 
   const health = (c: HttpContext) => {
diff --git a/packages/core/src/interface/http/env.ts b/packages/core/src/interface/http/env.ts
index 341779f..694c888 100644
--- a/packages/core/src/interface/http/env.ts
+++ b/packages/core/src/interface/http/env.ts
@@ -38,6 +38,11 @@ export interface HttpAppConfig {
   readonly authMode: AuthMode;
   /** Extra `Host` values accepted by the DNS-rebinding guard (in addition to loopback and `host`). */
   readonly allowedHosts?: readonly string[];
+  /**
+   * `publicUrl` (spec 08 §5.8, D-37): its host joins the `Host` allow-list and its origin passes
+   * the origin guard.
+   */
+  readonly publicUrl?: string;
   /** CIDR list of proxies whose `X-Forwarded-*` headers are trusted. */
   readonly trustedProxies?: readonly string[];
   /** `allowInsecureBind`: serve the local MCP principal to non-loopback peers under `auth=off`. */
diff --git a/packages/core/src/interface/http/health.ts b/packages/core/src/interface/http/health.ts
index 0241db0..7e71072 100644
--- a/packages/core/src/interface/http/health.ts
+++ b/packages/core/src/interface/http/health.ts
@@ -7,7 +7,7 @@ import type { HealthProbe, SystemFacts } from './services.ts';
 /** The health body and its status (200 only when `ready`). */
 export function healthOf(
   probe: HealthProbe,
-  facts: Pick,
+  facts: Pick,
   now: number,
 ): { status: 200 | 503; body: z.input } {
   const snapshot = probe.snapshot();
@@ -18,6 +18,7 @@ export function healthOf(
       phase: snapshot.phase,
       version: facts.version,
       uptime_ms: Math.max(0, now - facts.startedAt),
+      ...(facts.instanceId !== undefined && { instance_id: facts.instanceId }),
       checks: { ...snapshot.checks },
     },
   };
diff --git a/packages/core/src/interface/http/middleware/origin-guard.ts b/packages/core/src/interface/http/middleware/origin-guard.ts
index 471cc68..85f99a2 100644
--- a/packages/core/src/interface/http/middleware/origin-guard.ts
+++ b/packages/core/src/interface/http/middleware/origin-guard.ts
@@ -40,7 +40,10 @@ export function isSameOrigin(origin: string, host: string): boolean {
  * `same-origin`/`none`. Without either header the request is allowed only when it carries no
  * ambient cookie credential (a bearer or anonymous non-browser client cannot be forged cross-site).
  */
-export function originGuard(): MiddlewareHandler {
+export function originGuard(
+  options: { readonly trustedOrigins?: readonly string[] } = {},
+): MiddlewareHandler {
+  const trusted = new Set((options.trustedOrigins ?? []).map((o) => o.toLowerCase()));
   return async (c, next) => {
     const upgrade = c.req.header('upgrade')?.toLowerCase() === 'websocket';
     if (!MUTATING.has(c.req.method) && !upgrade) return next();
@@ -48,7 +51,8 @@ export function originGuard(): MiddlewareHandler {
     const origin = c.req.header('origin');
     const fetchSite = c.req.header('sec-fetch-site');
     let allowed: boolean;
-    if (origin !== undefined) allowed = isSameOrigin(origin, host);
+    if (origin !== undefined)
+      allowed = isSameOrigin(origin, host) || trusted.has(origin.toLowerCase());
     else if (fetchSite !== undefined) allowed = fetchSite === 'same-origin' || fetchSite === 'none';
     else allowed = c.req.header('cookie') === undefined;
     if (!allowed) {
diff --git a/packages/core/src/interface/http/routes/channels.ts b/packages/core/src/interface/http/routes/channels.ts
new file mode 100644
index 0000000..2eaaf77
--- /dev/null
+++ b/packages/core/src/interface/http/routes/channels.ts
@@ -0,0 +1,208 @@
+/** @module interface/http/routes/channels — notification channels: CRUD, pause/resume, test send, preview, the delivery log, the environment check and the Telegram connect flow (spec 03 §4.8.1). */
+
+import {
+  ChannelEnvQuery,
+  ChannelEnvResponse,
+  ChannelIdParams,
+  ChannelInput,
+  ChannelPatch,
+  ChannelPreview,
+  ChannelPreviewRequest,
+  ChannelResponse,
+  ChannelsResponse,
+  ChannelTestResponse,
+  DeliveriesPage,
+  DeliveriesQuery,
+  DeliveryDetailResponse,
+  DeliverySeqParams,
+  OkResponse,
+  TelegramConnectParams,
+  TelegramConnectRequest,
+  TelegramConnectResponse,
+  TelegramConnectStatus,
+} from '@browserhive/contracts/http';
+import { defineRoute, reply } from '../define-route.ts';
+import { appliedFilters } from '../serializers/page.ts';
+
+const tags = ['channels'];
+
+/** Channel routes. */
+export const CHANNEL_ROUTES = [
+  defineRoute({
+    operationId: 'listChannels',
+    tags,
+    summary:
+      'Every notification channel (dashboard and startup) with its state; never a secret value.',
+    request: {},
+    responses: { 200: ChannelsResponse },
+    async handler({ services, ctx }) {
+      return reply(200, { data: [...(await services.channels.list())], now: ctx.now });
+    },
+  }),
+  defineRoute({
+    operationId: 'createChannel',
+    tags,
+    summary: 'Create a channel. Secrets are environment variable names, never values (D-33).',
+    request: { body: ChannelInput },
+    responses: { 201: ChannelResponse },
+    errors: ['CHANNEL_NAME_TAKEN', 'CHANNEL_KIND_UNAVAILABLE'],
+    async handler({ input, services }) {
+      return reply(201, { channel: await services.channels.create(input.body) });
+    },
+  }),
+  defineRoute({
+    operationId: 'previewChannel',
+    tags,
+    summary: 'Render a sample notification exactly as the channel would send it. Sends nothing.',
+    request: { body: ChannelPreviewRequest },
+    responses: { 200: ChannelPreview },
+    errors: ['CHANNEL_NOT_FOUND', 'CHANNEL_KIND_UNAVAILABLE'],
+    async handler({ input, services }) {
+      return reply(200, services.channels.preview(input.body));
+    },
+  }),
+  defineRoute({
+    operationId: 'listDeliveries',
+    tags,
+    summary:
+      'The delivery log newest first: every send, edit and delete, and why anything was not sent.',
+    request: { query: DeliveriesQuery },
+    responses: { 200: DeliveriesPage },
+    async handler({ input, services, ctx }) {
+      const q = input.query;
+      const page = await services.channels.deliveries({
+        limit: q.limit,
+        ...(q.cursor !== undefined && { cursor: q.cursor }),
+        ...(q.channel_id !== undefined && { channelId: q.channel_id }),
+        ...(q.notification_id !== undefined && { notificationId: q.notification_id }),
+        ...(q.status !== undefined && { statuses: q.status }),
+        ...(q.op !== undefined && { ops: q.op }),
+        ...(q.kind !== undefined && { kinds: q.kind }),
+      });
+      return reply(200, {
+        data: [...page.items],
+        page: { next_cursor: page.nextCursor, limit: q.limit },
+        applied: { filters: appliedFilters(q), sort: { key: 'seq', dir: 'desc' } },
+        meta: { now: ctx.now },
+      });
+    },
+  }),
+  defineRoute({
+    operationId: 'getDelivery',
+    tags,
+    summary: 'One delivery with the message as that channel is shown it (redacted).',
+    request: { params: DeliverySeqParams },
+    responses: { 200: DeliveryDetailResponse },
+    errors: ['DELIVERY_NOT_FOUND'],
+    async handler({ input, services }) {
+      return reply(200, await services.channels.delivery(input.params.seq));
+    },
+  }),
+  defineRoute({
+    operationId: 'checkChannelEnv',
+    tags,
+    summary: 'Whether each named environment variable is set in the server (never its value).',
+    request: { query: ChannelEnvQuery },
+    responses: { 200: ChannelEnvResponse },
+    async handler({ input, services }) {
+      return reply(200, { vars: services.channels.env(input.query.names) });
+    },
+  }),
+  defineRoute({
+    operationId: 'startTelegramConnect',
+    tags,
+    summary: 'Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.',
+    request: { body: TelegramConnectRequest },
+    responses: { 200: TelegramConnectResponse },
+    errors: ['CHANNEL_NOT_READY', 'CHANNEL_PLATFORM_ERROR'],
+    rateLimit: { limit: 6, windowMs: 60_000, key: 'principal' },
+    async handler({ input, services }) {
+      return reply(200, await services.channels.telegramConnect(input.body.token_env));
+    },
+  }),
+  defineRoute({
+    operationId: 'getTelegramConnect',
+    tags,
+    summary: 'State of a Telegram connect: waiting, connected (with the chat), expired or failed.',
+    request: { params: TelegramConnectParams },
+    responses: { 200: TelegramConnectStatus },
+    async handler({ input, services }) {
+      return reply(200, services.channels.telegramConnectStatus(input.params.connect_id));
+    },
+  }),
+  defineRoute({
+    operationId: 'getChannel',
+    tags,
+    summary: 'One channel.',
+    request: { params: ChannelIdParams },
+    responses: { 200: ChannelResponse },
+    errors: ['CHANNEL_NOT_FOUND'],
+    async handler({ input, services }) {
+      return reply(200, { channel: await services.channels.get(input.params.channel_id) });
+    },
+  }),
+  defineRoute({
+    operationId: 'updateChannel',
+    tags,
+    summary: 'Edit a dashboard channel (startup channels are read-only).',
+    request: { params: ChannelIdParams, body: ChannelPatch },
+    responses: { 200: ChannelResponse },
+    errors: [
+      'CHANNEL_NOT_FOUND',
+      'CHANNEL_READ_ONLY',
+      'CHANNEL_NAME_TAKEN',
+      'CHANNEL_KIND_UNAVAILABLE',
+    ],
+    async handler({ input, services }) {
+      return reply(200, {
+        channel: await services.channels.update(input.params.channel_id, input.body),
+      });
+    },
+  }),
+  defineRoute({
+    operationId: 'deleteChannel',
+    tags,
+    summary: 'Delete a dashboard channel and its delivery log.',
+    request: { params: ChannelIdParams },
+    responses: { 200: OkResponse },
+    errors: ['CHANNEL_NOT_FOUND', 'CHANNEL_READ_ONLY'],
+    async handler({ input, services }) {
+      await services.channels.remove(input.params.channel_id);
+      return reply(200, { ok: true });
+    },
+  }),
+  defineRoute({
+    operationId: 'pauseChannel',
+    tags,
+    summary: 'Pause a channel; its pending deliveries are suppressed.',
+    request: { params: ChannelIdParams },
+    responses: { 200: ChannelResponse },
+    errors: ['CHANNEL_NOT_FOUND'],
+    async handler({ input, services }) {
+      return reply(200, { channel: await services.channels.pause(input.params.channel_id) });
+    },
+  }),
+  defineRoute({
+    operationId: 'resumeChannel',
+    tags,
+    summary: 'Resume a paused or broken channel.',
+    request: { params: ChannelIdParams },
+    responses: { 200: ChannelResponse },
+    errors: ['CHANNEL_NOT_FOUND'],
+    async handler({ input, services }) {
+      return reply(200, { channel: await services.channels.resume(input.params.channel_id) });
+    },
+  }),
+  defineRoute({
+    operationId: 'testChannel',
+    tags,
+    summary: 'Send a real test message through the channel now; the result says why it failed.',
+    request: { params: ChannelIdParams },
+    responses: { 200: ChannelTestResponse },
+    errors: ['CHANNEL_NOT_FOUND', 'CHANNEL_NOT_READY'],
+    rateLimit: { limit: 10, windowMs: 60_000, key: 'principal' },
+    async handler({ input, services }) {
+      return reply(200, await services.channels.test(input.params.channel_id));
+    },
+  }),
+];
diff --git a/packages/core/src/interface/http/routes/index.ts b/packages/core/src/interface/http/routes/index.ts
index 663e1ee..bc8e32b 100644
--- a/packages/core/src/interface/http/routes/index.ts
+++ b/packages/core/src/interface/http/routes/index.ts
@@ -5,6 +5,7 @@ import type { AnyRoute } from '../define-route.ts';
 import { ATTENTION_ROUTES } from './attention.ts';
 import { AUTH_ROUTES } from './auth.ts';
 import { BLOCKLIST_ROUTES } from './blocklist.ts';
+import { CHANNEL_ROUTES } from './channels.ts';
 import { FLEET_ROUTES } from './fleet.ts';
 import { LOG_ROUTES } from './logs.ts';
 import { type MetaRouteDeps, metaRoutes } from './meta.ts';
@@ -35,6 +36,7 @@ export function apiRoutes(deps: MetaRouteDeps & { readonly logger: Logger }): re
     ...SYSTEM_ROUTES,
     ...LOG_ROUTES,
     ...NOTIFICATION_ROUTES,
+    ...CHANNEL_ROUTES,
     ...searchRoutes(deps.logger),
   ];
 }
diff --git a/packages/core/src/interface/http/routes/system.ts b/packages/core/src/interface/http/routes/system.ts
index fb2227a..50cf247 100644
--- a/packages/core/src/interface/http/routes/system.ts
+++ b/packages/core/src/interface/http/routes/system.ts
@@ -3,6 +3,8 @@
 import {
   McpConnectionsQuery,
   McpConnectionsResponse,
+  PublicUrlQuery,
+  PublicUrlStatus,
   SetLogLevelRequest,
   SetLogLevelResponse,
   SystemConfigResponse,
@@ -40,6 +42,16 @@ export const SYSTEM_ROUTES = [
       return reply(200, { keys: configKeysToWire(services.system.configView()) });
     },
   }),
+  defineRoute({
+    operationId: 'getPublicUrlStatus',
+    tags,
+    summary: 'The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)',
+    request: { query: PublicUrlQuery },
+    responses: { 200: PublicUrlStatus },
+    async handler({ input, services }) {
+      return reply(200, await services.publicUrl.status(input.query.refresh === true));
+    },
+  }),
   defineRoute({
     operationId: 'getSystemRealtime',
     tags,
diff --git a/packages/core/src/interface/http/services.ts b/packages/core/src/interface/http/services.ts
index 9c17ff6..22626b0 100644
--- a/packages/core/src/interface/http/services.ts
+++ b/packages/core/src/interface/http/services.ts
@@ -13,6 +13,8 @@ import type { AttentionService } from '../../app/attention/attention-service.ts'
 import type { AuthService } from '../../app/auth/auth-service.ts';
 import type { ConfigView } from '../../app/config/provenance-view.ts';
 import type { DomainEvents } from '../../app/events/catalog.ts';
+import type { ChannelService } from '../../app/notifications/channel-service.ts';
+import type { PublicUrlChecker } from '../../app/notifications/public-url.ts';
 import type { SessionDirLayout } from '../../app/sessions/profile-dir.ts';
 import type { SessionService } from '../../app/sessions/session-service.ts';
 import type { VaultAdmin } from '../../app/vault/vault-admin.ts';
@@ -172,6 +174,8 @@ export interface SystemFacts {
   readonly startedAt: number;
   /** `trace` config key (trace descriptors report `enabled`). */
   readonly traceEnabled: boolean;
+  /** Random per start; `GET /health` reports it for the `publicUrl` check (spec 08 §5.8). */
+  readonly instanceId?: string;
 }
 
 /** The system surface (`GET /system/config` and the facts above). */
@@ -277,4 +281,30 @@ export interface HttpServices {
   readonly ids: Pick;
   /** Whether the Playwright trace viewer bundle is servable. */
   readonly traceViewerAvailable: boolean;
+  /** Notification channels (spec 03 §4.8.1). */
+  readonly channels: ChannelsPort;
+  /** The `publicUrl` check (spec 08 §5.8). */
+  readonly publicUrl: PublicUrlPort;
 }
+
+/** The notification channels API (a structural slice of `ChannelService`). */
+export type ChannelsPort = Pick<
+  ChannelService,
+  | 'list'
+  | 'get'
+  | 'create'
+  | 'update'
+  | 'remove'
+  | 'pause'
+  | 'resume'
+  | 'test'
+  | 'preview'
+  | 'deliveries'
+  | 'delivery'
+  | 'env'
+  | 'telegramConnect'
+  | 'telegramConnectStatus'
+>;
+
+/** The `publicUrl` check (a structural slice of `PublicUrlChecker`). */
+export type PublicUrlPort = Pick;
diff --git a/packages/core/src/public/runtime.ts b/packages/core/src/public/runtime.ts
index 6250d3b..024e570 100644
--- a/packages/core/src/public/runtime.ts
+++ b/packages/core/src/public/runtime.ts
@@ -15,6 +15,11 @@ export {
   retentionPolicyFromConfig,
 } from '../app/maintenance/retention-scheduler.ts';
 export { reconcileOnStartup } from '../app/maintenance/startup-reconcile.ts';
+export {
+  classifyPublicUrlProbe,
+  isInsecurePublicUrl,
+  type PublicUrlVerdict,
+} from '../app/notifications/public-url.ts';
 export { DegradationService } from '../app/observability/degradations.ts';
 export { createLogPersistSink, LogPersistSink } from '../app/observability/log-persist-sink.ts';
 export { createBunPasswordHasher } from '../infra/auth/bun-password-hasher.ts';
@@ -49,6 +54,7 @@ export type { FileSystem } from '../ports/file-system.ts';
 export type { HostEnvironment } from '../ports/host-environment.ts';
 export type { IdGenerator } from '../ports/id-generator.ts';
 export type { LogFields, Logger, LogLevel } from '../ports/logger.ts';
+export type { UrlProbeResult } from '../ports/notification-channel.ts';
 export {
   type ChannelCapabilities,
   type ChannelDelivery,

From 6814b65874adc65ff54e8b653a2f7acc5c9ec5e5 Mon Sep 17 00:00:00 2001
From: Amir Ghorbani 
Date: Mon, 28 Sep 2026 20:40:40 -0400
Subject: [PATCH 08/22] test(notifications): platform fakes, renderer goldens
 and adapter suites

Bun.serve fakes of the Telegram Bot API, Discord webhooks, ntfy and a
webhook receiver with scripted failures; renderer golden files per
platform, sample and variant (screenshot, local links, counts, edits,
Discord bot mode); escaping and length properties; every adapter against
the fakes including 429, 5xx, timeouts and vanished messages; the full
path event to send, edit and TTL delete on SQLite per platform; and the
redaction sentinel rendered through every platform and webhook body.
---
 package.json                                  |   2 +-
 packages/core/src/infra/notifications/http.ts |   3 +-
 packages/core/src/infra/notifications/ntfy.ts |   8 +-
 .../core/src/infra/notifications/url-probe.ts |   5 +-
 .../notifications/discord/attention-bot.json  |  84 ++++
 .../discord/attention-counts.json             |  51 +++
 .../discord/attention-image.json              |  93 ++++
 .../discord/attention-local.json              |  59 +++
 .../discord/attention-resolved-edit.json      |  70 +++
 .../attention-resolved-image-edit.json        |  77 ++++
 .../discord/attention-resolved.json           |  69 +++
 .../notifications/discord/attention.json      |  78 ++++
 .../goldens/notifications/discord/crash.json  |  62 +++
 .../notifications/discord/degraded.json       |  57 +++
 .../notifications/discord/test-local.json     |  44 ++
 .../goldens/notifications/discord/test.json   |  57 +++
 .../notifications/discord/tool-errors.json    |  67 +++
 .../notifications/discord/vault-confirm.json  |  72 +++
 .../notifications/ntfy/attention-counts.json  |  40 ++
 .../notifications/ntfy/attention-image.json   |  42 ++
 .../notifications/ntfy/attention-local.json   |  40 ++
 .../ntfy/attention-resolved-edit.json         |  25 +
 .../ntfy/attention-resolved-image-edit.json   |  27 ++
 .../ntfy/attention-resolved.json              |  25 +
 .../goldens/notifications/ntfy/attention.json |  40 ++
 .../goldens/notifications/ntfy/crash.json     |  34 ++
 .../goldens/notifications/ntfy/degraded.json  |  34 ++
 .../notifications/ntfy/test-local.json        |  34 ++
 .../test/goldens/notifications/ntfy/test.json |  34 ++
 .../notifications/ntfy/tool-errors.json       |  34 ++
 .../notifications/ntfy/vault-confirm.json     |  34 ++
 .../telegram/attention-counts.json            |  37 ++
 .../telegram/attention-image.json             |  38 ++
 .../telegram/attention-local.json             |  23 +
 .../telegram/attention-resolved-edit.json     |  26 ++
 .../attention-resolved-image-edit.json        |  23 +
 .../telegram/attention-resolved.json          |  23 +
 .../notifications/telegram/attention.json     |  37 ++
 .../goldens/notifications/telegram/crash.json |  33 ++
 .../notifications/telegram/degraded.json      |  33 ++
 .../notifications/telegram/test-local.json    |  23 +
 .../goldens/notifications/telegram/test.json  |  33 ++
 .../notifications/telegram/tool-errors.json   |  33 ++
 .../notifications/telegram/vault-confirm.json |  33 ++
 .../webhook/attention-counts.json             |  68 +++
 .../webhook/attention-image.json              | 145 ++++++
 .../webhook/attention-local.json              | 135 ++++++
 .../webhook/attention-resolved-edit.json      | 136 ++++++
 .../attention-resolved-image-edit.json        | 146 ++++++
 .../webhook/attention-resolved.json           | 136 ++++++
 .../notifications/webhook/attention.json      | 135 ++++++
 .../goldens/notifications/webhook/crash.json  |  95 ++++
 .../notifications/webhook/degraded.json       |  84 ++++
 .../notifications/webhook/test-local.json     |  82 ++++
 .../goldens/notifications/webhook/test.json   |  82 ++++
 .../notifications/webhook/tool-errors.json    | 106 +++++
 .../notifications/webhook/vault-confirm.json  | 117 +++++
 packages/core/test/helpers/fake-platforms.ts  | 320 +++++++++++++
 .../core/test/notifications/adapters.test.ts  | 427 ++++++++++++++++++
 .../notifications/full-path.sqlite.test.ts    | 177 ++++++++
 packages/core/test/notifications/helpers.ts   |  89 ++++
 .../test/notifications/render.golden.test.ts  | 105 +++++
 .../notifications/render.property.test.ts     | 240 ++++++++++
 .../renderer-redaction.property.test.ts       | 161 +++++++
 .../notifications/setup-store-probe.test.ts   | 146 ++++++
 65 files changed, 4921 insertions(+), 7 deletions(-)
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-bot.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-counts.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-image.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-local.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-resolved-edit.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-resolved-image-edit.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention-resolved.json
 create mode 100644 packages/core/test/goldens/notifications/discord/attention.json
 create mode 100644 packages/core/test/goldens/notifications/discord/crash.json
 create mode 100644 packages/core/test/goldens/notifications/discord/degraded.json
 create mode 100644 packages/core/test/goldens/notifications/discord/test-local.json
 create mode 100644 packages/core/test/goldens/notifications/discord/test.json
 create mode 100644 packages/core/test/goldens/notifications/discord/tool-errors.json
 create mode 100644 packages/core/test/goldens/notifications/discord/vault-confirm.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-counts.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-image.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-local.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-resolved-edit.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-resolved-image-edit.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention-resolved.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/attention.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/crash.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/degraded.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/test-local.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/test.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/tool-errors.json
 create mode 100644 packages/core/test/goldens/notifications/ntfy/vault-confirm.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-counts.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-image.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-local.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention-resolved.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/attention.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/crash.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/degraded.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/test-local.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/test.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/tool-errors.json
 create mode 100644 packages/core/test/goldens/notifications/telegram/vault-confirm.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-counts.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-image.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-local.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-resolved-edit.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-resolved-image-edit.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention-resolved.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/attention.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/crash.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/degraded.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/test-local.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/test.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/tool-errors.json
 create mode 100644 packages/core/test/goldens/notifications/webhook/vault-confirm.json
 create mode 100644 packages/core/test/helpers/fake-platforms.ts
 create mode 100644 packages/core/test/notifications/adapters.test.ts
 create mode 100644 packages/core/test/notifications/full-path.sqlite.test.ts
 create mode 100644 packages/core/test/notifications/helpers.ts
 create mode 100644 packages/core/test/notifications/render.golden.test.ts
 create mode 100644 packages/core/test/notifications/render.property.test.ts
 create mode 100644 packages/core/test/notifications/renderer-redaction.property.test.ts
 create mode 100644 packages/core/test/notifications/setup-store-probe.test.ts

diff --git a/package.json b/package.json
index a89326e..125fcb3 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
     "release:publish": "bun run build && bun run package:check && changeset publish",
     "sync:version": "bun scripts/sync-version.ts",
     "typecheck:tsc": "tsc -b --pretty",
-    "test:server": "bun test packages/contracts packages/core/src packages/core/test/persistence packages/core/test/lint packages/browserhive/src packages/browserhive/test/cli packages/browserhive/test/composition test/lint test/docs",
+    "test:server": "bun test packages/contracts packages/core/src packages/core/test/persistence packages/core/test/notifications packages/core/test/lint packages/browserhive/src packages/browserhive/test/cli packages/browserhive/test/composition test/lint test/docs",
     "test:dashboard": "bun test packages/dashboard/src",
     "website:install": "bun install --cwd website --frozen-lockfile",
     "website:dev": "bun run --cwd website dev",
diff --git a/packages/core/src/infra/notifications/http.ts b/packages/core/src/infra/notifications/http.ts
index c86b119..8cedd64 100644
--- a/packages/core/src/infra/notifications/http.ts
+++ b/packages/core/src/infra/notifications/http.ts
@@ -1,5 +1,6 @@
 /** @module infra/notifications/http — the one HTTP helper of the platform adapters (spec 03 §9.5): timeouts, JSON/multipart/binary bodies, manual redirects for operator-supplied URLs, and the classification of every failure into a `ChannelSendError` that never carries a secret. */
 
+import { serializeError } from '../../kernel/errors/serialize-error.ts';
 import { ChannelSendError } from '../../ports/notification-channel.ts';
 
 /** The `fetch` the adapters call; injectable for fakes. */
@@ -178,7 +179,7 @@ async function once(call: PlatformCall, url: string, options: CallOptions): Prom
     if (isAbort(err)) {
       throw new ChannelSendError('timeout', `${options.platform} did not answer in time`);
     }
-    const raw = err instanceof Error ? err.message : String(err);
+    const raw = serializeError(err).message;
     throw new ChannelSendError(
       'unavailable',
       `${options.platform} unreachable: ${scrubDetail(raw.replace(/https?:\/\/\S+/g, ''), options.secrets)}`,
diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts
index cb49435..36a9709 100644
--- a/packages/core/src/infra/notifications/ntfy.ts
+++ b/packages/core/src/infra/notifications/ntfy.ts
@@ -31,9 +31,13 @@ export const NTFY_MESSAGE_MAX_BYTES = 4000;
 /** ntfy allows three action buttons. */
 export const NTFY_ACTIONS_MAX = 3;
 
-/** What the ntfy renderer supports: plain text, a screenshot, three `view` actions, replace and delete. */
+/**
+ * What the ntfy renderer supports: a screenshot, three `view` actions, replace and delete. Rich
+ * blocks arrive as blocks and the renderer writes them as plain text itself (fields one per line,
+ * quotes in quotation marks), which reads better than `degrade`'s generic flattening.
+ */
 export const NTFY_CAPABILITIES: ChannelCapabilities = {
-  richBlocks: false,
+  richBlocks: true,
   tables: false,
   images: true,
   actButtons: false,
diff --git a/packages/core/src/infra/notifications/url-probe.ts b/packages/core/src/infra/notifications/url-probe.ts
index 566cb0a..207247f 100644
--- a/packages/core/src/infra/notifications/url-probe.ts
+++ b/packages/core/src/infra/notifications/url-probe.ts
@@ -1,5 +1,6 @@
 /** @module infra/notifications/url-probe — one GET without following redirects, for the `publicUrl` check (spec 08 §5.8). */
 
+import { serializeError } from '../../kernel/errors/serialize-error.ts';
 import type { UrlProbe, UrlProbeResult } from '../../ports/notification-channel.ts';
 import type { FetchFn } from './http.ts';
 
@@ -60,9 +61,7 @@ export function createUrlProbe(options: UrlProbeOptions = {}): UrlProbe {
       const detail =
         name === 'TimeoutError' || name === 'AbortError'
           ? `no answer within ${Math.round(timeoutMs / 1000)} s`
-          : err instanceof Error
-            ? err.message.replace(/https?:\/\/\S+/g, '')
-            : String(err);
+          : serializeError(err).message.replace(/https?:\/\/\S+/g, '');
       return { kind: 'error', detail };
     }
   };
diff --git a/packages/core/test/goldens/notifications/discord/attention-bot.json b/packages/core/test/goldens/notifications/discord/attention-bot.json
new file mode 100644
index 0000000..38fdf97
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-bot.json
@@ -0,0 +1,84 @@
+{
+  "kind": "discord",
+  "variant": "attention-bot",
+  "mode": "bot",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Take over",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+              },
+              {
+                "type": 2,
+                "style": 2,
+                "label": "Mark resolved",
+                "custom_id": "bh1:preview"
+              },
+              {
+                "type": 2,
+                "style": 4,
+                "label": "Reject",
+                "custom_id": "bh1:preview"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "⚠️ Attention requested",
+            "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "color": 16096779,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-counts.json b/packages/core/test/goldens/notifications/discord/attention-counts.json
new file mode 100644
index 0000000..b16bd63
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-counts.json
@@ -0,0 +1,51 @@
+{
+  "kind": "discord",
+  "variant": "attention-counts",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Take over",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+              },
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open in BrowserHive",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "⚠️ Attention requested",
+            "description": "Session checkout",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "color": 16096779,
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-image.json b/packages/core/test/goldens/notifications/discord/attention-image.json
new file mode 100644
index 0000000..0fe7439
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-image.json
@@ -0,0 +1,93 @@
+{
+  "kind": "discord",
+  "variant": "attention-image",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "multipart",
+      "body": {
+        "payload_json": {
+          "content": null,
+          "allowed_mentions": {
+            "parse": []
+          },
+          "components": [
+            {
+              "type": 1,
+              "components": [
+                {
+                  "type": 2,
+                  "style": 5,
+                  "label": "Take over",
+                  "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+                },
+                {
+                  "type": 2,
+                  "style": 5,
+                  "label": "Open in BrowserHive",
+                  "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1"
+                }
+              ]
+            }
+          ],
+          "embeds": [
+            {
+              "title": "⚠️ Attention requested",
+              "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen",
+              "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+              "color": 16096779,
+              "fields": [
+                {
+                  "name": "Mode",
+                  "value": "takeover",
+                  "inline": true
+                },
+                {
+                  "name": "Session",
+                  "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                  "inline": true
+                },
+                {
+                  "name": "Page",
+                  "value": "`https://shop.example.com/checkout/payment`",
+                  "inline": true
+                },
+                {
+                  "name": "Tool",
+                  "value": "`click`",
+                  "inline": true
+                },
+                {
+                  "name": "Waiting since",
+                  "value": "",
+                  "inline": true
+                }
+              ],
+              "image": {
+                "url": "attachment://screenshot.jpg"
+              },
+              "footer": {
+                "text": "BrowserHive"
+              },
+              "timestamp": "2026-09-21T14:13:20.000Z"
+            }
+          ],
+          "attachments": [
+            {
+              "id": 0,
+              "filename": "screenshot.jpg"
+            }
+          ]
+        }
+      },
+      "headers": {},
+      "file": {
+        "ref": "nimg-sample",
+        "name": "screenshot.jpg",
+        "content_type": "image/jpeg"
+      }
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-local.json b/packages/core/test/goldens/notifications/discord/attention-local.json
new file mode 100644
index 0000000..826c407
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-local.json
@@ -0,0 +1,59 @@
+{
+  "kind": "discord",
+  "variant": "attention-local",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [],
+        "embeds": [
+          {
+            "title": "⚠️ Attention requested",
+            "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\n**🖥 Open on this computer**\nTake over: `http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1`\nOpen in BrowserHive: `http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1`",
+            "color": 16096779,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "checkout",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-resolved-edit.json b/packages/core/test/goldens/notifications/discord/attention-resolved-edit.json
new file mode 100644
index 0000000..cf041e2
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-resolved-edit.json
@@ -0,0 +1,70 @@
+{
+  "kind": "discord",
+  "variant": "attention-resolved-edit",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "PATCH",
+      "path": "{secret:webhook}/messages/1101?with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [],
+        "embeds": [
+          {
+            "title": "✅ Attention requested",
+            "description": "Resolved by admin after 2m 10s\n\n> CAPTCHA on the checkout page: please solve it, then resume",
+            "color": 2278750,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              },
+              {
+                "name": "Outcome",
+                "value": "resolved",
+                "inline": true
+              },
+              {
+                "name": "Settled",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:15:30.000Z"
+          }
+        ],
+        "attachments": []
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/discord/attention-resolved-image-edit.json
new file mode 100644
index 0000000..32dcb6d
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-resolved-image-edit.json
@@ -0,0 +1,77 @@
+{
+  "kind": "discord",
+  "variant": "attention-resolved-image-edit",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "PATCH",
+      "path": "{secret:webhook}/messages/1101?with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [],
+        "embeds": [
+          {
+            "title": "✅ Attention requested",
+            "description": "Resolved by admin after 2m 10s\n\n> CAPTCHA on the checkout page: please solve it, then resume",
+            "color": 2278750,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              },
+              {
+                "name": "Outcome",
+                "value": "resolved",
+                "inline": true
+              },
+              {
+                "name": "Settled",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "image": {
+              "url": "attachment://screenshot.jpg"
+            },
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:15:30.000Z"
+          }
+        ],
+        "attachments": [
+          {
+            "id": "9001101"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention-resolved.json b/packages/core/test/goldens/notifications/discord/attention-resolved.json
new file mode 100644
index 0000000..b088d10
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention-resolved.json
@@ -0,0 +1,69 @@
+{
+  "kind": "discord",
+  "variant": "attention-resolved",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [],
+        "embeds": [
+          {
+            "title": "✅ Attention requested",
+            "description": "Resolved by admin after 2m 10s\n\n> CAPTCHA on the checkout page: please solve it, then resume",
+            "color": 2278750,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              },
+              {
+                "name": "Outcome",
+                "value": "resolved",
+                "inline": true
+              },
+              {
+                "name": "Settled",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:15:30.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/attention.json b/packages/core/test/goldens/notifications/discord/attention.json
new file mode 100644
index 0000000..25e4a69
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/attention.json
@@ -0,0 +1,78 @@
+{
+  "kind": "discord",
+  "variant": "attention",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Take over",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+              },
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open in BrowserHive",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "⚠️ Attention requested",
+            "description": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "color": 16096779,
+            "fields": [
+              {
+                "name": "Mode",
+                "value": "takeover",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://shop.example.com/checkout/payment`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`click`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/crash.json b/packages/core/test/goldens/notifications/discord/crash.json
new file mode 100644
index 0000000..5ffdc57
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/crash.json
@@ -0,0 +1,62 @@
+{
+  "kind": "discord",
+  "variant": "crash",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open session",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "🔴 Session crashed",
+            "description": "reason: crash",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4",
+            "color": 15680580,
+            "fields": [
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Reason",
+                "value": "`crash`",
+                "inline": true
+              },
+              {
+                "name": "Closed",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/degraded.json b/packages/core/test/goldens/notifications/discord/degraded.json
new file mode 100644
index 0000000..7f9e7d2
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/degraded.json
@@ -0,0 +1,57 @@
+{
+  "kind": "discord",
+  "variant": "degraded",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open System",
+                "url": "https://bh.example.net/system"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "🔴 The retention sweep failed: database is locked",
+            "description": "RETENTION\\_FAILED",
+            "url": "https://bh.example.net/system",
+            "color": 15680580,
+            "fields": [
+              {
+                "name": "Code",
+                "value": "`RETENTION_FAILED`",
+                "inline": true
+              },
+              {
+                "name": "Since",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/test-local.json b/packages/core/test/goldens/notifications/discord/test-local.json
new file mode 100644
index 0000000..3b6298f
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/test-local.json
@@ -0,0 +1,44 @@
+{
+  "kind": "discord",
+  "variant": "test-local",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [],
+        "embeds": [
+          {
+            "title": "ℹ️ BrowserHive test message",
+            "description": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\n**🖥 Open on this computer**\nOpen dashboard: `http://127.0.0.1:9876/notifications/channels`",
+            "color": 3900150,
+            "fields": [
+              {
+                "name": "Sent",
+                "value": "",
+                "inline": true
+              },
+              {
+                "name": "Kind",
+                "value": "`test`",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/test.json b/packages/core/test/goldens/notifications/discord/test.json
new file mode 100644
index 0000000..9bc8fd5
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/test.json
@@ -0,0 +1,57 @@
+{
+  "kind": "discord",
+  "variant": "test",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open dashboard",
+                "url": "https://bh.example.net/notifications/channels"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "ℹ️ BrowserHive test message",
+            "description": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.",
+            "url": "https://bh.example.net/notifications/channels",
+            "color": 3900150,
+            "fields": [
+              {
+                "name": "Sent",
+                "value": "",
+                "inline": true
+              },
+              {
+                "name": "Kind",
+                "value": "`test`",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/tool-errors.json b/packages/core/test/goldens/notifications/discord/tool-errors.json
new file mode 100644
index 0000000..d4166bd
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/tool-errors.json
@@ -0,0 +1,67 @@
+{
+  "kind": "discord",
+  "variant": "tool-errors",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Open errors",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "⚠️ checkout · 3 tool errors",
+            "description": "navigate · NAVIGATION\\_TIMEOUT \\(30000 ms\\)",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1",
+            "color": 16096779,
+            "fields": [
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Errors",
+                "value": "3",
+                "inline": true
+              },
+              {
+                "name": "Latest",
+                "value": "`navigate · NAVIGATION_TIMEOUT`",
+                "inline": true
+              },
+              {
+                "name": "Duration",
+                "value": "30 s",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:14:00.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/discord/vault-confirm.json b/packages/core/test/goldens/notifications/discord/vault-confirm.json
new file mode 100644
index 0000000..8972362
--- /dev/null
+++ b/packages/core/test/goldens/notifications/discord/vault-confirm.json
@@ -0,0 +1,72 @@
+{
+  "kind": "discord",
+  "variant": "vault-confirm",
+  "mode": "webhook",
+  "requests": [
+    {
+      "method": "POST",
+      "path": "{secret:webhook}?wait=true&with_components=true",
+      "encoding": "json",
+      "body": {
+        "content": null,
+        "allowed_mentions": {
+          "parse": []
+        },
+        "components": [
+          {
+            "type": 1,
+            "components": [
+              {
+                "type": 2,
+                "style": 5,
+                "label": "Review in BrowserHive",
+                "url": "https://bh.example.net/vault?tab=confirm"
+              }
+            ]
+          }
+        ],
+        "embeds": [
+          {
+            "title": "⚠️ Vault fill awaiting confirm",
+            "description": "entry github — approve or deny the release",
+            "url": "https://bh.example.net/vault?tab=confirm",
+            "color": 16096779,
+            "fields": [
+              {
+                "name": "Entry",
+                "value": "`github`",
+                "inline": true
+              },
+              {
+                "name": "Session",
+                "value": "[checkout](https://bh.example.net/sessions/checkout-a1b2c3d4)",
+                "inline": true
+              },
+              {
+                "name": "Page",
+                "value": "`https://github.com/login`",
+                "inline": true
+              },
+              {
+                "name": "Tool",
+                "value": "`vault_fill`",
+                "inline": true
+              },
+              {
+                "name": "Waiting since",
+                "value": "",
+                "inline": true
+              }
+            ],
+            "footer": {
+              "text": "BrowserHive"
+            },
+            "timestamp": "2026-09-21T14:13:20.000Z"
+          }
+        ]
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-counts.json b/packages/core/test/goldens/notifications/ntfy/attention-counts.json
new file mode 100644
index 0000000..32efa9c
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-counts.json
@@ -0,0 +1,40 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-counts",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Attention requested",
+        "message": "Session checkout",
+        "priority": 4,
+        "tags": [
+          "warning"
+        ],
+        "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Take over",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "clear": false
+          },
+          {
+            "action": "view",
+            "label": "Open in BrowserHive",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-image.json b/packages/core/test/goldens/notifications/ntfy/attention-image.json
new file mode 100644
index 0000000..0b8ff8c
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-image.json
@@ -0,0 +1,42 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-image",
+  "mode": null,
+  "requests": [
+    {
+      "method": "PUT",
+      "path": "/bh-alerts/n-sample000001",
+      "encoding": "binary",
+      "body": {
+        "title": "Attention requested",
+        "message": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "warning"
+        ],
+        "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Take over",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "clear": false
+          },
+          {
+            "action": "view",
+            "label": "Open in BrowserHive",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1",
+            "clear": false
+          }
+        ],
+        "filename": "screenshot.jpg"
+      },
+      "headers": {},
+      "file": {
+        "ref": "nimg-sample",
+        "name": "screenshot.jpg",
+        "content_type": "image/jpeg"
+      }
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-local.json b/packages/core/test/goldens/notifications/ntfy/attention-local.json
new file mode 100644
index 0000000..f1dacbc
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-local.json
@@ -0,0 +1,40 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-local",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Attention requested",
+        "message": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "warning"
+        ],
+        "click": "http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open on this computer",
+            "url": "http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "clear": false
+          },
+          {
+            "action": "view",
+            "label": "Open in BrowserHive",
+            "url": "http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-resolved-edit.json b/packages/core/test/goldens/notifications/ntfy/attention-resolved-edit.json
new file mode 100644
index 0000000..1020072
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-resolved-edit.json
@@ -0,0 +1,25 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-resolved-edit",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Attention requested",
+        "message": "Resolved by admin after 2m 10s\n\n“CAPTCHA on the checkout page: please solve it, then resume”\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC",
+        "priority": 2,
+        "tags": [
+          "white_check_mark"
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/ntfy/attention-resolved-image-edit.json
new file mode 100644
index 0000000..eedddd7
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-resolved-image-edit.json
@@ -0,0 +1,27 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-resolved-image-edit",
+  "mode": null,
+  "requests": [
+    {
+      "method": "PUT",
+      "path": "/bh-alerts/n-sample000001",
+      "encoding": "binary",
+      "body": {
+        "title": "Attention requested",
+        "message": "Resolved by admin after 2m 10s\n\n“CAPTCHA on the checkout page: please solve it, then resume”\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC",
+        "priority": 2,
+        "tags": [
+          "white_check_mark"
+        ],
+        "filename": "screenshot.jpg"
+      },
+      "headers": {},
+      "file": {
+        "ref": "nimg-sample",
+        "name": "screenshot.jpg",
+        "content_type": "image/jpeg"
+      }
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention-resolved.json b/packages/core/test/goldens/notifications/ntfy/attention-resolved.json
new file mode 100644
index 0000000..2be9b1d
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention-resolved.json
@@ -0,0 +1,25 @@
+{
+  "kind": "ntfy",
+  "variant": "attention-resolved",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Attention requested",
+        "message": "Resolved by admin after 2m 10s\n\n“CAPTCHA on the checkout page: please solve it, then resume”\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC",
+        "priority": 2,
+        "tags": [
+          "white_check_mark"
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/attention.json b/packages/core/test/goldens/notifications/ntfy/attention.json
new file mode 100644
index 0000000..e100c64
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/attention.json
@@ -0,0 +1,40 @@
+{
+  "kind": "ntfy",
+  "variant": "attention",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Attention requested",
+        "message": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "warning"
+        ],
+        "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Take over",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1",
+            "clear": false
+          },
+          {
+            "action": "view",
+            "label": "Open in BrowserHive",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/crash.json b/packages/core/test/goldens/notifications/ntfy/crash.json
new file mode 100644
index 0000000..4781e4e
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/crash.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "crash",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Session crashed",
+        "message": "reason: crash\n\nSession: checkout\nReason: crash\nClosed: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "rotating_light"
+        ],
+        "click": "https://bh.example.net/sessions/checkout-a1b2c3d4",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open session",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/degraded.json b/packages/core/test/goldens/notifications/ntfy/degraded.json
new file mode 100644
index 0000000..944026b
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/degraded.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "degraded",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "The retention sweep failed: database is locked",
+        "message": "RETENTION_FAILED\n\nCode: RETENTION_FAILED\nSince: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "rotating_light"
+        ],
+        "click": "https://bh.example.net/system",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open System",
+            "url": "https://bh.example.net/system",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/test-local.json b/packages/core/test/goldens/notifications/ntfy/test-local.json
new file mode 100644
index 0000000..ad28165
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/test-local.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "test-local",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "BrowserHive test message",
+        "message": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test",
+        "priority": 3,
+        "tags": [
+          "information_source"
+        ],
+        "click": "http://127.0.0.1:9876/notifications/channels",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open on this computer",
+            "url": "http://127.0.0.1:9876/notifications/channels",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/test.json b/packages/core/test/goldens/notifications/ntfy/test.json
new file mode 100644
index 0000000..af2e688
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/test.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "test",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "BrowserHive test message",
+        "message": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test",
+        "priority": 3,
+        "tags": [
+          "information_source"
+        ],
+        "click": "https://bh.example.net/notifications/channels",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open dashboard",
+            "url": "https://bh.example.net/notifications/channels",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/tool-errors.json b/packages/core/test/goldens/notifications/ntfy/tool-errors.json
new file mode 100644
index 0000000..98c1b52
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/tool-errors.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "tool-errors",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "checkout · 3 tool errors",
+        "message": "navigate · NAVIGATION_TIMEOUT (30000 ms)\n\nSession: checkout\nErrors: 3\nLatest: navigate · NAVIGATION_TIMEOUT\nDuration: 30 s",
+        "priority": 2,
+        "tags": [
+          "warning"
+        ],
+        "click": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Open errors",
+            "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/ntfy/vault-confirm.json b/packages/core/test/goldens/notifications/ntfy/vault-confirm.json
new file mode 100644
index 0000000..88a7f07
--- /dev/null
+++ b/packages/core/test/goldens/notifications/ntfy/vault-confirm.json
@@ -0,0 +1,34 @@
+{
+  "kind": "ntfy",
+  "variant": "vault-confirm",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "/",
+      "encoding": "json",
+      "body": {
+        "topic": "bh-alerts",
+        "title": "Vault fill awaiting confirm",
+        "message": "entry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC",
+        "priority": 4,
+        "tags": [
+          "warning"
+        ],
+        "click": "https://bh.example.net/vault?tab=confirm",
+        "actions": [
+          {
+            "action": "view",
+            "label": "Review in BrowserHive",
+            "url": "https://bh.example.net/vault?tab=confirm",
+            "clear": false
+          }
+        ],
+        "markdown": false,
+        "sequence_id": "n-sample000001"
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/telegram/attention-counts.json b/packages/core/test/goldens/notifications/telegram/attention-counts.json
new file mode 100644
index 0000000..04d50c8
--- /dev/null
+++ b/packages/core/test/goldens/notifications/telegram/attention-counts.json
@@ -0,0 +1,37 @@
+{
+  "kind": "telegram",
+  "variant": "attention-counts",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "sendMessage",
+      "encoding": "json",
+      "body": {
+        "chat_id": "-1001234567890",
+        "parse_mode": "HTML",
+        "disable_notification": false,
+        "reply_markup": {
+          "inline_keyboard": [
+            [
+              {
+                "text": "Take over",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+              },
+              {
+                "text": "Open in BrowserHive",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1"
+              }
+            ]
+          ]
+        },
+        "text": "⚠️ Attention requested\nSession checkout",
+        "link_preview_options": {
+          "is_disabled": true
+        }
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/telegram/attention-image.json b/packages/core/test/goldens/notifications/telegram/attention-image.json
new file mode 100644
index 0000000..7ab6a39
--- /dev/null
+++ b/packages/core/test/goldens/notifications/telegram/attention-image.json
@@ -0,0 +1,38 @@
+{
+  "kind": "telegram",
+  "variant": "attention-image",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "sendPhoto",
+      "encoding": "multipart",
+      "body": {
+        "chat_id": "-1001234567890",
+        "parse_mode": "HTML",
+        "disable_notification": false,
+        "reply_markup": {
+          "inline_keyboard": [
+            [
+              {
+                "text": "Take over",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1"
+              },
+              {
+                "text": "Open in BrowserHive",
+                "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1"
+              }
+            ]
+          ]
+        },
+        "caption": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC"
+      },
+      "headers": {},
+      "file": {
+        "ref": "nimg-sample",
+        "name": "screenshot.jpg",
+        "content_type": "image/jpeg"
+      }
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/telegram/attention-local.json b/packages/core/test/goldens/notifications/telegram/attention-local.json
new file mode 100644
index 0000000..bb1da53
--- /dev/null
+++ b/packages/core/test/goldens/notifications/telegram/attention-local.json
@@ -0,0 +1,23 @@
+{
+  "kind": "telegram",
+  "variant": "attention-local",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "sendMessage",
+      "encoding": "json",
+      "body": {
+        "chat_id": "-1001234567890",
+        "parse_mode": "HTML",
+        "disable_notification": false,
+        "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\n\n🖥 Open on this computer\nTake over: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1\nOpen in BrowserHive: http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1",
+        "link_preview_options": {
+          "is_disabled": true
+        }
+      },
+      "headers": {},
+      "file": null
+    }
+  ]
+}
diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json b/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json
new file mode 100644
index 0000000..35ea9c3
--- /dev/null
+++ b/packages/core/test/goldens/notifications/telegram/attention-resolved-edit.json
@@ -0,0 +1,26 @@
+{
+  "kind": "telegram",
+  "variant": "attention-resolved-edit",
+  "mode": null,
+  "requests": [
+    {
+      "method": "POST",
+      "path": "editMessageText",
+      "encoding": "json",
+      "body": {
+        "chat_id": -1001234567890,
+        "message_id": 101,
+        "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
CAPTCHA on the checkout page: please solve it, then resume
\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + }, + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json new file mode 100644 index 0000000..efe1fae --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention-resolved-image-edit.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram", + "variant": "attention-resolved-image-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageCaption", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "caption": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
CAPTCHA on the checkout page: please solve it, then resume
\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "parse_mode": "HTML", + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention-resolved.json b/packages/core/test/goldens/notifications/telegram/attention-resolved.json new file mode 100644 index 0000000..d4a09d9 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention-resolved.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram", + "variant": "attention-resolved", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": true, + "text": "✅ Attention requested\nResolved by admin after 2m 10s\n\n
CAPTCHA on the checkout page: please solve it, then resume
\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC\nOutcome: resolved\nSettled: 14:15 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/attention.json b/packages/core/test/goldens/notifications/telegram/attention.json new file mode 100644 index 0000000..6fec891 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/attention.json @@ -0,0 +1,37 @@ +{ + "kind": "telegram", + "variant": "attention", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Take over", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "text": "Open in BrowserHive", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + } + ] + ] + }, + "text": "⚠️ Attention requested\nCAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen\n\nMode: takeover\nSession: checkout\nPage: https://shop.example.com/checkout/payment\nTool: click\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/crash.json b/packages/core/test/goldens/notifications/telegram/crash.json new file mode 100644 index 0000000..68c7736 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/crash.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "crash", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open session", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4" + } + ] + ] + }, + "text": "🔴 Session crashed\nreason: crash\n\nSession: checkout\nReason: crash\nClosed: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/degraded.json b/packages/core/test/goldens/notifications/telegram/degraded.json new file mode 100644 index 0000000..0eda63c --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/degraded.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "degraded", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open System", + "url": "https://bh.example.net/system" + } + ] + ] + }, + "text": "🔴 The retention sweep failed: database is locked\nRETENTION_FAILED\n\nCode: RETENTION_FAILED\nSince: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/test-local.json b/packages/core/test/goldens/notifications/telegram/test-local.json new file mode 100644 index 0000000..0395b07 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/test-local.json @@ -0,0 +1,23 @@ +{ + "kind": "telegram", + "variant": "test-local", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test\n\n🖥 Open on this computer\nOpen dashboard: http://127.0.0.1:9876/notifications/channels", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/test.json b/packages/core/test/goldens/notifications/telegram/test.json new file mode 100644 index 0000000..7b97a90 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/test.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "test", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open dashboard", + "url": "https://bh.example.net/notifications/channels" + } + ] + ] + }, + "text": "ℹ️ BrowserHive test message\nThis channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.\n\nSent: 14:13 UTC\nKind: test", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/tool-errors.json b/packages/core/test/goldens/notifications/telegram/tool-errors.json new file mode 100644 index 0000000..6cdc393 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/tool-errors.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "tool-errors", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": true, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open errors", + "url": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1" + } + ] + ] + }, + "text": "⚠️ checkout · 3 tool errors\nnavigate · NAVIGATION_TIMEOUT (30000 ms)\n\nSession: checkout\nErrors: 3\nLatest: navigate · NAVIGATION_TIMEOUT\nDuration: 30 s", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/vault-confirm.json b/packages/core/test/goldens/notifications/telegram/vault-confirm.json new file mode 100644 index 0000000..eab543f --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/vault-confirm.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "vault-confirm", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Review in BrowserHive", + "url": "https://bh.example.net/vault?tab=confirm" + } + ] + ] + }, + "text": "⚠️ Vault fill awaiting confirm\nentry github — approve or deny the release\n\nEntry: github\nSession: checkout\nPage: https://github.com/login\nTool: vault_fill\nWaiting since: 14:13 UTC", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-counts.json b/packages/core/test/goldens/notifications/webhook/attention-counts.json new file mode 100644 index 0000000..0678376 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-counts.json @@ -0,0 +1,68 @@ +{ + "kind": "webhook", + "variant": "attention-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "Session checkout", + "blocks": [], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "open", + "id": "resolve", + "label": "Open in BrowserHive", + "style": "default", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout" + }, + "privacy": { + "level": "counts", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-image.json b/packages/core/test/goldens/notifications/webhook/attention-image.json new file mode 100644 index 0000000..d2e8c0e --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-image.json @@ -0,0 +1,145 @@ +{ + "kind": "webhook", + "variant": "attention-image", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "text", + "content": [ + { + "type": "link", + "text": "View screenshot", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ] + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "open", + "id": "resolve", + "label": "Open in BrowserHive", + "style": "default", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-local.json b/packages/core/test/goldens/notifications/webhook/attention-local.json new file mode 100644 index 0000000..3b23dce --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-local.json @@ -0,0 +1,135 @@ +{ + "kind": "webhook", + "variant": "attention-local", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "http://127.0.0.1:9876/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": true, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "open", + "id": "resolve", + "label": "Open in BrowserHive", + "style": "default", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-resolved-edit.json b/packages/core/test/goldens/notifications/webhook/attention-resolved-edit.json new file mode 100644 index 0000000..5e9d2e1 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-resolved-edit.json @@ -0,0 +1,136 @@ +{ + "kind": "webhook", + "variant": "attention-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "edit", + "delivered_at": 1790000130000, + "channel": null, + "links": {}, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 2, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "resolved", + "alert": false, + "at": { + "created": 1790000000000, + "updated": 1790000130000 + }, + "title": "Attention requested", + "summary": "Resolved by admin after 2m 10s", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + }, + { + "label": "Outcome", + "value": [ + { + "type": "text", + "text": "resolved" + } + ] + }, + { + "label": "Settled", + "value": [ + { + "type": "time", + "at": 1790000130000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-resolved-image-edit.json b/packages/core/test/goldens/notifications/webhook/attention-resolved-image-edit.json new file mode 100644 index 0000000..5be2dec --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-resolved-image-edit.json @@ -0,0 +1,146 @@ +{ + "kind": "webhook", + "variant": "attention-resolved-image-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "edit", + "delivered_at": 1790000130000, + "channel": null, + "links": {}, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 2, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "resolved", + "alert": false, + "at": { + "created": 1790000000000, + "updated": 1790000130000 + }, + "title": "Attention requested", + "summary": "Resolved by admin after 2m 10s", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "text", + "content": [ + { + "type": "link", + "text": "View screenshot", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ] + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + }, + { + "label": "Outcome", + "value": [ + { + "type": "text", + "text": "resolved" + } + ] + }, + { + "label": "Settled", + "value": [ + { + "type": "time", + "at": 1790000130000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention-resolved.json b/packages/core/test/goldens/notifications/webhook/attention-resolved.json new file mode 100644 index 0000000..b2b2f49 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention-resolved.json @@ -0,0 +1,136 @@ +{ + "kind": "webhook", + "variant": "attention-resolved", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000130000, + "channel": null, + "links": {}, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 2, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "resolved", + "alert": false, + "at": { + "created": 1790000000000, + "updated": 1790000130000 + }, + "title": "Attention requested", + "summary": "Resolved by admin after 2m 10s", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + }, + { + "label": "Outcome", + "value": [ + { + "type": "text", + "text": "resolved" + } + ] + }, + { + "label": "Settled", + "value": [ + { + "type": "time", + "at": 1790000130000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/attention.json b/packages/core/test/goldens/notifications/webhook/attention.json new file mode 100644 index 0000000..6501758 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/attention.json @@ -0,0 +1,135 @@ +{ + "kind": "webhook", + "variant": "attention", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "take-over": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1", + "resolve": "https://bh.example.net/sessions/checkout-a1b2c3d4?live=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "attention:a-sample000001", + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Attention requested", + "summary": "CAPTCHA on the checkout page: please solve it, then resume · takeover — agent blocked, lease frozen", + "blocks": [ + { + "type": "quote", + "content": [ + { + "type": "text", + "text": "CAPTCHA on the checkout page: please solve it, then resume" + } + ], + "collapsible": true + }, + { + "type": "fields", + "items": [ + { + "label": "Mode", + "value": [ + { + "type": "text", + "text": "takeover" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://shop.example.com/checkout/payment" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "click" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "take-over", + "label": "Take over", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?live=1&takeover=1" + }, + { + "kind": "open", + "id": "resolve", + "label": "Open in BrowserHive", + "style": "default", + "path": "/sessions/checkout-a1b2c3d4?live=1" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "click", + "domain": "shop.example.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/crash.json b/packages/core/test/goldens/notifications/webhook/crash.json new file mode 100644 index 0000000..9a41b28 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/crash.json @@ -0,0 +1,95 @@ +{ + "kind": "webhook", + "variant": "crash", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "open-session": "https://bh.example.net/sessions/checkout-a1b2c3d4" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "session:checkout-a1b2c3d4", + "kind": "session.crashed", + "category": "problems", + "severity": "error", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Session crashed", + "summary": "reason: crash", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Reason", + "value": [ + { + "type": "code", + "text": "crash" + } + ] + }, + { + "label": "Closed", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "open-session", + "label": "Open session", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/degraded.json b/packages/core/test/goldens/notifications/webhook/degraded.json new file mode 100644 index 0000000..7d3755d --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/degraded.json @@ -0,0 +1,84 @@ +{ + "kind": "webhook", + "variant": "degraded", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "open-system": "https://bh.example.net/system" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "system:e-00000000000000000000000009", + "kind": "system.degraded", + "category": "system", + "severity": "error", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "The retention sweep failed: database is locked", + "summary": "RETENTION_FAILED", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Code", + "value": [ + { + "type": "code", + "text": "RETENTION_FAILED" + } + ] + }, + { + "label": "Since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "open-system", + "label": "Open System", + "style": "primary", + "path": "/system" + } + ], + "entities": { + "error_code": "RETENTION_FAILED" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/test-local.json b/packages/core/test/goldens/notifications/webhook/test-local.json new file mode 100644 index 0000000..f60b7e8 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/test-local.json @@ -0,0 +1,82 @@ +{ + "kind": "webhook", + "variant": "test-local", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "open-dashboard": "http://127.0.0.1:9876/notifications/channels" + }, + "local_links": true, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "test:sample", + "kind": "test", + "category": "system", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "BrowserHive test message", + "summary": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sent", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + }, + { + "label": "Kind", + "value": [ + { + "type": "code", + "text": "test" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "open-dashboard", + "label": "Open dashboard", + "style": "primary", + "path": "/notifications/channels" + } + ], + "entities": {}, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/test.json b/packages/core/test/goldens/notifications/webhook/test.json new file mode 100644 index 0000000..8f07ce7 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/test.json @@ -0,0 +1,82 @@ +{ + "kind": "webhook", + "variant": "test", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "open-dashboard": "https://bh.example.net/notifications/channels" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "test:sample", + "kind": "test", + "category": "system", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "BrowserHive test message", + "summary": "This channel works. Tap \"Open dashboard\" on your phone to check that links reach BrowserHive.", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sent", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + }, + { + "label": "Kind", + "value": [ + { + "type": "code", + "text": "test" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "open-dashboard", + "label": "Open dashboard", + "style": "primary", + "path": "/notifications/channels" + } + ], + "entities": {}, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/tool-errors.json b/packages/core/test/goldens/notifications/webhook/tool-errors.json new file mode 100644 index 0000000..286390b --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/tool-errors.json @@ -0,0 +1,106 @@ +{ + "kind": "webhook", + "variant": "tool-errors", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000040000, + "channel": null, + "links": { + "open-errors": "https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 3, + "thread": "tool-errors:checkout-a1b2c3d4", + "kind": "tool.errors", + "category": "problems", + "severity": "warn", + "state": "open", + "alert": false, + "at": { + "created": 1790000000000, + "updated": 1790000040000 + }, + "title": "checkout · 3 tool errors", + "summary": "navigate · NAVIGATION_TIMEOUT (30000 ms)", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Errors", + "value": [ + { + "type": "text", + "text": "3" + } + ] + }, + { + "label": "Latest", + "value": [ + { + "type": "code", + "text": "navigate · NAVIGATION_TIMEOUT" + } + ] + }, + { + "label": "Duration", + "value": [ + { + "type": "text", + "text": "30 s" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "open-errors", + "label": "Open errors", + "style": "primary", + "path": "/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "harness": "claude-code", + "tool": "navigate", + "error_code": "NAVIGATION_TIMEOUT" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/vault-confirm.json b/packages/core/test/goldens/notifications/webhook/vault-confirm.json new file mode 100644 index 0000000..52a8a71 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/vault-confirm.json @@ -0,0 +1,117 @@ +{ + "kind": "webhook", + "variant": "vault-confirm", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "approve": "https://bh.example.net/vault?tab=confirm" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "vault:a-sample000001", + "kind": "vault.confirm", + "category": "needs-you", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Vault fill awaiting confirm", + "summary": "entry github — approve or deny the release", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Entry", + "value": [ + { + "type": "code", + "text": "github" + } + ] + }, + { + "label": "Session", + "value": [ + { + "type": "link", + "text": "checkout", + "path": "/sessions/checkout-a1b2c3d4" + } + ] + }, + { + "label": "Page", + "value": [ + { + "type": "code", + "text": "https://github.com/login" + } + ] + }, + { + "label": "Tool", + "value": [ + { + "type": "code", + "text": "vault_fill" + } + ] + }, + { + "label": "Waiting since", + "value": [ + { + "type": "time", + "at": 1790000000000, + "style": "absolute" + } + ] + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "approve", + "label": "Review in BrowserHive", + "style": "primary", + "path": "/vault?tab=confirm" + } + ], + "entities": { + "session_id": "checkout-a1b2c3d4", + "session_slug": "checkout", + "owner": "local", + "tool": "vault_fill", + "domain": "github.com", + "request_id": "a-sample000001" + }, + "privacy": { + "level": "full", + "has_image": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/helpers/fake-platforms.ts b/packages/core/test/helpers/fake-platforms.ts new file mode 100644 index 0000000..9dc44f7 --- /dev/null +++ b/packages/core/test/helpers/fake-platforms.ts @@ -0,0 +1,320 @@ +/** @module test/helpers/fake-platforms — one `Bun.serve` faking the Telegram Bot API (`/tg`), Discord webhooks (`/api/webhooks`), an ntfy server (`/ntfy`) and a plain webhook receiver (`/hook`) (spec 09 §4). Every request is recorded; failures are scripted per route. */ + +/** A request the fakes received. */ +export interface RecordedRequest { + readonly platform: 'telegram' | 'discord' | 'ntfy' | 'webhook'; + readonly method: string; + /** Path without the platform prefix (Telegram: the method name). */ + readonly path: string; + readonly query: Readonly>; + readonly headers: Readonly>; + /** JSON body, or the parsed `payload_json` of a multipart body. */ + readonly json: unknown; + /** Text fields of a multipart body. */ + readonly form: Readonly> | null; + readonly files: readonly { + readonly field: string; + readonly name: string; + readonly type: string; + readonly size: number; + }[]; + /** Size of a raw (binary) body. */ + readonly bytes: number; + /** The raw text body (JSON requests). */ + readonly raw: string; +} + +/** A scripted answer for the next call of a route. */ +export type ScriptedAnswer = + | { + readonly status: number; + readonly body?: unknown; + readonly headers?: Readonly>; + } + | { readonly hang: true }; + +/** Route keys for scripts: `telegram:`, `discord:`, `ntfy:`, `webhook`. */ +export type RouteKey = string; + +/** A Telegram update the fake returns from `getUpdates`. */ +export interface FakeUpdate { + readonly update_id: number; + readonly message?: Record; +} + +/** Low-entropy fake credentials (gitleaks scans every commit). */ +export const FAKE_TG_TOKEN = `1234:${'a'.repeat(35)}`; +/** Token part of the fake Discord webhook URL. */ +export const FAKE_DISCORD_TOKEN = 'b'.repeat(24); + +/** + * The fakes. `start()` binds an ephemeral port; `stop()` releases it (hanging requests included). + */ +export class FakePlatforms { + readonly requests: RecordedRequest[] = []; + readonly updates: FakeUpdate[] = []; + private readonly scripts = new Map(); + private readonly ntfyMessages = new Map[]>(); + private server: ReturnType | undefined; + private counter = 100; + + /** Starts the server. */ + start(): this { + this.server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => this.handle(req) }); + return this; + } + + /** Stops the server. */ + async stop(): Promise { + await this.server?.stop(true); + } + + /** `http://127.0.0.1:`. */ + get url(): string { + return `http://127.0.0.1:${this.server?.port ?? 0}`; + } + + /** Bot API base for `createTelegramChannel({ apiBase })`. */ + get telegramBase(): string { + return `${this.url}/tg`; + } + + /** A webhook URL on the Discord fake. */ + get discordWebhook(): string { + return `${this.url}/api/webhooks/1/${FAKE_DISCORD_TOKEN}`; + } + + /** Base of the ntfy fake (`target.server`). */ + get ntfyServer(): string { + return `${this.url}/ntfy`; + } + + /** A receiver URL for the generic webhook. */ + get webhookUrl(): string { + return `${this.url}/hook/bh`; + } + + /** Queues answers for the next calls of `route` (after them, the default success). */ + script(route: RouteKey, ...answers: ScriptedAnswer[]): void { + this.scripts.set(route, [...(this.scripts.get(route) ?? []), ...answers]); + } + + /** Requests to one platform. */ + of(platform: RecordedRequest['platform']): RecordedRequest[] { + return this.requests.filter((r) => r.platform === platform); + } + + /** Messages the ntfy fake holds for a topic (what `GET //json?poll=1` returns). */ + ntfyTopic(topic: string): readonly Record[] { + return this.ntfyMessages.get(topic) ?? []; + } + + private next(): number { + this.counter += 1; + return this.counter; + } + + private async record( + req: Request, + platform: RecordedRequest['platform'], + path: string, + ): Promise { + const url = new URL(req.url); + const headers: Record = {}; + req.headers.forEach((value, key) => { + headers[key] = value; + }); + const type = req.headers.get('content-type') ?? ''; + let json: unknown = null; + let form: Record | null = null; + const files: { field: string; name: string; type: string; size: number }[] = []; + let bytes = 0; + let raw = ''; + if (type.includes('multipart/form-data')) { + const data = await req.formData(); + form = {}; + for (const [field, value] of data.entries()) { + const entry: unknown = value; + if (typeof entry === 'string') form[field] = entry; + else if (entry instanceof File) { + files.push({ field, name: entry.name, type: entry.type, size: entry.size }); + } + } + if (form['payload_json'] !== undefined) json = JSON.parse(form['payload_json']); + } else if (type.includes('application/json')) { + raw = await req.text(); + json = raw === '' ? null : JSON.parse(raw); + } else if (req.method !== 'GET' && req.method !== 'DELETE') { + bytes = (await req.arrayBuffer()).byteLength; + } + const recorded: RecordedRequest = { + platform, + method: req.method, + path, + query: Object.fromEntries(url.searchParams), + headers, + json, + form, + files, + bytes, + raw, + }; + this.requests.push(recorded); + return recorded; + } + + private scripted(route: RouteKey): ScriptedAnswer | undefined { + const queue = this.scripts.get(route); + return queue?.shift(); + } + + private async answer( + route: RouteKey, + fallback: () => Response | Promise, + ): Promise { + const script = this.scripted(route); + if (script === undefined) return fallback(); + if ('hang' in script) return new Promise(() => undefined); + return Response.json(script.body ?? {}, { + status: script.status, + ...(script.headers !== undefined && { headers: script.headers }), + }); + } + + private async handle(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + if (path.startsWith('/tg/bot')) return this.telegram(req, path); + if (path.startsWith('/api/webhooks/')) return this.discord(req, path); + if (path.startsWith('/ntfy')) return this.ntfy(req, path.slice('/ntfy'.length) || '/'); + if (path.startsWith('/hook')) { + await this.record(req, 'webhook', path); + return this.answer('webhook', () => new Response(null, { status: 204 })); + } + return new Response('not found', { status: 404 }); + } + + private async telegram(req: Request, path: string): Promise { + const method = path.split('/').pop() ?? ''; + const recorded = await this.record(req, 'telegram', method); + const body = (recorded.json ?? recorded.form ?? {}) as Record; + return this.answer(`telegram:${method}`, () => { + const chat = { id: Number(body['chat_id'] ?? 0) || String(body['chat_id']), type: 'private' }; + switch (method) { + case 'getMe': + return Response.json({ + ok: true, + result: { id: 1234, is_bot: true, username: 'bh_test_bot' }, + }); + case 'getUpdates': { + const offset = Number(body['offset'] ?? 0); + const pending = this.updates.filter((u) => u.update_id >= offset); + if (pending.length > 0) return Response.json({ ok: true, result: pending }); + return new Promise((resolve) => + setTimeout(() => resolve(Response.json({ ok: true, result: [] })), 30), + ); + } + case 'sendMessage': + return Response.json({ + ok: true, + result: { message_id: this.next(), chat, text: body['text'] }, + }); + case 'sendPhoto': + return Response.json({ + ok: true, + result: { + message_id: this.next(), + chat, + photo: [{ file_id: 'p1', width: 1280, height: 720 }], + }, + }); + case 'editMessageText': + case 'editMessageCaption': + return Response.json({ + ok: true, + result: { message_id: Number(body['message_id']), chat }, + }); + case 'deleteMessage': + return Response.json({ ok: true, result: true }); + default: + return Response.json( + { ok: false, error_code: 404, description: 'Not Found' }, + { status: 404 }, + ); + } + }); + } + + private async discord(req: Request, path: string): Promise { + const parts = path.split('/'); + // /api/webhooks//[/messages/] + const messageId = parts[6]; + const recorded = await this.record(req, 'discord', parts.slice(5).join('/')); + return this.answer(`discord:${req.method}`, () => { + if (req.method === 'DELETE') return new Response(null, { status: 204 }); + const id = messageId ?? String(this.next()); + const attachments = recorded.files.map((f, i) => ({ id: `90${i}${id}`, filename: f.name })); + const kept = + (recorded.json as { attachments?: { id: string | number }[] } | null)?.attachments ?? []; + return Response.json({ + id, + channel_id: '42', + attachments: [ + ...kept + .filter((a) => typeof a.id === 'string') + .map((a) => ({ id: a.id, filename: 'screenshot.jpg' })), + ...attachments, + ], + }); + }); + } + + private async ntfy(req: Request, path: string): Promise { + const recorded = await this.record(req, 'ntfy', path); + const segments = path.split('/').filter(Boolean); + if (req.method === 'GET') { + const topic = segments[0] ?? ''; + const lines = this.ntfyTopic(topic).map((m) => JSON.stringify(m)); + return new Response(lines.join('\n'), { + headers: { 'content-type': 'application/x-ndjson' }, + }); + } + return this.answer(`ntfy:${req.method}`, () => { + if (req.method === 'DELETE') { + const [topic = '', sequence = ''] = segments; + this.push(topic, { + id: `d${this.next()}`, + event: 'message_delete', + topic, + sequence_id: sequence, + }); + return Response.json({ id: `d${this.counter}`, event: 'message_delete' }); + } + const body = (recorded.json ?? {}) as Record; + const topic = String(req.method === 'PUT' ? (segments[0] ?? '') : (body['topic'] ?? '')); + const sequence = + req.method === 'PUT' + ? (segments[1] ?? recorded.headers['x-sequence-id']) + : body['sequence_id']; + const message: Record = { + id: `m${this.next()}`, + event: 'message', + topic, + ...(sequence !== undefined && { sequence_id: sequence }), + ...(req.method === 'PUT' + ? { + title: recorded.query['title'], + message: recorded.query['message'], + attachment: { name: recorded.query['filename'] ?? 'file', size: recorded.bytes }, + } + : { title: body['title'], message: body['message'] }), + }; + this.push(topic, message); + return Response.json(message); + }); + } + + private push(topic: string, message: Record): void { + this.ntfyMessages.set(topic, [...this.ntfyTopic(topic), message]); + } +} diff --git a/packages/core/test/notifications/adapters.test.ts b/packages/core/test/notifications/adapters.test.ts new file mode 100644 index 0000000..b2c677b --- /dev/null +++ b/packages/core/test/notifications/adapters.test.ts @@ -0,0 +1,427 @@ +/** @module test/notifications/adapters.test — every platform adapter against the `Bun.serve` fakes (spec 09 §3.2): send, edit, delete, screenshots, and the classification of 429, 5xx, timeouts, auth failures and vanished messages; no secret in any error. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createHmac } from 'node:crypto'; +import { + callPlatform, + createDiscordChannel, + createNtfyChannel, + createTelegramChannel, + createWebhookChannel, + DISCORD_WEBHOOK_CAPABILITIES, + NTFY_CAPABILITIES, + TELEGRAM_CAPABILITIES, + WEBHOOK_CAPABILITIES, +} from '../../src/infra/notifications/index.ts'; +import { ChannelSendError } from '../../src/ports/notification-channel.ts'; +import { FAKE_DISCORD_TOKEN, FAKE_TG_TOKEN, FakePlatforms } from '../helpers/fake-platforms.ts'; +import { delivery, LOCAL_LINKS, platformRecord, SAMPLE_IMAGES } from './helpers.ts'; + +let fakes: FakePlatforms; +beforeEach(() => { + fakes = new FakePlatforms().start(); +}); +afterEach(async () => { + await fakes.stop(); +}); + +function required(fn: T | undefined): T { + if (fn === undefined) throw new Error('the adapter lacks this method'); + return fn; +} + +async function failure(promise: Promise): Promise { + try { + await promise; + } catch (err) { + if (err instanceof ChannelSendError) return err; + throw err; + } + throw new Error('expected a ChannelSendError'); +} + +function telegram(overrides = {}) { + return createTelegramChannel( + platformRecord('telegram', { target: { chat_id: '-100123' }, ...overrides }), + { + token: FAKE_TG_TOKEN, + images: SAMPLE_IMAGES, + apiBase: fakes.telegramBase, + }, + ); +} + +describe('telegram', () => { + it('sends HTML text with an inline keyboard and returns the ref', async () => { + const channel = telegram(); + const { ref } = await channel.send(delivery('attention', TELEGRAM_CAPABILITIES)); + expect(ref).toEqual({ chat_id: -100123, message_id: 101, photo: 0 }); + const [req] = fakes.of('telegram'); + expect(req?.path).toBe('sendMessage'); + expect(req?.json).toMatchObject({ + chat_id: '-100123', + parse_mode: 'HTML', + disable_notification: false, + }); + const body = req?.json as { + text: string; + reply_markup: { inline_keyboard: { url: string }[][] }; + }; + expect(body.text).toContain('Attention requested'); + expect(body.reply_markup.inline_keyboard.flat().map((b) => b.url)).toContain( + 'https://bh.example.net/sessions/checkout-a1b2c3d4?live=1&takeover=1', + ); + }); + + it('sends a screenshot as a photo and edits its caption', async () => { + const channel = telegram(); + const { ref } = await channel.send( + delivery('attention', TELEGRAM_CAPABILITIES, { image: 'masked' }), + ); + expect(ref['photo']).toBe(1); + const [photo] = fakes.of('telegram'); + expect(photo?.path).toBe('sendPhoto'); + expect(photo?.files).toEqual([ + { field: 'photo', name: 'screenshot.jpg', type: 'image/jpeg', size: 8 }, + ]); + expect(photo?.form?.['caption']).toContain('Attention requested'); + await channel.edit?.( + ref, + delivery('attention-resolved', TELEGRAM_CAPABILITIES, { image: 'masked' }), + ); + const edit = fakes.of('telegram')[1]; + expect(edit?.path).toBe('editMessageCaption'); + expect(edit?.json).toMatchObject({ chat_id: -100123, message_id: 101 }); + expect((edit?.json as { reply_markup: unknown } | undefined)?.reply_markup).toEqual({ + inline_keyboard: [], + }); + }); + + it('falls back to a text message when the screenshot is gone', async () => { + const channel = createTelegramChannel( + platformRecord('telegram', { target: { chat_id: '1' } }), + { + token: FAKE_TG_TOKEN, + images: { read: async () => null }, + apiBase: fakes.telegramBase, + }, + ); + const { ref } = await channel.send( + delivery('attention', TELEGRAM_CAPABILITIES, { image: 'masked' }), + ); + expect(fakes.of('telegram')[0]?.path).toBe('sendMessage'); + expect(ref['photo']).toBe(0); + }); + + it('edits text, treats "not modified" as done, and replies within a thread', async () => { + const channel = telegram({ target: { chat_id: '-100123', thread_id: '7' } }); + await channel.send( + delivery('crash', TELEGRAM_CAPABILITIES, { replyTo: { chat_id: 1, message_id: 55 } }), + ); + expect(fakes.of('telegram')[0]?.json).toMatchObject({ + message_thread_id: 7, + reply_parameters: { message_id: 55, allow_sending_without_reply: true }, + }); + fakes.script('telegram:editMessageText', { + status: 400, + body: { ok: false, error_code: 400, description: 'Bad Request: message is not modified' }, + }); + const ref = { chat_id: 1, message_id: 9, photo: 0 }; + expect(await channel.edit?.(ref, delivery('crash', TELEGRAM_CAPABILITIES))).toEqual({ ref }); + }); + + it('classifies vanished, too old, rate limited, auth and 5xx failures', async () => { + const channel = telegram(); + const ref = { chat_id: 1, message_id: 9, photo: 0 }; + fakes.script('telegram:editMessageText', { + status: 400, + body: { ok: false, description: 'Bad Request: message to edit not found' }, + }); + expect( + (await failure(required(channel.edit)(ref, delivery('crash', TELEGRAM_CAPABILITIES)))).code, + ).toBe('message_gone'); + fakes.script('telegram:deleteMessage', { + status: 400, + body: { ok: false, description: "Bad Request: message can't be deleted for everyone" }, + }); + expect((await failure(required(channel.delete)(ref))).code).toBe('too_old'); + fakes.script('telegram:sendMessage', { + status: 429, + body: { + ok: false, + description: 'Too Many Requests: retry after 7', + parameters: { retry_after: 7 }, + }, + }); + const limited = await failure(channel.send(delivery('crash', TELEGRAM_CAPABILITIES))); + expect([limited.code, limited.retryAfterMs, limited.retryable]).toEqual([ + 'rate_limited', + 7000, + true, + ]); + fakes.script('telegram:sendMessage', { + status: 401, + body: { ok: false, description: 'Unauthorized' }, + }); + const auth = await failure(channel.send(delivery('crash', TELEGRAM_CAPABILITIES))); + expect([auth.code, auth.retryable]).toEqual(['auth', false]); + expect(auth.message).not.toContain(FAKE_TG_TOKEN); + fakes.script('telegram:sendMessage', { + status: 502, + body: { ok: false, description: 'Bad Gateway' }, + }); + expect((await failure(channel.send(delivery('crash', TELEGRAM_CAPABILITIES)))).code).toBe( + 'unavailable', + ); + }); + + it('puts links in the text instead of buttons without a public address', async () => { + await telegram().send(delivery('test', TELEGRAM_CAPABILITIES, { links: LOCAL_LINKS })); + const body = fakes.of('telegram')[0]?.json as { text: string; reply_markup?: unknown }; + expect(body.reply_markup).toBeUndefined(); + expect(body.text).toContain('Open on this computer'); + expect(body.text).toContain('http://127.0.0.1:9876/notifications/channels'); + }); +}); + +describe('discord (webhook mode)', () => { + const discord = () => + createDiscordChannel(platformRecord('discord'), { + webhookUrl: fakes.discordWebhook, + images: SAMPLE_IMAGES, + }); + + it('sends one embed with link buttons and waits for the message id', async () => { + const { ref } = await discord().send(delivery('tool-errors', DISCORD_WEBHOOK_CAPABILITIES)); + const [req] = fakes.of('discord'); + expect(req?.query).toEqual({ wait: 'true', with_components: 'true' }); + const body = req?.json as { + embeds: { title: string; color: number }[]; + components: unknown[]; + allowed_mentions: unknown; + }; + expect(body.embeds[0]?.title).toContain('checkout · 3 tool errors'); + expect(body.allowed_mentions).toEqual({ parse: [] }); + expect(body.components).toEqual([ + { + type: 1, + components: [ + { + type: 2, + style: 5, + label: 'Open errors', + url: 'https://bh.example.net/sessions/checkout-a1b2c3d4?kinds=tool&errors_only=1', + }, + ], + }, + ]); + expect(ref).toEqual({ message_id: '101', channel_id: '42' }); + }); + + it('uploads a screenshot and keeps it when editing', async () => { + const channel = discord(); + const { ref } = await channel.send( + delivery('attention', DISCORD_WEBHOOK_CAPABILITIES, { image: 'masked' }), + ); + const [send] = fakes.of('discord'); + expect(send?.files.map((f) => f.field)).toEqual(['files[0]']); + expect( + (send?.json as { embeds: { image: { url: string } }[] } | undefined)?.embeds[0]?.image.url, + ).toBe('attachment://screenshot.jpg'); + expect(ref['attachment_id']).toBe('900101'); + await channel.edit?.( + ref, + delivery('attention-resolved', DISCORD_WEBHOOK_CAPABILITIES, { image: 'masked' }), + ); + const edit = fakes.of('discord')[1]; + expect([edit?.method, edit?.path]).toEqual(['PATCH', 'messages/101']); + expect((edit?.json as { attachments: unknown } | undefined)?.attachments).toEqual([ + { id: '900101' }, + ]); + }); + + it('classifies a deleted message, a deleted webhook and rate limits', async () => { + const channel = discord(); + fakes.script('discord:PATCH', { + status: 404, + body: { code: 10008, message: 'Unknown Message' }, + }); + const gone = await failure( + required(channel.edit)({ message_id: '5' }, delivery('crash', DISCORD_WEBHOOK_CAPABILITIES)), + ); + expect(gone.code).toBe('message_gone'); + fakes.script('discord:POST', { + status: 404, + body: { code: 10015, message: 'Unknown Webhook' }, + }); + const auth = await failure(channel.send(delivery('crash', DISCORD_WEBHOOK_CAPABILITIES))); + expect(auth.code).toBe('auth'); + fakes.script('discord:POST', { + status: 429, + body: { message: 'You are being rate limited.', retry_after: 1.25, global: false }, + }); + const limited = await failure(channel.send(delivery('crash', DISCORD_WEBHOOK_CAPABILITIES))); + expect([limited.code, limited.retryAfterMs]).toEqual(['rate_limited', 1250]); + expect(limited.message).not.toContain(FAKE_DISCORD_TOKEN); + await channel.delete?.({ message_id: '5' }); + expect(fakes.of('discord').at(-1)?.method).toBe('DELETE'); + }); + + it('refuses bot mode until act buttons ship', () => { + expect(() => + createDiscordChannel(platformRecord('discord', { mode: 'bot' }), { + webhookUrl: fakes.discordWebhook, + images: SAMPLE_IMAGES, + }), + ).toThrow(/bot mode/); + }); +}); + +describe('ntfy', () => { + const ntfy = (overrides = {}, token: string | null = null, topic: string | null = null) => + createNtfyChannel( + platformRecord('ntfy', { + target: { server: fakes.ntfyServer, topic: 'bh-alerts' }, + ...overrides, + }), + { token, topic, images: SAMPLE_IMAGES }, + ); + + it('publishes JSON with the notification id as sequence id and replaces it on edit', async () => { + const channel = ntfy(); + const { ref } = await channel.send(delivery('attention', NTFY_CAPABILITIES)); + const [send] = fakes.of('ntfy'); + expect(send?.json).toMatchObject({ + topic: 'bh-alerts', + title: 'Attention requested', + priority: 4, + tags: ['warning'], + markdown: false, + sequence_id: 'n-sample000001', + }); + expect((send?.json as { actions: unknown[] } | undefined)?.actions).toHaveLength(2); + expect(ref['sequence_id']).toBe('n-sample000001'); + await channel.edit?.(ref, delivery('attention-resolved', NTFY_CAPABILITIES)); + expect(fakes.of('ntfy')[1]?.json).toMatchObject({ + sequence_id: 'n-sample000001', + priority: 2, + tags: ['white_check_mark'], + }); + await channel.delete?.(ref); + expect([fakes.of('ntfy')[2]?.method, fakes.of('ntfy')[2]?.path]).toEqual([ + 'DELETE', + '/bh-alerts/n-sample000001', + ]); + }); + + it('uploads a screenshot with the fields as query parameters and sends the token', async () => { + const channel = ntfy({}, 'tk_x', null); + await channel.send(delivery('attention', NTFY_CAPABILITIES, { image: 'unmasked' })); + const [put] = fakes.of('ntfy'); + expect([put?.method, put?.path, put?.bytes]).toEqual(['PUT', '/bh-alerts/n-sample000001', 8]); + expect(put?.query['filename']).toBe('screenshot.jpg'); + expect(JSON.parse(put?.query['actions'] ?? '[]')).toHaveLength(2); + expect(put?.headers['authorization']).toBe('Bearer tk_x'); + }); + + it('reads the topic from a variable and never stores it in the ref', async () => { + const channel = ntfy({ target: { server: fakes.ntfyServer } }, null, 'secret-topic-x'); + const { ref } = await channel.send(delivery('crash', NTFY_CAPABILITIES)); + expect(fakes.of('ntfy')[0]?.json).toMatchObject({ topic: 'secret-topic-x' }); + expect(JSON.stringify(ref)).not.toContain('secret-topic-x'); + }); + + it('labels the first action "Open on this computer" without a public address', async () => { + await ntfy().send(delivery('test', NTFY_CAPABILITIES, { links: LOCAL_LINKS })); + const body = fakes.of('ntfy')[0]?.json as { actions: { label: string }[] }; + expect(body.actions[0]?.label).toBe('Open on this computer'); + }); +}); + +describe('generic webhook', () => { + it('posts the signed contract', async () => { + const channel = createWebhookChannel( + platformRecord('webhook', { target: { url: fakes.webhookUrl } }), + { + url: null, + secret: 'k'.repeat(24), + now: () => 1_790_000_000_500, + }, + ); + await channel.send(delivery('attention', WEBHOOK_CAPABILITIES)); + const [req] = fakes.of('webhook'); + const body = req?.json as Record; + expect(body).toMatchObject({ + schema: 1, + event: 'notification', + op: 'send', + delivered_at: 1_790_000_000_500, + channel: { id: 'nc-000000000009', name: 'my-webhook' }, + local_links: false, + }); + expect((body['message'] as { id: string }).id).toBe('n-sample000001'); + const expected = `sha256=${createHmac('sha256', 'k'.repeat(24)) + .update(req?.raw ?? '') + .digest('hex')}`; + expect(req?.headers['x-browserhive-signature']).toBe(expected); + expect(req?.headers['x-browserhive-timestamp']).toBe('1790000000500'); + }); + + it('refuses other schemes and cross-host redirects', async () => { + expect(() => + createWebhookChannel(platformRecord('webhook', { target: { url: 'file:///etc/passwd' } }), { + url: null, + secret: null, + }), + ).toThrow(/http/); + const redirecting = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: (req) => + new URL(req.url).pathname === '/same' + ? Response.redirect(`${fakes.webhookUrl}`, 307) + : Response.redirect('http://localhost:1/elsewhere', 307), + }); + try { + const away = createWebhookChannel( + platformRecord('webhook', { target: { url: `http://127.0.0.1:${redirecting.port}/away` } }), + { url: null, secret: null }, + ); + const err = await failure(away.send(delivery('crash', WEBHOOK_CAPABILITIES))); + expect([err.code, err.message]).toEqual([ + 'rejected', + 'Webhook redirect: refused a redirect to another scheme or host', + ]); + } finally { + await redirecting.stop(true); + } + }); +}); + +describe('http helper', () => { + it('times out a hanging platform', async () => { + fakes.script('telegram:sendMessage', { hang: true }); + const err = await failure( + callPlatform( + { + url: `${fakes.telegramBase}/bot${FAKE_TG_TOKEN}/sendMessage`, + method: 'POST', + timeoutMs: 100, + }, + { fetch, secrets: [FAKE_TG_TOKEN], platform: 'Telegram' }, + ), + ); + expect([err.code, err.retryable]).toEqual(['timeout', true]); + }); + + it('reports an unreachable host without the URL', async () => { + const err = await failure( + callPlatform( + { url: `http://127.0.0.1:1/bot${FAKE_TG_TOKEN}/getMe`, method: 'POST' }, + { fetch, secrets: [FAKE_TG_TOKEN], platform: 'Telegram' }, + ), + ); + expect(err.code).toBe('unavailable'); + expect(err.message).not.toContain(FAKE_TG_TOKEN); + }); +}); diff --git a/packages/core/test/notifications/full-path.sqlite.test.ts b/packages/core/test/notifications/full-path.sqlite.test.ts new file mode 100644 index 0000000..50e5f1c --- /dev/null +++ b/packages/core/test/notifications/full-path.sqlite.test.ts @@ -0,0 +1,177 @@ +/** @module test/notifications/full-path.sqlite.test — the whole notification path through each real adapter on SQLite against the platform fakes (spec 09 §3.2, D-34, D-35): bus event → row and outbox job in one transaction → send → attention resolved → silent edit → TTL delete; secrets read through the registry and registered with the redactor. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { DomainEvents } from '../../src/app/events/catalog.ts'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { NotificationService } from '../../src/app/notifications/notification-service.ts'; +import { NotificationOutbox } from '../../src/app/notifications/outbox.ts'; +import { attentionCreated, attentionResolved } from '../../src/app/notifications/test-fixtures.ts'; +import { channelFactories } from '../../src/infra/notifications/index.ts'; +import type { NotificationChannelRecord } from '../../src/ports/persistence/records.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { FAKE_TG_TOKEN, FakePlatforms, type RecordedRequest } from '../helpers/fake-platforms.ts'; +import { RecordingEventBus } from '../helpers/recording-event-bus.ts'; +import { sessionRecord } from '../persistence/helpers.ts'; +import { openMemory, type TestDb } from '../persistence/setup.ts'; +import { PUBLIC_LINKS, platformRecord, SAMPLE_IMAGES } from './helpers.ts'; + +let t: TestDb; +let fakes: FakePlatforms; +beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); + fakes = new FakePlatforms().start(); +}); +afterEach(async () => { + await fakes.stop(); + await t.close(); +}); + +const RULES = { content: 'full' as const, ttl_ms: { 'needs-you': 3_600_000 } }; + +async function wire(record: NotificationChannelRecord, env: Record) { + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const registered: string[] = []; + await t.repos.notificationChannels.upsert(record); + const registry = new ChannelRegistry({ + repo: t.repos.notificationChannels, + clock: t.clock, + ids, + logger, + factories: channelFactories({ + images: SAMPLE_IMAGES, + apiBases: { telegram: fakes.telegramBase }, + }), + env: (name) => env[name], + registerSecret: (value) => registered.push(value), + }); + await registry.load(); + const outbox = new NotificationOutbox({ + uow: t.uow, + repos: t.repos, + registry, + links: PUBLIC_LINKS, + clock: t.clock, + logger, + bus, + }); + const service = new NotificationService({ + repo: t.repos.notifications, + bus, + clock: t.clock, + ids, + logger, + uow: t.uow, + outbox: { plan: (m, now) => outbox.plan(m, now), kick: () => undefined }, + }); + return { outbox, service, registry, registered }; +} + +async function lifecycle(w: Awaited>): Promise { + await w.service.produce(attentionCreated('a-000000000001', 'takeover', { reason: 'captcha' })); + await w.outbox.tick(); + t.clock.advance(5_000); + await w.service.produce(attentionResolved('a-000000000001', 'resolved')); + await w.outbox.tick(); + t.clock.advance(3_600_000); + await w.outbox.tick(); + const log = await t.repos.notificationDeliveries.list({}); + return log.map((d) => [d.op, String(d.revision), d.status, d.reason ?? '']); +} + +const calls = (requests: readonly RecordedRequest[]) => + requests.map((r) => `${r.method} ${r.path}`); + +describe('full notification path through the real adapters', () => { + it('telegram: send, silent edit, TTL delete', async () => { + const w = await wire( + platformRecord('telegram', { + target: { chat_id: '-100123' }, + secretRefs: { token: 'BH_TG_TOKEN' }, + rules: RULES, + }), + { BH_TG_TOKEN: FAKE_TG_TOKEN }, + ); + expect(w.registered).toContain(FAKE_TG_TOKEN); + const log = await lifecycle(w); + expect(calls(fakes.of('telegram'))).toEqual([ + 'POST sendMessage', + 'POST editMessageText', + 'POST deleteMessage', + ]); + expect(log).toEqual([ + ['delete', '2', 'sent', ''], + ['edit', '2', 'sent', ''], + ['send', '1', 'sent', ''], + ]); + const edit = fakes.of('telegram')[1]?.json as { text: string; message_id: number }; + expect(edit.message_id).toBe(101); + expect(edit.text).toContain('Resolved by local'); + }); + + it('discord: send, edit, TTL delete', async () => { + const w = await wire( + platformRecord('discord', { secretRefs: { webhook: 'BH_DISCORD_WEBHOOK' }, rules: RULES }), + { BH_DISCORD_WEBHOOK: fakes.discordWebhook }, + ); + const log = await lifecycle(w); + expect(calls(fakes.of('discord'))).toEqual([ + 'POST ', + 'PATCH messages/101', + 'DELETE messages/101', + ]); + expect(log.map((l) => l[2])).toEqual(['sent', 'sent', 'sent']); + }); + + it('ntfy: publish, replace by sequence id, delete', async () => { + const w = await wire( + platformRecord('ntfy', { + target: { server: fakes.ntfyServer, topic: 'bh-alerts' }, + rules: RULES, + }), + {}, + ); + const log = await lifecycle(w); + const requests = fakes.of('ntfy'); + expect(calls(requests)).toEqual([ + 'POST /', + 'POST /', + `DELETE /bh-alerts/${String((requests[0]?.json as { sequence_id: string } | undefined)?.sequence_id)}`, + ]); + expect((requests[1]?.json as { priority: number } | undefined)?.priority).toBe(2); + expect(log.map((l) => l[2])).toEqual(['sent', 'sent', 'sent']); + expect(fakes.ntfyTopic('bh-alerts').at(-1)?.['event']).toBe('message_delete'); + }); + + it('webhook: posts both revisions; deletes are unsupported', async () => { + const w = await wire( + platformRecord('webhook', { target: { url: fakes.webhookUrl }, rules: RULES }), + {}, + ); + const log = await lifecycle(w); + expect(fakes.of('webhook').map((r) => (r.json as { op: string }).op)).toEqual(['send', 'edit']); + expect(log).toEqual([ + ['delete', '2', 'suppressed', 'delete_unsupported'], + ['edit', '2', 'sent', ''], + ['send', '1', 'sent', ''], + ]); + }); + + it('a missing variable leaves the channel without an adapter, and its jobs say why', async () => { + const w = await wire( + platformRecord('telegram', { + target: { chat_id: '1' }, + secretRefs: { token: 'BH_TG_TOKEN' }, + rules: RULES, + }), + {}, + ); + expect(w.registry.channels()[0]?.adapter).toBeNull(); + await w.service.produce(attentionCreated('a-000000000001', 'takeover')); + const [job] = await t.repos.notificationDeliveries.list({}); + expect([job?.status, job?.reason]).toEqual(['suppressed', 'no_adapter']); + }); +}); diff --git a/packages/core/test/notifications/helpers.ts b/packages/core/test/notifications/helpers.ts new file mode 100644 index 0000000..53c3e6c --- /dev/null +++ b/packages/core/test/notifications/helpers.ts @@ -0,0 +1,89 @@ +/** @module test/notifications/helpers — deliveries built from the preview samples through the real pipeline (content level, degrade), link builders and an in-memory screenshot reader for the adapter suites. */ + +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import type { PreviewSample } from '@browserhive/contracts/notifications'; +import { restrictContent } from '../../src/app/notifications/content-level.ts'; +import { degrade } from '../../src/app/notifications/degrade.ts'; +import { SAMPLE_IMAGE_REF, sampleMessage } from '../../src/app/notifications/samples.ts'; +import type { + ChannelCapabilities, + ChannelDelivery, + LinkBuilder, + NotificationImageReader, + PlatformMessageRef, +} from '../../src/ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../src/ports/persistence/records.ts'; + +/** Links through a public address. */ +export const PUBLIC_LINKS: LinkBuilder = { + local: false, + url: (path) => `https://bh.example.net${path}`, +}; + +/** Links to this computer (no `publicUrl`). */ +export const LOCAL_LINKS: LinkBuilder = { + local: true, + url: (path) => `http://127.0.0.1:9876${path}`, +}; + +/** A few JPEG-looking bytes. */ +export const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]); + +/** Resolves only the sample screenshot ref. */ +export const SAMPLE_IMAGES: NotificationImageReader = { + read: async (ref) => + ref === SAMPLE_IMAGE_REF + ? { bytes: JPEG, contentType: 'image/jpeg', filename: 'screenshot.jpg' } + : null, +}; + +/** Options of {@link delivery}. */ +export interface DeliveryOptions { + readonly image?: 'none' | 'masked' | 'unmasked'; + readonly level?: NotificationContentLevel; + readonly links?: LinkBuilder; + readonly replyTo?: PlatformMessageRef | null; +} + +/** + * A delivery of a sample as the outbox would hand it to a channel with `capabilities`. + * + * @returns The delivery. + */ +export function delivery( + sample: PreviewSample, + capabilities: ChannelCapabilities, + options: DeliveryOptions = {}, +): ChannelDelivery { + const message = sampleMessage(sample, { image: options.image ?? 'none' }); + return { + message: degrade(restrictContent(message, options.level ?? 'full'), capabilities), + links: options.links ?? PUBLIC_LINKS, + replyTo: options.replyTo ?? null, + }; +} + +/** A channel row of a real platform kind. */ +export function platformRecord( + kind: string, + overrides: Partial = {}, +): NotificationChannelRecord { + return { + channelId: 'nc-000000000009', + name: `my-${kind}`, + kind, + mode: kind === 'discord' ? 'webhook' : null, + source: 'db', + status: 'active', + target: {}, + secretRefs: {}, + rules: {}, + failureCount: 0, + lastError: null, + lastOkAt: null, + lastFailureAt: null, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} diff --git a/packages/core/test/notifications/render.golden.test.ts b/packages/core/test/notifications/render.golden.test.ts new file mode 100644 index 0000000..a61ea75 --- /dev/null +++ b/packages/core/test/notifications/render.golden.test.ts @@ -0,0 +1,105 @@ +/** @module test/notifications/render.golden.test — renderer golden files per platform × sample × variant (spec 09 §3.2): the realistic pipeline `degrade(restrictContent(sample, level), capabilities)` → `render`. `UPDATE_GOLDENS=1` blesses. */ + +import { describe, expect, it } from 'bun:test'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import { PREVIEW_SAMPLES, type PreviewSample } from '@browserhive/contracts/notifications'; +import { CHANNEL_RENDERERS } from '../../src/infra/notifications/index.ts'; +import type { + LinkBuilder, + PlatformMessageRef, + RenderContext, +} from '../../src/ports/notification-channel.ts'; +import { delivery, LOCAL_LINKS, PUBLIC_LINKS } from './helpers.ts'; + +const GOLDEN_DIR = join(import.meta.dir, '..', 'goldens', 'notifications'); +const UPDATE = process.env['UPDATE_GOLDENS'] === '1'; + +const TARGETS: Readonly>>> = { + telegram: { chat_id: '-1001234567890' }, + discord: {}, + ntfy: { server: 'https://ntfy.example.net', topic: 'bh-alerts' }, + webhook: { url: 'https://hooks.example.net/bh' }, +}; + +const EDIT_REFS: Readonly> = { + telegram: { chat_id: -1001234567890, message_id: 101, photo: 0 }, + discord: { message_id: '1101', channel_id: '42' }, + ntfy: { id: 'm1', sequence_id: 'n-sample000001' }, + webhook: { notification_id: 'n-sample000001', revision: 1 }, +}; + +const IMAGE_EDIT_REFS: Readonly> = { + ...EDIT_REFS, + telegram: { chat_id: -1001234567890, message_id: 101, photo: 1 }, + discord: { + message_id: '1101', + channel_id: '42', + attachment_id: '9001101', + attachment_name: 'screenshot.jpg', + }, +}; + +interface Variant { + readonly name: string; + readonly sample: PreviewSample; + readonly image?: 'masked'; + readonly links?: LinkBuilder; + readonly level?: NotificationContentLevel; + readonly mode?: string; + readonly edit?: boolean; +} + +function variants(kind: string): Variant[] { + const out: Variant[] = PREVIEW_SAMPLES.map((sample) => ({ name: sample, sample })); + out.push( + { name: 'attention-image', sample: 'attention', image: 'masked' }, + { name: 'attention-local', sample: 'attention', links: LOCAL_LINKS }, + { name: 'test-local', sample: 'test', links: LOCAL_LINKS }, + { name: 'attention-counts', sample: 'attention', level: 'counts' }, + { name: 'attention-resolved-edit', sample: 'attention-resolved', edit: true }, + { + name: 'attention-resolved-image-edit', + sample: 'attention-resolved', + image: 'masked', + edit: true, + }, + ); + if (kind === 'discord') out.push({ name: 'attention-bot', sample: 'attention', mode: 'bot' }); + return out; +} + +describe('renderer goldens', () => { + for (const [kind, renderer] of CHANNEL_RENDERERS) { + for (const v of variants(kind)) { + it(`${kind} ${v.name}`, () => { + const mode = v.mode ?? (kind === 'discord' ? 'webhook' : null); + const capabilities = renderer.capabilities(mode); + const d = delivery(v.sample, capabilities, { + ...(v.image !== undefined && { image: v.image }), + links: v.links ?? PUBLIC_LINKS, + ...(v.level !== undefined && { level: v.level }), + }); + const context: RenderContext = { + mode, + target: TARGETS[kind] ?? {}, + op: v.edit === true ? 'edit' : 'send', + ref: + v.edit === true + ? ((v.image === undefined ? EDIT_REFS : IMAGE_EDIT_REFS)[kind] ?? null) + : null, + actToken: () => 'bh1:preview', + }; + const body = { kind, variant: v.name, mode, requests: renderer.render(d, context) }; + const dir = join(GOLDEN_DIR, kind); + const file = join(dir, `${v.name}.json`); + if (UPDATE || !existsSync(file)) { + mkdirSync(dir, { recursive: true }); + writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`); + } + expect(JSON.parse(JSON.stringify(body))).toEqual(JSON.parse(readFileSync(file, 'utf8'))); + }); + } + } +}); diff --git a/packages/core/test/notifications/render.property.test.ts b/packages/core/test/notifications/render.property.test.ts new file mode 100644 index 0000000..2267ab7 --- /dev/null +++ b/packages/core/test/notifications/render.property.test.ts @@ -0,0 +1,240 @@ +/** @module test/notifications/render.property.test — escaping and length properties of the renderers over seeded random text (spec 09 §3.2): Telegram HTML never carries an unescaped `<`, `>` or `&` from text and stays within 4096 / 1024 visible characters; Discord embeds stay within Discord's limits; ntfy bodies stay under 4096 bytes with at most three actions. 500 cases per platform. */ + +import { describe, expect, it } from 'bun:test'; +import { + NOTIFICATION_LABEL_MAX, + NOTIFICATION_SUMMARY_MAX, + NOTIFICATION_TITLE_MAX, + NotificationMessage, + PREVIEW_SAMPLES, +} from '@browserhive/contracts/notifications'; +import { degrade } from '../../src/app/notifications/degrade.ts'; +import { sampleMessage } from '../../src/app/notifications/samples.ts'; +import { + DISCORD_LIMITS, + discordRenderer, + ntfyRenderer, + TELEGRAM_CAPTION_MAX, + TELEGRAM_TEXT_MAX, + telegramRenderer, +} from '../../src/infra/notifications/index.ts'; +import type { ChannelRenderer, RenderContext } from '../../src/ports/notification-channel.ts'; +import { LOCAL_LINKS, PUBLIC_LINKS } from './helpers.ts'; + +/** Deterministic PRNG (mulberry32). */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +const PIECES = [ + '', + '', + '&', + '&', + '<', + '>', + '"', + "'", + '*', + '_', + '~', + '`', + '```', + '|', + '#', + '- ', + '@everyone', + '', + '[x](http://evil)', + '\n', + ' ', + 'ü', + '日本', + '🔥', + 'word', + 'lorem ipsum ', + 'https://example.com/a?b=c&d=', +]; + +function randomText(next: () => number, max: number): string { + const target = Math.floor(next() ** 3 * max); + let out = ''; + while (out.length < target) out += PIECES[Math.floor(next() * PIECES.length)]; + return out.slice(0, max); +} + +/** A sample with every free-text leaf replaced by random text, still valid against the contract. */ +function randomMessage(next: () => number): NotificationMessage { + const sample = PREVIEW_SAMPLES[Math.floor(next() * PREVIEW_SAMPLES.length)] ?? 'attention'; + const base = sampleMessage(sample, { image: next() < 0.3 ? 'masked' : 'none' }); + const walk = (value: unknown, key: string): unknown => { + if (typeof value === 'string') { + if (key === 'text') return randomText(next, next() < 0.1 ? 4000 : 900); + if (key === 'label') return randomText(next, NOTIFICATION_LABEL_MAX) || 'x'; + return value; + } + if (Array.isArray(value)) return value.map((v) => walk(v, key)); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = walk(v, k); + return out; + } + return value; + }; + const mutated = walk(base, '') as NotificationMessage; + return NotificationMessage.parse({ + ...mutated, + title: randomText(next, NOTIFICATION_TITLE_MAX) || 'x', + summary: randomText(next, NOTIFICATION_SUMMARY_MAX), + }); +} + +function render( + renderer: ChannelRenderer, + message: NotificationMessage, + next: () => number, + mode: string | null, +) { + const capabilities = renderer.capabilities(mode); + const context: RenderContext = { + mode, + target: { chat_id: '1', topic: 't' }, + op: 'send', + ref: null, + actToken: () => 'bh1:x', + }; + return renderer.render( + { + message: degrade(message, capabilities), + links: next() < 0.5 ? PUBLIC_LINKS : LOCAL_LINKS, + replyTo: null, + }, + context, + ); +} + +const TG_TAG = /<\/?(b|i|code|pre|blockquote|a|tg-time)(\s[^<>]*)?>/g; +const TG_ENTITY = /&(lt|gt|amp|quot);/g; + +/** Visible text of Telegram HTML, or an error sentence when it is not well formed. */ +function telegramVisible(html: string): { visible: string } | { error: string } { + const stack: string[] = []; + for (const match of html.matchAll(TG_TAG)) { + const tag = match[1] ?? ''; + if (match[0].startsWith('` }; + } else { + stack.push(tag); + } + } + if (stack.length > 0) return { error: `unclosed <${stack.join(',')}>` }; + const stripped = html.replace(TG_TAG, ''); + if (/[<>]/.test(stripped)) return { error: 'raw < or > outside a tag' }; + if (/&/.test(stripped.replace(TG_ENTITY, ''))) return { error: 'raw & outside an entity' }; + return { + visible: stripped.replace( + TG_ENTITY, + (_m, e: string) => ({ lt: '<', gt: '>', amp: '&', quot: '"' })[e] ?? '', + ), + }; +} + +describe('renderer properties', () => { + it('Telegram HTML is well formed and within the text and caption limits', () => { + const next = rng(7); + const problems: string[] = []; + let nearLimit = 0; + for (let i = 0; i < 500; i++) { + const [request] = render(telegramRenderer, randomMessage(next), next, null); + const body = request?.body as { text?: string; caption?: string }; + const html = body.caption ?? body.text ?? ''; + const limit = body.caption !== undefined ? TELEGRAM_CAPTION_MAX : TELEGRAM_TEXT_MAX; + const result = telegramVisible(html); + if ('error' in result) problems.push(`case ${i}: ${result.error}`); + else if (result.visible.length > limit * 0.8) nearLimit++; + if ('visible' in result && result.visible.length > limit) + problems.push(`case ${i}: ${result.visible.length} > ${limit}`); + } + expect(problems).toEqual([]); + // Not vacuous: many cases come close to a limit and are clipped. + expect(nearLimit).toBeGreaterThan(20); + }); + + it('Discord embeds stay within Discord limits and never ping', () => { + const next = rng(11); + const problems: string[] = []; + for (let i = 0; i < 500; i++) { + const [request] = render( + discordRenderer, + randomMessage(next), + next, + next() < 0.5 ? 'webhook' : 'bot', + ); + const payload = (request?.body['payload_json'] ?? request?.body) as { + embeds: { + title: string; + description?: string; + fields?: { name: string; value: string }[]; + footer: { text: string }; + }[]; + components: { components: { label: string }[] }[]; + allowed_mentions: { parse: string[] }; + }; + const e = payload.embeds[0]; + if (e === undefined) { + problems.push(`case ${i}: no embed`); + continue; + } + const fields = e.fields ?? []; + const total = + e.title.length + + (e.description?.length ?? 0) + + e.footer.text.length + + fields.reduce((n, f) => n + f.name.length + f.value.length, 0); + if (e.title.length > DISCORD_LIMITS.title) problems.push(`case ${i}: title`); + if ((e.description?.length ?? 0) > DISCORD_LIMITS.description) + problems.push(`case ${i}: description`); + if (fields.length > DISCORD_LIMITS.fields) problems.push(`case ${i}: fields`); + if ( + fields.some( + (f) => + f.name.length > DISCORD_LIMITS.fieldName || f.value.length > DISCORD_LIMITS.fieldValue, + ) + ) { + problems.push(`case ${i}: field size`); + } + if (total > DISCORD_LIMITS.total) problems.push(`case ${i}: total ${total}`); + if (payload.components.length > 5) problems.push(`case ${i}: rows`); + if ( + payload.components.some((r) => + r.components.some((b) => b.label.length > DISCORD_LIMITS.buttonLabel), + ) + ) { + problems.push(`case ${i}: button label`); + } + if (payload.allowed_mentions.parse.length !== 0) problems.push(`case ${i}: mentions`); + } + expect(problems).toEqual([]); + }); + + it('ntfy bodies stay under 4096 bytes with at most three actions', () => { + const next = rng(13); + const encoder = new TextEncoder(); + const problems: string[] = []; + for (let i = 0; i < 500; i++) { + const [request] = render(ntfyRenderer, randomMessage(next), next, null); + const body = request?.body as { message: string; title: string; actions?: unknown[] }; + if (encoder.encode(body.message).length > 4096) problems.push(`case ${i}: message bytes`); + if (body.title.length > 250) problems.push(`case ${i}: title`); + if ((body.actions?.length ?? 0) > 3) problems.push(`case ${i}: actions`); + } + expect(problems).toEqual([]); + }); +}); diff --git a/packages/core/test/notifications/renderer-redaction.property.test.ts b/packages/core/test/notifications/renderer-redaction.property.test.ts new file mode 100644 index 0000000..f03cb1a --- /dev/null +++ b/packages/core/test/notifications/renderer-redaction.property.test.ts @@ -0,0 +1,161 @@ +/** @module test/notifications/renderer-redaction.property.test — the redaction invariant extended to the platform renderers (spec 10 §9): a sentinel registered in the `SecretRegistry` and routed through every producer input never appears in any renderer's request (paths, headers, bodies, at every content level, with public and local links) nor in the generic webhook body a real transport posts. The in-app, stored-message and delivery-log sinks stay covered by `app/notifications/redaction.property.test.ts`; this suite lives under `test/` because it joins app and infra, which the layer rules keep apart in `src/`. Seeded, 300 cases. */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import type { DomainEvents } from '../../src/app/events/catalog.ts'; +import { restrictContent } from '../../src/app/notifications/content-level.ts'; +import { degrade } from '../../src/app/notifications/degrade.ts'; +import { decodeMessage } from '../../src/app/notifications/message.ts'; +import { NotificationService } from '../../src/app/notifications/notification-service.ts'; +import type { ProducedEvent } from '../../src/app/notifications/producers.ts'; +import { + attentionCreated, + systemDegraded, + toolCalled, + vaultConfirmCreated, +} from '../../src/app/notifications/test-fixtures.ts'; +import { + CHANNEL_RENDERERS, + createWebhookChannel, + WEBHOOK_CAPABILITIES, +} from '../../src/infra/notifications/index.ts'; +import { createRedactor, SecretRegistry } from '../../src/kernel/redact.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { FakeClock } from '../helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { FakePlatforms } from '../helpers/fake-platforms.ts'; +import { InMemoryRepositories } from '../helpers/in-memory-repos.ts'; +import { RecordingEventBus } from '../helpers/recording-event-bus.ts'; +import { LOCAL_LINKS, PUBLIC_LINKS, platformRecord } from './helpers.ts'; + +/** Deterministic PRNG (mulberry32). */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +function sentinelOf(next: () => number): string { + let out = 'zq'; + const length = 12 + Math.floor(next() * 20); + for (let i = 0; i < length; i++) out += ALPHABET[Math.floor(next() * ALPHABET.length)]; + return out; +} + +function events(next: () => number, secret: string): ProducedEvent[] { + const text = `before ${secret} after`; + const picks: (() => ProducedEvent)[] = [ + () => + attentionCreated('a-000000000001', next() < 0.5 ? 'takeover' : 'notify', { + reason: text, + page_url: `https://example.com/${secret}/login?q=${secret}`, + tool: `tool-${secret}`.slice(0, 60), + }), + () => vaultConfirmCreated('a-000000000002', text), + () => toolCalled(1, { ok: false, code: text }), + () => { + const e = systemDegraded('error'); + if (e.name !== 'system.degraded') return e; + return { + ...e, + payload: { + ...e.payload, + event: { ...e.payload.event, message: text, code: `C_${secret}` }, + }, + }; + }, + ]; + const pick = picks[Math.floor(next() * picks.length)] ?? picks[0]; + return pick === undefined ? [] : [pick()]; +} + +const LEVELS: readonly NotificationContentLevel[] = ['counts', 'titles', 'full']; + +let fakes: FakePlatforms; +beforeAll(() => { + fakes = new FakePlatforms().start(); +}); +afterAll(async () => { + await fakes.stop(); +}); + +describe('renderer redaction invariant', () => { + it('a registered sentinel never reaches a platform request or a webhook body (300 cases)', async () => { + const leaks: string[] = []; + let rendered = 0; + for (let seed = 1; seed <= 300; seed++) { + const next = rng(seed); + const secret = sentinelOf(next); + const clock = new FakeClock(); + const registry = new SecretRegistry({ now: () => clock.now() }); + registry.add(secret); + const redactor = createRedactor(registry); + const repos = new InMemoryRepositories(); + const service = new NotificationService({ + repo: repos.notifications, + bus: new RecordingEventBus(), + clock, + ids: new FakeIdGenerator(), + logger: new CollectingLogger(), + redactor, + }); + for (const event of events(next, secret)) await service.produce(event); + for (const row of repos.notifications.rows.values()) { + const message = decodeMessage(row.messageJson); + if (message === null) continue; + for (const [kind, renderer] of CHANNEL_RENDERERS) { + for (const mode of kind === 'discord' ? ['webhook', 'bot'] : [null]) { + const capabilities = renderer.capabilities(mode); + for (const level of LEVELS) { + for (const links of [PUBLIC_LINKS, LOCAL_LINKS]) { + const requests = renderer.render( + { + message: degrade(restrictContent(message, level), capabilities), + links, + replyTo: null, + }, + { + mode, + target: { chat_id: '1', topic: 't' }, + op: 'send', + ref: null, + actToken: () => 'bh1:x', + }, + ); + rendered++; + if (JSON.stringify(requests).includes(secret)) { + leaks.push(`${kind}/${mode ?? '-'}/${level} (seed ${seed})`); + } + } + } + } + } + if (seed % 30 === 0) { + const before = fakes.of('webhook').length; + const channel = createWebhookChannel( + platformRecord('webhook', { target: { url: fakes.webhookUrl } }), + { url: null, secret: 's'.repeat(24) }, + ); + await channel.send({ + message: degrade(message, WEBHOOK_CAPABILITIES), + links: PUBLIC_LINKS, + replyTo: null, + }); + const posted = fakes.of('webhook').slice(before); + if (posted.length !== 1 || posted.some((r) => r.raw.includes(secret))) { + leaks.push(`webhook body (seed ${seed})`); + } + } + } + } + expect(leaks).toEqual([]); + expect(rendered).toBeGreaterThan(300 * 20); + }); +}); diff --git a/packages/core/test/notifications/setup-store-probe.test.ts b/packages/core/test/notifications/setup-store-probe.test.ts new file mode 100644 index 0000000..77a6cc2 --- /dev/null +++ b/packages/core/test/notifications/setup-store-probe.test.ts @@ -0,0 +1,146 @@ +/** @module test/notifications/setup-store-probe.test — the Telegram connect calls against the fake Bot API, the screenshot store on a temp dir (0600 files, refs validated before any path is built, pruning) and the `publicUrl` probe (spec 03 §4.8.1, §9.5; spec 08 §5.8). */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + createNotificationImageStore, + createTelegramSetup, + createUrlProbe, +} from '../../src/infra/notifications/index.ts'; +import { isStartCommand } from '../../src/infra/notifications/telegram-setup.ts'; +import { FAKE_TG_TOKEN, FakePlatforms } from '../helpers/fake-platforms.ts'; +import { withTempDir } from '../helpers/temp-dir.ts'; +import { JPEG } from './helpers.ts'; + +let fakes: FakePlatforms; +beforeEach(() => { + fakes = new FakePlatforms().start(); +}); +afterEach(async () => { + await fakes.stop(); +}); + +describe('telegram setup', () => { + it('reads the bot username', async () => { + const setup = createTelegramSetup({ apiBase: fakes.telegramBase }); + expect(await setup.botUsername(FAKE_TG_TOKEN)).toBe('bh_test_bot'); + }); + + it('refuses a bad token with auth and without echoing it', async () => { + fakes.script('telegram:getMe', { + status: 401, + body: { ok: false, description: 'Unauthorized' }, + }); + const setup = createTelegramSetup({ apiBase: fakes.telegramBase }); + const err = await setup.botUsername(FAKE_TG_TOKEN).catch((e: Error & { code?: string }) => e); + expect((err as { code?: string }).code).toBe('auth'); + expect((err as Error).message).not.toContain(FAKE_TG_TOKEN); + }); + + it('captures the chat and the sender of /start in a group topic', async () => { + fakes.updates.push( + { + update_id: 10, + message: { message_id: 1, text: 'hello', chat: { id: 5, type: 'private' } }, + }, + { + update_id: 11, + message: { + message_id: 2, + text: '/start@bh_test_bot c0de123', + chat: { id: -1009, type: 'supergroup', title: 'Ops' }, + from: { id: 77, first_name: 'Amir', last_name: 'G' }, + message_thread_id: 3, + is_topic_message: true, + }, + }, + ); + const setup = createTelegramSetup({ apiBase: fakes.telegramBase }); + const start = await setup.waitForStart(FAKE_TG_TOKEN, 'c0de123', { + signal: new AbortController().signal, + deadline: Date.now() + 5_000, + }); + expect(start).toEqual({ + chat: { id: '-1009', title: 'Ops', type: 'supergroup', threadId: '3' }, + user: { id: '77', name: 'Amir G' }, + }); + const polls = fakes.of('telegram').filter((r) => r.path === 'getUpdates'); + expect(polls.at(-1)?.json).toMatchObject({ offset: 12 }); + }); + + it('gives up at the deadline and on abort', async () => { + const setup = createTelegramSetup({ apiBase: fakes.telegramBase }); + expect( + await setup.waitForStart(FAKE_TG_TOKEN, 'nope', { + signal: new AbortController().signal, + deadline: Date.now() + 200, + }), + ).toBeNull(); + const controller = new AbortController(); + controller.abort(); + expect( + await setup.waitForStart(FAKE_TG_TOKEN, 'nope', { + signal: controller.signal, + deadline: Date.now() + 5_000, + }), + ).toBeNull(); + }); + + it('matches only the exact code', () => { + expect(isStartCommand('/start abc', 'abc')).toBe(true); + expect(isStartCommand('/start@bot abc', 'abc')).toBe(true); + expect(isStartCommand('/start abcd', 'abc')).toBe(false); + expect(isStartCommand('start abc', 'abc')).toBe(false); + }); +}); + +describe('image store', () => { + it('stores 0600 files, reads them back, refuses traversal and prunes old ones', async () => { + await withTempDir(async (root) => { + const dir = join(root, 'notifications', 'images'); + const store = createNotificationImageStore(dir); + const ref = await store.put({ bytes: JPEG, contentType: 'image/jpeg', filename: 'x.jpg' }); + expect(ref).toMatch(/^nimg-[A-Za-z0-9_-]{16}$/); + expect((await store.read(ref))?.bytes).toEqual(JPEG); + expect((await stat(join(dir, `${ref}.jpg`))).mode & 0o777).toBe(0o600); + expect((await stat(dir)).mode & 0o777).toBe(0o700); + expect(await store.read('../../etc/passwd')).toBeNull(); + expect(await store.read('nimg-doesnotexist12')).toBeNull(); + expect(await store.prune(Date.now() - 60_000)).toBe(0); + expect(await store.prune(Date.now() + 60_000)).toBe(1); + expect(await store.read(ref)).toBeNull(); + }); + }); +}); + +describe('url probe', () => { + it('reports answers, redirects without following them, and errors', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch: (req) => + new URL(req.url).pathname === '/health' + ? Response.json({ status: 'ready', instance_id: 'i-1' }) + : Response.redirect('https://login.example.com/', 302), + }); + try { + const probe = createUrlProbe(); + const ok = await probe(`http://127.0.0.1:${server.port}/health`, 2_000); + expect(ok).toMatchObject({ kind: 'response', status: 200 }); + expect(ok.kind === 'response' && JSON.parse(ok.body)).toEqual({ + status: 'ready', + instance_id: 'i-1', + }); + const moved = await probe(`http://127.0.0.1:${server.port}/other`, 2_000); + expect(moved).toMatchObject({ + kind: 'response', + status: 302, + location: 'https://login.example.com/', + }); + expect((await probe('http://127.0.0.1:1/health', 2_000)).kind).toBe('error'); + } finally { + await server.stop(true); + } + }); +}); From e09e14593c280774b34ab203e09b58f3e067d818 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:42:53 -0400 Subject: [PATCH 09/22] ci(notifications): real ntfy job and the weekly live notification check A non-required ntfy job runs the adapter against binwiederhier/ntfy v2.28.0 (publish, read back, upload, replace by sequence id, delete). notify-live.yml runs weekly, on dispatch and on PRs labelled live-notify from this repository, in the notify-live environment: real Telegram, a Discord webhook and ntfy, send with a screenshot, read back, edit and delete, skipping a platform without secrets and opening an issue on failure. --- .github/workflows/ci.yml | 37 +++ .github/workflows/notify-live.yml | 69 ++++++ .../notifications/ntfy-live.test.ts | 83 +++++++ scripts/fixtures/notify-live.jpg | Bin 0 -> 4817 bytes scripts/notify-live.ts | 227 ++++++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 .github/workflows/notify-live.yml create mode 100644 packages/core/test/integration/notifications/ntfy-live.test.ts create mode 100644 scripts/fixtures/notify-live.jpg create mode 100644 scripts/notify-live.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18a659..ea60168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,6 +224,43 @@ jobs: - if: env.RUN == 'true' run: bun run test:integration + ntfy: + # The ntfy adapter against a real ntfy server (binwiederhier/ntfy): publish, read back, upload + # a screenshot, replace by sequence id, delete. Needs no secrets. Not a required check; the + # adapter is also covered by the fakes in `unit`. + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: 'bun-${{ runner.os }}-${{ hashFiles(''bun.lock'') }}' + - run: bun install --frozen-lockfile + # A service container cannot pass the image's `serve` command, so the server is started here. + - name: Start ntfy + run: | + docker run -d --name ntfy -p 127.0.0.1:8080:80 \ + -e NTFY_BASE_URL=http://127.0.0.1:8080 \ + -e NTFY_CACHE_FILE=/tmp/cache.db \ + -e NTFY_ATTACHMENT_CACHE_DIR=/tmp/attachments \ + binwiederhier/ntfy:v2.28.0 serve + for _ in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/v1/health > /dev/null; then break; fi + sleep 1 + done + curl -sf http://127.0.0.1:8080/v1/health + - name: ntfy adapter against the real server + env: + BHDEV_NTFY_URL: http://127.0.0.1:8080 + run: bun test packages/core/test/integration/notifications/ntfy-live.test.ts + - if: failure() + run: docker logs ntfy || true + sandbox-matrix: # Cross-OS sandbox and stealth smoke check: launches sessions through BrowserHive under each # `sandbox` setting (off, auto, on) and with an agent's `chromiumSandbox: true`, for the bundled diff --git a/.github/workflows/notify-live.yml b/.github/workflows/notify-live.yml new file mode 100644 index 0000000..25d986c --- /dev/null +++ b/.github/workflows/notify-live.yml @@ -0,0 +1,69 @@ +name: notify-live + +# Live notification check (spec 09 §8): real Telegram, a Discord webhook and ntfy, through the +# real adapters: send with a screenshot, read back where the platform allows, edit, delete. It +# guards against the fakes drifting from the platforms. Runs weekly, on dispatch, and on pull +# requests labelled `live-notify` from this repository (never from forks). The `notify-live` +# environment holds the secrets and needs the owner's approval. A platform whose secrets are not +# set is skipped with a note. A failure opens (or comments on) an issue. + +on: + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + pull_request: + types: [labeled, opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: notify-live-${{ github.ref }} + cancel-in-progress: true + +jobs: + live: + if: >- + github.event_name != 'pull_request' || + (contains(github.event.pull_request.labels.*.name, 'live-notify') && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + environment: notify-live + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: 'bun-${{ runner.os }}-${{ hashFiles(''bun.lock'') }}' + - run: bun install --frozen-lockfile + - name: Send, read back, edit and delete on each platform + env: + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }} + run: bun scripts/notify-live.ts + - name: Open or update the drift issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Weekly live notification check failed" + body="The live notification check against the real platforms failed. + + - Run: $RUN_URL + + A platform's API may have changed, or a secret in the notify-live environment expired. The fakes in packages/core/test/helpers/fake-platforms.ts may have drifted from the real platforms; see the job summary for which platform failed." + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number')" + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$body" + else + gh issue create --title "$title" --body "$body" + fi diff --git a/packages/core/test/integration/notifications/ntfy-live.test.ts b/packages/core/test/integration/notifications/ntfy-live.test.ts new file mode 100644 index 0000000..5e00589 --- /dev/null +++ b/packages/core/test/integration/notifications/ntfy-live.test.ts @@ -0,0 +1,83 @@ +/** @module test/integration/notifications/ntfy-live.test — the ntfy adapter against a real ntfy server (spec 09 §3.2): publish, read back, attachment upload, replace by sequence id, delete. Runs only when `BHDEV_NTFY_URL` points at a server (CI starts `binwiederhier/ntfy` in the `ntfy` job); skipped otherwise. */ + +import { describe, expect, it } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { createNtfyChannel, NTFY_CAPABILITIES } from '../../../src/infra/notifications/index.ts'; +import { delivery, platformRecord, SAMPLE_IMAGES } from '../../notifications/helpers.ts'; + +const SERVER = process.env['BHDEV_NTFY_URL']; + +interface NtfyEvent { + readonly event: string; + readonly sequence_id?: string; + readonly title?: string; + readonly message?: string; + readonly priority?: number; + readonly tags?: string[]; + readonly actions?: { label: string; url: string }[]; + readonly attachment?: { name: string; size: number; url: string }; +} + +async function poll(server: string, topic: string): Promise { + const response = await fetch(`${server}/${topic}/json?poll=1`); + expect(response.status).toBe(200); + const text = await response.text(); + return text + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line) as NtfyEvent); +} + +describe.skipIf(SERVER === undefined)('ntfy adapter against a real server', () => { + it('publishes, uploads, replaces by sequence id and deletes', async () => { + const server = (SERVER ?? '').replace(/\/+$/, ''); + const topic = `bh-ci-${randomBytes(6).toString('hex')}`; + const channel = createNtfyChannel(platformRecord('ntfy', { target: { server, topic } }), { + token: null, + topic: null, + images: SAMPLE_IMAGES, + }); + + const { ref } = await channel.send( + delivery('attention', NTFY_CAPABILITIES, { image: 'masked' }), + ); + expect(ref['sequence_id']).toBe('n-sample000001'); + let events = await poll(server, topic); + const first = events.find((e) => e.event === 'message'); + expect(first).toMatchObject({ + sequence_id: 'n-sample000001', + title: 'Attention requested', + priority: 4, + }); + expect(first?.tags).toEqual(['warning']); + expect(first?.attachment?.name).toBe('screenshot.jpg'); + expect(first?.actions?.map((a) => a.label)).toEqual(['Take over', 'Open in BrowserHive']); + + await channel.edit?.(ref, delivery('attention-resolved', NTFY_CAPABILITIES)); + events = await poll(server, topic); + const replaced = events.filter( + (e) => e.event === 'message' && e.sequence_id === 'n-sample000001', + ); + expect(replaced).toHaveLength(2); + expect(replaced.at(-1)?.message).toContain('Resolved by admin'); + expect(replaced.at(-1)?.priority).toBe(2); + + await channel.delete?.(ref); + events = await poll(server, topic); + expect(events.at(-1)).toMatchObject({ event: 'message_delete', sequence_id: 'n-sample000001' }); + }); + + it('refuses a fourth action like ntfy does, by never sending more than three', async () => { + const server = (SERVER ?? '').replace(/\/+$/, ''); + const topic = `bh-ci-${randomBytes(6).toString('hex')}`; + const channel = createNtfyChannel(platformRecord('ntfy', { target: { server, topic } }), { + token: null, + topic: null, + images: SAMPLE_IMAGES, + }); + await channel.send(delivery('vault-confirm', NTFY_CAPABILITIES)); + const [event] = await poll(server, topic); + expect(event?.title).toBe('Vault fill awaiting confirm'); + expect((event?.actions ?? []).length).toBeLessThanOrEqual(3); + }); +}); diff --git a/scripts/fixtures/notify-live.jpg b/scripts/fixtures/notify-live.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ca0d9ec27531aecc98a6c39527fde8ad8ec08287 GIT binary patch literal 4817 zcmex=+ zFc20J77-B<1_B@@Dk>@|DG4M&ObH1|Nog5rDJe-Q85ub_ITaNpB`qy&V`IDjM;NLZ z7=h-a0Cq+QKoVqSV_{|z04ii-VrFCjA_xdx%qq%!iD`2)qp6^fB8#CQP=cA24QK#N z4KoWGu*K@P?YrUqt4B?cxz24+Eq|F;-; zfQB;(0@?NqlN(-EG$1*GzD(|8H9!6{ob8_$vuA%lpZ)xO{~2t5|M~m<_4V~YRQI3Z z{g%#~_lo8`EPBd4C$7-)^V8GQwf0#4`1IjFgJu1(_44xKHGB4J{8|0{-2Q)-fe-&P z#M$4M|NHdeAIGCj-~Iaa^;+M}m5Ym&v7B=Dkl8-r#ao&y+m}n(>4@(tsjMu~FM09l z(~GoVbB(hpau!XH*p~esmBl=md66TdHwE@A{Uq z3Hb(J9=oGBk>^y}L+N;qw+ivJEo?Vh)#|z$a+;jMHue=yU5aI)4 z&DkxvHOiyj8jY7N}%fB$^{`TqL*=lkpS0<-Pks_*|9Y8BqS z)Vx<*^XSua?m2n|KR-P^Kiz6i<&V!Ff!X~(!?F4N{OmSI_G`$jcz$aCzn|fcfg0t{ z^Z$MR_%B0v0l4Ve5caREUOr!>Hev0bH~;MJ>0A%`Yb39~N@~yY$;My4t(0e6>OpcD z$J*5Sc|)ajrSfZ5;pYjT%09W@TsQky`oH)P*UJ5B(J$Y6Fndh^naXr9`sd9*wR-}; zHf{gB`N!{y&9UBJPSosMk@abw&-u%@mejCKRUp}VF)sCU2cNus(!WN;e{S>1+b8AI zcICgE|Fbqw^x2=8`OCK|@M<}LOyy?Hf1dvF+ei0nT=&nNfBg2**;4(>il2ThTz#t2 z*xo;D;s>da29QFMK!nD8Yxc?8C)sTXv@7FR&sV9P^>Y8R`lnxA!e@U<-8b(_gUL#; zfjXl5=gvQV`>1?`cD=QI|MqUvEBk%gPrqtguleN6U)xpg2Qq_5vKge9F=0{78#%vw z9={H{{Jdc^chAIc*~|XT{Le7`s$g9H$!Wi;gBUI?0UJ9S_oHz?n(s&R{b-p#0?Yi0 z^UdCfMcByGT{kv*j%(hP}*~Pe3tys?Np5|4;IkUVfz37^9-o@UJ)7`f6=SBp6 zOxP5>z~}VNl;sXHrx#mK+bCVulNKv=#dd2Vuql7iuhCfV>7=<#X|ZSCMIFh#WjM1W zJK)>ZR$zl2$x!2+*@D|Mj-_5)>wG&*q$WaV$_rtmcafSG)Kc%9d%DK$?qbPN>qY|; zl>SDO3c-8_YnkpzIN1N^#*arwkN^1a`1jA(-|z1~f4}bU?|*-Pe}Dh)x8uE9wS9rf ze&MH&AKLYD_4NMlhv!@U{Lip{=c~$#E_-eMGi v !== undefined && v !== '', +); + +/** A real JPEG, so Telegram accepts the photo. */ +const JPEG = new Uint8Array(readFileSync(join(import.meta.dir, 'fixtures', 'notify-live.jpg'))); +const IMAGES: NotificationImageReader = { + read: async (ref) => + ref === SAMPLE_IMAGE_REF + ? { bytes: JPEG, contentType: 'image/jpeg', filename: 'screenshot.jpg' } + : null, +}; +/** Links point at the public website so every platform accepts them as buttons. */ +const LINKS: LinkBuilder = { local: false, url: (path) => `https://browserhive.ai${path}` }; + +type Outcome = { platform: string; status: 'passed' | 'failed' | 'skipped'; detail: string }; + +function record(kind: string, target: Record): NotificationChannelRecord { + return { + channelId: `nc-live-${kind}`, + name: `live-${kind}`, + kind, + mode: kind === 'discord' ? 'webhook' : null, + source: 'db', + status: 'active', + target, + secretRefs: {}, + rules: {}, + failureCount: 0, + lastError: null, + lastOkAt: null, + lastFailureAt: null, + createdAt: 0, + updatedAt: 0, + }; +} + +function deliveryOf( + channel: NotificationChannel, + sample: PreviewSample, + image: boolean, +): ChannelDelivery { + const message = sampleMessage(sample, { now: Date.now(), image: image ? 'masked' : 'none' }); + const marked = { ...message, title: `Live check · ${message.title}`.slice(0, 120) }; + return { + message: degrade(restrictContent(marked, 'full'), channel.capabilities), + links: LINKS, + replyTo: null, + }; +} + +function safe(err: unknown): string { + return scrubDetail(err instanceof Error ? err.message : 'unknown failure', SECRETS); +} + +function check(condition: boolean, what: string): void { + if (!condition) throw new Error(`read-back: ${what}`); +} + +async function telegram(): Promise { + const token = env['TG_BOT_TOKEN']; + const chat = env['TG_CHAT_ID']; + if (!token || !chat) + return { + platform: 'Telegram', + status: 'skipped', + detail: 'TG_BOT_TOKEN or TG_CHAT_ID not set', + }; + const channel = createTelegramChannel(record('telegram', { chat_id: chat }), { + token, + images: IMAGES, + }); + const photo = await channel.send(deliveryOf(channel, 'attention', true)); + check(photo.ref['photo'] === 1, 'the send result is a photo message'); + await channel.edit?.(photo.ref, deliveryOf(channel, 'attention-resolved', true)); + await channel.delete?.(photo.ref); + const text = await channel.send(deliveryOf(channel, 'tool-errors', false)); + await channel.edit?.(text.ref, deliveryOf(channel, 'tool-errors', false)); + await channel.delete?.(text.ref); + return { platform: 'Telegram', status: 'passed', detail: 'photo and text: send, edit, delete' }; +} + +async function discord(): Promise { + const webhook = env['DISCORD_WEBHOOK_URL']; + if (!webhook) + return { platform: 'Discord', status: 'skipped', detail: 'DISCORD_WEBHOOK_URL not set' }; + const channel = createDiscordChannel(record('discord', {}), { + webhookUrl: webhook, + images: IMAGES, + }); + const read = async (id: string | number) => { + const response = await fetch(`${webhook.replace(/\/+$/, '')}/messages/${id}`); + return { + status: response.status, + body: (await response.json().catch(() => null)) as Record | null, + }; + }; + const { ref } = await channel.send(deliveryOf(channel, 'attention', true)); + const id = ref['message_id'] ?? ''; + let back = await read(id); + const embeds = (back.body?.['embeds'] ?? []) as { title?: string }[]; + check( + back.status === 200 && (embeds[0]?.title ?? '').includes('Attention requested'), + 'the embed title', + ); + check( + ((back.body?.['attachments'] ?? []) as unknown[]).length === 1, + 'the screenshot attachment', + ); + await channel.edit?.(ref, deliveryOf(channel, 'attention-resolved', true)); + back = await read(id); + const edited = (back.body?.['embeds'] ?? []) as { title?: string }[]; + check((edited[0]?.title ?? '').startsWith('✅'), 'the edited embed'); + check(((back.body?.['attachments'] ?? []) as unknown[]).length === 1, 'the kept attachment'); + await channel.delete?.(ref); + back = await read(id); + check(back.status === 404, 'the message is gone after delete'); + return { + platform: 'Discord', + status: 'passed', + detail: 'send, read back, edit (screenshot kept), delete', + }; +} + +async function ntfy(): Promise { + const topic = env['NTFY_TOPIC']; + if (!topic) return { platform: 'ntfy', status: 'skipped', detail: 'NTFY_TOPIC not set' }; + const server = (env['NTFY_SERVER'] || 'https://ntfy.sh').replace(/\/+$/, ''); + const channel = createNtfyChannel(record('ntfy', { server }), { + token: null, + topic, + images: IMAGES, + }); + const since = Math.floor(Date.now() / 1000) - 5; + const poll = async () => { + const response = await fetch(`${server}/${topic}/json?poll=1&since=${since}`); + const text = await response.text(); + return text + .split('\n') + .filter((l) => l.trim() !== '') + .map( + (l) => + JSON.parse(l) as { + event: string; + sequence_id?: string; + message?: string; + attachment?: unknown; + }, + ); + }; + const { ref } = await channel.send(deliveryOf(channel, 'attention', true)); + const sequence = String(ref['sequence_id']); + let events = await poll(); + check( + events.some( + (e) => e.event === 'message' && e.sequence_id === sequence && e.attachment !== undefined, + ), + 'the upload', + ); + await channel.edit?.(ref, deliveryOf(channel, 'attention-resolved', false)); + events = await poll(); + const latest = events.filter((e) => e.event === 'message' && e.sequence_id === sequence).at(-1); + check((latest?.message ?? '').includes('Resolved'), 'the replacement'); + await channel.delete?.(ref); + events = await poll(); + check( + events.some((e) => e.event === 'message_delete' && e.sequence_id === sequence), + 'the delete event', + ); + return { + platform: 'ntfy', + status: 'passed', + detail: `send with screenshot, replace, delete on ${new URL(server).host}`, + }; +} + +const outcomes: Outcome[] = []; +for (const [name, run] of [ + ['Telegram', telegram], + ['Discord', discord], + ['ntfy', ntfy], +] as const) { + try { + outcomes.push(await run()); + } catch (err) { + outcomes.push({ platform: name, status: 'failed', detail: safe(err) }); + } +} + +const icon = { passed: '✅', failed: '❌', skipped: '⏭️' } as const; +const table = [ + '### Live notification check', + '', + '| Platform | Result | Detail |', + '|---|---|---|', + ...outcomes.map( + (o) => `| ${o.platform} | ${icon[o.status]} ${o.status} | ${o.detail.replace(/\|/g, '/')} |`, + ), + '', +].join('\n'); +console.log(table); +const summary = env['GITHUB_STEP_SUMMARY']; +if (summary) appendFileSync(summary, `${table}\n`); +process.exit(outcomes.some((o) => o.status === 'failed') ? 1 : 0); From d749058cc7e9cbf22633b4726546f1a6728ab701 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:43:40 -0400 Subject: [PATCH 10/22] feat(notifications): wire the platform adapters, screenshots and publicUrl into the server Composition registers the Telegram, Discord, ntfy and webhook factories and renderers, the screenshot store and snapshots, the channel service, the publicUrl link builder, host trust on /mcp and the check; startup channels come from --notificationChannel. The help lists the flag and the channels command; route, doctor and help tests cover them; OpenAPI and the references are regenerated. --- docs/reference/api.md | 24 +- docs/reference/config.schema.json | 6 + docs/reference/configuration.md | 18 +- docs/reference/errors.md | 184 +- docs/reference/websocket.md | 22 + packages/browserhive/src/cli/help.ts | 17 +- .../browserhive/src/composition/context.ts | 8 + .../src/composition/notification-snapshots.ts | 99 + .../src/composition/phases/build-domain.ts | 61 +- .../src/composition/phases/domain-ops.ts | 70 +- .../src/composition/phases/listeners-http.ts | 10 +- .../cli/__goldens__/help-channels-list.txt | 26 + .../cli/__goldens__/help-channels-preview.txt | 31 + .../cli/__goldens__/help-channels-test.txt | 29 + .../test/cli/__goldens__/help-channels.txt | 51 + .../cli/__goldens__/help-config-schema.txt | 1 + .../test/cli/__goldens__/help-config-show.txt | 1 + .../cli/__goldens__/help-config-validate.txt | 1 + .../test/cli/__goldens__/help-config.txt | 1 + .../test/cli/__goldens__/help-doctor.txt | 15 +- .../test/cli/__goldens__/help-global.txt | 28 +- .../test/cli/__goldens__/help-serve.txt | 12 +- packages/browserhive/test/cli/doctor.test.ts | 73 +- packages/contracts/generated/openapi.json | 14957 +++++++++++----- packages/core/src/app/notifications/index.ts | 30 +- packages/core/src/public/server.ts | 5 + packages/core/test/helpers/http-kit.ts | 48 +- .../core/test/helpers/http-route-cases.ts | 164 + 28 files changed, 11928 insertions(+), 4064 deletions(-) create mode 100644 packages/browserhive/src/composition/notification-snapshots.ts create mode 100644 packages/browserhive/test/cli/__goldens__/help-channels-list.txt create mode 100644 packages/browserhive/test/cli/__goldens__/help-channels-preview.txt create mode 100644 packages/browserhive/test/cli/__goldens__/help-channels-test.txt create mode 100644 packages/browserhive/test/cli/__goldens__/help-channels.txt diff --git a/docs/reference/api.md b/docs/reference/api.md index ef94292..e8620ef 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -2,7 +2,7 @@ # REST API reference -The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (86 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. +The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (101 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. Summaries come from `packages/contracts/generated/openapi.json`. @@ -18,7 +18,7 @@ Summaries come from `packages/contracts/generated/openapi.json`. ## Scopes -`sessions:read` · `sessions:write` · `sessions:takeover` · `attention:read` · `attention:resolve` · `vault:read` · `vault:write` · `vault:confirm` · `blocklist:read` · `blocklist:write` · `system:read` · `system:write` · `logs:read` · `notifications:read` · `notifications:write` · `preferences:write` · `mcp:tools` +`sessions:read` · `sessions:write` · `sessions:takeover` · `attention:read` · `attention:resolve` · `vault:read` · `vault:write` · `vault:confirm` · `blocklist:read` · `blocklist:write` · `system:read` · `system:write` · `logs:read` · `notifications:read` · `notifications:write` · `channels:read` · `channels:write` · `preferences:write` · `mcp:tools` ## Health @@ -134,6 +134,7 @@ Summaries come from `packages/contracts/generated/openapi.json`. | GET | `/api/v1/system/config` | `getSystemConfig` | `system:read` | cookie, bearer | Every config key with its value, source and shadowed values (secrets redacted). | | GET | `/api/v1/system/realtime` | `getSystemRealtime` | `system:read` | cookie, bearer | Open realtime connections with topics, screencasts and backpressure counters. | | GET | `/api/v1/system/mcp/connections` | `listMcpConnections` | `system:read` | cookie, bearer | MCP connections with their self-reported identity: live ones first, then recent (D-30). | +| GET | `/api/v1/system/public-url` | `getPublicUrlStatus` | `system:read` | cookie, bearer | The publicUrl check: does the public address reach this BrowserHive? (cached 60 s) | | PATCH | `/api/v1/system/log-level` | `setLogLevel` | `system:write` | cookie, bearer | Change the log level spec at runtime (`info,sessions=debug`). | | GET | `/api/v1/system/events` | `listSystemEvents` | `system:read` | cookie, bearer | Degradations (`resolved=open` by default). | @@ -163,6 +164,25 @@ Summaries come from `packages/contracts/generated/openapi.json`. | GET | `/api/v1/me/preferences` | `getPreferences` | — | cookie, bearer | The caller's stored preferences (known keys only). | | PUT | `/api/v1/me/preferences` | `putPreferences` | `preferences:write` | cookie, bearer | Replace the preferences document (≤ 64 KiB; unknown keys rejected). | +## channels + +| Method | Path | operationId | Scope | Auth | Summary | +|---|---|---|---|---|---| +| GET | `/api/v1/channels` | `listChannels` | `channels:read` | cookie, bearer | Every notification channel (dashboard and startup) with its state; never a secret value. | +| POST | `/api/v1/channels` | `createChannel` | `channels:write` | cookie, bearer | Create a channel. Secrets are environment variable names, never values (D-33). | +| POST | `/api/v1/channels/preview` | `previewChannel` | `channels:read` | cookie, bearer | Render a sample notification exactly as the channel would send it. Sends nothing. | +| GET | `/api/v1/channels/deliveries` | `listDeliveries` | `channels:read` | cookie, bearer | The delivery log newest first: every send, edit and delete, and why anything was not sent. | +| GET | `/api/v1/channels/deliveries/{seq}` | `getDelivery` | `channels:read` | cookie, bearer | One delivery with the message as that channel is shown it (redacted). | +| GET | `/api/v1/channels/env` | `checkChannelEnv` | `channels:read` | cookie, bearer | Whether each named environment variable is set in the server (never its value). | +| POST | `/api/v1/channels/telegram/connect` | `startTelegramConnect` | `channels:write` | cookie, bearer | Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start. | +| GET | `/api/v1/channels/telegram/connect/{connect_id}` | `getTelegramConnect` | `channels:read` | cookie, bearer | State of a Telegram connect: waiting, connected (with the chat), expired or failed. | +| GET | `/api/v1/channels/{channel_id}` | `getChannel` | `channels:read` | cookie, bearer | One channel. | +| PATCH | `/api/v1/channels/{channel_id}` | `updateChannel` | `channels:write` | cookie, bearer | Edit a dashboard channel (startup channels are read-only). | +| DELETE | `/api/v1/channels/{channel_id}` | `deleteChannel` | `channels:write` | cookie, bearer | Delete a dashboard channel and its delivery log. | +| POST | `/api/v1/channels/{channel_id}/pause` | `pauseChannel` | `channels:write` | cookie, bearer | Pause a channel; its pending deliveries are suppressed. | +| POST | `/api/v1/channels/{channel_id}/resume` | `resumeChannel` | `channels:write` | cookie, bearer | Resume a paused or broken channel. | +| POST | `/api/v1/channels/{channel_id}/test` | `testChannel` | `channels:write` | cookie, bearer | Send a real test message through the channel now; the result says why it failed. | + ## Search | Method | Path | operationId | Scope | Auth | Summary | diff --git a/docs/reference/config.schema.json b/docs/reference/config.schema.json index f2677d4..f8a95fa 100644 --- a/docs/reference/config.schema.json +++ b/docs/reference/config.schema.json @@ -103,6 +103,12 @@ "x-browserhive-env": "BROWSERHIVE_ALLOWED_HOSTS", "x-browserhive-cli": "--allowedHosts" }, + "publicUrl": { + "type": "string", + "description": "Address where you made the dashboard reachable (reverse proxy, tunnel, Tailscale name). Notification links use it, and its host is trusted like allowedHosts.", + "x-browserhive-env": "BROWSERHIVE_PUBLIC_URL", + "x-browserhive-cli": "--publicUrl" + }, "admin": { "description": "Enable the dashboard, REST API, WebSocket and trace viewer (http only).", "x-browserhive-env": "BROWSERHIVE_ADMIN", diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 015cc11..4e336b2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2,7 +2,7 @@ # Configuration reference -Every configuration key of BrowserHive (52 keys), generated from the zod schema in `@browserhive/contracts/config`. For a guided introduction see [the configuration guide](../guide/configuration.md). +Every configuration key of BrowserHive (53 keys), generated from the zod schema in `@browserhive/contracts/config`. For a guided introduction see [the configuration guide](../guide/configuration.md). ## Precedence @@ -102,6 +102,7 @@ These names are reserved for future releases. Setting any of them fails fast wit | [`allowInsecureBind`](#allowInsecureBind) | `--allowInsecureBind` | `BROWSERHIVE_ALLOW_INSECURE_BIND` | `false` | | [`trustedProxies`](#trustedProxies) | `--trustedProxies` | `BROWSERHIVE_TRUSTED_PROXIES` | empty list | | [`allowedHosts`](#allowedHosts) | `--allowedHosts` | `BROWSERHIVE_ALLOWED_HOSTS` | empty list | +| [`publicUrl`](#publicUrl) | `--publicUrl` | `BROWSERHIVE_PUBLIC_URL` | unset | | [`admin`](#admin) | `--admin` | `BROWSERHIVE_ADMIN` | `false` | | [`dataDir`](#dataDir) | `--dataDir` | `BROWSERHIVE_DATA_DIR` | derived (platform) | | [`shutdownTimeout`](#shutdownTimeout) | `--shutdownTimeout` | `BROWSERHIVE_SHUTDOWN_TIMEOUT` | `20s` | @@ -235,6 +236,21 @@ Extra Host names to accept besides loopback and the bound host, such as the name | Examples | `browserhive.example.com` | | Notes | restart required | + +### `publicUrl` + +Address where you made the dashboard reachable (reverse proxy, tunnel, Tailscale name). Notification links use it, and its host is trusted like allowedHosts. + +| Property | Value | +|---|---| +| CLI flag | `--publicUrl` | +| Environment | `BROWSERHIVE_PUBLIC_URL` | +| Config file | `"publicUrl"` | +| Type | an absolute http: or https: URL without query or fragment, like 'https://browserhive.example.net' | +| Default | unset | +| Examples | `https://browserhive.example.net` | +| Notes | restart required | + ### `admin` diff --git a/docs/reference/errors.md b/docs/reference/errors.md index b75bc97..02a4a7f 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -2,7 +2,7 @@ # Error reference -Every error code BrowserHive can produce (99 codes), generated from `ERROR_REGISTRY` in `@browserhive/contracts/errors`. Each code has a stable anchor: `errors.md#`, which is also the `type` URL of HTTP problem responses (`https://browserhive.ai/docs/errors#`). +Every error code BrowserHive can produce (106 codes), generated from `ERROR_REGISTRY` in `@browserhive/contracts/errors`. Each code has a stable anchor: `errors.md#`, which is also the `type` URL of HTTP problem responses (`https://browserhive.ai/docs/errors#`). ## How errors reach you @@ -73,6 +73,13 @@ Returned by tools and the REST API when a request cannot be served (unknown sess | [`INVALID_ARGUMENTS`](#INVALID_ARGUMENTS) | Invalid arguments | 400 | different_args | | [`TRACE_UNAVAILABLE`](#TRACE_UNAVAILABLE) | Trace unavailable | 404 | never | | [`SCREENSHOT_UNAVAILABLE`](#SCREENSHOT_UNAVAILABLE) | Screenshot unavailable | 404 | never | +| [`CHANNEL_NOT_FOUND`](#CHANNEL_NOT_FOUND) | Notification channel not found | 404 | never | +| [`CHANNEL_NAME_TAKEN`](#CHANNEL_NAME_TAKEN) | Channel name in use | 409 | different_args | +| [`CHANNEL_READ_ONLY`](#CHANNEL_READ_ONLY) | Startup channel is read-only | 409 | never | +| [`CHANNEL_NOT_READY`](#CHANNEL_NOT_READY) | Channel is not ready | 409 | after_operator | +| [`CHANNEL_KIND_UNAVAILABLE`](#CHANNEL_KIND_UNAVAILABLE) | Platform not available yet | 400 | different_args | +| [`CHANNEL_PLATFORM_ERROR`](#CHANNEL_PLATFORM_ERROR) | The platform refused the request | 502 | backoff | +| [`DELIVERY_NOT_FOUND`](#DELIVERY_NOT_FOUND) | Delivery not found | 404 | never | | [`INTERNAL_ERROR`](#INTERNAL_ERROR) | Internal error | 500 | backoff | @@ -1196,6 +1203,181 @@ Details: |---|---|---|---| | `event_id` | `string` | yes | — | + +### `CHANNEL_NOT_FOUND` + +| Property | Value | +|---|---| +| Title | Notification channel not found | +| HTTP status | 404 | +| Category | `domain` | +| Retryable | `never` (do not retry; the request cannot succeed as sent) | + +Message: `Notification channel '{channel_id}' does not exist.` + +Hint: List the channels with GET /api/v1/channels. + +Cause: The channel was deleted, or the id is wrong. + +Resolution: Refresh the channel list. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `channel_id` | `string` | yes | — | + + +### `CHANNEL_NAME_TAKEN` + +| Property | Value | +|---|---| +| Title | Channel name in use | +| HTTP status | 409 | +| Category | `domain` | +| Retryable | `different_args` (retry only with different arguments) | + +Message: `A notification channel named '{name}' already exists.` + +Hint: Pick another name. + +Cause: Channel names are unique across dashboard and startup channels. + +Resolution: Choose a different name, or edit the existing channel. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `name` | `string` | yes | — | + + +### `CHANNEL_READ_ONLY` + +| Property | Value | +|---|---| +| Title | Startup channel is read-only | +| HTTP status | 409 | +| Category | `domain` | +| Retryable | `never` (do not retry; the request cannot succeed as sent) | + +Message: `Notification channel '{name}' comes from --notificationChannel and cannot be edited or deleted here.` + +Hint: Change or remove the --notificationChannel flag and restart; pausing is allowed. + +Cause: Startup channels are declared by a command-line flag (D-39); the flag is their truth. + +Resolution: Edit the flag and restart BrowserHive, or pause the channel from the dashboard. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `channel_id` | `string` | yes | — | +| `name` | `string` | yes | — | + + +### `CHANNEL_NOT_READY` + +| Property | Value | +|---|---| +| Title | Channel is not ready | +| HTTP status | 409 | +| Category | `domain` | +| Retryable | `after_operator` (retry after an operator acts (unlock the vault, resolve a request, change policy)) | + +Message: `The notification channel cannot send: {problem}` + +Hint: Set the missing environment variables and restart BrowserHive. + +Cause: An environment variable the channel names is not set in the server’s environment (secrets are never stored, D-33). + +Resolution: Export the variable where BrowserHive runs (shell, systemd, Docker) and restart it. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `channel_id` | `string` | no | — | +| `problem` | `string` | yes | — | +| `missing` | `string[]` | yes | — | + + +### `CHANNEL_KIND_UNAVAILABLE` + +| Property | Value | +|---|---| +| Title | Platform not available yet | +| HTTP status | 400 | +| Category | `domain` | +| Retryable | `different_args` (retry only with different arguments) | + +Message: `Notification channels of kind '{kind}'{mode_text} are not available in this release.` + +Hint: Use telegram, discord (webhook mode), ntfy or webhook. + +Cause: The platform (or Discord bot mode) is reserved for a later release. + +Resolution: Pick an available platform, or Discord in webhook mode. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `kind` | `string` | yes | — | +| `mode` | `string` | no | — | +| `mode_text` | `string` | yes | — | + + +### `CHANNEL_PLATFORM_ERROR` + +| Property | Value | +|---|---| +| Title | The platform refused the request | +| HTTP status | 502 | +| Category | `domain` | +| Retryable | `backoff` (retry later with backoff) | + +Message: `{kind} answered: {detail}` + +Hint: Check the credentials the channel names, then retry. + +Cause: The notification platform rejected the call (a wrong token, a network failure, a limit). + +Resolution: Read the detail; fix the token or URL in the environment and restart, or retry later. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `kind` | `string` | yes | — | +| `code` | `string` | yes | — | +| `detail` | `string` | yes | — | + + +### `DELIVERY_NOT_FOUND` + +| Property | Value | +|---|---| +| Title | Delivery not found | +| HTTP status | 404 | +| Category | `domain` | +| Retryable | `never` (do not retry; the request cannot succeed as sent) | + +Message: `Delivery {seq} does not exist.` + +Hint: Delivery rows are kept for 30 days. + +Cause: The row was pruned by retention, or deleted with its channel. + +Resolution: Nothing to do. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `seq` | `number` | yes | — | + ### `INTERNAL_ERROR` diff --git a/docs/reference/websocket.md b/docs/reference/websocket.md index 6a376dd..72ccc7c 100644 --- a/docs/reference/websocket.md +++ b/docs/reference/websocket.md @@ -138,6 +138,7 @@ Subscribe with `{ "type": "subscribe", "topic": "", "cursor"?: | `system` | `system:read` | [`system.degraded`](#event-system-degraded), [`system.recovered`](#event-system-recovered), [`system.tick`](#event-system-tick), [`system.capacity`](#event-system-capacity), [`retention.completed`](#event-retention-completed) | | `logs` | `logs:read` | [`log.record`](#event-log-record) | | `notifications` | `notifications:read` | [`notification.created`](#event-notification-created), [`notification.updated`](#event-notification-updated) | +| `channels` | `channels:read` | [`channel.changed`](#event-channel-changed), [`channel.removed`](#event-channel-removed), [`delivery.updated`](#event-delivery-updated) | | `session:` | `sessions:read` | [`session.opened`](#event-session-opened), [`session.updated`](#event-session-updated), [`session.closed`](#event-session-closed), [`session.removed`](#event-session-removed), [`session.warning`](#event-session-warning), [`tool.called`](#event-tool-called), [`page.visited`](#event-page-visited), [`screenshot.captured`](#event-screenshot-captured), [`vault.access`](#event-vault-access), [`blocklist.hit`](#event-blocklist-hit), [`attention.created`](#event-attention-created), [`attention.resolved`](#event-attention-resolved), [`vault.confirm.created`](#event-vault-confirm-created), [`vault.confirm.resolved`](#event-vault-confirm-resolved) | | `screencast:` | `sessions:read` | stream messages `meta`, `started`, `stopped`, `failed` and binary frames | @@ -335,6 +336,27 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio |---|---|---|---| | `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at`, `kind`, `category`, `severity`, `state`, `revision`, `thread` | + +### `channel.changed` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `channel` | `object` | yes | keys `channel_id`, `name`, `kind`, `mode`, `source`, `status`, `target`, `target_hint`, `secret_refs`, `secrets`, `rules`, `capabilities`, `ready`, `problem`, `failure_count`, `last_error`, `last_ok_at`, `last_failure_at`, `created_at`, `updated_at`, `stats` | + + +### `channel.removed` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `channel_id` | `string` | yes | — | + + +### `delivery.updated` + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `delivery` | `object` | yes | keys `seq`, `channel_id`, `channel_name`, `channel_kind`, `notification_id`, `notification_kind`, `notification_title`, `revision`, `op`, `status`, `reason`, `attempts`, `next_attempt_at`, `last_error`, `duration_ms`, `message_ref`, `created_at`, `updated_at` | + ### `log.record` diff --git a/packages/browserhive/src/cli/help.ts b/packages/browserhive/src/cli/help.ts index b83464c..9d3f8e9 100644 --- a/packages/browserhive/src/cli/help.ts +++ b/packages/browserhive/src/cli/help.ts @@ -175,6 +175,20 @@ function serverFlagSections(options: HelpOptions): string[] { return lines; } +/** `serve`'s own flags (not config keys): `--notificationChannel` (spec 08 §5.7). */ +function serveFlagSection(options: HelpOptions): string[] { + const serve = COMMANDS.find((command) => command.name === 'serve'); + if (serve === undefined || serve.flags.length === 0) return []; + return [ + '', + header('FLAGS — notifications (flag only)', options.style), + ...renderRows( + serve.flags.map((f) => commandFlagRow(f, options.style)), + options, + ), + ]; +} + function footer(options: HelpOptions): string[] { return [ '', @@ -215,6 +229,7 @@ export function renderGlobalHelp(options: HelpOptions): readonly string[] { header('GLOBAL FLAGS', style), ...renderRows(GLOBAL_ROWS(style), options), ...serverFlagSections(options), + ...serveFlagSection(options), ...footer(options), ]; return lines; @@ -299,7 +314,7 @@ export function renderCommandHelp(topic: HelpTopic, options: HelpOptions): reado ...paragraph(command.description, options), ); lines.push('', header('GLOBAL FLAGS', style), ...renderRows(GLOBAL_ROWS(style), options)); - lines.push(...serverFlagSections(options), ...footer(options)); + lines.push(...serverFlagSections(options), ...serveFlagSection(options), ...footer(options)); return lines; } diff --git a/packages/browserhive/src/composition/context.ts b/packages/browserhive/src/composition/context.ts index f0f06ea..36995b3 100644 --- a/packages/browserhive/src/composition/context.ts +++ b/packages/browserhive/src/composition/context.ts @@ -32,12 +32,14 @@ import type { AuthStateStore, BlocklistService, ChannelRegistry, + ChannelService, LeaseSweeper, NotificationOutbox, NotificationService, OperatorRequestBroker, PageActions, PreferenceService, + PublicUrlChecker, Recorder, RuntimeFacts, SessionService, @@ -113,6 +115,12 @@ export interface DomainPart { readonly channels: ChannelRegistry; /** The notification delivery outbox worker (D-34). */ readonly notificationOutbox: NotificationOutbox; + /** The channels API (spec 03 §4.8.1). */ + readonly channelService: ChannelService; + /** The `publicUrl` check (spec 08 §5.8). */ + readonly publicUrl: PublicUrlChecker; + /** Random per start; `GET /health` reports it (D-37). */ + readonly instanceId: string; readonly preferences: PreferenceService; readonly recorder: Recorder; readonly retention: RetentionScheduler; diff --git a/packages/browserhive/src/composition/notification-snapshots.ts b/packages/browserhive/src/composition/notification-snapshots.ts new file mode 100644 index 0000000..487029e --- /dev/null +++ b/packages/browserhive/src/composition/notification-snapshots.ts @@ -0,0 +1,99 @@ +/** @module composition/notification-snapshots — `NotificationSnapshots` over the live sessions (D-36, spec 03 §9.5): a JPEG of the active page (form fields masked on request) or a crashed session's last stored screenshot, kept in the notification image store; never while the session's secret window is open. */ + +import type { + CapturedImage, + NotificationImageStore, + NotificationSnapshots, +} from '@browserhive/core/ports/notification-channel'; +import type { ScreenshotRepository } from '@browserhive/core/ports/persistence/screenshots'; +import { type Logger, serializeError } from '@browserhive/core/runtime'; +import type { SessionService } from '@browserhive/core/server'; + +/** JPEG quality of notification screenshots. */ +export const SNAPSHOT_QUALITY = 70; +/** Longest wait for Playwright's screenshot. */ +const SNAPSHOT_TIMEOUT_MS = 3_000; +/** What `mask_images` blacks out. */ +export const MASK_SELECTOR = + 'input:not([type="hidden"]), textarea, select, [contenteditable]:not([contenteditable="false"])'; + +/** Dependencies of {@link createNotificationSnapshots}. */ +export interface NotificationSnapshotsDeps { + readonly sessions: Pick; + readonly screenshots: ScreenshotRepository; + readonly images: NotificationImageStore; + /** Whether the session's vault secret window is open (`SecretRegistry.isWindowOpen`). */ + readonly secretWindowOpen: (sessionId: string) => boolean; + readonly now: () => number; + readonly logger: Logger; + /** Reads a stored screenshot file (default `Bun.file`). */ + readonly readFile?: (path: string) => Promise; +} + +async function readWithBun(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return null; + return new Uint8Array(await file.arrayBuffer()); +} + +/** + * The screenshot seam of the notification service. Every method returns `null` instead of + * throwing. + * + * @returns The snapshots port. + */ +export function createNotificationSnapshots( + deps: NotificationSnapshotsDeps, +): NotificationSnapshots { + const log = deps.logger.child({ module: 'notifications' }); + const readFile = deps.readFile ?? readWithBun; + return { + async capture(sessionId, options): Promise { + if (deps.secretWindowOpen(sessionId)) return null; + const session = deps.sessions.peek(sessionId); + if (session === undefined) return null; + try { + const page = deps.sessions.page(session); + const bytes = await page.screenshot({ + type: 'jpeg', + quality: SNAPSHOT_QUALITY, + scale: 'css', + timeout: SNAPSHOT_TIMEOUT_MS, + ...(options.masked && { + mask: [page.locator(MASK_SELECTOR)], + maskColor: '#1f2937', + }), + }); + // A fill that started while the capture ran: drop the frame (D-36). + if (deps.secretWindowOpen(sessionId)) return null; + const ref = await deps.images.put({ + bytes: new Uint8Array(bytes), + contentType: 'image/jpeg', + filename: 'screenshot.jpg', + }); + return { ref, capturedAt: deps.now() }; + } catch (err) { + log.debug('snapshot skipped', { session_id: sessionId, err: serializeError(err) }); + return null; + } + }, + async lastFrame(sessionId): Promise { + try { + const page = await deps.screenshots.listBySession(sessionId, { limit: 1 }); + const row = page.items[0]; + if (row === undefined) return null; + const bytes = await readFile(row.path); + if (bytes === null) return null; + const extension = row.contentType === 'image/png' ? 'png' : 'jpg'; + const ref = await deps.images.put({ + bytes, + contentType: row.contentType, + filename: `last-screenshot.${extension}`, + }); + return { ref, capturedAt: row.ts }; + } catch { + return null; + } + }, + }; +} diff --git a/packages/browserhive/src/composition/phases/build-domain.ts b/packages/browserhive/src/composition/phases/build-domain.ts index a3d31ed..c2a224f 100644 --- a/packages/browserhive/src/composition/phases/build-domain.ts +++ b/packages/browserhive/src/composition/phases/build-domain.ts @@ -1,9 +1,18 @@ /** @module composition/phases/build-domain — phase 4: clock/ids/bus, degradations, sessions, operators, vault, auth (seed flow), notifications, schedulers, startup reconcile, tools. */ +import { join } from 'node:path'; +import { + CHANNEL_RENDERERS, + channelFactories, + createNotificationImageStore, + createTelegramSetup, + createUrlProbe, +} from '@browserhive/core/notifications'; import type { DomainEvents } from '@browserhive/core/runtime'; import { createNanoidIdGenerator, DegradationService, + isInsecurePublicUrl, serializeError, } from '@browserhive/core/runtime'; import { createPlaywrightPageActions, InProcessEventBus } from '@browserhive/core/server'; @@ -11,6 +20,7 @@ import { asyncTick, createTimers } from '../adapters/timers.ts'; import { createAuthStack } from '../auth-stack.ts'; import { type BootContext, part, type SeedNotice } from '../context.ts'; import { hostFactsOf } from '../host.ts'; +import { createNotificationSnapshots } from '../notification-snapshots.ts'; import type { PhaseHandle } from '../unwind.ts'; import { buildOperators } from './domain-operators.ts'; import { buildOps, reconcile } from './domain-ops.ts'; @@ -19,6 +29,10 @@ import { buildTools } from './domain-tools.ts'; /** How often expired auth sessions, grants and rate buckets are swept. */ export const AUTH_SWEEP_INTERVAL_MS = 5 * 60 * 1000; +/** Notification screenshots are kept this long (retries last at most 24 h, D-34). */ +export const IMAGE_KEEP_MS = 7 * 24 * 60 * 60 * 1000; +/** How often old notification screenshots are pruned. */ +export const IMAGE_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000; /** Phase `build-domain`. Stop drains sessions (the 15 s budget) and settles operator requests. */ export async function buildDomainPhase(ctx: BootContext): Promise { @@ -110,7 +124,12 @@ async function buildDomain( registerSecret: (literal) => secrets.add(literal), }); const seeds = await seedCredentials(ctx, auth.service); + const timers = createTimers((err) => + logger.warn('timer callback failed', { err: serializeError(err) }), + ); + const images = createNotificationImageStore(join(config.dataDir, 'notifications', 'images')); + const instanceId = ids.opaque(16); const ops = buildOps({ config, repos, @@ -129,9 +148,39 @@ async function buildDomain( registerSecret: (literal) => secrets.add(literal), dashboardUrl: () => ctx.listeners?.url ?? `http://${config.host}:${config.port}`, deliveryCounter: telemetry.instruments.notificationDeliveries, + channelFactories: channelFactories({ images }), + renderers: CHANNEL_RENDERERS, + telegram: createTelegramSetup(), + probe: createUrlProbe(), + instanceId, + snapshots: createNotificationSnapshots({ + sessions, + screenshots: repos.screenshots, + images, + secretWindowOpen: (sessionId) => secrets.isWindowOpen(sessionId), + now: () => clock.now(), + logger, + }), }); - // Startup channels (--notificationChannel, D-39) arrive with the first platform adapters. - await ops.channels.load(); + // Startup channels (--notificationChannel, D-39): projected into the table, read-only. A name a + // dashboard channel already uses stops startup with CONFIG_INVALID (exit 64). + for (const warning of ctx.input.startupChannelWarnings ?? []) { + logger.warn('startup channel warning', { detail: warning }); + } + await ops.channels.load(ctx.input.startupChannels ?? []); + if (isInsecurePublicUrl(config.publicUrl)) { + logger.warn('publicUrl is plain http', { public_url: config.publicUrl }); + } + const stopImagePrune = timers.every( + asyncTick( + async () => { + await images.prune(clock.now() - IMAGE_KEEP_MS); + }, + (err) => logger.warn('image prune failed', { err: serializeError(err) }), + ), + IMAGE_PRUNE_INTERVAL_MS, + ); + undo.push(stopImagePrune); await reconcile({ repos, clock, logger, degradations, broker: operators.broker }); // Requests settled while nothing listened (the last shutdown, the orphan recovery above) revise // their notifications now; the producers subscribe later, in wire-observers. @@ -159,9 +208,6 @@ async function buildDomain( logger, }); - const timers = createTimers((err) => - logger.warn('timer callback failed', { err: serializeError(err) }), - ); const stopAuthSweep = timers.every( asyncTick( () => auth.service.sweep(), @@ -193,6 +239,9 @@ async function buildDomain( notifications: ops.notifications, channels: ops.channels, notificationOutbox: ops.notificationOutbox, + channelService: ops.channelService, + publicUrl: ops.publicUrl, + instanceId, preferences: ops.preferences, recorder: ops.recorder, retention: ops.retention, @@ -207,6 +256,8 @@ async function buildDomain( return { async stop(deadlineMs) { stopAuthSweep(); + stopImagePrune(); + ops.channelService.stop(); sessionParts.sweeper.stop(); sessionParts.stopWatcher?.(); await sessions.closeAll('shutdown', Math.max(1_000, deadlineMs - 500)); diff --git a/packages/browserhive/src/composition/phases/domain-ops.ts b/packages/browserhive/src/composition/phases/domain-ops.ts index f588600..2ede8ad 100644 --- a/packages/browserhive/src/composition/phases/domain-ops.ts +++ b/packages/browserhive/src/composition/phases/domain-ops.ts @@ -2,6 +2,12 @@ import type { ServerConfig } from '@browserhive/contracts/config'; import type { DatabaseHandle, SqliteMaintenanceService } from '@browserhive/core/persistence'; +import type { + ChannelRenderer, + NotificationSnapshots, + TelegramSetup, + UrlProbe, +} from '@browserhive/core/ports/notification-channel'; import type { AnalyticsQueries, Clock, @@ -27,11 +33,14 @@ import type { OperatorRequestBroker } from '@browserhive/core/server'; import { type ChannelAdapterFactory, ChannelRegistry, - createLocalLinkBuilder, + ChannelService, type DeliveryCounter, + imageVariants, + linkBuilderFor, NotificationOutbox, NotificationService, PreferenceService, + PublicUrlChecker, Recorder, } from '@browserhive/core/server'; @@ -59,8 +68,17 @@ export interface OpsInput { readonly dashboardUrl: () => string; /** `browserhive.notifications.deliveries` (a no-op without telemetry). */ readonly deliveryCounter?: DeliveryCounter; - /** Platform adapter factories by channel kind; none ship yet. */ + /** Platform adapter factories by channel kind (`@browserhive/core/notifications`). */ readonly channelFactories?: ReadonlyMap; + /** The platform renderers (the preview uses the adapters' own). */ + readonly renderers: ReadonlyMap; + /** Screenshot seam (D-36); late-bound because it needs the sessions. */ + readonly snapshots?: NotificationSnapshots; + readonly telegram?: TelegramSetup; + /** One-shot URL probe of the `publicUrl` check. */ + readonly probe: UrlProbe; + /** Random per start (`GET /health`). */ + readonly instanceId: string; } /** Built operations services (not started; `wire-observers` starts them). */ @@ -71,6 +89,10 @@ export interface OpsParts { readonly channels: ChannelRegistry; /** The delivery outbox worker (started by `wire-observers`). */ readonly notificationOutbox: NotificationOutbox; + /** The channels API (spec 03 §4.8.1). */ + readonly channelService: ChannelService; + /** The `publicUrl` check (spec 08 §5.8). */ + readonly publicUrl: PublicUrlChecker; readonly preferences: PreferenceService; readonly retention: RetentionScheduler; readonly outbox: ArtifactOutboxSweeper; @@ -90,17 +112,45 @@ export function buildOps(input: OpsInput): OpsParts { registerSecret: input.registerSecret, ...(input.channelFactories !== undefined && { factories: input.channelFactories }), }); + const links = linkBuilderFor(config.publicUrl, input.dashboardUrl); + // The feed is late-bound: the channel service is built after the outbox that reports to it. + let feed: ChannelService | undefined; const notificationOutbox = new NotificationOutbox({ uow: input.uow, repos, registry: channels, - links: createLocalLinkBuilder(input.dashboardUrl), + links, clock, logger, bus, redactor: input.redactor, jitter: Math.random, ...(input.deliveryCounter !== undefined && { counter: input.deliveryCounter }), + onDeliveryChange: (channelId, notificationId) => + feed?.onDeliveryChange(notificationId, channelId), + }); + const channelService = new ChannelService({ + repos, + uow: input.uow, + registry: channels, + renderers: input.renderers, + links, + clock, + ids, + logger, + bus, + env: (name) => input.env[name], + registerSecret: input.registerSecret, + redactor: input.redactor, + ...(input.telegram !== undefined && { telegram: input.telegram }), + }); + feed = channelService; + const publicUrl = new PublicUrlChecker({ + publicUrl: config.publicUrl, + localUrl: input.dashboardUrl, + instanceId: input.instanceId, + probe: input.probe, + clock, }); return { recorder: new Recorder({ @@ -122,9 +172,23 @@ export function buildOps(input: OpsInput): OpsParts { uow: input.uow, outbox: notificationOutbox, redactor: input.redactor, + onDeliveryChange: (notificationId) => channelService.onDeliveryChange(notificationId), + ...(input.snapshots !== undefined && { + screenshots: { + enabled: config.recordToolResults !== 'none', + snapshots: input.snapshots, + variants: (category) => + imageVariants( + channels.channels().map((c) => c.record), + category, + ), + }, + }), }), channels, notificationOutbox, + channelService, + publicUrl, preferences: new PreferenceService({ repo: repos.preferences, clock, logger }), retention: new RetentionScheduler({ maintenance: input.maintenance, diff --git a/packages/browserhive/src/composition/phases/listeners-http.ts b/packages/browserhive/src/composition/phases/listeners-http.ts index 3581ac8..e67bd1e 100644 --- a/packages/browserhive/src/composition/phases/listeners-http.ts +++ b/packages/browserhive/src/composition/phases/listeners-http.ts @@ -25,6 +25,7 @@ import { createRealtimeHub, createTraceViewerAssets, playwrightBridgeFactory, + publicUrlHost, sessionDirLayout, } from '@browserhive/core/server'; import { @@ -187,6 +188,9 @@ export async function openHttpListener( throw bindError(err, config.host, config.port); } const port = server.port ?? config.port; + // publicUrl's host is trusted like an allowedHosts entry, on /mcp too (D-37). + const publicHost = publicUrlHost(config.publicUrl); + const trustedHosts = [...config.allowedHosts, ...(publicHost === null ? [] : [publicHost])]; const url = urlFor(config.host, port); let mcp: McpHttpHandler | undefined; @@ -199,7 +203,7 @@ export async function openHttpListener( logger, connections: storage.uow.repos.mcpConnections, host: config.host, - allowedHosts: config.allowedHosts, + allowedHosts: trustedHosts, onSessionClosed: (closed) => { if (closed.remainingForSubject > 0) return; void cancelAttentionOf(domain.broker, closed.subject).catch((err: unknown) => @@ -218,6 +222,7 @@ export async function openHttpListener( trustedProxies: config.trustedProxies, allowedHosts: config.allowedHosts, allowInsecureBind: config.allowInsecureBind, + ...(config.publicUrl !== undefined && { publicUrl: config.publicUrl }), }, services: { sessions, @@ -237,6 +242,7 @@ export async function openHttpListener( transport: 'http', startedAt: ctx.startedAt, traceEnabled: config.trace, + instanceId: domain.instanceId, }), configView: () => configView(ctx.input.resolved.config, ctx.input.resolved.provenance), }, @@ -258,6 +264,8 @@ export async function openHttpListener( idempotency: storage.uow.repos.idempotency, events: domain.bus, ids: domain.ids, + channels: domain.channelService, + publicUrl: domain.publicUrl, traceViewerAvailable: traceViewer.available, }, adminAuthenticator: domain.adminAuthenticator, diff --git a/packages/browserhive/test/cli/__goldens__/help-channels-list.txt b/packages/browserhive/test/cli/__goldens__/help-channels-list.txt new file mode 100644 index 0000000..d72ae85 --- /dev/null +++ b/packages/browserhive/test/cli/__goldens__/help-channels-list.txt @@ -0,0 +1,26 @@ +browserhive channels list — List channels with status, target, secret variables and 24 h counts + +USAGE + browserhive channels list [flags] + +Talks to a running server over its REST API (--url, default the configured host and port) with an +operator bearer (--token) or the dashboard cookie (--cookie). test sends a real message and exits 1 +when the platform refused it; preview sends nothing. + +FLAGS + --json Print machine-readable JSON instead of text. + --url Talk to a running server over its REST API instead of opening the + database (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + --host
Bind address. A non-loopback host requires auth=token or + allowInsecureBind=true. default: 127.0.0.1 + --port <1-65535> Bind port for MCP, REST, WebSocket and the dashboard. default: 9876 + --config Config file path. CLI and environment only: a config file cannot + point at another. + --color Colour for logs and CLI output. auto honours NO_COLOR, FORCE_COLOR, + TERM=dumb and TTY. default: auto + +GLOBAL FLAGS + -h, --help Show help. + -v, --version Print the version. diff --git a/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt b/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt new file mode 100644 index 0000000..7a79c67 --- /dev/null +++ b/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt @@ -0,0 +1,31 @@ +browserhive channels preview — Print the platform request a channel would send (sends nothing) + +USAGE + browserhive channels preview [flags] + +Talks to a running server over its REST API (--url, default the configured host and port) with an +operator bearer (--token) or the dashboard cookie (--cookie). test sends a real message and exits 1 +when the platform refused it; preview sends nothing. + +ARGUMENTS + Channel name. + +FLAGS + --json Print machine-readable JSON instead of text. + --sample Sample notification: attention, attention-resolved, vault-confirm, + tool-errors, crash, degraded or test. default: attention + --url Talk to a running server over its REST API instead of opening the + database (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + --host
Bind address. A non-loopback host requires auth=token or + allowInsecureBind=true. default: 127.0.0.1 + --port <1-65535> Bind port for MCP, REST, WebSocket and the dashboard. default: 9876 + --config Config file path. CLI and environment only: a config file cannot + point at another. + --color Colour for logs and CLI output. auto honours NO_COLOR, FORCE_COLOR, + TERM=dumb and TTY. default: auto + +GLOBAL FLAGS + -h, --help Show help. + -v, --version Print the version. diff --git a/packages/browserhive/test/cli/__goldens__/help-channels-test.txt b/packages/browserhive/test/cli/__goldens__/help-channels-test.txt new file mode 100644 index 0000000..27f772e --- /dev/null +++ b/packages/browserhive/test/cli/__goldens__/help-channels-test.txt @@ -0,0 +1,29 @@ +browserhive channels test — Send a real test message through a channel + +USAGE + browserhive channels test [flags] + +Talks to a running server over its REST API (--url, default the configured host and port) with an +operator bearer (--token) or the dashboard cookie (--cookie). test sends a real message and exits 1 +when the platform refused it; preview sends nothing. + +ARGUMENTS + Channel name. + +FLAGS + --json Print machine-readable JSON instead of text. + --url Talk to a running server over its REST API instead of opening the + database (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + --host
Bind address. A non-loopback host requires auth=token or + allowInsecureBind=true. default: 127.0.0.1 + --port <1-65535> Bind port for MCP, REST, WebSocket and the dashboard. default: 9876 + --config Config file path. CLI and environment only: a config file cannot + point at another. + --color Colour for logs and CLI output. auto honours NO_COLOR, FORCE_COLOR, + TERM=dumb and TTY. default: auto + +GLOBAL FLAGS + -h, --help Show help. + -v, --version Print the version. diff --git a/packages/browserhive/test/cli/__goldens__/help-channels.txt b/packages/browserhive/test/cli/__goldens__/help-channels.txt new file mode 100644 index 0000000..aae7c32 --- /dev/null +++ b/packages/browserhive/test/cli/__goldens__/help-channels.txt @@ -0,0 +1,51 @@ +browserhive channels — List, test and preview notification channels + +USAGE + browserhive channels list [flags] + browserhive channels test [flags] + browserhive channels preview [flags] + +Talks to a running server over its REST API (--url, default the configured host and port) with an +operator bearer (--token) or the dashboard cookie (--cookie). test sends a real message and exits 1 +when the platform refused it; preview sends nothing. + +COMMANDS + list List channels with status, target, secret variables and 24 h counts + test Send a real test message through a channel + preview Print the platform request a channel would send (sends nothing) + +FLAGS + --host
Bind address. A non-loopback host requires auth=token or + allowInsecureBind=true. default: 127.0.0.1 + --port <1-65535> Bind port for MCP, REST, WebSocket and the dashboard. default: 9876 + --config Config file path. CLI and environment only: a config file cannot + point at another. + --color Colour for logs and CLI output. auto honours NO_COLOR, FORCE_COLOR, + TERM=dumb and TTY. default: auto + +FLAGS — list + --json Print machine-readable JSON instead of text. + --url Talk to a running server over its REST API instead of opening the database + (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + +FLAGS — test + --json Print machine-readable JSON instead of text. + --url Talk to a running server over its REST API instead of opening the database + (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + +FLAGS — preview + --json Print machine-readable JSON instead of text. + --sample Sample notification: attention, attention-resolved, vault-confirm, tool-errors, + crash, degraded or test. default: attention + --url Talk to a running server over its REST API instead of opening the database + (http://127.0.0.1:9876). + --token Operator bearer token for --url (bh_operator_…). + --cookie Dashboard session cookie value for --url (browserhive_session). + +GLOBAL FLAGS + -h, --help Show help. + -v, --version Print the version. diff --git a/packages/browserhive/test/cli/__goldens__/help-config-schema.txt b/packages/browserhive/test/cli/__goldens__/help-config-schema.txt index 3253fd7..886f2ef 100644 --- a/packages/browserhive/test/cli/__goldens__/help-config-schema.txt +++ b/packages/browserhive/test/cli/__goldens__/help-config-schema.txt @@ -23,6 +23,7 @@ NAMES --allowInsecureBind BROWSERHIVE_ALLOW_INSECURE_BIND --trustedProxies BROWSERHIVE_TRUSTED_PROXIES --allowedHosts BROWSERHIVE_ALLOWED_HOSTS + --publicUrl BROWSERHIVE_PUBLIC_URL --admin BROWSERHIVE_ADMIN --dataDir BROWSERHIVE_DATA_DIR --shutdownTimeout BROWSERHIVE_SHUTDOWN_TIMEOUT diff --git a/packages/browserhive/test/cli/__goldens__/help-config-show.txt b/packages/browserhive/test/cli/__goldens__/help-config-show.txt index c6ae0de..22680ba 100644 --- a/packages/browserhive/test/cli/__goldens__/help-config-show.txt +++ b/packages/browserhive/test/cli/__goldens__/help-config-show.txt @@ -27,6 +27,7 @@ NAMES --allowInsecureBind BROWSERHIVE_ALLOW_INSECURE_BIND --trustedProxies BROWSERHIVE_TRUSTED_PROXIES --allowedHosts BROWSERHIVE_ALLOWED_HOSTS + --publicUrl BROWSERHIVE_PUBLIC_URL --admin BROWSERHIVE_ADMIN --dataDir BROWSERHIVE_DATA_DIR --shutdownTimeout BROWSERHIVE_SHUTDOWN_TIMEOUT diff --git a/packages/browserhive/test/cli/__goldens__/help-config-validate.txt b/packages/browserhive/test/cli/__goldens__/help-config-validate.txt index 37f1056..eb6ca3b 100644 --- a/packages/browserhive/test/cli/__goldens__/help-config-validate.txt +++ b/packages/browserhive/test/cli/__goldens__/help-config-validate.txt @@ -26,6 +26,7 @@ NAMES --allowInsecureBind BROWSERHIVE_ALLOW_INSECURE_BIND --trustedProxies BROWSERHIVE_TRUSTED_PROXIES --allowedHosts BROWSERHIVE_ALLOWED_HOSTS + --publicUrl BROWSERHIVE_PUBLIC_URL --admin BROWSERHIVE_ADMIN --dataDir BROWSERHIVE_DATA_DIR --shutdownTimeout BROWSERHIVE_SHUTDOWN_TIMEOUT diff --git a/packages/browserhive/test/cli/__goldens__/help-config.txt b/packages/browserhive/test/cli/__goldens__/help-config.txt index 7e9f849..30d29fb 100644 --- a/packages/browserhive/test/cli/__goldens__/help-config.txt +++ b/packages/browserhive/test/cli/__goldens__/help-config.txt @@ -37,6 +37,7 @@ NAMES --allowInsecureBind BROWSERHIVE_ALLOW_INSECURE_BIND --trustedProxies BROWSERHIVE_TRUSTED_PROXIES --allowedHosts BROWSERHIVE_ALLOWED_HOSTS + --publicUrl BROWSERHIVE_PUBLIC_URL --admin BROWSERHIVE_ADMIN --dataDir BROWSERHIVE_DATA_DIR --shutdownTimeout BROWSERHIVE_SHUTDOWN_TIMEOUT diff --git a/packages/browserhive/test/cli/__goldens__/help-doctor.txt b/packages/browserhive/test/cli/__goldens__/help-doctor.txt index 41ea45a..4007a52 100644 --- a/packages/browserhive/test/cli/__goldens__/help-doctor.txt +++ b/packages/browserhive/test/cli/__goldens__/help-doctor.txt @@ -8,11 +8,16 @@ warnings only. Accepts the server flags so the checks see the configuration serv launch each installed browser once to test the sandbox. FLAGS - --json Print machine-readable JSON instead of text. - --printApparmorProfile Print an AppArmor profile that lets the configured browser sandbox on - Ubuntu 23.10+, then exit. Install it with sudo tee /etc/apparmor.d/; - nothing is installed for you. - Every flag of 'browserhive serve' (see 'browserhive serve --help'). + --json Print machine-readable JSON instead of text. + --notificationChannel + Declare a notification channel for this run (repeatable; secrets + as env:NAME), e.g. + "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456". + --printApparmorProfile Print an AppArmor profile that lets the configured browser + sandbox on Ubuntu 23.10+, then exit. Install it with sudo tee + /etc/apparmor.d/; nothing is installed for you. + Every flag of 'browserhive serve' (see 'browserhive serve + --help'). GLOBAL FLAGS -h, --help Show help. diff --git a/packages/browserhive/test/cli/__goldens__/help-global.txt b/packages/browserhive/test/cli/__goldens__/help-global.txt index a030952..b2869af 100644 --- a/packages/browserhive/test/cli/__goldens__/help-global.txt +++ b/packages/browserhive/test/cli/__goldens__/help-global.txt @@ -5,15 +5,16 @@ USAGE browserhive [flags] COMMANDS - serve Start the MCP server (default) - init Install browsers and prepare the data directory - doctor Check the host, browsers, and configuration - purge Delete local state after an inventory and confirmation - config Show, validate, or export the configuration schema - db Inspect, back up, restore, or migrate the database - admin Administrative actions (reset-password, tokens) - version Print version information - help Show help for a command + serve Start the MCP server (default) + init Install browsers and prepare the data directory + doctor Check the host, browsers, and configuration + purge Delete local state after an inventory and confirmation + config Show, validate, or export the configuration schema + db Inspect, back up, restore, or migrate the database + admin Administrative actions (reset-password, tokens) + channels List, test and preview notification channels + version Print version information + help Show help for a command GLOBAL FLAGS -h, --help Show help. @@ -39,6 +40,9 @@ FLAGS — server --allowedHosts Extra Host names to accept besides loopback and the bound host, such as the name a reverse proxy forwards. Ports are ignored. default: none + --publicUrl Address where you made the dashboard reachable (reverse proxy, + tunnel, Tailscale name). Notification links use it, and its host + is trusted like allowedHosts. --admin Enable the dashboard, REST API, WebSocket and trace viewer (http only). default: false --dataDir Data directory (database, sessions, auth states, uploads, @@ -138,6 +142,12 @@ FLAGS — telemetry --otelTraceUrlTemplate Dashboard deep-link template for a trace; {trace_id} is substituted. Used only by the dashboard. +FLAGS — notifications (flag only) + --notificationChannel + Declare a notification channel for this run (repeatable; secrets + as env:NAME), e.g. + "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456". + Precedence: defaults < environment < browserhive.config.json < flags (rightmost wins). References: a config-file value may contain {env:NAME} or {env:NAME:-default}. Environment variables and JSON keys: browserhive help config. diff --git a/packages/browserhive/test/cli/__goldens__/help-serve.txt b/packages/browserhive/test/cli/__goldens__/help-serve.txt index d95833d..d472dbf 100644 --- a/packages/browserhive/test/cli/__goldens__/help-serve.txt +++ b/packages/browserhive/test/cli/__goldens__/help-serve.txt @@ -4,7 +4,8 @@ USAGE browserhive [serve] [flags] Resolves the configuration (defaults < environment < browserhive.config.json < flags), starts the -server and waits for a signal. Every configuration key is a flag. +server and waits for a signal. Every configuration key is a flag; --notificationChannel is a flag +only (never an environment variable or a config-file key). GLOBAL FLAGS -h, --help Show help. @@ -30,6 +31,9 @@ FLAGS — server --allowedHosts Extra Host names to accept besides loopback and the bound host, such as the name a reverse proxy forwards. Ports are ignored. default: none + --publicUrl Address where you made the dashboard reachable (reverse proxy, + tunnel, Tailscale name). Notification links use it, and its host + is trusted like allowedHosts. --admin Enable the dashboard, REST API, WebSocket and trace viewer (http only). default: false --dataDir Data directory (database, sessions, auth states, uploads, @@ -129,6 +133,12 @@ FLAGS — telemetry --otelTraceUrlTemplate Dashboard deep-link template for a trace; {trace_id} is substituted. Used only by the dashboard. +FLAGS — notifications (flag only) + --notificationChannel + Declare a notification channel for this run (repeatable; secrets + as env:NAME), e.g. + "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456". + Precedence: defaults < environment < browserhive.config.json < flags (rightmost wins). References: a config-file value may contain {env:NAME} or {env:NAME:-default}. Environment variables and JSON keys: browserhive help config. diff --git a/packages/browserhive/test/cli/doctor.test.ts b/packages/browserhive/test/cli/doctor.test.ts index 1627ab6..ff80097 100644 --- a/packages/browserhive/test/cli/doctor.test.ts +++ b/packages/browserhive/test/cli/doctor.test.ts @@ -6,6 +6,7 @@ import { DATA_DIR, detected, MemoryFs, + type ProbeState, probeState, sandboxEnvironment, storageState, @@ -54,6 +55,8 @@ describe('doctor', () => { 'otel', 'max sessions', 'secrets', + 'publicUrl', + 'notification channels', ]); expect(rows.every((r) => r.status === 'ok')).toBe(true); }); @@ -68,7 +71,7 @@ describe('doctor', () => { expect(run.stdout).toMatch(/^\s+CHECK\s+DETAIL/m); expect(run.stdout).toContain('✓ bun'); expect(run.stdout).toContain('config: maxSessions=4 (cli) shadows env=2'); - expect(run.stdout).toContain('18 passed, 0 warnings, 0 failed'); + expect(run.stdout).toContain('20 passed, 0 warnings, 0 failed'); }); it.each([ @@ -187,6 +190,74 @@ describe('doctor', () => { expect(run.code).toBe(2); }); + it('publicUrl: points here ✓, a login in front !, unreachable !, elsewhere ✗ (spec 08 §5.8)', async () => { + const local = 'http://127.0.0.1:9876/health'; + const pub = 'https://bh.example.net/health'; + const health = (id: string) => ({ + kind: 'response' as const, + status: 200, + contentType: 'application/json', + location: null, + body: JSON.stringify({ status: 'ready', version: '0.2.0', instance_id: id }), + }); + const verdict = async (answer: ProbeState['fetches']) => + ( + await doctorJson({ + argv: ['--publicUrl', 'https://bh.example.net/'], + fs: healthyFs(), + probes: probeState({ fetches: { [local]: health('me'), ...answer } }), + }) + ).byName.get('publicUrl'); + expect((await verdict({ [pub]: health('me') }))?.status).toBe('ok'); + expect((await verdict({ [pub]: health('other') }))?.status).toBe('fail'); + expect( + ( + await verdict({ + [pub]: { + kind: 'response', + status: 302, + contentType: null, + location: 'https://login.example.com/', + body: '', + }, + }) + )?.status, + ).toBe('warn'); + const none = await verdict({}); + expect(none?.status).toBe('warn'); + expect(none?.detail).toContain('hairpin'); + }); + + it('notification channels: a startup flag with an unset variable or a dashboard channel with one fails', async () => { + const flag = await doctorJson({ + argv: ['--notificationChannel', 'telegram:name=phone,token=env:BH_TG_TOKEN,chat=1'], + fs: healthyFs(), + }); + expect(flag.byName.get('notification channels')?.status).toBe('fail'); + expect(flag.byName.get('notification channels')?.detail).toContain('BH_TG_TOKEN is not set'); + const db = await doctorJson({ + argv: [], + fs: healthyFs(), + storage: storageState({ + channels: [ + { name: 'team', kind: 'discord', source: 'db', secretRefs: { webhook: 'BH_DISCORD' } }, + ], + }), + }); + expect(db.byName.get('notification channels')?.detail).toBe('team: BH_DISCORD is not set'); + const ok = await doctorJson({ + argv: [], + env: { BH_DISCORD: 'x'.repeat(40) }, + fs: healthyFs(), + storage: storageState({ + channels: [ + { name: 'team', kind: 'discord', source: 'db', secretRefs: { webhook: 'BH_DISCORD' } }, + ], + }), + }); + expect(ok.byName.get('notification channels')?.status).toBe('ok'); + }); + it('maxSessions unbounded or above RAM is a warning', async () => { expect( (await doctorJson({ argv: ['--maxSessions', 'unbounded'], fs: healthyFs() })).byName.get( diff --git a/packages/contracts/generated/openapi.json b/packages/contracts/generated/openapi.json index e660020..b21630d 100644 --- a/packages/contracts/generated/openapi.json +++ b/packages/contracts/generated/openapi.json @@ -59,6 +59,9 @@ "type": "integer", "minimum": 0 }, + "instance_id": { + "type": "string" + }, "checks": { "type": "object", "properties": { @@ -172,6 +175,13 @@ "INVALID_ARGUMENTS", "TRACE_UNAVAILABLE", "SCREENSHOT_UNAVAILABLE", + "CHANNEL_NOT_FOUND", + "CHANNEL_NAME_TAKEN", + "CHANNEL_READ_ONLY", + "CHANNEL_NOT_READY", + "CHANNEL_KIND_UNAVAILABLE", + "CHANNEL_PLATFORM_ERROR", + "DELIVERY_NOT_FOUND", "INTERNAL_ERROR", "ADMIN_REQUIRES_HTTP", "INSECURE_BIND_REFUSED", @@ -359,6 +369,8 @@ "logs:read", "notifications:read", "notifications:write", + "channels:read", + "channels:write", "preferences:write", "mcp:tools" ] @@ -559,6 +571,8 @@ "logs:read", "notifications:read", "notifications:write", + "channels:read", + "channels:write", "preferences:write", "mcp:tools" ] @@ -650,6 +664,8 @@ "logs:read", "notifications:read", "notifications:write", + "channels:read", + "channels:write", "preferences:write", "mcp:tools" ] @@ -7993,6 +8009,66 @@ "keys" ] }, + "PublicUrlStatus": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "local_url": { + "type": "string" + }, + "host_trusted": { + "type": "boolean" + }, + "outcome": { + "type": "string", + "enum": [ + "ok", + "elsewhere", + "login", + "unreachable", + "unset" + ] + }, + "detail": { + "type": "string" + }, + "status_code": { + "type": [ + "integer", + "null" + ] + }, + "checked_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "insecure": { + "type": "boolean" + } + }, + "required": [ + "configured", + "url", + "local_url", + "host_trusted", + "outcome", + "detail", + "status_code", + "checked_at", + "insecure" + ] + }, "SystemRealtimeResponse": { "type": "object", "properties": { @@ -9178,221 +9254,6664 @@ ], "additionalProperties": false }, - "SearchResponse": { + "ChannelsResponse": { "type": "object", "properties": { - "sessions": { + "data": { "type": "array", "items": { "type": "object", "properties": { - "session_id": { + "channel_id": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" }, - "slug": { + "name": { "type": "string" - } - }, - "required": [ - "session_id", - "slug" - ] - } - }, - "tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "vault_handles": { - "type": "array", - "items": { - "type": "string" - } - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "sessions", - "tools", - "vault_handles", - "patterns" - ] - }, - "ClientErrorReport": { - "type": "object", - "properties": { - "message": { - "type": "string", - "minLength": 1, - "maxLength": 2000 - }, - "stack": { - "type": "string", - "maxLength": 16000 - }, - "route": { - "type": "string", - "maxLength": 512 - }, - "user_agent": { - "type": "string", - "maxLength": 512 - }, - "build": { - "type": "string", - "maxLength": 128 - } - }, - "required": [ - "message", - "route", - "user_agent", - "build" - ], - "additionalProperties": false - } - }, - "parameters": {} - }, - "paths": { - "/api/v1/health": { - "get": { - "operationId": "getHealth", - "tags": [ - "health" - ], - "summary": "Liveness/readiness; 200 only when ready (also served at `/health`).", - "security": [], - "x-browserhive-scope": null, - "responses": { - "200": { - "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "503": { - "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - } - } - } - }, - "/api/v1/openapi.json": { - "get": { - "operationId": "getOpenApi", - "tags": [ - "meta" - ], - "summary": "This OpenAPI 3.1 document.", - "security": [], - "x-browserhive-scope": null, - "responses": { - "200": { - "description": "OpenAPI 3.1.", - "content": { - "application/json": { - "schema": { + }, + "kind": { "type": "string", - "format": "binary" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/docs": { - "get": { - "operationId": "getDocs", - "tags": [ - "meta" - ], - "summary": "API reference UI (admin surface only).", - "security": [], - "x-browserhive-scope": null, - "responses": { - "200": { - "description": "Reference UI.", - "content": { - "text/html": { - "schema": { + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { "type": "string", - "format": "binary" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ] + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "capabilities": { + "type": [ + "object", + "null" + ], + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_failure_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0 + }, + "failed_24h": { + "type": "integer", + "minimum": 0 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "last_delivery_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded", + null + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ] + } + }, + "required": [ + "channel_id", + "name", + "kind", + "mode", + "source", + "status", + "target", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", + "created_at", + "updated_at", + "stats" + ] + } + }, + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "data", + "now" + ] + }, + "ChannelResponse": { + "type": "object", + "properties": { + "channel": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ] + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "capabilities": { + "type": [ + "object", + "null" + ], + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_failure_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0 + }, + "failed_24h": { + "type": "integer", + "minimum": 0 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "last_delivery_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded", + null + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ] + } + }, + "required": [ + "channel_id", + "name", + "kind", + "mode", + "source", + "status", + "target", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", + "created_at", + "updated_at", + "stats" + ] + } + }, + "required": [ + "channel" + ] + }, + "ChannelInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + }, + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + }, + "default": {} + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + }, + "default": {} + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + }, + "default": {} + } + }, + "required": [ + "name", + "kind" + ], + "additionalProperties": false + }, + "ChannelPreview": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "sample": { + "type": "string", + "enum": [ + "attention", + "attention-resolved", + "vault-confirm", + "tool-errors", + "crash", + "degraded", + "test" + ] + }, + "capabilities": { + "type": "object", + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "message": { + "type": "object", + "properties": { + "schema": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "alert": { + "type": "boolean" + }, + "at": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "created", + "updated" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "heading" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fields" + ] + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "value": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "label", + "value" + ] + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "type", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quote" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "collapsible": { + "type": "boolean" + } + }, + "required": [ + "type", + "content", + "collapsible" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "ordered": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "type", + "ordered", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "table" + ] + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "maxItems": 20 + } + }, + "required": [ + "type", + "columns", + "rows" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0 + }, + "masked": { + "type": "boolean" + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + } + }, + "required": [ + "type", + "text", + "language" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "footer" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + } + ] + }, + "maxItems": 50 + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "act" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "command": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "attention.resolve", + "vault.confirm.resolve", + "session.extend_lease", + "session.close" + ] + }, + "args": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "op", + "args" + ] + }, + "confirm": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ] + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "open" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "path" + ] + } + ] + }, + "maxItems": 5 + }, + "entities": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "session_slug": { + "type": "string", + "maxLength": 64 + }, + "harness": { + "type": "string", + "maxLength": 64 + }, + "owner": { + "type": "string", + "maxLength": 128 + }, + "tool": { + "type": "string", + "maxLength": 64 + }, + "error_code": { + "type": "string", + "maxLength": 64 + }, + "domain": { + "type": "string", + "maxLength": 253 + }, + "request_id": { + "type": "string", + "maxLength": 64 + } + } + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ] + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ] + }, + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "json", + "multipart", + "binary" + ] + }, + "body": { + "type": "object", + "additionalProperties": {} + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "file": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + } + }, + "required": [ + "name", + "content_type" + ] + } + }, + "required": [ + "method", + "path", + "encoding", + "body", + "headers", + "file" + ] + } + }, + "local_links": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "mode", + "sample", + "capabilities", + "message", + "requests", + "local_links", + "notes" + ] + }, + "ChannelPreviewRequest": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "sample": { + "type": "string", + "enum": [ + "attention", + "attention-resolved", + "vault-confirm", + "tool-errors", + "crash", + "degraded", + "test" + ], + "default": "attention" + } + }, + "additionalProperties": false + }, + "DeliveriesPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test", + null + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at" + ] + } + }, + "page": { + "type": "object", + "properties": { + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "prev_cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "next_cursor", + "limit" + ] + }, + "facets": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "value", + "count" + ] + } + } + }, + "applied": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "additionalProperties": {} + }, + "sort": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "key", + "dir" + ] + } + }, + "required": [ + "filters", + "sort" + ] + }, + "meta": { + "type": "object", + "properties": { + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "now" + ] + } + }, + "required": [ + "data", + "page", + "applied", + "meta" + ] + }, + "DeliveryDetailResponse": { + "type": "object", + "properties": { + "delivery": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test", + null + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at" + ] + }, + "message": { + "type": [ + "object", + "null" + ], + "properties": { + "schema": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "alert": { + "type": "boolean" + }, + "at": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "created", + "updated" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "heading" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fields" + ] + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "value": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "label", + "value" + ] + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "type", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quote" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "collapsible": { + "type": "boolean" + } + }, + "required": [ + "type", + "content", + "collapsible" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "ordered": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "type", + "ordered", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "table" + ] + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "maxItems": 20 + } + }, + "required": [ + "type", + "columns", + "rows" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0 + }, + "masked": { + "type": "boolean" + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + } + }, + "required": [ + "type", + "text", + "language" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "footer" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + } + ] + }, + "maxItems": 50 + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "act" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "command": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "attention.resolve", + "vault.confirm.resolve", + "session.extend_lease", + "session.close" + ] + }, + "args": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "op", + "args" + ] + }, + "confirm": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ] + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "open" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "path" + ] + } + ] + }, + "maxItems": 5 + }, + "entities": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "session_slug": { + "type": "string", + "maxLength": 64 + }, + "harness": { + "type": "string", + "maxLength": 64 + }, + "owner": { + "type": "string", + "maxLength": 128 + }, + "tool": { + "type": "string", + "maxLength": 64 + }, + "error_code": { + "type": "string", + "maxLength": 64 + }, + "domain": { + "type": "string", + "maxLength": 253 + }, + "request_id": { + "type": "string", + "maxLength": 64 + } + } + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ] + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ] + } + }, + "required": [ + "delivery", + "message" + ] + }, + "ChannelEnvResponse": { + "type": "object", + "properties": { + "vars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "name", + "set" + ] + } + } + }, + "required": [ + "vars" + ] + }, + "TelegramConnectResponse": { + "type": "object", + "properties": { + "connect_id": { + "type": "string" + }, + "bot_username": { + "type": "string" + }, + "link": { + "type": "string" + }, + "group_link": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "connect_id", + "bot_username", + "link", + "group_link", + "expires_at" + ] + }, + "TelegramConnectRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "token_env" + ], + "additionalProperties": false + }, + "TelegramConnectStatus": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "waiting", + "connected", + "expired", + "failed" + ] + }, + "chat": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "thread_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "title", + "type", + "thread_id" + ] + }, + "user": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "status", + "chat", + "user", + "error", + "expires_at" + ] + }, + "ChannelPatch": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + } + }, + "additionalProperties": false + }, + "ChannelTestResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "delivery": { + "type": [ + "object", + "null" + ], + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test", + null + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at" + ] + }, + "error": { + "type": [ + "object", + "null" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + }, + "required": [ + "ok", + "delivery", + "error" + ] + }, + "SearchResponse": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "session_id", + "slug" + ] + } + }, + "tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "vault_handles": { + "type": "array", + "items": { + "type": "string" + } + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "sessions", + "tools", + "vault_handles", + "patterns" + ] + }, + "ClientErrorReport": { + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "stack": { + "type": "string", + "maxLength": 16000 + }, + "route": { + "type": "string", + "maxLength": 512 + }, + "user_agent": { + "type": "string", + "maxLength": 512 + }, + "build": { + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "message", + "route", + "user_agent", + "build" + ], + "additionalProperties": false + } + }, + "parameters": {} + }, + "paths": { + "/api/v1/health": { + "get": { + "operationId": "getHealth", + "tags": [ + "health" + ], + "summary": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "getOpenApi", + "tags": [ + "meta" + ], + "summary": "This OpenAPI 3.1 document.", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "OpenAPI 3.1.", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/docs": { + "get": { + "operationId": "getDocs", + "tags": [ + "meta" + ], + "summary": "API reference UI (admin surface only).", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Reference UI.", + "content": { + "text/html": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/ws": { + "get": { + "operationId": "wsUpgrade", + "tags": [ + "realtime" + ], + "summary": "Realtime WebSocket (`Sec-WebSocket-Protocol: browserhive.v1`). Auth failures upgrade then close 4401; see the WS protocol.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "101": { + "description": "Switching protocols." + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "login", + "tags": [ + "auth" + ], + "summary": "Log in with the operator password; sets the session cookie.", + "security": [], + "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Log in with the operator password; sets the session cookie.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "INVALID_CREDENTIALS", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout", + "tags": [ + "auth" + ], + "summary": "Destroy the current session and clear the cookie.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Destroy the current session and clear the cookie.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "operationId": "getMe", + "tags": [ + "auth" + ], + "summary": "The authenticated principal.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "The authenticated principal.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/change-password": { + "post": { + "operationId": "changePassword", + "tags": [ + "auth" + ], + "summary": "Change the operator password; revokes every other session.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Change the operator password; revokes every other session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED, BAD_CURRENT_PASSWORD, WEAK_PASSWORD", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/sessions": { + "get": { + "operationId": "listAuthSessions", + "tags": [ + "auth" + ], + "summary": "The caller's operator sessions.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "The caller's operator sessions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSessionList" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/sessions/{id_prefix}": { + "delete": { + "operationId": "revokeAuthSession", + "tags": [ + "auth" + ], + "summary": "Revoke one operator session by id prefix.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 4, + "maxLength": 32 + }, + "required": true, + "name": "id_prefix", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Revoke one operator session by id prefix.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/sessions/revoke-all": { + "post": { + "operationId": "revokeAllAuthSessions", + "tags": [ + "auth" + ], + "summary": "Revoke every session of the caller except the current one.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Revoke every session of the caller except the current one.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeAllSessionsResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/tokens": { + "get": { + "operationId": "listTokens", + "tags": [ + "auth" + ], + "summary": "Issued API tokens (never the secret).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Issued API tokens (never the secret).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiTokenList" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "operationId": "createToken", + "tags": [ + "auth" + ], + "summary": "Issue an API token; the token is shown once.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Issue an API token; the token is shown once.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/tokens/{credential_id}": { + "delete": { + "operationId": "revokeToken", + "tags": [ + "auth" + ], + "summary": "Revoke an API token.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": true, + "name": "credential_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Revoke an API token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/grants": { + "post": { + "operationId": "createGrant", + "tags": [ + "auth" + ], + "summary": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGrantRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGrantResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/sessions": { + "get": { + "operationId": "listSessions", + "tags": [ + "sessions" + ], + "summary": "List sessions with facets; the live registry overlays stored rows.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "created_at", + "slug", + "channel", + "last_activity_at", + "errors", + "lease_expires_at", + "closed_at", + "owner", + "persistence_mode", + "blocked", + "harness" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "reserved", + "launching", + "live", + "paused", + "draining", + "closed", + "crashed" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "state", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "all", + "live", + "closed", + "archived" + ], + "default": "all" + }, + "required": false, + "name": "view", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "exclude", + "include", + "only" + ], + "default": "exclude" + }, + "required": false, + "name": "archived", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "owner", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "chromium", + "chrome", + "edge" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "channel", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "memory", + "persistent", + "storage-state" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "persistence_mode", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "harness", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List sessions with facets; the live registry overlays stored rows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsPage" + } } } }, - "500": { - "description": "INTERNAL_ERROR", + "400": { + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -9400,29 +15919,6 @@ } } } - } - } - } - }, - "/api/v1/ws": { - "get": { - "operationId": "wsUpgrade", - "tags": [ - "realtime" - ], - "summary": "Realtime WebSocket (`Sec-WebSocket-Protocol: browserhive.v1`). Auth failures upgrade then close 4401; see the WS protocol.", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": null, - "responses": { - "101": { - "description": "Switching protocols." }, "401": { "description": "UNAUTHORIZED", @@ -9467,32 +15963,50 @@ } } }, - "/api/v1/auth/login": { + "/api/v1/sessions/bulk": { "post": { - "operationId": "login", + "operationId": "bulkSessions", "tags": [ - "auth" + "sessions" + ], + "summary": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "idempotency-key", + "in": "header" + } ], - "summary": "Log in with the operator password; sets the session cookie.", - "security": [], - "x-browserhive-scope": null, "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "$ref": "#/components/schemas/BulkSessionsRequest" } } } }, "responses": { "200": { - "description": "Log in with the operator password; sets the session cookie.", + "description": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" + "$ref": "#/components/schemas/BulkSessionsResponse" } } } @@ -9508,7 +16022,17 @@ } }, "401": { - "description": "INVALID_CREDENTIALS", + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -9550,13 +16074,13 @@ } } }, - "/api/v1/auth/logout": { - "post": { - "operationId": "logout", + "/api/v1/sessions/{session_id}": { + "get": { + "operationId": "getSession", "tags": [ - "auth" + "sessions" ], - "summary": "Destroy the current session and clear the cookie.", + "summary": "One session with trace/data-dir descriptors and counters (no embedded arrays).", "security": [ { "cookieAuth": [] @@ -9565,14 +16089,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], "responses": { "200": { - "description": "Destroy the current session and clear the cookie.", + "description": "One session with trace/data-dir descriptors and counters (no embedded arrays).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/SessionDetail" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -9597,6 +16142,16 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -9618,15 +16173,13 @@ } } } - } - }, - "/api/v1/auth/me": { - "get": { - "operationId": "getMe", + }, + "delete": { + "operationId": "deleteSession", "tags": [ - "auth" + "sessions" ], - "summary": "The authenticated principal.", + "summary": "Terminate if live, then delete rows and artifacts.", "security": [ { "cookieAuth": [] @@ -9635,14 +16188,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], "responses": { "200": { - "description": "The authenticated principal.", + "description": "Terminate if live, then delete rows and artifacts.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MeResponse" + "$ref": "#/components/schemas/DeleteSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -9667,6 +16241,16 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -9690,13 +16274,13 @@ } } }, - "/api/v1/auth/change-password": { + "/api/v1/sessions/{session_id}/terminate": { "post": { - "operationId": "changePassword", + "operationId": "terminateSession", "tags": [ - "auth" + "sessions" ], - "summary": "Change the operator password; revokes every other session.", + "summary": "Close a live session (operator reason).", "security": [ { "cookieAuth": [] @@ -9705,30 +16289,31 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Change the operator password; revokes every other session.", + "description": "Close a live session (operator reason).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordResponse" + "$ref": "#/components/schemas/TerminateSessionResponse" } } } }, "400": { - "description": "VALIDATION_FAILED, BAD_CURRENT_PASSWORD, WEAK_PASSWORD", + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -9757,8 +16342,18 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "SESSION_NOT_LIVE", "content": { "application/problem+json": { "schema": { @@ -9790,13 +16385,13 @@ } } }, - "/api/v1/auth/sessions": { - "get": { - "operationId": "listAuthSessions", + "/api/v1/sessions/{session_id}/archive": { + "post": { + "operationId": "archiveSession", "tags": [ - "auth" + "sessions" ], - "summary": "The caller's operator sessions.", + "summary": "Archive a finished session (exempt from retention).", "security": [ { "cookieAuth": [] @@ -9805,14 +16400,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], "responses": { "200": { - "description": "The caller's operator sessions.", + "description": "Archive a finished session (exempt from retention).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthSessionList" + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -9837,6 +16453,26 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "SESSION_LIVE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -9860,13 +16496,13 @@ } } }, - "/api/v1/auth/sessions/{id_prefix}": { - "delete": { - "operationId": "revokeAuthSession", + "/api/v1/sessions/{session_id}/unarchive": { + "post": { + "operationId": "unarchiveSession", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke one operator session by id prefix.", + "summary": "Unarchive a session.", "security": [ { "cookieAuth": [] @@ -9875,22 +16511,21 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", "parameters": [ { "schema": { "type": "string", - "minLength": 4, - "maxLength": 32 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": true, - "name": "id_prefix", + "name": "session_id", "in": "path" } ], "responses": { "200": { - "description": "Revoke one operator session by id prefix.", + "description": "Unarchive a session.", "content": { "application/json": { "schema": { @@ -9930,7 +16565,7 @@ } }, "404": { - "description": "NOT_FOUND", + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -9962,55 +16597,190 @@ } } }, - "/api/v1/auth/sessions/revoke-all": { - "post": { - "operationId": "revokeAllAuthSessions", + "/api/v1/sessions/{session_id}/tool-calls": { + "get": { + "operationId": "listSessionToolCalls", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke every session of the caller except the current one.", + "summary": "Tool calls of one session (`?expand=detail` adds args/result).", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "duration_ms" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "tool", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ok", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "error_code", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "detail" + ] + }, + "required": false, + "name": "expand", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" } ], - "x-browserhive-scope": null, "responses": { "200": { - "description": "Revoke every session of the caller except the current one.", + "description": "Tool calls of one session (`?expand=detail` adds args/result).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevokeAllSessionsResponse" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SessionToolCallsPage" } } } }, - "429": { - "description": "RATE_LIMITED", + "400": { + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -10019,8 +16789,8 @@ } } }, - "500": { - "description": "INTERNAL_ERROR", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -10028,39 +16798,9 @@ } } } - } - } - } - }, - "/api/v1/auth/tokens": { - "get": { - "operationId": "listTokens", - "tags": [ - "auth" - ], - "summary": "Issued API tokens (never the secret).", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": null, - "responses": { - "200": { - "description": "Issued API tokens (never the secret).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiTokenList" - } - } - } }, - "401": { - "description": "UNAUTHORIZED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -10069,8 +16809,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "404": { + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -10100,13 +16840,15 @@ } } } - }, - "post": { - "operationId": "createToken", + } + }, + "/api/v1/sessions/{session_id}/tool-calls/{event_id}": { + "get": { + "operationId": "getSessionToolCall", "tags": [ - "auth" + "sessions" ], - "summary": "Issue an API token; the token is shown once.", + "summary": "One tool call with args, result and its screenshot.", "security": [ { "cookieAuth": [] @@ -10115,24 +16857,34 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTokenRequest" - } - } + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" + }, + "required": true, + "name": "event_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Issue an API token; the token is shown once.", + "description": "One tool call with args, result and its screenshot.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTokenResponse" + "$ref": "#/components/schemas/ToolCallDetail" } } } @@ -10167,8 +16919,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND, NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -10200,41 +16952,147 @@ } } }, - "/api/v1/auth/tokens/{credential_id}": { - "delete": { - "operationId": "revokeToken", + "/api/v1/sessions/{session_id}/pages": { + "get": { + "operationId": "listSessionPages", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke an API token.", + "summary": "Pages visited by one session.", "security": [ { "cookieAuth": [] }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": null, - "parameters": [ + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "category", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^t-[0-9a-z]{6}$" + }, + "required": false, + "name": "tab_id", + "in": "query" + }, { "schema": { "type": "string", "minLength": 1, - "maxLength": 128 + "maxLength": 200 }, - "required": true, - "name": "credential_id", - "in": "path" + "required": false, + "name": "q", + "in": "query" } ], "responses": { "200": { - "description": "Revoke an API token.", + "description": "Pages visited by one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/SessionPagesPage" } } } @@ -10270,7 +17128,7 @@ } }, "404": { - "description": "NOT_FOUND", + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -10302,13 +17160,13 @@ } } }, - "/api/v1/auth/grants": { - "post": { - "operationId": "createGrant", + "/api/v1/sessions/{session_id}/attention": { + "get": { + "operationId": "listSessionAttention", "tags": [ - "auth" + "sessions" ], - "summary": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "summary": "Attention requests of one session.", "security": [ { "cookieAuth": [] @@ -10317,24 +17175,124 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateGrantRequest" - } - } + "x-browserhive-scope": "attention:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "created_at", + "resolved_at", + "waited_ms" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "takeover", + "notify" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "mode", + "in": "query" } - }, + ], "responses": { "200": { - "description": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "description": "Attention requests of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateGrantResponse" + "$ref": "#/components/schemas/SessionAttentionPage" } } } @@ -10369,8 +17327,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -10402,13 +17360,13 @@ } } }, - "/api/v1/sessions": { + "/api/v1/sessions/{session_id}/vault-access": { "get": { - "operationId": "listSessions", + "operationId": "listSessionVaultAccess", "tags": [ "sessions" ], - "summary": "List sessions with facets; the live registry overlays stored rows.", + "summary": "Vault access audit rows of one session.", "security": [ { "cookieAuth": [] @@ -10417,8 +17375,17 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "vault:read", "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, { "schema": { "type": "string", @@ -10455,97 +17422,27 @@ "in": "query" }, { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "created_at", - "slug", - "channel", - "last_activity_at", - "errors", - "lease_expires_at", - "closed_at", - "owner", - "persistence_mode", - "blocked", - "harness" - ], - "default": "created_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "reserved", - "launching", - "live", - "paused", - "draining", - "closed", - "crashed" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "state", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "all", - "live", - "closed", - "archived" - ], - "default": "all" - }, - "required": false, - "name": "view", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "exclude", - "include", - "only" - ], - "default": "exclude" + "schema": { + "type": "boolean", + "default": false }, "required": false, - "name": "archived", + "name": "total", "in": "query" }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 128 + "enum": [ + "ts", + "entry_name", + "result", + "session" + ], + "default": "ts" }, "required": false, - "name": "owner", + "name": "sort", "in": "query" }, { @@ -10557,15 +17454,17 @@ "items": { "type": "string", "enum": [ - "chromium", - "chrome", - "edge" + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" ] }, "minItems": 1 }, "required": false, - "name": "channel", + "name": "result", "in": "query" }, { @@ -10577,32 +17476,46 @@ "items": { "type": "string", "enum": [ - "memory", - "persistent", - "storage-state" + "pass", + "fail", + "skipped" ] }, "minItems": 1 }, "required": false, - "name": "persistence_mode", + "name": "origin_check", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 + "type": "string", + "enum": [ + "on", + "off" + ] }, "required": false, - "name": "harness", + "name": "evaluate", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "entry_name", "in": "query" }, { @@ -10642,11 +17555,11 @@ ], "responses": { "200": { - "description": "List sessions with facets; the live registry overlays stored rows.", + "description": "Vault access audit rows of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionsPage" + "$ref": "#/components/schemas/VaultLogPage" } } } @@ -10681,6 +17594,16 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -10704,13 +17627,13 @@ } } }, - "/api/v1/sessions/bulk": { - "post": { - "operationId": "bulkSessions", + "/api/v1/sessions/{session_id}/blocked": { + "get": { + "operationId": "listSessionBlocked", "tags": [ "sessions" ], - "summary": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", + "summary": "Blocked requests of one session.", "security": [ { "cookieAuth": [] @@ -10719,136 +17642,167 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "blocklist:read", "parameters": [ { "schema": { "type": "string", - "format": "uuid" + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": true, - "name": "idempotency-key", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkSessionsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkSessionsResponse" - } - } - } + "name": "session_id", + "in": "path" }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "ts", + "domain", + "pattern", + "session", + "source" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "required": false, + "name": "pattern", + "in": "query" }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/{session_id}": { - "get": { - "operationId": "getSession", - "tags": [ - "sessions" - ], - "summary": "One session with trace/data-dir descriptors and counters (no embedded arrays).", - "security": [ { - "cookieAuth": [] + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "request" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "source", + "in": "query" + }, { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "minLength": 1, + "maxLength": 200 }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" } ], "responses": { "200": { - "description": "One session with trace/data-dir descriptors and counters (no embedded arrays).", + "description": "Blocked requests of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetail" + "$ref": "#/components/schemas/BlockedAttemptsPage" } } } @@ -10914,13 +17868,15 @@ } } } - }, - "delete": { - "operationId": "deleteSession", + } + }, + "/api/v1/sessions/{session_id}/screenshots": { + "get": { + "operationId": "listSessionScreenshots", "tags": [ "sessions" ], - "summary": "Terminate if live, then delete rows and artifacts.", + "summary": "Screenshots of one session (image URLs accept grants).", "security": [ { "cookieAuth": [] @@ -10929,7 +17885,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -10939,15 +17895,90 @@ "required": true, "name": "session_id", "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "trace" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "kind", + "in": "query" } ], "responses": { "200": { - "description": "Terminate if live, then delete rows and artifacts.", + "description": "Screenshots of one session (image URLs accept grants).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSessionResponse" + "$ref": "#/components/schemas/ScreenshotsPage" } } } @@ -11015,13 +18046,13 @@ } } }, - "/api/v1/sessions/{session_id}/terminate": { - "post": { - "operationId": "terminateSession", + "/api/v1/sessions/{session_id}/timeline": { + "get": { + "operationId": "getSessionTimeline", "tags": [ "sessions" ], - "summary": "Close a live session (operator reason).", + "summary": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", "security": [ { "cookieAuth": [] @@ -11030,7 +18061,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -11040,15 +18071,78 @@ "required": true, "name": "session_id", "in": "path" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "page", + "attention", + "vault", + "blocked" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "kinds", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "errors_only", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" } ], "responses": { "200": { - "description": "Close a live session (operator reason).", + "description": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminateSessionResponse" + "$ref": "#/components/schemas/TimelinePage" } } } @@ -11072,19 +18166,9 @@ } } } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "SESSION_NOT_FOUND", + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -11093,8 +18177,8 @@ } } }, - "409": { - "description": "SESSION_NOT_LIVE", + "404": { + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -11126,22 +18210,25 @@ } } }, - "/api/v1/sessions/{session_id}/archive": { - "post": { - "operationId": "archiveSession", + "/api/v1/sessions/{session_id}/screenshots/{event_id}": { + "get": { + "operationId": "getScreenshotImage", "tags": [ "sessions" ], - "summary": "Archive a finished session (exempt from retention).", + "summary": "Screenshot bytes (cookie, bearer or `?grant=` for route `screenshot` = event id).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] + }, + { + "grantAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -11151,15 +18238,35 @@ "required": true, "name": "session_id", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" + }, + "required": true, + "name": "event_id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "required": false, + "name": "grant", + "in": "query" } ], "responses": { "200": { - "description": "Archive a finished session (exempt from retention).", + "description": "The image bytes.", "content": { - "application/json": { + "image/*": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "type": "string", + "format": "binary" } } } @@ -11195,17 +18302,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "SESSION_LIVE", + "description": "NOT_FOUND, SCREENSHOT_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -11237,22 +18334,25 @@ } } }, - "/api/v1/sessions/{session_id}/unarchive": { - "post": { - "operationId": "unarchiveSession", + "/api/v1/sessions/{session_id}/trace.zip": { + "get": { + "operationId": "getTraceZip", "tags": [ "sessions" ], - "summary": "Unarchive a session.", + "summary": "The session trace (single `Range` supported; `?grant=` for route `trace` = session id).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] + }, + { + "grantAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -11262,15 +18362,37 @@ "required": true, "name": "session_id", "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "required": false, + "name": "grant", + "in": "query" } ], "responses": { "200": { - "description": "Unarchive a session.", + "description": "Whole trace.", "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "Requested byte range.", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" } } } @@ -11306,7 +18428,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -11315,6 +18437,17 @@ } } }, + "416": { + "description": "Range not satisfiable.", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -11336,186 +18469,54 @@ } } } - } - }, - "/api/v1/sessions/{session_id}/tool-calls": { - "get": { - "operationId": "listSessionToolCalls", + }, + "head": { + "operationId": "headTraceZip", "tags": [ "sessions" ], - "summary": "Tool calls of one session (`?expand=detail` adds args/result).", + "summary": "Trace size probe.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "duration_ms" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "tool", - "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "ok", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "error_code", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" }, { - "schema": { - "type": "string", - "enum": [ - "detail" - ] - }, - "required": false, - "name": "expand", - "in": "query" - }, + "grantAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": false, - "name": "since", - "in": "query" + "required": true, + "name": "session_id", + "in": "path" }, { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "minLength": 1, + "maxLength": 256 }, "required": false, - "name": "until", + "name": "grant", "in": "query" } ], "responses": { "200": { - "description": "Tool calls of one session (`?expand=detail` adds args/result).", + "description": "Headers only.", "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/SessionToolCallsPage" + "type": "string", + "format": "binary" } } } @@ -11551,7 +18552,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -11583,13 +18584,13 @@ } } }, - "/api/v1/sessions/{session_id}/tool-calls/{event_id}": { + "/api/v1/sessions/{session_id}/trace": { "get": { - "operationId": "getSessionToolCall", + "operationId": "getSessionTrace", "tags": [ "sessions" ], - "summary": "One tool call with args, result and its screenshot.", + "summary": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", "security": [ { "cookieAuth": [] @@ -11608,24 +18609,15 @@ "required": true, "name": "session_id", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" - }, - "required": true, - "name": "event_id", - "in": "path" } ], "responses": { "200": { - "description": "One tool call with args, result and its screenshot.", + "description": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolCallDetail" + "$ref": "#/components/schemas/SessionTraceInfo" } } } @@ -11661,7 +18653,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND, NOT_FOUND", + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -11693,13 +18685,13 @@ } } }, - "/api/v1/sessions/{session_id}/pages": { - "get": { - "operationId": "listSessionPages", + "/api/v1/sessions/{session_id}/data-dir/reveal": { + "post": { + "operationId": "revealSessionDataDir", "tags": [ "sessions" ], - "summary": "Pages visited by one session.", + "summary": "Open the session's data directory in the host file manager (honest result).", "security": [ { "cookieAuth": [] @@ -11718,62 +18710,107 @@ "required": true, "name": "session_id", "in": "path" + } + ], + "responses": { + "200": { + "description": "Open the session's data directory in the host file manager (honest result).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevealDataDirResponse" + } + } + } }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/sessions/{session_id}/export": { + "get": { + "operationId": "exportSession", + "tags": [ + "sessions" + ], + "summary": "Streamed timeline export (NDJSON or CSV by `Accept`), capped at 100k rows.", + "security": [ { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ { "schema": { "type": "string", - "enum": [ - "ts" - ], - "default": "ts" + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": false, - "name": "sort", - "in": "query" + "required": true, + "name": "session_id", + "in": "path" }, { "schema": { @@ -11784,56 +18821,149 @@ "items": { "type": "string", "enum": [ - "public", - "ip", - "local", - "ftp", - "other" + "tool", + "page", + "attention", + "vault", + "blocked" ] }, "minItems": 1 }, "required": false, - "name": "category", + "name": "kinds", "in": "query" + } + ], + "responses": { + "200": { + "description": "kind,ts,id,data rows.", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 - }, - "required": false, - "name": "domain", - "in": "query" + "406": { + "description": "NOT_ACCEPTABLE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/sessions/{session_id}/viewport": { + "post": { + "operationId": "setSessionViewport", + "tags": [ + "sessions" + ], + "summary": "Resize the active page viewport; not attention-gated (D-10).", + "security": [ { - "schema": { - "type": "string", - "pattern": "^t-[0-9a-z]{6}$" - }, - "required": false, - "name": "tab_id", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:write", + "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 200 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": false, - "name": "q", - "in": "query" + "required": true, + "name": "session_id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetViewportRequest" + } + } + } + }, "responses": { "200": { - "description": "Pages visited by one session.", + "description": "Resize the active page viewport; not attention-gated (D-10).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPagesPage" + "$ref": "#/components/schemas/SetViewportResponse" } } } @@ -11878,6 +19008,26 @@ } } }, + "409": { + "description": "SESSION_NOT_AVAILABLE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -11901,13 +19051,13 @@ } } }, - "/api/v1/sessions/{session_id}/attention": { - "get": { - "operationId": "listSessionAttention", + "/api/v1/sessions/{session_id}/input": { + "post": { + "operationId": "sendSessionInput", "tags": [ "sessions" ], - "summary": "Attention requests of one session.", + "summary": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", "security": [ { "cookieAuth": [] @@ -11916,7 +19066,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:read", + "x-browserhive-scope": "sessions:takeover", "parameters": [ { "schema": { @@ -11926,114 +19076,25 @@ "required": true, "name": "session_id", "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "created_at", - "resolved_at", - "waited_ms" - ], - "default": "created_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pending", - "resolved", - "rejected", - "timeout", - "cancelled" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "takeover", - "notify" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "mode", - "in": "query" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionInputRequest" + } + } + } + }, "responses": { "200": { - "description": "Attention requests of one session.", + "description": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionAttentionPage" + "$ref": "#/components/schemas/SessionInputResponse" } } } @@ -12078,6 +19139,26 @@ } } }, + "409": { + "description": "INPUT_NOT_PERMITTED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -12101,13 +19182,13 @@ } } }, - "/api/v1/sessions/{session_id}/vault-access": { + "/api/v1/tool-calls": { "get": { - "operationId": "listSessionVaultAccess", + "operationId": "listToolCalls", "tags": [ - "sessions" + "activity" ], - "summary": "Vault access audit rows of one session.", + "summary": "Tool calls across sessions (live feed seed, fleet error views).", "security": [ { "cookieAuth": [] @@ -12116,17 +19197,8 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "sessions:read", "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, { "schema": { "type": "string", @@ -12176,9 +19248,7 @@ "type": "string", "enum": [ "ts", - "entry_name", - "result", - "session" + "duration_ms" ], "default": "ts" }, @@ -12194,18 +19264,21 @@ ], "items": { "type": "string", - "enum": [ - "success", - "origin_mismatch", - "auth_failed", - "blocked", - "denied" - ] + "minLength": 1, + "maxLength": 64 }, "minItems": 1 }, "required": false, - "name": "result", + "name": "tool", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ok", "in": "query" }, { @@ -12216,47 +19289,13 @@ ], "items": { "type": "string", - "enum": [ - "pass", - "fail", - "skipped" - ] + "minLength": 1, + "maxLength": 64 }, "minItems": 1 }, "required": false, - "name": "origin_check", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "on", - "off" - ] - }, - "required": false, - "name": "evaluate", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "entry_name", + "name": "error_code", "in": "query" }, { @@ -12271,14 +19310,13 @@ }, { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "enum": [ + "detail" + ] }, "required": false, - "name": "since", + "name": "expand", "in": "query" }, { @@ -12287,200 +19325,39 @@ "integer", "null" ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Vault access audit rows of one session.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultLogPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/{session_id}/blocked": { - "get": { - "operationId": "listSessionBlocked", - "tags": [ - "sessions" - ], - "summary": "Blocked requests of one session.", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "blocklist:read", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "domain", - "pattern", - "session", - "source" - ], - "default": "ts" + "minimum": 0 }, "required": false, - "name": "sort", + "name": "since", "in": "query" }, { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "session_id", + "name": "until", "in": "query" }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 512 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "pattern", + "name": "session_id", "in": "query" }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 + "type": "boolean" }, "required": false, - "name": "domain", + "name": "has_session", "in": "query" }, { @@ -12491,27 +19368,97 @@ ], "items": { "type": "string", - "enum": [ - "tool", - "request" - ] + "minLength": 1, + "maxLength": 64 }, "minItems": 1 }, "required": false, - "name": "source", + "name": "harness", "in": "query" + } + ], + "responses": { + "200": { + "description": "Tool calls across sessions (live feed seed, fleet error views).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolCallsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/activity": { + "get": { + "operationId": "getActivity", + "tags": [ + "activity" + ], + "summary": "Gap-filled activity buckets (≤ 720) and headline counters.", + "security": [ { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ { "schema": { "type": [ @@ -12535,15 +19482,38 @@ "required": false, "name": "until", "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 60000, + "maximum": 86400000 + }, + "required": false, + "name": "bucket_ms", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "tool", + "error_code", + "session" + ] + }, + "required": false, + "name": "group_by", + "in": "query" } ], "responses": { "200": { - "description": "Blocked requests of one session.", + "description": "Gap-filled activity buckets (≤ 720) and headline counters.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlockedAttemptsPage" + "$ref": "#/components/schemas/ActivityResponse" } } } @@ -12578,16 +19548,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -12611,13 +19571,13 @@ } } }, - "/api/v1/sessions/{session_id}/screenshots": { + "/api/v1/metrics/tools": { "get": { - "operationId": "listSessionScreenshots", + "operationId": "getToolMetrics", "tags": [ - "sessions" + "activity" ], - "summary": "Screenshots of one session (image URLs accept grants).", + "summary": "Per-tool call counts, error rate and latency percentiles.", "security": [ { "cookieAuth": [] @@ -12630,96 +19590,59 @@ "parameters": [ { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" + "type": [ + "integer", + "null" ], - "default": "desc" + "minimum": 0 }, "required": false, - "name": "dir", + "name": "since", "in": "query" }, { "schema": { - "type": "boolean", - "default": false + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "total", + "name": "until", "in": "query" }, { "schema": { "type": "string", "enum": [ - "ts" + "tool", + "error_code", + "tool,error_code" ], - "default": "ts" + "default": "tool" }, "required": false, - "name": "sort", + "name": "group_by", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "trace" - ] - }, - "minItems": 1 + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "kind", + "name": "session_id", "in": "query" } ], "responses": { "200": { - "description": "Screenshots of one session (image URLs accept grants).", + "description": "Per-tool call counts, error rate and latency percentiles.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScreenshotsPage" + "$ref": "#/components/schemas/ToolMetricsResponse" } } } @@ -12734,18 +19657,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -12754,8 +19667,8 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -12787,13 +19700,13 @@ } } }, - "/api/v1/sessions/{session_id}/timeline": { + "/api/v1/metrics/harnesses": { "get": { - "operationId": "getSessionTimeline", + "operationId": "getHarnessMetrics", "tags": [ - "sessions" + "activity" ], - "summary": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", + "summary": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", "security": [ { "cookieAuth": [] @@ -12804,86 +19717,38 @@ ], "x-browserhive-scope": "sessions:read", "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, { "schema": { "type": [ - "array", + "integer", "null" ], - "items": { - "type": "string", - "enum": [ - "tool", - "page", - "attention", - "vault", - "blocked" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "kinds", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "errors_only", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" + "minimum": 0 }, "required": false, - "name": "cursor", + "name": "since", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "limit", + "name": "until", "in": "query" } ], "responses": { "200": { - "description": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", + "description": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TimelinePage" + "$ref": "#/components/schemas/HarnessMetricsResponse" } } } @@ -12918,16 +19783,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -12951,63 +19806,165 @@ } } }, - "/api/v1/sessions/{session_id}/screenshots/{event_id}": { + "/api/v1/pages": { "get": { - "operationId": "getScreenshotImage", + "operationId": "listPages", "tags": [ - "sessions" + "pages" ], - "summary": "Screenshot bytes (cookie, bearer or `?grant=` for route `screenshot` = event id).", + "summary": "Pages across sessions (navigation history) with category facets.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - }, - { - "grantAuth": [] } ], "x-browserhive-scope": "sessions:read", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "domain", + "category", + "session" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "category", + "in": "query" + }, { "schema": { "type": "string", "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": true, + "required": false, "name": "session_id", - "in": "path" + "in": "query" }, { "schema": { "type": "string", - "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" + "minLength": 1, + "maxLength": 253 }, - "required": true, - "name": "event_id", - "in": "path" + "required": false, + "name": "domain", + "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "maxLength": 256 + "maxLength": 200 }, "required": false, - "name": "grant", + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", "in": "query" } ], "responses": { "200": { - "description": "The image bytes.", + "description": "Pages across sessions (navigation history) with category facets.", "content": { - "image/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/PagesPage" } } } @@ -13042,16 +19999,6 @@ } } }, - "404": { - "description": "NOT_FOUND, SCREENSHOT_UNAVAILABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -13075,65 +20022,42 @@ } } }, - "/api/v1/sessions/{session_id}/trace.zip": { + "/api/v1/pages/recent": { "get": { - "operationId": "getTraceZip", + "operationId": "listRecentPages", "tags": [ - "sessions" + "pages" ], - "summary": "The session trace (single `Range` supported; `?grant=` for route `trace` = session id).", + "summary": "Most recent page visits across sessions.", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] - }, - { - "grantAuth": [] + "bearerAuth": [] } ], "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 15 }, "required": false, - "name": "grant", + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "Whole trace.", - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "206": { - "description": "Requested byte range.", + "description": "Most recent page visits across sessions.", "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/RecentPagesResponse" } } } @@ -13168,27 +20092,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "416": { - "description": "Range not satisfiable.", - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -13210,54 +20113,68 @@ } } } - }, - "head": { - "operationId": "headTraceZip", + } + }, + "/api/v1/pages/domains": { + "get": { + "operationId": "listPageDomains", "tags": [ - "sessions" + "pages" ], - "summary": "Trace size probe.", + "summary": "Most visited domains (all-time when no window).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - }, - { - "grantAuth": [] } ], "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "since", + "in": "query" }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "grant", + "name": "until", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 5 + }, + "required": false, + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "Headers only.", + "description": "Most visited domains (all-time when no window).", "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/PageDomainsResponse" } } } @@ -13292,16 +20209,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -13325,40 +20232,173 @@ } } }, - "/api/v1/sessions/{session_id}/trace": { + "/api/v1/attention": { "get": { - "operationId": "getSessionTrace", + "operationId": "listAttention", "tags": [ - "sessions" + "attention" ], - "summary": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", + "summary": "Attention requests (open and history) with the live open count and status/mode facets.", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ + "bearerAuth": [] + } + ], + "x-browserhive-scope": "attention:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "created_at", + "resolved_at", + "waited_ms" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "takeover", + "notify" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "mode", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "until", + "in": "query" } ], "responses": { "200": { - "description": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", + "description": "Attention requests (open and history) with the live open count and status/mode facets.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionTraceInfo" + "$ref": "#/components/schemas/AttentionPage" } } } @@ -13393,16 +20433,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -13426,13 +20456,13 @@ } } }, - "/api/v1/sessions/{session_id}/data-dir/reveal": { + "/api/v1/attention/{request_id}/resolve": { "post": { - "operationId": "revealSessionDataDir", + "operationId": "resolveAttention", "tags": [ - "sessions" + "attention" ], - "summary": "Open the session's data directory in the host file manager (honest result).", + "summary": "Resolve or reject an open attention request.", "security": [ { "cookieAuth": [] @@ -13441,25 +20471,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "attention:resolve", "parameters": [ { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "pattern": "^a-[A-Za-z0-9_-]{12}$" }, "required": true, - "name": "session_id", + "name": "request_id", "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveAttentionRequest" + } + } + } + }, "responses": { "200": { - "description": "Open the session's data directory in the host file manager (honest result).", + "description": "Resolve or reject an open attention request.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevealDataDirResponse" + "$ref": "#/components/schemas/ResolveRequestResponse" } } } @@ -13495,7 +20535,27 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "ATTENTION_NOT_OPEN", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -13527,13 +20587,13 @@ } } }, - "/api/v1/sessions/{session_id}/export": { - "get": { - "operationId": "exportSession", + "/api/v1/attention/bulk": { + "post": { + "operationId": "bulkAttention", "tags": [ - "sessions" + "attention" ], - "summary": "Streamed timeline export (NDJSON or CSV by `Accept`), capped at 100k rows.", + "summary": "Resolve or reject several attention requests (per-item results).", "security": [ { "cookieAuth": [] @@ -13542,48 +20602,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "attention:resolve", "parameters": [ { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "format": "uuid" }, "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "page", - "attention", - "vault", - "blocked" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "kinds", - "in": "query" + "name": "idempotency-key", + "in": "header" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAttentionRequest" + } + } + } + }, "responses": { "200": { - "description": "kind,ts,id,data rows.", + "description": "Resolve or reject several attention requests (per-item results).", "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/BulkRequestsResponse" } } } @@ -13618,18 +20665,8 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "406": { - "description": "NOT_ACCEPTABLE", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -13661,50 +20698,119 @@ } } }, - "/api/v1/sessions/{session_id}/viewport": { - "post": { - "operationId": "setSessionViewport", + "/api/v1/vault/confirm": { + "get": { + "operationId": "listVaultConfirm", "tags": [ - "sessions" + "vault" ], - "summary": "Resize the active page viewport; not attention-gated (D-10).", + "summary": "Vault fill confirmations (open and history).", "security": [ { "cookieAuth": [] }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:write", - "parameters": [ + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "vault:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "created_at", + "resolved_at" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "status", + "in": "query" + }, { "schema": { "type": "string", "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": true, + "required": false, "name": "session_id", - "in": "path" + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetViewportRequest" - } - } - } - }, "responses": { "200": { - "description": "Resize the active page viewport; not attention-gated (D-10).", + "description": "Vault fill confirmations (open and history).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetViewportResponse" + "$ref": "#/components/schemas/VaultConfirmPage" } } } @@ -13740,27 +20846,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "SESSION_NOT_AVAILABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -13792,13 +20878,13 @@ } } }, - "/api/v1/sessions/{session_id}/input": { + "/api/v1/vault/confirm/{request_id}/resolve": { "post": { - "operationId": "sendSessionInput", + "operationId": "resolveVaultConfirm", "tags": [ - "sessions" + "vault" ], - "summary": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", + "summary": "Approve or deny a pending vault fill (`reason` is audit-only).", "security": [ { "cookieAuth": [] @@ -13807,15 +20893,15 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:takeover", + "x-browserhive-scope": "vault:confirm", "parameters": [ { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "pattern": "^a-[A-Za-z0-9_-]{12}$" }, "required": true, - "name": "session_id", + "name": "request_id", "in": "path" } ], @@ -13824,18 +20910,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionInputRequest" + "$ref": "#/components/schemas/ResolveVaultConfirmRequest" } } } }, "responses": { "200": { - "description": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", + "description": "Approve or deny a pending vault fill (`reason` is audit-only).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionInputResponse" + "$ref": "#/components/schemas/ResolveRequestResponse" } } } @@ -13871,7 +20957,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "VAULT_NOT_CONFIGURED, NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -13881,7 +20967,7 @@ } }, "409": { - "description": "INPUT_NOT_PERMITTED", + "description": "CONFIRM_NOT_OPEN", "content": { "application/problem+json": { "schema": { @@ -13923,13 +21009,13 @@ } } }, - "/api/v1/tool-calls": { - "get": { - "operationId": "listToolCalls", + "/api/v1/vault/confirm/bulk": { + "post": { + "operationId": "bulkVaultConfirm", "tags": [ - "activity" + "vault" ], - "summary": "Tool calls across sessions (live feed seed, fleet error views).", + "summary": "Approve or deny several vault confirmations (per-item results).", "security": [ { "cookieAuth": [] @@ -13938,194 +21024,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "vault:confirm", "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "duration_ms" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "tool", - "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "ok", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "error_code", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "detail" - ] - }, - "required": false, - "name": "expand", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "has_session", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 + "format": "uuid" }, - "required": false, - "name": "harness", - "in": "query" + "required": true, + "name": "idempotency-key", + "in": "header" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkVaultConfirmRequest" + } + } + } + }, "responses": { "200": { - "description": "Tool calls across sessions (live feed seed, fleet error views).", + "description": "Approve or deny several vault confirmations (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolCallsPage" + "$ref": "#/components/schemas/BulkRequestsResponse" } } } @@ -14160,6 +21087,26 @@ } } }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -14183,13 +21130,13 @@ } } }, - "/api/v1/activity": { + "/api/v1/vault": { "get": { - "operationId": "getActivity", + "operationId": "getVault", "tags": [ - "activity" + "vault" ], - "summary": "Gap-filled activity buckets (≤ 720) and headline counters.", + "summary": "Backend capabilities, unlock descriptor and counts (never shells out).", "security": [ { "cookieAuth": [] @@ -14198,69 +21145,100 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" + "x-browserhive-scope": "vault:read", + "responses": { + "200": { + "description": "Backend capabilities, unlock descriptor and counts (never shells out).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultOverview" + } + } + } }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/vault/status": { + "get": { + "operationId": "getVaultStatus", + "tags": [ + "vault" + ], + "summary": "Lock state (may call the backend).", + "security": [ { - "schema": { - "type": "integer", - "minimum": 60000, - "maximum": 86400000 - }, - "required": false, - "name": "bucket_ms", - "in": "query" + "cookieAuth": [] }, { - "schema": { - "type": "string", - "enum": [ - "tool", - "error_code", - "session" - ] - }, - "required": false, - "name": "group_by", - "in": "query" + "bearerAuth": [] } ], + "x-browserhive-scope": "vault:read", "responses": { "200": { - "description": "Gap-filled activity buckets (≤ 720) and headline counters.", + "description": "Lock state (may call the backend).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActivityResponse" + "$ref": "#/components/schemas/VaultStatus" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -14269,8 +21247,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -14279,8 +21257,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -14308,17 +21286,27 @@ } } } + }, + "502": { + "description": "VAULT_BACKEND_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/metrics/tools": { - "get": { - "operationId": "getToolMetrics", + "/api/v1/vault/unlock": { + "post": { + "operationId": "unlockVault", "tags": [ - "activity" + "vault" ], - "summary": "Per-tool call counts, error rate and latency percentiles.", + "summary": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", "security": [ { "cookieAuth": [] @@ -14327,63 +21315,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "tool", - "error_code", - "tool,error_code" - ], - "default": "tool" - }, - "required": false, - "name": "group_by", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" + "x-browserhive-scope": "vault:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlockVaultRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Per-tool call counts, error rate and latency percentiles.", + "description": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolMetricsResponse" + "$ref": "#/components/schemas/UnlockVaultResponse" } } } @@ -14399,7 +21348,7 @@ } }, "401": { - "description": "UNAUTHORIZED", + "description": "UNAUTHORIZED, VAULT_UNLOCK_FAILED", "content": { "application/problem+json": { "schema": { @@ -14418,6 +21367,26 @@ } } }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -14441,13 +21410,13 @@ } } }, - "/api/v1/metrics/harnesses": { - "get": { - "operationId": "getHarnessMetrics", + "/api/v1/vault/lock": { + "post": { + "operationId": "lockVault", "tags": [ - "activity" + "vault" ], - "summary": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", + "summary": "Forget the backend session.", "security": [ { "cookieAuth": [] @@ -14456,46 +21425,20 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - } - ], + "x-browserhive-scope": "vault:write", "responses": { "200": { - "description": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", + "description": "Forget the backend session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HarnessMetricsResponse" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -14504,8 +21447,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -14514,8 +21457,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -14547,13 +21490,13 @@ } } }, - "/api/v1/pages": { - "get": { - "operationId": "listPages", + "/api/v1/vault/sync": { + "post": { + "operationId": "syncVault", "tags": [ - "pages" + "vault" ], - "summary": "Pages across sessions (navigation history) with category facets.", + "summary": "Refresh the backend's local cache.", "security": [ { "cookieAuth": [] @@ -14562,156 +21505,40 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "domain", - "category", - "session" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "public", - "ip", - "local", - "ftp", - "other" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "category", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 - }, - "required": false, - "name": "domain", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - } - ], + "x-browserhive-scope": "vault:write", "responses": { "200": { - "description": "Pages across sessions (navigation history) with category facets.", + "description": "Refresh the backend's local cache.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PagesPage" + "$ref": "#/components/schemas/SyncVaultResponse" } } } }, "400": { - "description": "VALIDATION_FAILED", + "description": "VAULT_SYNC_UNSUPPORTED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -14720,8 +21547,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -14730,8 +21557,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "409": { + "description": "VAULT_LOCKED", "content": { "application/problem+json": { "schema": { @@ -14763,13 +21590,13 @@ } } }, - "/api/v1/pages/recent": { + "/api/v1/vault/groups": { "get": { - "operationId": "listRecentPages", + "operationId": "listVaultGroups", "tags": [ - "pages" + "vault" ], - "summary": "Most recent page visits across sessions.", + "summary": "Backend groups with item/binding coverage, policies and same-name duplicates.", "security": [ { "cookieAuth": [] @@ -14778,33 +21605,20 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 15 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], + "x-browserhive-scope": "vault:read", "responses": { "200": { - "description": "Most recent page visits across sessions.", + "description": "Backend groups with item/binding coverage, policies and same-name duplicates.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecentPagesResponse" + "$ref": "#/components/schemas/VaultGroupsResponse" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -14813,8 +21627,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -14823,8 +21637,18 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "VAULT_LOCKED", "content": { "application/problem+json": { "schema": { @@ -14856,13 +21680,13 @@ } } }, - "/api/v1/pages/domains": { - "get": { - "operationId": "listPageDomains", + "/api/v1/vault/groups/{group_id}/policy": { + "put": { + "operationId": "putVaultGroupPolicy", "tags": [ - "pages" + "vault" ], - "summary": "Most visited domains (all-time when no window).", + "summary": "Create or update a group policy (`If-Match: ` on update).", "security": [ { "cookieAuth": [] @@ -14871,51 +21695,45 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "vault:write", "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "minLength": 1, + "maxLength": 128 }, - "required": false, - "name": "until", - "in": "query" + "required": true, + "name": "group_id", + "in": "path" }, { "schema": { "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 5 + "exclusiveMinimum": 0 }, "required": false, - "name": "limit", - "in": "query" + "name": "if-match", + "in": "header" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutGroupPolicyRequest" + } + } + } + }, "responses": { "200": { - "description": "Most visited domains (all-time when no window).", + "description": "Create or update a group policy (`If-Match: ` on update).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PageDomainsResponse" + "$ref": "#/components/schemas/PutGroupPolicyResponse" } } } @@ -14950,6 +21768,36 @@ } } }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -14973,13 +21821,13 @@ } } }, - "/api/v1/attention": { + "/api/v1/vault/items": { "get": { - "operationId": "listAttention", + "operationId": "listVaultItems", "tags": [ - "attention" + "vault" ], - "summary": "Attention requests (open and history) with the live open count and status/mode facets.", + "summary": "Backend items with derived handles and binding coverage.", "security": [ { "cookieAuth": [] @@ -14988,7 +21836,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:read", + "x-browserhive-scope": "vault:read", "parameters": [ { "schema": { @@ -15038,64 +21886,23 @@ "schema": { "type": "string", "enum": [ - "created_at", - "resolved_at", - "waited_ms" + "handle", + "name" ], - "default": "created_at" + "default": "handle" }, "required": false, "name": "sort", "in": "query" }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pending", - "resolved", - "rejected", - "timeout", - "cancelled" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "takeover", - "notify" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "mode", - "in": "query" - }, { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "minLength": 1, + "maxLength": 128 }, "required": false, - "name": "session_id", + "name": "group_id", "in": "query" }, { @@ -15107,45 +21914,41 @@ "required": false, "name": "q", "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" } ], "responses": { "200": { - "description": "Attention requests (open and history) with the live open count and status/mode facets.", + "description": "Backend items with derived handles and binding coverage.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultItemsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AttentionPage" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -15154,8 +21957,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -15164,8 +21967,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "409": { + "description": "VAULT_LOCKED", "content": { "application/problem+json": { "schema": { @@ -15197,13 +22000,13 @@ } } }, - "/api/v1/attention/{request_id}/resolve": { - "post": { - "operationId": "resolveAttention", + "/api/v1/vault/bindings": { + "get": { + "operationId": "listVaultBindings", "tags": [ - "attention" + "vault" ], - "summary": "Resolve or reject an open attention request.", + "summary": "Stored bindings, ordered by handle.", "security": [ { "cookieAuth": [] @@ -15212,35 +22015,94 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:resolve", + "x-browserhive-scope": "vault:read", "parameters": [ { "schema": { "type": "string", - "pattern": "^a-[A-Za-z0-9_-]{12}$" + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" }, - "required": true, - "name": "request_id", - "in": "path" + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "handle", + "updated_at", + "created_at" + ], + "default": "handle" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "group_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResolveAttentionRequest" - } - } - } - }, "responses": { "200": { - "description": "Resolve or reject an open attention request.", + "description": "Stored bindings, ordered by handle.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveRequestResponse" + "$ref": "#/components/schemas/VaultBindingsPage" } } } @@ -15276,27 +22138,7 @@ } }, "404": { - "description": "NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "ATTENTION_NOT_OPEN", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -15328,13 +22170,13 @@ } } }, - "/api/v1/attention/bulk": { - "post": { - "operationId": "bulkAttention", + "/api/v1/vault/bindings/{handle}": { + "put": { + "operationId": "putVaultBinding", "tags": [ - "attention" + "vault" ], - "summary": "Resolve or reject several attention requests (per-item results).", + "summary": "Create (item_name required) or update a binding (`If-Match: `).", "security": [ { "cookieAuth": [] @@ -15343,15 +22185,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:resolve", + "x-browserhive-scope": "vault:write", "parameters": [ { "schema": { "type": "string", - "format": "uuid" + "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" }, "required": true, - "name": "idempotency-key", + "name": "handle", + "in": "path" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "required": false, + "name": "if-match", "in": "header" } ], @@ -15360,18 +22211,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAttentionRequest" + "$ref": "#/components/schemas/PutVaultBindingRequest" } } } }, "responses": { "200": { - "description": "Resolve or reject several attention requests (per-item results).", + "description": "Create (item_name required) or update a binding (`If-Match: `).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkRequestsResponse" + "$ref": "#/components/schemas/PutVaultBindingResponse" } } } @@ -15406,8 +22257,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -15416,8 +22267,8 @@ } } }, - "429": { - "description": "RATE_LIMITED", + "409": { + "description": "CONFLICT", "content": { "application/problem+json": { "schema": { @@ -15426,8 +22277,8 @@ } } }, - "500": { - "description": "INTERNAL_ERROR", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -15435,123 +22286,62 @@ } } } - } - } - } - }, - "/api/v1/vault/confirm": { - "get": { - "operationId": "listVaultConfirm", - "tags": [ - "vault" - ], - "summary": "Vault fill confirmations (open and history).", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "vault:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteVaultBinding", + "tags": [ + "vault" + ], + "summary": "Remove a binding.", + "security": [ { - "schema": { - "type": "string", - "enum": [ - "created_at", - "resolved_at" - ], - "default": "created_at" - }, - "required": false, - "name": "sort", - "in": "query" + "cookieAuth": [] }, { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pending", - "resolved", - "rejected", - "timeout", - "cancelled" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "status", - "in": "query" - }, + "bearerAuth": [] + } + ], + "x-browserhive-scope": "vault:write", + "parameters": [ { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" }, - "required": false, - "name": "session_id", - "in": "query" + "required": true, + "name": "handle", + "in": "path" } ], "responses": { "200": { - "description": "Vault fill confirmations (open and history).", + "description": "Remove a binding.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultConfirmPage" + "$ref": "#/components/schemas/DeleteVaultBindingResponse" } } } @@ -15619,13 +22409,13 @@ } } }, - "/api/v1/vault/confirm/{request_id}/resolve": { + "/api/v1/vault/bindings/resolve": { "post": { - "operationId": "resolveVaultConfirm", + "operationId": "resolveVaultBindings", "tags": [ "vault" ], - "summary": "Approve or deny a pending vault fill (`reason` is audit-only).", + "summary": "Dry-run the fill gates of every binding against a URL.", "security": [ { "cookieAuth": [] @@ -15634,35 +22424,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:confirm", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^a-[A-Za-z0-9_-]{12}$" - }, - "required": true, - "name": "request_id", - "in": "path" - } - ], + "x-browserhive-scope": "vault:read", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveVaultConfirmRequest" + "$ref": "#/components/schemas/ResolveBindingsRequest" } } } }, "responses": { "200": { - "description": "Approve or deny a pending vault fill (`reason` is audit-only).", + "description": "Dry-run the fill gates of every binding against a URL.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveRequestResponse" + "$ref": "#/components/schemas/ResolveBindingsResponse" } } } @@ -15698,17 +22477,7 @@ } }, "404": { - "description": "VAULT_NOT_CONFIGURED, NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "CONFIRM_NOT_OPEN", + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -15750,50 +22519,197 @@ } } }, - "/api/v1/vault/confirm/bulk": { - "post": { - "operationId": "bulkVaultConfirm", + "/api/v1/vault/log": { + "get": { + "operationId": "listVaultLog", "tags": [ "vault" ], - "summary": "Approve or deny several vault confirmations (per-item results).", + "summary": "Vault access audit log.", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "vault:confirm", - "parameters": [ + "bearerAuth": [] + } + ], + "x-browserhive-scope": "vault:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "entry_name", + "result", + "session" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "result", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pass", + "fail", + "skipped" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "origin_check", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "on", + "off" + ] + }, + "required": false, + "name": "evaluate", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "entry_name", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, { "schema": { - "type": "string", - "format": "uuid" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "idempotency-key", - "in": "header" + "required": false, + "name": "until", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkVaultConfirmRequest" - } - } - } - }, "responses": { "200": { - "description": "Approve or deny several vault confirmations (per-item results).", + "description": "Vault access audit log.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkRequestsResponse" + "$ref": "#/components/schemas/VaultLogPage" } } } @@ -15838,16 +22754,6 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -15871,13 +22777,13 @@ } } }, - "/api/v1/vault": { + "/api/v1/vault/export": { "get": { - "operationId": "getVault", + "operationId": "exportVault", "tags": [ "vault" ], - "summary": "Backend capabilities, unlock descriptor and counts (never shells out).", + "summary": "Export bindings and policies as the v3 document.", "security": [ { "cookieAuth": [] @@ -15889,11 +22795,11 @@ "x-browserhive-scope": "vault:read", "responses": { "200": { - "description": "Backend capabilities, unlock descriptor and counts (never shells out).", + "description": "Export bindings and policies as the v3 document.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultOverview" + "$ref": "#/components/schemas/VaultExportDocument" } } } @@ -15951,13 +22857,13 @@ } } }, - "/api/v1/vault/status": { - "get": { - "operationId": "getVaultStatus", + "/api/v1/vault/import": { + "post": { + "operationId": "importVault", "tags": [ "vault" ], - "summary": "Lock state (may call the backend).", + "summary": "Import a v3 document (`?mode=merge|replace`).", "security": [ { "cookieAuth": [] @@ -15966,114 +22872,39 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", - "responses": { - "200": { - "description": "Lock state (may call the backend).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultStatus" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "502": { - "description": "VAULT_BACKEND_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/vault/unlock": { - "post": { - "operationId": "unlockVault", - "tags": [ - "vault" - ], - "summary": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", - "security": [ - { - "cookieAuth": [] - }, + "x-browserhive-scope": "vault:write", + "parameters": [ { - "bearerAuth": [] + "schema": { + "type": "string", + "enum": [ + "merge", + "replace" + ], + "default": "merge" + }, + "required": false, + "name": "mode", + "in": "query" } ], - "x-browserhive-scope": "vault:write", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnlockVaultRequest" + "$ref": "#/components/schemas/VaultExportDocument" } } } }, "responses": { "200": { - "description": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", + "description": "Import a v3 document (`?mode=merge|replace`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnlockVaultResponse" + "$ref": "#/components/schemas/ImportVaultResponse" } } } @@ -16089,7 +22920,7 @@ } }, "401": { - "description": "UNAUTHORIZED, VAULT_UNLOCK_FAILED", + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -16151,13 +22982,13 @@ } } }, - "/api/v1/vault/lock": { - "post": { - "operationId": "lockVault", + "/api/v1/blocklist": { + "get": { + "operationId": "getBlocklist", "tags": [ - "vault" + "blocklist" ], - "summary": "Forget the backend session.", + "summary": "Loaded patterns with hit counts, skipped lines and window stats.", "security": [ { "cookieAuth": [] @@ -16166,20 +22997,46 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", + "x-browserhive-scope": "blocklist:read", + "parameters": [ + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" + } + ], "responses": { "200": { - "description": "Forget the backend session.", + "description": "Loaded patterns with hit counts, skipped lines and window stats.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/BlocklistOverview" } } } }, - "401": { - "description": "UNAUTHORIZED", + "400": { + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -16188,8 +23045,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -16198,8 +23055,8 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -16231,13 +23088,13 @@ } } }, - "/api/v1/vault/sync": { + "/api/v1/blocklist/reload": { "post": { - "operationId": "syncVault", + "operationId": "reloadBlocklist", "tags": [ - "vault" + "blocklist" ], - "summary": "Refresh the backend's local cache.", + "summary": "Re-read the blocklist file; on failure the previous list stays active.", "security": [ { "cookieAuth": [] @@ -16246,20 +23103,20 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", + "x-browserhive-scope": "blocklist:write", "responses": { "200": { - "description": "Refresh the backend's local cache.", + "description": "Re-read the blocklist file; on failure the previous list stays active.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SyncVaultResponse" + "$ref": "#/components/schemas/ReloadBlocklistResponse" } } } }, "400": { - "description": "VAULT_SYNC_UNSUPPORTED", + "description": "BLOCKLIST_LOAD_FAILED", "content": { "application/problem+json": { "schema": { @@ -16288,26 +23145,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "VAULT_LOCKED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -16331,13 +23168,13 @@ } } }, - "/api/v1/vault/groups": { + "/api/v1/blocklist/attempts": { "get": { - "operationId": "listVaultGroups", + "operationId": "listBlockedAttempts", "tags": [ - "vault" + "blocklist" ], - "summary": "Backend groups with item/binding coverage, policies and same-name duplicates.", + "summary": "Blocked request audit (served even when no blocklist is configured).", "security": [ { "cookieAuth": [] @@ -16346,30 +23183,164 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "blocklist:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "domain", + "pattern", + "session", + "source" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "required": false, + "name": "pattern", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "request" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "source", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" + } + ], "responses": { "200": { - "description": "Backend groups with item/binding coverage, policies and same-name duplicates.", + "description": "Blocked request audit (served even when no blocklist is configured).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultGroupsResponse" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/BlockedAttemptsPage" } } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "400": { + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -16378,8 +23349,8 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -16388,8 +23359,8 @@ } } }, - "409": { - "description": "VAULT_LOCKED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -16421,13 +23392,13 @@ } } }, - "/api/v1/vault/groups/{group_id}/policy": { - "put": { - "operationId": "putVaultGroupPolicy", + "/api/v1/system": { + "get": { + "operationId": "getSystem", "tags": [ - "vault" + "system" ], - "summary": "Create or update a group policy (`If-Match: ` on update).", + "summary": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", "security": [ { "cookieAuth": [] @@ -16436,51 +23407,30 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": true, - "name": "group_id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "required": false, - "name": "if-match", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutGroupPolicyRequest" - } - } - } - }, + "x-browserhive-scope": "system:read", "responses": { "200": { - "description": "Create or update a group policy (`If-Match: ` on update).", + "description": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemInfo" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PutGroupPolicyResponse" + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "VALIDATION_FAILED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -16489,8 +23439,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", + "429": { + "description": "RATE_LIMITED", "content": { "application/problem+json": { "schema": { @@ -16499,8 +23449,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "500": { + "description": "INTERNAL_ERROR", "content": { "application/problem+json": { "schema": { @@ -16508,19 +23458,39 @@ } } } + } + } + } + }, + "/api/v1/system/config": { + "get": { + "operationId": "getSystemConfig", + "tags": [ + "system" + ], + "summary": "Every config key with its value, source and shadowed values (secrets redacted).", + "security": [ + { + "cookieAuth": [] }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "system:read", + "responses": { + "200": { + "description": "Every config key with its value, source and shadowed values (secrets redacted).", "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SystemConfigResponse" } } } }, - "409": { - "description": "CONFLICT", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -16529,8 +23499,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -16562,13 +23532,13 @@ } } }, - "/api/v1/vault/items": { + "/api/v1/system/public-url": { "get": { - "operationId": "listVaultItems", + "operationId": "getPublicUrlStatus", "tags": [ - "vault" + "system" ], - "summary": "Backend items with derived handles and binding coverage.", + "summary": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", "security": [ { "cookieAuth": [] @@ -16577,93 +23547,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "system:read", "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "handle", - "name" - ], - "default": "handle" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "group_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "boolean" }, "required": false, - "name": "q", + "name": "refresh", "in": "query" } ], "responses": { "200": { - "description": "Backend items with derived handles and binding coverage.", + "description": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultItemsPage" + "$ref": "#/components/schemas/PublicUrlStatus" } } } @@ -16698,26 +23599,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "VAULT_LOCKED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -16741,13 +23622,13 @@ } } }, - "/api/v1/vault/bindings": { + "/api/v1/system/realtime": { "get": { - "operationId": "listVaultBindings", + "operationId": "getSystemRealtime", "tags": [ - "vault" + "system" ], - "summary": "Stored bindings, ordered by handle.", + "summary": "Open realtime connections with topics, screencasts and backpressure counters.", "security": [ { "cookieAuth": [] @@ -16756,104 +23637,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "handle", - "updated_at", - "created_at" - ], - "default": "handle" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "group_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - } - ], + "x-browserhive-scope": "system:read", "responses": { "200": { - "description": "Stored bindings, ordered by handle.", + "description": "Open realtime connections with topics, screencasts and backpressure counters.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultBindingsPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SystemRealtimeResponse" } } } @@ -16878,16 +23669,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -16911,13 +23692,13 @@ } } }, - "/api/v1/vault/bindings/{handle}": { - "put": { - "operationId": "putVaultBinding", + "/api/v1/system/mcp/connections": { + "get": { + "operationId": "listMcpConnections", "tags": [ - "vault" + "system" ], - "summary": "Create (item_name required) or update a binding (`If-Match: `).", + "summary": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", "security": [ { "cookieAuth": [] @@ -16926,44 +23707,40 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", + "x-browserhive-scope": "system:read", "parameters": [ { "schema": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 }, - "required": true, - "name": "handle", - "in": "path" + "required": false, + "name": "limit", + "in": "query" }, { "schema": { - "type": "integer", - "exclusiveMinimum": 0 + "type": [ + "integer", + "null" + ], + "minimum": 0, + "default": 0 }, "required": false, - "name": "if-match", - "in": "header" + "name": "offset", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutVaultBindingRequest" - } - } - } - }, "responses": { "200": { - "description": "Create (item_name required) or update a binding (`If-Match: `).", + "description": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PutVaultBindingResponse" + "$ref": "#/components/schemas/McpConnectionsResponse" } } } @@ -16998,36 +23775,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "CONFLICT", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -17049,13 +23796,15 @@ } } } - }, - "delete": { - "operationId": "deleteVaultBinding", + } + }, + "/api/v1/system/log-level": { + "patch": { + "operationId": "setLogLevel", "tags": [ - "vault" + "system" ], - "summary": "Remove a binding.", + "summary": "Change the log level spec at runtime (`info,sessions=debug`).", "security": [ { "cookieAuth": [] @@ -17064,25 +23813,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" - }, - "required": true, - "name": "handle", - "in": "path" + "x-browserhive-scope": "system:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetLogLevelRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Remove a binding.", + "description": "Change the log level spec at runtime (`info,sessions=debug`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteVaultBindingResponse" + "$ref": "#/components/schemas/SetLogLevelResponse" } } } @@ -17117,8 +23865,8 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -17150,13 +23898,13 @@ } } }, - "/api/v1/vault/bindings/resolve": { - "post": { - "operationId": "resolveVaultBindings", + "/api/v1/system/events": { + "get": { + "operationId": "listSystemEvents", "tags": [ - "vault" + "system" ], - "summary": "Dry-run the fill gates of every binding against a URL.", + "summary": "Degradations (`resolved=open` by default).", "security": [ { "cookieAuth": [] @@ -17165,24 +23913,117 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResolveBindingsRequest" - } - } + "x-browserhive-scope": "system:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "last_seen_at", + "first_seen_at", + "count" + ], + "default": "last_seen_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "info", + "warn", + "error" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "severity", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "all", + "open", + "resolved" + ], + "default": "open" + }, + "required": false, + "name": "resolved", + "in": "query" } - }, + ], "responses": { "200": { - "description": "Dry-run the fill gates of every binding against a URL.", + "description": "Degradations (`resolved=open` by default).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveBindingsResponse" + "$ref": "#/components/schemas/SystemEventsPage" } } } @@ -17217,26 +24058,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -17260,13 +24081,13 @@ } } }, - "/api/v1/vault/log": { + "/api/v1/logs": { "get": { - "operationId": "listVaultLog", + "operationId": "listLogs", "tags": [ - "vault" + "logs" ], - "summary": "Vault access audit log.", + "summary": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", "security": [ { "cookieAuth": [] @@ -17275,7 +24096,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "logs:read", "parameters": [ { "schema": { @@ -17292,8 +24113,8 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 500, - "default": 50 + "maximum": 1000, + "default": 200 }, "required": false, "name": "limit", @@ -17314,28 +24135,200 @@ }, { "schema": { - "type": "boolean", - "default": false + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "total", + "name": "after_seq", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "level", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "module", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "ts", - "entry_name", - "result", - "session" + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "required": false, + "name": "trace_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "request_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" ], - "default": "ts" + "minimum": 0 }, "required": false, - "name": "sort", + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", "in": "query" + } + ], + "responses": { + "200": { + "description": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/logs/export": { + "get": { + "operationId": "exportLogs", + "tags": [ + "logs" + ], + "summary": "Every matching ring-buffer record as NDJSON.", + "security": [ + { + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "logs:read", + "parameters": [ { "schema": { "type": [ @@ -17345,17 +24338,17 @@ "items": { "type": "string", "enum": [ - "success", - "origin_mismatch", - "auth_failed", - "blocked", - "denied" + "error", + "warn", + "info", + "debug", + "trace" ] }, "minItems": 1 }, "required": false, - "name": "result", + "name": "level", "in": "query" }, { @@ -17366,47 +24359,42 @@ ], "items": { "type": "string", - "enum": [ - "pass", - "fail", - "skipped" - ] + "minLength": 1, + "maxLength": 64 }, "minItems": 1 }, "required": false, - "name": "origin_check", + "name": "module", "in": "query" }, { "schema": { - "type": "string", - "enum": [ - "on", - "off" - ] + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "evaluate", + "name": "session_id", "in": "query" }, { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "minLength": 1, + "maxLength": 64 }, "required": false, - "name": "session_id", + "name": "trace_id", "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 128 }, "required": false, - "name": "entry_name", + "name": "request_id", "in": "query" }, { @@ -17446,11 +24434,12 @@ ], "responses": { "200": { - "description": "Vault access audit log.", + "description": "One record per line.", "content": { - "application/json": { + "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/VaultLogPage" + "type": "string", + "format": "binary" } } } @@ -17485,16 +24474,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -17518,13 +24497,13 @@ } } }, - "/api/v1/vault/export": { + "/api/v1/notifications": { "get": { - "operationId": "exportVault", + "operationId": "listNotifications", "tags": [ - "vault" + "notifications" ], - "summary": "Export bindings and policies as the v3 document.", + "summary": "Notifications newest first with the unread count.", "security": [ { "cookieAuth": [] @@ -17533,119 +24512,133 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", - "responses": { - "200": { - "description": "Export bindings and policies as the v3 document.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultExportDocument" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + "x-browserhive-scope": "notifications:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/vault/import": { - "post": { - "operationId": "importVault", - "tags": [ - "vault" - ], - "summary": "Import a v3 document (`?mode=merge|replace`).", - "security": [ { - "cookieAuth": [] + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "vault:write", - "parameters": [ + "schema": { + "type": "string", + "enum": [ + "updated_at", + "created_at" + ], + "default": "updated_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, { "schema": { "type": "string", "enum": [ - "merge", - "replace" + "all", + "unread", + "read" ], - "default": "merge" + "default": "all" }, "required": false, - "name": "mode", + "name": "read", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultExportDocument" - } - } - } - }, "responses": { "200": { - "description": "Import a v3 document (`?mode=merge|replace`).", + "description": "Notifications newest first with the unread count.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportVaultResponse" + "$ref": "#/components/schemas/NotificationsPage" } } } @@ -17660,28 +24653,8 @@ } } }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -17690,8 +24663,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -17723,13 +24696,13 @@ } } }, - "/api/v1/blocklist": { - "get": { - "operationId": "getBlocklist", + "/api/v1/notifications/{notification_id}/read": { + "post": { + "operationId": "markNotificationRead", "tags": [ - "blocklist" + "notifications" ], - "summary": "Loaded patterns with hit counts, skipped lines and window stats.", + "summary": "Mark one notification read.", "security": [ { "cookieAuth": [] @@ -17738,40 +24711,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:read", + "x-browserhive-scope": "notifications:write", "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" }, - "required": false, - "name": "until", - "in": "query" + "required": true, + "name": "notification_id", + "in": "path" } ], "responses": { "200": { - "description": "Loaded patterns with hit counts, skipped lines and window stats.", + "description": "Mark one notification read.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlocklistOverview" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } @@ -17829,13 +24787,13 @@ } } }, - "/api/v1/blocklist/reload": { + "/api/v1/notifications/read-all": { "post": { - "operationId": "reloadBlocklist", + "operationId": "markAllNotificationsRead", "tags": [ - "blocklist" + "notifications" ], - "summary": "Re-read the blocklist file; on failure the previous list stays active.", + "summary": "Mark every notification read.", "security": [ { "cookieAuth": [] @@ -17844,24 +24802,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:write", + "x-browserhive-scope": "notifications:write", "responses": { "200": { - "description": "Re-read the blocklist file; on failure the previous list stays active.", + "description": "Mark every notification read.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReloadBlocklistResponse" - } - } - } - }, - "400": { - "description": "BLOCKLIST_LOAD_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/NotificationsUpdatedResponse" } } } @@ -17909,13 +24857,13 @@ } } }, - "/api/v1/blocklist/attempts": { - "get": { - "operationId": "listBlockedAttempts", + "/api/v1/notifications/{notification_id}": { + "delete": { + "operationId": "dismissNotification", "tags": [ - "blocklist" + "notifications" ], - "summary": "Blocked request audit (served even when no blocklist is configured).", + "summary": "Dismiss one notification.", "security": [ { "cookieAuth": [] @@ -17924,158 +24872,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:read", + "x-browserhive-scope": "notifications:write", "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "domain", - "pattern", - "session", - "source" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "required": false, - "name": "pattern", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 - }, - "required": false, - "name": "domain", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "request" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "source", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "pattern": "^n-[A-Za-z0-9_-]{12}$" }, - "required": false, - "name": "until", - "in": "query" + "required": true, + "name": "notification_id", + "in": "path" } ], "responses": { "200": { - "description": "Blocked request audit (served even when no blocklist is configured).", + "description": "Dismiss one notification.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlockedAttemptsPage" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } @@ -18133,13 +24948,13 @@ } } }, - "/api/v1/system": { - "get": { - "operationId": "getSystem", + "/api/v1/notifications/dismiss-all": { + "post": { + "operationId": "dismissAllNotifications", "tags": [ - "system" + "notifications" ], - "summary": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", + "summary": "Dismiss every notification.", "security": [ { "cookieAuth": [] @@ -18148,14 +24963,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", + "x-browserhive-scope": "notifications:write", "responses": { "200": { - "description": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", + "description": "Dismiss every notification.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemInfo" + "$ref": "#/components/schemas/NotificationsUpdatedResponse" } } } @@ -18203,13 +25018,13 @@ } } }, - "/api/v1/system/config": { + "/api/v1/me/preferences": { "get": { - "operationId": "getSystemConfig", + "operationId": "getPreferences", "tags": [ - "system" + "preferences" ], - "summary": "Every config key with its value, source and shadowed values (secrets redacted).", + "summary": "The caller's stored preferences (known keys only).", "security": [ { "cookieAuth": [] @@ -18218,14 +25033,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", + "x-browserhive-scope": null, "responses": { "200": { - "description": "Every config key with its value, source and shadowed values (secrets redacted).", + "description": "The caller's stored preferences (known keys only).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigResponse" + "$ref": "#/components/schemas/PreferencesResponse" } } } @@ -18271,15 +25086,13 @@ } } } - } - }, - "/api/v1/system/realtime": { - "get": { - "operationId": "getSystemRealtime", + }, + "put": { + "operationId": "putPreferences", "tags": [ - "system" + "preferences" ], - "summary": "Open realtime connections with topics, screencasts and backpressure counters.", + "summary": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", "security": [ { "cookieAuth": [] @@ -18288,14 +25101,34 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", + "x-browserhive-scope": "preferences:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutPreferencesRequest" + } + } + } + }, "responses": { "200": { - "description": "Open realtime connections with topics, screencasts and backpressure counters.", + "description": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemRealtimeResponse" + "$ref": "#/components/schemas/PutPreferencesResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -18320,6 +25153,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -18343,13 +25186,13 @@ } } }, - "/api/v1/system/mcp/connections": { + "/api/v1/channels": { "get": { - "operationId": "listMcpConnections", + "operationId": "listChannels", "tags": [ - "system" + "channels" ], - "summary": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", + "summary": "Every notification channel (dashboard and startup) with its state; never a secret value.", "security": [ { "cookieAuth": [] @@ -18358,50 +25201,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0, - "default": 0 - }, - "required": false, - "name": "offset", - "in": "query" - } - ], + "x-browserhive-scope": "channels:read", "responses": { "200": { - "description": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", + "description": "Every notification channel (dashboard and startup) with its state; never a secret value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/McpConnectionsResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ChannelsResponse" } } } @@ -18447,15 +25254,13 @@ } } } - } - }, - "/api/v1/system/log-level": { - "patch": { - "operationId": "setLogLevel", + }, + "post": { + "operationId": "createChannel", "tags": [ - "system" + "channels" ], - "summary": "Change the log level spec at runtime (`info,sessions=debug`).", + "summary": "Create a channel. Secrets are environment variable names, never values (D-33).", "security": [ { "cookieAuth": [] @@ -18464,30 +25269,30 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:write", + "x-browserhive-scope": "channels:write", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetLogLevelRequest" + "$ref": "#/components/schemas/ChannelInput" } } } }, "responses": { - "200": { - "description": "Change the log level spec at runtime (`info,sessions=debug`).", + "201": { + "description": "Create a channel. Secrets are environment variable names, never values (D-33).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetLogLevelResponse" + "$ref": "#/components/schemas/ChannelResponse" } } } }, "400": { - "description": "VALIDATION_FAILED", + "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -18516,6 +25321,16 @@ } } }, + "409": { + "description": "CHANNEL_NAME_TAKEN", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "413": { "description": "PAYLOAD_TOO_LARGE", "content": { @@ -18549,13 +25364,13 @@ } } }, - "/api/v1/system/events": { - "get": { - "operationId": "listSystemEvents", + "/api/v1/channels/preview": { + "post": { + "operationId": "previewChannel", "tags": [ - "system" + "channels" ], - "summary": "Degradations (`resolved=open` by default).", + "summary": "Render a sample notification exactly as the channel would send it. Sends nothing.", "security": [ { "cookieAuth": [] @@ -18564,123 +25379,30 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "last_seen_at", - "first_seen_at", - "count" - ], - "default": "last_seen_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "info", - "warn", - "error" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "severity", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "all", - "open", - "resolved" - ], - "default": "open" - }, - "required": false, - "name": "resolved", - "in": "query" + "x-browserhive-scope": "channels:read", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelPreviewRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Degradations (`resolved=open` by default).", + "description": "Render a sample notification exactly as the channel would send it. Sends nothing.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemEventsPage" + "$ref": "#/components/schemas/ChannelPreview" } } } }, "400": { - "description": "VALIDATION_FAILED", + "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -18709,6 +25431,26 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -18732,13 +25474,13 @@ } } }, - "/api/v1/logs": { + "/api/v1/channels/deliveries": { "get": { - "operationId": "listLogs", + "operationId": "listDeliveries", "tags": [ - "logs" + "channels" ], - "summary": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", + "summary": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", "security": [ { "cookieAuth": [] @@ -18747,7 +25489,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "logs:read", + "x-browserhive-scope": "channels:read", "parameters": [ { "schema": { @@ -18764,8 +25506,8 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 1000, - "default": 200 + "maximum": 200, + "default": 50 }, "required": false, "name": "limit", @@ -18774,26 +25516,19 @@ { "schema": { "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" }, "required": false, - "name": "dir", + "name": "channel_id", "in": "query" }, { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" }, "required": false, - "name": "after_seq", + "name": "notification_id", "in": "query" }, { @@ -18805,17 +25540,19 @@ "items": { "type": "string", "enum": [ - "error", - "warn", - "info", - "debug", - "trace" + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" ] }, "minItems": 1 }, "required": false, - "name": "level", + "name": "status", "in": "query" }, { @@ -18826,86 +25563,253 @@ ], "items": { "type": "string", - "minLength": 1, - "maxLength": 64 + "enum": [ + "send", + "edit", + "delete" + ] }, "minItems": 1 }, "required": false, - "name": "module", + "name": "op", "in": "query" }, { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "minItems": 1 }, "required": false, - "name": "session_id", + "name": "kind", "in": "query" + } + ], + "responses": { + "200": { + "description": "The delivery log newest first: every send, edit and delete, and why anything was not sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveriesPage" + } + } + } }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "required": false, - "name": "trace_id", - "in": "query" + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/channels/deliveries/{seq}": { + "get": { + "operationId": "getDelivery", + "tags": [ + "channels" + ], + "summary": "One delivery with the message as that channel is shown it (redacted).", + "security": [ { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "request_id", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "channels:read", + "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "integer", + "exclusiveMinimum": 0 }, - "required": false, - "name": "q", - "in": "query" + "required": true, + "name": "seq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "One delivery with the message as that channel is shown it (redacted).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliveryDetailResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "404": { + "description": "DELIVERY_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/channels/env": { + "get": { + "operationId": "checkChannelEnv", + "tags": [ + "channels" + ], + "summary": "Whether each named environment variable is set in the server (never its value).", + "security": [ { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "channels:read", + "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "array", + "items": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "minItems": 1, + "maxItems": 16 }, - "required": false, - "name": "until", + "required": true, + "name": "names", "in": "query" } ], "responses": { "200": { - "description": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", + "description": "Whether each named environment variable is set in the server (never its value).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogsPage" + "$ref": "#/components/schemas/ChannelEnvResponse" } } } @@ -18963,13 +25867,13 @@ } } }, - "/api/v1/logs/export": { - "get": { - "operationId": "exportLogs", + "/api/v1/channels/telegram/connect": { + "post": { + "operationId": "startTelegramConnect", "tags": [ - "logs" + "channels" ], - "summary": "Every matching ring-buffer record as NDJSON.", + "summary": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", "security": [ { "cookieAuth": [] @@ -18978,119 +25882,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "logs:read", - "parameters": [ - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "error", - "warn", - "info", - "debug", - "trace" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "level", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "module", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "required": false, - "name": "trace_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "request_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TelegramConnectRequest" + } + } } - ], + }, "responses": { "200": { - "description": "One record per line.", + "description": "Start the one-tap Telegram connect: a t.me link and a 2-minute wait for /start.", "content": { - "application/x-ndjson": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/TelegramConnectResponse" } } } @@ -19125,6 +25934,26 @@ } } }, + "409": { + "description": "CHANNEL_NOT_READY", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19144,152 +25973,54 @@ } } } + }, + "502": { + "description": "CHANNEL_PLATFORM_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/notifications": { + "/api/v1/channels/telegram/connect/{connect_id}": { "get": { - "operationId": "listNotifications", + "operationId": "getTelegramConnect", "tags": [ - "notifications" + "channels" ], - "summary": "Notifications newest first with the unread count.", + "summary": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "notifications:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "updated_at", - "created_at" - ], - "default": "updated_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "all", - "unread", - "read" - ], - "default": "all" - }, - "required": false, - "name": "read", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "attention", - "error", - "vault", - "lifecycle", - "system" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, + "bearerAuth": [] + } + ], + "x-browserhive-scope": "channels:read", + "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "pattern": "^[A-Za-z0-9_-]{8,64}$" }, - "required": false, - "name": "until", - "in": "query" + "required": true, + "name": "connect_id", + "in": "path" } ], "responses": { "200": { - "description": "Notifications newest first with the unread count.", + "description": "State of a Telegram connect: waiting, connected (with the chat), expired or failed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsPage" + "$ref": "#/components/schemas/TelegramConnectStatus" } } } @@ -19347,13 +26078,13 @@ } } }, - "/api/v1/notifications/{notification_id}/read": { - "post": { - "operationId": "markNotificationRead", + "/api/v1/channels/{channel_id}": { + "get": { + "operationId": "getChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Mark one notification read.", + "summary": "One channel.", "security": [ { "cookieAuth": [] @@ -19362,25 +26093,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:read", "parameters": [ { "schema": { "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" }, "required": true, - "name": "notification_id", + "name": "channel_id", "in": "path" } ], "responses": { "200": { - "description": "Mark one notification read.", + "description": "One channel.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/ChannelResponse" } } } @@ -19415,6 +26146,16 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19436,15 +26177,13 @@ } } } - } - }, - "/api/v1/notifications/read-all": { - "post": { - "operationId": "markAllNotificationsRead", + }, + "patch": { + "operationId": "updateChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Mark every notification read.", + "summary": "Edit a dashboard channel (startup channels are read-only).", "security": [ { "cookieAuth": [] @@ -19453,14 +26192,45 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": true, + "name": "channel_id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelPatch" + } + } + } + }, "responses": { "200": { - "description": "Mark every notification read.", + "description": "Edit a dashboard channel (startup channels are read-only).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsUpdatedResponse" + "$ref": "#/components/schemas/ChannelResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED, CHANNEL_KIND_UNAVAILABLE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -19485,6 +26255,36 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CHANNEL_READ_ONLY, CHANNEL_NAME_TAKEN", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19506,15 +26306,13 @@ } } } - } - }, - "/api/v1/notifications/{notification_id}": { + }, "delete": { - "operationId": "dismissNotification", + "operationId": "deleteChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Dismiss one notification.", + "summary": "Delete a dashboard channel and its delivery log.", "security": [ { "cookieAuth": [] @@ -19523,21 +26321,21 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:write", "parameters": [ { "schema": { "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" }, "required": true, - "name": "notification_id", + "name": "channel_id", "in": "path" } ], "responses": { "200": { - "description": "Dismiss one notification.", + "description": "Delete a dashboard channel and its delivery log.", "content": { "application/json": { "schema": { @@ -19576,6 +26374,26 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CHANNEL_READ_ONLY", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19599,13 +26417,13 @@ } } }, - "/api/v1/notifications/dismiss-all": { + "/api/v1/channels/{channel_id}/pause": { "post": { - "operationId": "dismissAllNotifications", + "operationId": "pauseChannel", "tags": [ - "notifications" + "channels" ], - "summary": "Dismiss every notification.", + "summary": "Pause a channel; its pending deliveries are suppressed.", "security": [ { "cookieAuth": [] @@ -19614,14 +26432,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "notifications:write", + "x-browserhive-scope": "channels:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": true, + "name": "channel_id", + "in": "path" + } + ], "responses": { "200": { - "description": "Dismiss every notification.", + "description": "Pause a channel; its pending deliveries are suppressed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationsUpdatedResponse" + "$ref": "#/components/schemas/ChannelResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -19646,6 +26485,16 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19669,13 +26518,13 @@ } } }, - "/api/v1/me/preferences": { - "get": { - "operationId": "getPreferences", + "/api/v1/channels/{channel_id}/resume": { + "post": { + "operationId": "resumeChannel", "tags": [ - "preferences" + "channels" ], - "summary": "The caller's stored preferences (known keys only).", + "summary": "Resume a paused or broken channel.", "security": [ { "cookieAuth": [] @@ -19684,14 +26533,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "channels:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": true, + "name": "channel_id", + "in": "path" + } + ], "responses": { "200": { - "description": "The caller's stored preferences (known keys only).", + "description": "Resume a paused or broken channel.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreferencesResponse" + "$ref": "#/components/schemas/ChannelResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -19716,6 +26586,16 @@ } } }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19737,13 +26617,15 @@ } } } - }, - "put": { - "operationId": "putPreferences", + } + }, + "/api/v1/channels/{channel_id}/test": { + "post": { + "operationId": "testChannel", "tags": [ - "preferences" + "channels" ], - "summary": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "summary": "Send a real test message through the channel now; the result says why it failed.", "security": [ { "cookieAuth": [] @@ -19752,24 +26634,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "preferences:write", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutPreferencesRequest" - } - } + "x-browserhive-scope": "channels:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": true, + "name": "channel_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Replace the preferences document (≤ 64 KiB; unknown keys rejected).", + "description": "Send a real test message through the channel now; the result says why it failed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PutPreferencesResponse" + "$ref": "#/components/schemas/ChannelTestResponse" } } } @@ -19804,8 +26687,18 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CHANNEL_NOT_READY", "content": { "application/problem+json": { "schema": { diff --git a/packages/core/src/app/notifications/index.ts b/packages/core/src/app/notifications/index.ts index 602b6bb..e60e983 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -7,10 +7,25 @@ export { type ChannelRegistryDeps, type RegisteredChannel, } from './channel-registry.ts'; +export { + ChannelService, + type ChannelServiceDeps, + capabilitiesDto, + type DeliveryListInput, + type DeliveryPage, + TELEGRAM_CONNECT_MS, + targetHint, +} from './channel-service.ts'; export { restrictContent } from './content-level.ts'; export { degrade, OPEN_IN_BROWSERHIVE } from './degrade.ts'; +export { + applyImageRule, + type ImageVariants, + imageVariants, + wantsImages, +} from './images.ts'; export { createInAppChannel, IN_APP_CAPABILITIES, IN_APP_CHANNEL } from './in-app-channel.ts'; -export { createLocalLinkBuilder } from './links.ts'; +export { createLocalLinkBuilder, createPublicLinkBuilder, linkBuilderFor } from './links.ts'; export { type BuildMessageInput, bold, @@ -32,6 +47,7 @@ export { DEDUP_WINDOW, NOTIFICATION_DAYS, NOTIFICATION_SEEN_DAYS, + type NotificationScreenshots, NotificationService, type NotificationServiceDeps, toNotification, @@ -47,6 +63,7 @@ export { export { crashed, draftFor, + type ImageRequest, NO_SESSION_LABEL, NOTIFICATION_GROUP_IDLE_MS, NOTIFICATION_GROUP_MAX_AGE_MS, @@ -61,6 +78,17 @@ export { type ThreadRevision, toolErrorsTitle, } from './producers.ts'; +export { + classifyPublicUrlProbe, + isInsecurePublicUrl, + PUBLIC_URL_CACHE_MS, + PUBLIC_URL_PROBE_TIMEOUT_MS, + PublicUrlChecker, + type PublicUrlCheckerDeps, + type PublicUrlVerdict, + publicUrlHost, + publicUrlOrigin, +} from './public-url.ts'; export { contentLevelOf, deleteWhenResolved, diff --git a/packages/core/src/public/server.ts b/packages/core/src/public/server.ts index 2e565d6..67fae95 100644 --- a/packages/core/src/public/server.ts +++ b/packages/core/src/public/server.ts @@ -8,10 +8,15 @@ export { InProcessEventBus } from '../app/events/bus.ts'; export { type ChannelAdapterFactory, ChannelRegistry, + ChannelService, createLocalLinkBuilder, type DeliveryCounter, + imageVariants, + linkBuilderFor, NotificationOutbox, NotificationService, + PublicUrlChecker, + publicUrlHost, } from '../app/notifications/index.ts'; export { Recorder } from '../app/observability/recorder.ts'; export { SystemStatusService } from '../app/observability/system-status.ts'; diff --git a/packages/core/test/helpers/http-kit.ts b/packages/core/test/helpers/http-kit.ts index 3b6d631..fad528f 100644 --- a/packages/core/test/helpers/http-kit.ts +++ b/packages/core/test/helpers/http-kit.ts @@ -9,11 +9,16 @@ import { configView } from '../../src/app/config/provenance-view.ts'; import { resolveOk } from '../../src/app/config/test-support.ts'; import { InProcessEventBus } from '../../src/app/events/bus.ts'; import type { DomainEvents } from '../../src/app/events/catalog.ts'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { ChannelService } from '../../src/app/notifications/channel-service.ts'; +import { createLocalLinkBuilder } from '../../src/app/notifications/links.ts'; +import { PublicUrlChecker } from '../../src/app/notifications/public-url.ts'; import { sessionDirLayout } from '../../src/app/sessions/profile-dir.ts'; import { SessionService } from '../../src/app/sessions/session-service.ts'; import { FakeSessionDirFs, testConfig } from '../../src/app/sessions/test-support.ts'; import { VaultService } from '../../src/app/vault/vault-service.ts'; import { OperatorRequestBroker } from '../../src/domain/operator-requests/broker.ts'; +import { CHANNEL_RENDERERS, channelFactories } from '../../src/infra/notifications/index.ts'; import { createHttpApp, type HttpApp } from '../../src/interface/http/app.ts'; import type { HttpServices } from '../../src/interface/http/services.ts'; import type { StaticAssets } from '../../src/ports/static-assets.ts'; @@ -36,10 +41,13 @@ import { SYSTEM_FACTS, } from './http-fakes.ts'; import { SYSTEM_INFO, seedDataset } from './http-fixtures.ts'; -import { InMemoryRepositories } from './in-memory-repos.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from './in-memory-repos.ts'; import { createVaultRepos } from './in-memory-vault-repos.ts'; import { RecordingEventBus } from './recording-event-bus.ts'; +/** Environment the channel service sees in the HTTP suites (names only matter). */ +export const CHANNEL_ENV: Readonly> = { BH_TELEGRAM_TOKEN: 'a'.repeat(40) }; + /** Operator password used by every suite. */ export const PASSWORD = 'correct horse battery'; /** Same-origin header set for mutating requests. */ @@ -131,6 +139,42 @@ export async function createHttpKit(options: HttpKitOptions = {}) { const blocklist = fakeBlocklist(); const idempotency = fakeIdempotency(); const config = resolveOk(); + const channelRegistry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids: auth.ids, + logger, + env: (name) => CHANNEL_ENV[name], + factories: channelFactories({ + images: { read: async () => null }, + fetch: async () => new Response('{}', { status: 200 }), + }), + }); + await channelRegistry.load(); + const channels = new ChannelService({ + repos, + uow: new InMemoryUnitOfWork(repos), + registry: channelRegistry, + renderers: CHANNEL_RENDERERS, + links: createLocalLinkBuilder(() => 'http://127.0.0.1:9876'), + clock, + ids: auth.ids, + logger, + bus: events, + env: (name) => CHANNEL_ENV[name], + schedule: () => undefined, + telegram: { + botUsername: async () => 'bh_test_bot', + waitForStart: async () => null, + }, + }); + const publicUrl = new PublicUrlChecker({ + publicUrl: undefined, + localUrl: () => 'http://127.0.0.1:9876', + instanceId: 'test-instance', + probe: async () => ({ kind: 'error', detail: 'no network in tests' }), + clock, + }); const services: HttpServices = { sessions, repos: { ...repos, operatorActions: vaultRepos.actions }, @@ -158,6 +202,8 @@ export async function createHttpKit(options: HttpKitOptions = {}) { events, ids: auth.ids, traceViewerAvailable: true, + channels, + publicUrl, }; if (options.seed !== false) await seedDataset(repos, vaultRepos, files, sessionDirs); const http: HttpApp = createHttpApp({ diff --git a/packages/core/test/helpers/http-route-cases.ts b/packages/core/test/helpers/http-route-cases.ts index 1bc53fe..9a6b593 100644 --- a/packages/core/test/helpers/http-route-cases.ts +++ b/packages/core/test/helpers/http-route-cases.ts @@ -37,6 +37,36 @@ export const IDEMPOTENCY_KEY = '5b3e6f2a-6d8f-4e8a-9d62-2b0d8a1d2c11'; const api = (path: string) => `/api/v1${path}`; const s = (path: string) => api(`/sessions/${CLOSED_ID}${path}`); +async function webhookChannel(ctx: CaseContext): Promise { + const response = await ctx.kit.request('POST', api('/channels'), { + cookie: ctx.cookie, + body: { name: 'hook', kind: 'webhook', target: { url: 'https://hooks.example.net/bh' } }, + }); + // Under a pending password change the create is refused: a well-formed id keeps the path valid. + const body = (await response.json()) as { channel?: { channel_id: string } }; + ctx.state['channel'] = body.channel?.channel_id ?? 'nc-placeholder01'; +} + +async function testedChannel(ctx: CaseContext): Promise { + await webhookChannel(ctx); + const response = await ctx.kit.request( + 'POST', + api(`/channels/${ctx.state['channel'] ?? ''}/test`), + { cookie: ctx.cookie }, + ); + const body = (await response.json()) as { delivery?: { seq: number } }; + ctx.state['seq'] = String(body.delivery?.seq ?? 1); +} + +async function telegramConnect(ctx: CaseContext): Promise { + const response = await ctx.kit.request('POST', api('/channels/telegram/connect'), { + cookie: ctx.cookie, + body: { token_env: 'BH_TELEGRAM_TOKEN' }, + }); + const body = (await response.json()) as { connect_id?: string }; + ctx.state['connect'] = body.connect_id ?? 'placeholder01'; +} + async function liveSession(ctx: CaseContext): Promise { const session = await ctx.kit.sessions.create({ slug: 'live' }, { subject: 'admin' }); ctx.state['live'] = session.id; @@ -645,6 +675,140 @@ export const ROUTE_CASES: readonly RouteCase[] = [ }, invalid: { method: 'POST', path: api('/client-errors'), body: { message: '' } }, }, + { + operationId: 'listChannels', + success: { path: api('/channels'), status: 200 }, + invalid: null, + }, + { + operationId: 'createChannel', + success: { + method: 'POST', + path: api('/channels'), + body: { name: 'ops', kind: 'webhook', target: { url: 'https://hooks.example.net/bh' } }, + status: 201, + }, + invalid: { + method: 'POST', + path: api('/channels'), + body: { name: 'Bad Name', kind: 'webhook' }, + }, + }, + { + operationId: 'previewChannel', + success: { + method: 'POST', + path: api('/channels/preview'), + body: { kind: 'telegram', sample: 'attention' }, + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/preview'), body: { sample: 'nope' } }, + }, + { + operationId: 'listDeliveries', + success: { path: api('/channels/deliveries'), status: 200 }, + invalid: { path: api('/channels/deliveries?status=nope') }, + }, + { + operationId: 'getDelivery', + setup: testedChannel, + success: { path: (ctx) => api(`/channels/deliveries/${ctx.state['seq'] ?? ''}`), status: 200 }, + invalid: { path: api('/channels/deliveries/abc') }, + }, + { + operationId: 'checkChannelEnv', + success: { path: api('/channels/env?names=BH_TELEGRAM_TOKEN,BH_MISSING'), status: 200 }, + invalid: { path: api('/channels/env?names=BROWSERHIVE_TOKEN') }, + }, + { + operationId: 'startTelegramConnect', + success: { + method: 'POST', + path: api('/channels/telegram/connect'), + body: { token_env: 'BH_TELEGRAM_TOKEN' }, + status: 200, + }, + invalid: { + method: 'POST', + path: api('/channels/telegram/connect'), + body: { token_env: 'not a name' }, + }, + }, + { + operationId: 'getTelegramConnect', + setup: telegramConnect, + success: { + path: (ctx) => api(`/channels/telegram/connect/${ctx.state['connect'] ?? ''}`), + status: 200, + }, + invalid: { path: api('/channels/telegram/connect/x!') }, + }, + { + operationId: 'getChannel', + setup: webhookChannel, + success: { path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}`), status: 200 }, + invalid: { path: api('/channels/bad') }, + }, + { + operationId: 'updateChannel', + setup: webhookChannel, + success: { + method: 'PATCH', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}`), + body: { rules: { min_severity: 'error' } }, + status: 200, + }, + invalid: { + method: 'PATCH', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}`), + body: { kind: 'telegram' }, + }, + }, + { + operationId: 'deleteChannel', + setup: webhookChannel, + success: { + method: 'DELETE', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}`), + status: 200, + }, + invalid: { method: 'DELETE', path: api('/channels/bad') }, + }, + { + operationId: 'pauseChannel', + setup: webhookChannel, + success: { + method: 'POST', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}/pause`), + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/bad/pause') }, + }, + { + operationId: 'resumeChannel', + setup: webhookChannel, + success: { + method: 'POST', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}/resume`), + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/bad/resume') }, + }, + { + operationId: 'testChannel', + setup: webhookChannel, + success: { + method: 'POST', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}/test`), + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/bad/test') }, + }, + { + operationId: 'getPublicUrlStatus', + success: { path: api('/system/public-url?refresh=true'), status: 200 }, + invalid: { path: api('/system/public-url?refresh=maybe') }, + }, ]; async function principalOf(ctx: CaseContext) { From 721db161d5fc1add47cb238dad5bb0db4f3b455c Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:45:42 -0400 Subject: [PATCH 11/22] fix(notifications): notification channel routes name secret variables anywhere in a preview A secret parameter can appear in a request body as well as the path (an ntfy topic kept in a variable); the preview shows the variable name in both. --- packages/core/src/app/notifications/channel-service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/app/notifications/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts index cfff802..65e8eb3 100644 --- a/packages/core/src/app/notifications/channel-service.ts +++ b/packages/core/src/app/notifications/channel-service.ts @@ -724,12 +724,15 @@ export class ChannelService { secretRefs[param] ?? spec.secrets.find((s) => s.param === param)?.suggestedEnv ?? param.toUpperCase(); + // A secret parameter appears as `{secret:}` (a path, an ntfy topic); show its variable. + const named = (text: string) => + text.replace(/\{secret:([a-z_]+)\}/g, (_m, param: string) => `{${envOf(param)}}`); const requests: PlatformRequest[] = rendered.map((r) => ({ method: r.method, - path: r.path.replace(/\{secret:([a-z_]+)\}/g, (_m, param: string) => `{${envOf(param)}}`), + path: named(r.path), encoding: r.encoding, - body: { ...r.body }, - headers: { ...r.headers }, + body: JSON.parse(named(JSON.stringify(r.body))) as Record, + headers: JSON.parse(named(JSON.stringify(r.headers))) as Record, file: r.file === null ? null : { name: r.file.name, content_type: r.file.content_type }, })); return { From 0663ae2ac29aa0426fb64b0995aea582388e0a15 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 20:48:34 -0400 Subject: [PATCH 12/22] feat(dashboard): notification channels, setup wizard, platform previews and delivery log Notifications gains Inbox, Channels and Delivery log sections and a sidebar entry. Channel cards show status, secret variables (set or missing, never values), 24 h counts and test sends; startup channels are read-only. The add-channel wizard keeps its draft in localStorage and walks platform, credentials with launch-method snippets, connect (Telegram one-tap link with QR, ntfy subscribe QR, webhook URL), rules (presets, quiet hours, content level, screenshots with masking, self-destruct capped at 47 h on Telegram) and a preview drawn from the renderer's own requests. The Discord difference panel draws webhook and bot messages side by side. The System page shows the public address check. --- bun.lock | 3 + docs/guide/notifications.md | 40 ++ packages/dashboard/package.json | 1 + .../notifications/NotificationsNav.tsx | 58 ++ .../notifications/NotificationsPage.tsx | 2 + .../notifications/channels/ChannelCard.tsx | 316 ++++++++++ .../notifications/channels/ChannelsPage.tsx | 198 ++++++ .../notifications/channels/QrCode.tsx | 40 ++ .../features/notifications/channels/api.ts | 232 +++++++ .../notifications/channels/discord-shots.ts | 34 ++ .../channels/log/DeliveryDetailSheet.tsx | 177 ++++++ .../channels/log/DeliveryLogPage.tsx | 250 ++++++++ .../features/notifications/channels/model.ts | 446 ++++++++++++++ .../notifications/channels/platforms.tsx | 117 ++++ .../channels/preview/DiscordMock.tsx | 141 +++++ .../channels/preview/MockParts.tsx | 98 +++ .../channels/preview/NtfyMock.tsx | 97 +++ .../channels/preview/PlatformPreview.tsx | 137 +++++ .../channels/preview/TelegramMock.tsx | 218 +++++++ .../channels/preview/discord-markdown.tsx | 150 +++++ .../channels/preview/read-request.ts | 304 +++++++++ .../channels/preview/telegram-html.test.ts | 60 ++ .../channels/preview/telegram-html.ts | 177 ++++++ .../features/notifications/channels/search.ts | 37 ++ .../channels/wizard/ChannelWizardPage.tsx | 576 ++++++++++++++++++ .../channels/wizard/DiscordDifference.tsx | 173 ++++++ .../channels/wizard/StepConnect.tsx | 478 +++++++++++++++ .../channels/wizard/StepCredentials.tsx | 172 ++++++ .../channels/wizard/StepPlatform.tsx | 184 ++++++ .../channels/wizard/StepPreview.tsx | 168 +++++ .../channels/wizard/StepRules.tsx | 472 ++++++++++++++ .../notifications/channels/wizard/fields.tsx | 227 +++++++ packages/dashboard/src/features/system/api.ts | 23 +- .../system/status/PublicAddressPanel.tsx | 157 +++++ .../features/system/status/StatusSection.tsx | 2 + packages/dashboard/src/lib/api/keys.ts | 12 + packages/dashboard/src/lib/api/operations.ts | 36 ++ packages/dashboard/src/lib/icons.ts | 34 ++ packages/dashboard/src/lib/links.ts | 10 + .../dashboard/src/lib/status-registry.test.ts | 15 +- packages/dashboard/src/lib/status-registry.ts | 44 +- packages/dashboard/src/lib/ws/bridge.ts | 93 +++ packages/dashboard/src/routeTree.gen.ts | 88 +++ .../src/routes/_auth/notifications.tsx | 6 +- .../routes/_auth/notifications_.channels.tsx | 14 + .../notifications_.channels_.$channelId.tsx | 14 + .../_auth/notifications_.channels_.new.tsx | 14 + .../src/routes/_auth/notifications_.log.tsx | 18 + packages/dashboard/src/styles/tokens.css | 106 ++++ 49 files changed, 6464 insertions(+), 5 deletions(-) create mode 100644 packages/dashboard/src/features/notifications/NotificationsNav.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/ChannelCard.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/QrCode.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/api.ts create mode 100644 packages/dashboard/src/features/notifications/channels/discord-shots.ts create mode 100644 packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/model.ts create mode 100644 packages/dashboard/src/features/notifications/channels/platforms.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/DiscordMock.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/MockParts.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/discord-markdown.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/preview/read-request.ts create mode 100644 packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts create mode 100644 packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts create mode 100644 packages/dashboard/src/features/notifications/channels/search.ts create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/DiscordDifference.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/StepConnect.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/StepCredentials.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/StepPlatform.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/StepPreview.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/fields.tsx create mode 100644 packages/dashboard/src/features/system/status/PublicAddressPanel.tsx create mode 100644 packages/dashboard/src/routes/_auth/notifications_.channels.tsx create mode 100644 packages/dashboard/src/routes/_auth/notifications_.channels_.$channelId.tsx create mode 100644 packages/dashboard/src/routes/_auth/notifications_.channels_.new.tsx create mode 100644 packages/dashboard/src/routes/_auth/notifications_.log.tsx diff --git a/bun.lock b/bun.lock index 6d61e6d..84da11c 100644 --- a/bun.lock +++ b/bun.lock @@ -130,6 +130,7 @@ "react-resizable-panels": "^4.12.4", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", + "uqr": "^0.1.3", "zod": "4.6.5", }, "devDependencies": { @@ -1368,6 +1369,8 @@ "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="], + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 5b7b0fa..3d17952 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -49,3 +49,43 @@ Your own accounts, no servers: BrowserHive never runs a relay or a shared bot. Y ## Retention Read or dismissed notifications are kept for 30 days, others for 90. Delivery history is kept for 30 days. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. + +## Channels + +Placeholder: the channel setup guides are written with the channels release. + +### Telegram + +Placeholder: create a bot with @BotFather and connect a chat. + +### Discord + +Placeholder: create a webhook in a channel's Integrations settings. + +### ntfy + +Placeholder: pick a server and a topic, then subscribe on your phone. + +### Webhook + +Placeholder: receive the notification contract, signed with HMAC SHA-256. + +### Public address + +Placeholder: set `publicUrl` so links open on your phone. + +### Screenshots + +Placeholder: opt-in screenshots per category, with form-field masking. + +### Self-destruct + +Placeholder: per-category message TTL and delete when resolved. + +### Startup channels + +Placeholder: channels declared with `--notificationChannel`. + +### Delivery log + +Placeholder: every delivery, and why a notification was not sent. diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 0332ee1..2e96c72 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -34,6 +34,7 @@ "react-resizable-panels": "^4.12.4", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", + "uqr": "^0.1.3", "zod": "4.6.5" }, "devDependencies": { diff --git a/packages/dashboard/src/features/notifications/NotificationsNav.tsx b/packages/dashboard/src/features/notifications/NotificationsNav.tsx new file mode 100644 index 0000000..b7b0608 --- /dev/null +++ b/packages/dashboard/src/features/notifications/NotificationsNav.tsx @@ -0,0 +1,58 @@ +/** @module features/notifications/NotificationsNav — the Notifications area's section nav (Inbox · Channels · Delivery log): underline tabs made of real links under the page header (spec 04 §12.11.1) */ +import { Link, useRouterState } from '@tanstack/react-router'; +import { ICONS, type IconName } from '@/lib/icons.ts'; +import { cn } from '@/lib/utils.ts'; + +const SECTIONS: readonly { + readonly to: string; + readonly label: string; + readonly icon: IconName; +}[] = [ + { to: '/notifications', label: 'Inbox', icon: 'inbox' }, + { to: '/notifications/channels', label: 'Channels', icon: 'channels' }, + { to: '/notifications/log', label: 'Delivery log', icon: 'deliveryLog' }, +]; + +/** Which section a pathname belongs to. */ +export function activeSection(pathname: string): string { + if (pathname.startsWith('/notifications/channels')) return '/notifications/channels'; + if (pathname.startsWith('/notifications/log')) return '/notifications/log'; + return '/notifications'; +} + +/** Section nav (pass as `PageHeader` `tabs`). */ +export function NotificationsNav() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const active = activeSection(pathname); + return ( + + ); +} diff --git a/packages/dashboard/src/features/notifications/NotificationsPage.tsx b/packages/dashboard/src/features/notifications/NotificationsPage.tsx index 2eb63d6..feac1e0 100644 --- a/packages/dashboard/src/features/notifications/NotificationsPage.tsx +++ b/packages/dashboard/src/features/notifications/NotificationsPage.tsx @@ -32,6 +32,7 @@ import { NOTIFICATION_TYPE } from '@/lib/status-registry.ts'; import { useNotificationList, usePreferences, useSavePreferences } from './api.ts'; import { NotificationRow } from './components/NotificationRow.tsx'; import { PreferencesForm } from './components/PreferencesForm.tsx'; +import { NotificationsNav } from './NotificationsNav.tsx'; import { NOTIFICATION_RANGES, type NotificationsSearch } from './search.ts'; /** Visible (not dismissed) notifications, latest activity first (a folded group moves up as it grows). */ @@ -96,6 +97,7 @@ export function NotificationsPage() { description="Attention requests, tool errors, vault and lifecycle events for your account." learnMore="A notification keeps its place when the thing it announces changes: a settled request shows its outcome (resolved, expired) instead of a new row, and a growing group of tool errors updates its count." learnMoreDocs="notifications" + tabs={} actions={ <> + {channel.status === 'active' ? ( + + ) : ( + + )} +
+ +
+ + + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx b/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx new file mode 100644 index 0000000..7273740 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx @@ -0,0 +1,198 @@ +/** @module features/notifications/channels/ChannelsPage — `/notifications/channels`: every notification channel as a card (live through the `channels` topic), Add channel, the public-address hint when links would only open on this computer, and an empty state that explains channels (spec 04 §12.11.1) */ +import type { ChannelView } from '@browserhive/contracts/http'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { useConfirm } from '@/app/providers/ConfirmProvider.tsx'; +import { useTopic } from '@/app/providers/SocketProvider.tsx'; +import { Callout } from '@/components/shared/Callout.tsx'; +import { DataPanel } from '@/components/shared/DataPanel.tsx'; +import { PageHeader } from '@/components/shared/PageHeader.tsx'; +import { buttonVariants } from '@/components/ui/button.tsx'; +import { Skeleton } from '@/components/ui/skeleton.tsx'; +import { usePublicUrl } from '@/features/system/api.ts'; +import { toAppError } from '@/lib/api/errors.ts'; +import { ICONS } from '@/lib/icons.ts'; +import { docsUrl } from '@/lib/links.ts'; +import { useServerNow } from '@/lib/server-now.ts'; +import { cn } from '@/lib/utils.ts'; +import { NotificationsNav } from '../NotificationsNav.tsx'; +import { useChannelActions, useChannels, useTestChannel } from './api.ts'; +import { ChannelCard, type TestState } from './ChannelCard.tsx'; +import { PLATFORMS, PlatformMark } from './platforms.tsx'; + +/** The channels, sorted: dashboard channels and startup channels by name. */ +export function sortChannels(rows: readonly ChannelView[]): readonly ChannelView[] { + return [...rows].sort((a, b) => a.name.localeCompare(b.name)); +} + +function EmptyChannels() { + const Plus = ICONS.plus; + return ( +
+
+ {PLATFORMS.map((p) => ( + + ))} +
+
+

Get notified on your phone

+

+ When an agent needs you, a session crashes or tools keep failing, BrowserHive can message + you on Telegram, Discord or ntfy, or post to your own webhook. Messages update as things + change and can delete themselves later. You bring your own bot or topic; nothing goes + through a BrowserHive server. +

+
+ +
+ ); +} + +function CardsSkeleton() { + return ( +
+ {[0, 1, 2].map((n) => ( +
+
+ +
+ + +
+
+ + + +
+ ))} +
+ ); +} + +/** Notifications › Channels. */ +export function ChannelsPage() { + const channels = useChannels(); + const publicUrl = usePublicUrl(); + const actions = useChannelActions(); + const testChannel = useTestChannel(); + const confirm = useConfirm(); + const navigate = useNavigate(); + const now = useServerNow(30_000); + const [tests, setTests] = useState>>({}); + useTopic('channels'); + const Plus = ICONS.plus; + + const runTest = (id: string) => { + setTests((t) => ({ ...t, [id]: { phase: 'sending' } })); + testChannel.mutate(id, { + onSuccess: (result) => setTests((t) => ({ ...t, [id]: { phase: 'done', result } })), + onError: (error) => + setTests((t) => ({ ...t, [id]: { phase: 'error', message: toAppError(error).message } })), + }); + }; + + const remove = async (channel: ChannelView) => { + const ok = await confirm({ + title: `Delete ${channel.name}?`, + description: + 'BrowserHive stops sending to it, and its delivery log goes with it. Messages already sent stay in the chat.', + confirmLabel: 'Delete channel', + danger: true, + }); + if (ok) actions.remove.mutate(channel.channel_id); + }; + + const unsetPublicUrl = publicUrl.data !== undefined && !publicUrl.data.configured; + const hasChannels = (channels.data?.data.length ?? 0) > 0; + + return ( +
+ +
+ ); +} diff --git a/packages/dashboard/src/features/notifications/channels/QrCode.tsx b/packages/dashboard/src/features/notifications/channels/QrCode.tsx new file mode 100644 index 0000000..a1b5e6e --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/QrCode.tsx @@ -0,0 +1,40 @@ +/** @module features/notifications/channels/QrCode — a QR code for a link (subscribe to an ntfy topic, open the Telegram bot on the phone), drawn as one SVG path from `uqr`'s module matrix; dark modules on a white quiet zone in both themes so phone cameras read it */ +import { useMemo } from 'react'; +import { encode } from 'uqr'; +import { cn } from '@/lib/utils.ts'; + +/** The SVG path of a QR matrix (one `h1v1h-1z` square per dark module). */ +export function qrPath(data: readonly (readonly boolean[])[]): string { + let d = ''; + data.forEach((row, y) => { + row.forEach((dark, x) => { + if (dark) d += `M${x} ${y}h1v1h-1z`; + }); + }); + return d; +} + +/** Props. */ +export interface QrCodeProps { + readonly value: string; + /** Accessible description ("QR code to open the bot on your phone"). */ + readonly label: string; + readonly className?: string; +} + +/** QR code. */ +export function QrCode({ value, label, className }: QrCodeProps) { + const qr = useMemo(() => encode(value, { ecc: 'M', border: 2 }), [value]); + const path = useMemo(() => qrPath(qr.data), [qr]); + return ( + + + + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/api.ts b/packages/dashboard/src/features/notifications/channels/api.ts new file mode 100644 index 0000000..fb11066 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/api.ts @@ -0,0 +1,232 @@ +/** @module features/notifications/channels/api — notification channel queries and mutations over `/channels` (list, detail, create/update/delete, pause/resume, test, preview, env check, Telegram connect, delivery log) and `GET /system/public-url`; the `channels` WS topic patches the caches through the bridge (spec 03 §4.8.1, spec 04 §12.11.1) */ +import type { + ChannelInput, + ChannelPatch, + ChannelPreviewRequest, + ChannelView, +} from '@browserhive/contracts/http'; +import type { PreviewSample } from '@browserhive/contracts/notifications'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useApi } from '@/app/providers/AuthProvider.tsx'; +import { useToast } from '@/app/providers/ToastProvider.tsx'; +import { useCursorPager } from '@/components/shared/use-cursor-pages.ts'; +import { toAppError } from '@/lib/api/errors.ts'; +import { keys, stableParams } from '@/lib/api/keys.ts'; + +/** `GET /channels`. */ +export function useChannels() { + const api = useApi(); + return useQuery({ queryKey: keys.channels.list(), queryFn: () => api.listChannels() }); +} + +/** `GET /channels/{id}` (disabled without an id). */ +export function useChannel(channelId: string | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.detail(channelId ?? ''), + queryFn: () => api.getChannel({ params: { channel_id: channelId ?? '' } }), + enabled: channelId !== null, + }); +} + +/** Replace one channel in the list and detail caches (mutation results, WS events). */ +export function patchChannelCaches( + queryClient: ReturnType, + channel: ChannelView, +): void { + queryClient.setQueryData(keys.channels.detail(channel.channel_id), { channel }); + queryClient.setQueryData<{ data: ChannelView[]; now: number } | undefined>( + keys.channels.list(), + (current) => { + if (current === undefined) return current; + const index = current.data.findIndex((c) => c.channel_id === channel.channel_id); + const data = + index < 0 + ? [...current.data, channel].sort((a, b) => a.name.localeCompare(b.name)) + : current.data.map((c) => (c.channel_id === channel.channel_id ? channel : c)); + return { ...current, data }; + }, + ); +} + +/** `POST /channels`. */ +export function useCreateChannel() { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: ChannelInput) => api.createChannel({ body }), + onSuccess: (result) => patchChannelCaches(queryClient, result.channel), + onSettled: () => queryClient.invalidateQueries({ queryKey: keys.channels.list() }), + }); +} + +/** `PATCH /channels/{id}`. */ +export function useUpdateChannel(channelId: string) { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: ChannelPatch) => + api.updateChannel({ params: { channel_id: channelId }, body }), + onSuccess: (result) => patchChannelCaches(queryClient, result.channel), + onSettled: () => queryClient.invalidateQueries({ queryKey: keys.channels.list() }), + }); +} + +/** Channel actions of a card: pause, resume, delete (toasts on failure). */ +export function useChannelActions() { + const api = useApi(); + const toast = useToast(); + const queryClient = useQueryClient(); + const pause = useMutation({ + mutationFn: (id: string) => api.pauseChannel({ params: { channel_id: id } }), + onSuccess: (result) => patchChannelCaches(queryClient, result.channel), + onError: (error) => toast.fromError(toAppError(error), 'Could not pause the channel'), + }); + const resume = useMutation({ + mutationFn: (id: string) => api.resumeChannel({ params: { channel_id: id } }), + onSuccess: (result) => patchChannelCaches(queryClient, result.channel), + onError: (error) => toast.fromError(toAppError(error), 'Could not resume the channel'), + }); + const remove = useMutation({ + mutationFn: (id: string) => api.deleteChannel({ params: { channel_id: id } }), + onSuccess: (_result, id) => { + queryClient.setQueryData<{ data: ChannelView[]; now: number } | undefined>( + keys.channels.list(), + (current) => + current === undefined + ? current + : { ...current, data: current.data.filter((c) => c.channel_id !== id) }, + ); + queryClient.removeQueries({ queryKey: keys.channels.detail(id) }); + void queryClient.invalidateQueries({ queryKey: keys.channels.deliveryLists() }); + }, + onError: (error) => toast.fromError(toAppError(error), 'Could not delete the channel'), + }); + return { pause, resume, remove }; +} + +/** `POST /channels/{id}/test`: sends a real message now and returns the delivery row. */ +export function useTestChannel() { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.testChannel({ params: { channel_id: id } }), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: keys.channels.list() }); + void queryClient.invalidateQueries({ queryKey: keys.channels.deliveryLists() }); + }, + }); +} + +/** The preview body for a draft or a saved channel. */ +export type PreviewInput = Omit & { + readonly sample: PreviewSample; +}; + +/** `POST /channels/preview` as a query (pure on the server, so it caches by its input). */ +export function useChannelPreview(input: PreviewInput | null) { + const api = useApi(); + const params = input === null ? {} : stableParams(input); + return useQuery({ + queryKey: keys.channels.preview(params), + queryFn: () => api.previewChannel({ body: input ?? { kind: 'webhook', sample: 'attention' } }), + enabled: input !== null, + placeholderData: keepPreviousData, + staleTime: 30_000, + }); +} + +/** `GET /channels/env`: whether each variable is set on the server (polled while `poll`). */ +export function useChannelEnv(names: readonly string[], poll: boolean) { + const api = useApi(); + const valid = names.filter( + (n) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(n) && !n.startsWith('BROWSERHIVE_'), + ); + return useQuery({ + queryKey: keys.channels.env(valid), + queryFn: () => api.checkChannelEnv({ query: { names: valid } }), + enabled: valid.length > 0, + refetchInterval: poll ? 3_000 : false, + placeholderData: keepPreviousData, + }); +} + +/** `POST /channels/telegram/connect`. */ +export function useStartTelegramConnect() { + const api = useApi(); + return useMutation({ + mutationFn: (tokenEnv: string) => api.startTelegramConnect({ body: { token_env: tokenEnv } }), + }); +} + +/** `GET /channels/telegram/connect/{id}`, polled every 2 s while waiting. */ +export function useTelegramConnect(connectId: string | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.connect(connectId ?? ''), + queryFn: () => api.getTelegramConnect({ params: { connect_id: connectId ?? '' } }), + enabled: connectId !== null, + refetchInterval: (query) => (query.state.data?.status === 'waiting' ? 2_000 : false), + }); +} + +/** Delivery log filters (URL search, spec 04 §12.11.1). */ +export interface DeliveryFilters { + readonly channel?: string | undefined; + readonly status?: readonly string[] | undefined; + readonly op?: readonly string[] | undefined; + readonly kind?: readonly string[] | undefined; + readonly notification?: string | undefined; +} + +/** `GET /channels/deliveries` query for the filters (without cursor). */ +export function deliveriesQuery(filters: DeliveryFilters, limit: number) { + return { + limit, + ...(filters.channel !== undefined && { channel_id: filters.channel }), + ...(filters.notification !== undefined && { notification_id: filters.notification }), + ...(filters.status !== undefined && + filters.status.length > 0 && { status: [...filters.status] }), + ...(filters.op !== undefined && filters.op.length > 0 && { op: [...filters.op] }), + ...(filters.kind !== undefined && filters.kind.length > 0 && { kind: [...filters.kind] }), + }; +} + +/** One page of the delivery log (newest first, keyset cursor). */ +export function useDeliveries(filters: DeliveryFilters, page: number, limit: number) { + const api = useApi(); + const pager = useCursorPager(); + const query = deliveriesQuery(filters, limit); + const filterKey = JSON.stringify(stableParams(query)); + return useQuery({ + queryKey: keys.channels.deliveries({ ...query, page }), + queryFn: () => + pager.resolve(filterKey, page, (cursor) => + api.listDeliveries({ + query: { ...query, ...(cursor !== undefined && { cursor }) }, + }), + ), + placeholderData: keepPreviousData, + }); +} + +/** Every delivery of one notification (the "why wasn't this sent?" timeline). */ +export function useNotificationDeliveries(notificationId: string | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.deliveries({ notification_id: notificationId, limit: 200 }), + queryFn: () => + api.listDeliveries({ query: { notification_id: notificationId ?? '', limit: 200 } }), + enabled: notificationId !== null, + }); +} + +/** `GET /channels/deliveries/{seq}`. */ +export function useDelivery(seq: number | null) { + const api = useApi(); + return useQuery({ + queryKey: keys.channels.delivery(seq ?? 0), + queryFn: () => api.getDelivery({ params: { seq: seq ?? 0 } }), + enabled: seq !== null, + }); +} diff --git a/packages/dashboard/src/features/notifications/channels/discord-shots.ts b/packages/dashboard/src/features/notifications/channels/discord-shots.ts new file mode 100644 index 0000000..f3746e9 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/discord-shots.ts @@ -0,0 +1,34 @@ +/** + * @module features/notifications/channels/discord-shots — the screenshot slot of the "What's the + * difference?" panel (D-38, plan §6). + * + * The panel always draws both Discord styles live, side by side, from the preview endpoint + * (`mode: webhook` and `mode: bot`), so they match the current renderer and theme. When the owner + * captures real screenshots of BrowserHive's OWN messages in a Discord test server, they can be + * added here and the panel shows them under the live previews: + * + * 1. Save the images as `packages/dashboard/public/discord/webhook-message.png` and + * `packages/dashboard/public/discord/bot-message.png` (PNG or WebP, about 900 px wide, light or + * dark Discord theme). They are BrowserHive's own messages, so no third-party rights apply; + * never use images copied from Discord's site or the web. + * 2. Set the paths below (`/discord/webhook-message.png`) and write alt text that describes what + * the picture shows. + * + * `null` means "no screenshot yet": only the live previews are shown. + */ + +/** One real screenshot of a BrowserHive message in Discord. */ +export interface DiscordShot { + /** Path under the dashboard's public directory (`/discord/webhook-message.png`). */ + readonly src: string; + readonly alt: string; +} + +/** Screenshots per mode; `null` until the owner adds them. */ +export const DISCORD_SHOTS: { + readonly webhook: DiscordShot | null; + readonly bot: DiscordShot | null; +} = { + webhook: null, + bot: null, +}; diff --git a/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx b/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx new file mode 100644 index 0000000..5c5c32f --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx @@ -0,0 +1,177 @@ +/** @module features/notifications/channels/log/DeliveryDetailSheet — one delivery in a side sheet: its status and reason in words, attempts, latency, last error, the platform message ref, the notification's timeline on every channel ("why wasn't this sent?") and the redacted message as this channel was shown it (`GET /channels/deliveries/{seq}`) */ +import type { DeliveryRow } from '@browserhive/contracts/http'; +import { deliveryReasonText } from '@browserhive/contracts/notifications'; +import { JsonView } from '@/components/shared/JsonView.tsx'; +import { KeyValue } from '@/components/shared/KeyValue.tsx'; +import { RelativeTime } from '@/components/shared/RelativeTime.tsx'; +import { TonePill } from '@/components/shared/StatusBadge.tsx'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet.tsx'; +import { Skeleton } from '@/components/ui/skeleton.tsx'; +import { formatAbsolute, formatMs } from '@/lib/format/time.ts'; +import { DELIVERY_STATUS } from '@/lib/status-registry.ts'; +import { cn } from '@/lib/utils.ts'; +import { useDelivery, useNotificationDeliveries } from '../api.ts'; +import { PlatformMark } from '../platforms.tsx'; + +/** "sent", "not sent: quiet hours", … as one sentence for the timeline. */ +export function deliverySentence(row: DeliveryRow): string { + const op = row.op === 'send' ? 'Send' : row.op === 'edit' ? 'Update' : 'Delete'; + const status = DELIVERY_STATUS[row.status].label; + return `${op} of revision ${row.revision}: ${status}`; +} + +function Timeline({ + notificationId, + current, +}: { + readonly notificationId: string; + readonly current: number; +}) { + const rows = useNotificationDeliveries(notificationId); + if (rows.data === undefined) return ; + const list = [...rows.data.data].sort((a, b) => a.seq - b.seq); + return ( +
    + {list.map((row) => { + const reason = deliveryReasonText(row.reason); + const entry = DELIVERY_STATUS[row.status]; + return ( +
  1. +
    +
    + {row.channel_kind !== null ? ( + + ) : null} + {row.channel_name ?? row.channel_id} + + + + +
    +

    + {deliverySentence(row)} + {reason !== null ? ( + {reason} + ) : null} +

    +
    +
  2. + ); + })} +
+ ); +} + +/** Props. */ +export interface DeliveryDetailSheetProps { + readonly seq: number | null; + readonly onClose: () => void; +} + +/** The detail sheet. */ +export function DeliveryDetailSheet({ seq, onClose }: DeliveryDetailSheetProps) { + const detail = useDelivery(seq); + const row = detail.data?.delivery; + const message = detail.data?.message ?? null; + return ( + (open ? undefined : onClose())}> + + + {row?.notification_title ?? 'Delivery'} + + {row !== undefined + ? `${row.channel_name ?? row.channel_id} · ${deliverySentence(row)}` + : 'Loading…'} + + + {row === undefined ? ( +
+ + +
+ ) : ( +
+ {deliveryReasonText(row.reason) !== null ? ( +

+ Why: + {deliveryReasonText(row.reason)} +

+ ) : null} + }, + { key: 'Operation', value: `${row.op} · revision ${row.revision}` }, + { key: 'Attempts', value: String(row.attempts) }, + { + key: 'Latency', + value: row.duration_ms === null ? '—' : formatMs(row.duration_ms), + }, + { key: 'Queued', value: formatAbsolute(row.created_at) }, + { key: 'Updated', value: formatAbsolute(row.updated_at) }, + ...(row.next_attempt_at !== null && + (row.status === 'retrying' || row.status === 'pending') + ? [{ key: 'Next attempt', value: formatAbsolute(row.next_attempt_at) }] + : []), + ...(row.reason !== null + ? [{ key: 'Reason code', value: {row.reason} }] + : []), + ]} + /> + {row.last_error !== null ? ( +
+

Last error

+
+                  {row.last_error}
+                
+
+ ) : null} + {row.message_ref !== null ? ( +
+

Platform message

+ +
+ ) : null} +
+

This notification on every channel

+ +
+
+

The message as this channel was shown it

+

+ Redacted, at the channel's content level. Secrets never appear here. +

+ {message !== null ? ( + + ) : ( +

Not available.

+ )} +
+
+ )} +
+
+ ); +} diff --git a/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx new file mode 100644 index 0000000..8ab6c49 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx @@ -0,0 +1,250 @@ +/** @module features/notifications/channels/log/DeliveryLogPage — `/notifications/log`: every delivery job newest first (time, channel, notification, revision and op, status with the reason in words, attempts, latency), filters in the URL, live through the `channels` topic, and a detail sheet (`?seq=`) with the "why wasn't this sent?" timeline (spec 04 §12.11.1) */ +import type { NotificationKind } from '@browserhive/contracts/enums'; +import type { DeliveryRow } from '@browserhive/contracts/http'; +import { deliveryReasonText } from '@browserhive/contracts/notifications'; +import { useTopic } from '@/app/providers/SocketProvider.tsx'; +import { DataPanel } from '@/components/shared/DataPanel.tsx'; +import { EmptyState } from '@/components/shared/EmptyState.tsx'; +import { FilterBar } from '@/components/shared/FilterBar.tsx'; +import { PageHeader } from '@/components/shared/PageHeader.tsx'; +import { Pagination } from '@/components/shared/Pagination.tsx'; +import { RelativeTime } from '@/components/shared/RelativeTime.tsx'; +import { Panel } from '@/components/shared/Section.tsx'; +import { TonePill } from '@/components/shared/StatusBadge.tsx'; +import { ListSkeleton } from '@/features/overview/components/ListSkeleton.tsx'; +import { formatMs } from '@/lib/format/time.ts'; +import { ICONS } from '@/lib/icons.ts'; +import { useSearchState } from '@/lib/search/use-search-state.ts'; +import { DELIVERY_STATUS } from '@/lib/status-registry.ts'; +import { cn } from '@/lib/utils.ts'; +import { NotificationsNav } from '../../NotificationsNav.tsx'; +import { useChannels, useDeliveries } from '../api.ts'; +import { PlatformMark } from '../platforms.tsx'; +import type { DeliveryLogSearch } from '../search.ts'; +import { DeliveryDetailSheet } from './DeliveryDetailSheet.tsx'; + +const STATUS_OPTIONS = ['sent', 'pending', 'retrying', 'dead', 'suppressed', 'superseded'] as const; +const OP_LABEL: Readonly> = { + send: 'Send', + edit: 'Update', + delete: 'Delete', +}; +const KIND_OPTIONS: readonly NotificationKind[] = [ + 'attention.requested', + 'vault.confirm', + 'tool.errors', + 'session.crashed', + 'session.reaped', + 'system.degraded', + 'test', +]; +const KIND_LABEL: Readonly> = { + 'attention.requested': 'Attention', + 'vault.confirm': 'Vault confirm', + 'tool.errors': 'Tool errors', + 'session.crashed': 'Crash', + 'session.reaped': 'Reaped', + 'system.degraded': 'System', + test: 'Test', +}; + +function Row({ row, onOpen }: { readonly row: DeliveryRow; readonly onOpen: () => void }) { + const entry = DELIVERY_STATUS[row.status]; + const reason = row.status === 'sent' ? null : deliveryReasonText(row.reason); + const Chevron = ICONS.chevronRight; + return ( +
  • + +
  • + ); +} + +/** Notifications › Delivery log. */ +export function DeliveryLogPage() { + const { search, set, clear } = useSearchState(); + const channels = useChannels(); + const list = useDeliveries( + { + channel: search.channel, + status: search.status, + op: search.op, + kind: search.kind, + notification: search.notification, + }, + search.page, + search.ps, + ); + useTopic('channels'); + const filtered = + search.channel !== undefined || + search.status !== undefined || + search.op !== undefined || + search.kind !== undefined || + search.notification !== undefined; + return ( +
    + } + /> + ({ + value: c.channel_id, + label: c.name, + })), + }, + ]} + chips={[ + { + param: 'status', + label: 'Status', + options: STATUS_OPTIONS.map((value) => ({ value, count: 0 })), + selected: search.status ?? [], + counts: false, + format: (v) => DELIVERY_STATUS[v as keyof typeof DELIVERY_STATUS]?.label ?? v, + }, + { + param: 'op', + label: 'Operation', + options: ['send', 'edit', 'delete'].map((value) => ({ value, count: 0 })), + selected: search.op ?? [], + counts: false, + format: (v) => OP_LABEL[v] ?? v, + }, + { + param: 'kind', + label: 'Kind', + options: KIND_OPTIONS.map((value) => ({ value, count: 0 })), + selected: search.kind ?? [], + counts: false, + format: (v) => KIND_LABEL[v] ?? v, + }, + ]} + tokens={ + search.notification !== undefined + ? [ + { + key: 'notification', + value: search.notification, + onRemove: () => set({ notification: undefined }), + }, + ] + : [] + } + {...(list.data?.page.total !== undefined && { matching: list.data.page.total })} + onChange={(param, value) => set({ [param]: value })} + onClear={() => clear()} + /> + + + + } + isEmpty={(page) => page.data.length === 0} + empty={ + clear()} + /> + } + > + {(page) => ( +
    + + +
      + {page.data.map((row) => ( + set({ seq: row.seq, page: search.page })} + /> + ))} +
    +
    + set({ page: p })} + onPageSize={(ps) => set({ ps })} + /> +
    + )} +
    + set({ seq: undefined, page: search.page })} + /> +
    + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/model.ts b/packages/dashboard/src/features/notifications/channels/model.ts new file mode 100644 index 0000000..019d83a --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/model.ts @@ -0,0 +1,446 @@ +/** @module features/notifications/channels/model — pure channel logic for the dashboard: categories, presets, TTL choices (Telegram capped at 47 h), rule summaries, launch-method snippets for environment variables, the wizard draft (localStorage, survives a restart) and its conversion to the API body (spec 04 §12.11.1, D-33, D-35, D-36) */ +import type { NotificationCategory } from '@browserhive/contracts/enums'; +import type { ChannelInput, ChannelPatch, ChannelView } from '@browserhive/contracts/http'; +import { + type AvailableChannelKind, + CHANNEL_KIND_SPECS, + CHANNEL_PRESETS, + type ChannelConfigProblem, + checkChannelConfig, + type NotificationChannelRules, + NTFY_DEFAULT_SERVER, + TELEGRAM_TTL_MAX_MS, +} from '@browserhive/contracts/notifications'; +import { readStorage, removeStorage, writeStorage } from '@/lib/storage.ts'; + +/** Every category, in the order the setup lists them. */ +export const CATEGORIES: readonly { + readonly id: NotificationCategory; + readonly label: string; + readonly describe: string; + /** Screenshots can be attached to this category (attention, vault confirm, crash; D-36). */ + readonly images: boolean; +}[] = [ + { + id: 'needs-you', + label: 'Needs you', + describe: 'Attention requests and vault fills waiting for approval.', + images: true, + }, + { + id: 'problems', + label: 'Problems', + describe: 'Crashed or reaped sessions and failing tool calls.', + images: true, + }, + { + id: 'wrap-ups', + label: 'Wrap-ups', + describe: 'Finished sessions and completed fills.', + images: false, + }, + { + id: 'reports', + label: 'Reports', + describe: 'Daily digests and anomaly reports.', + images: false, + }, + { + id: 'system', + label: 'System', + describe: 'BrowserHive itself degraded or recovered.', + images: false, + }, +]; + +/** Label of a category. */ +export function categoryLabel(id: string): string { + return CATEGORIES.find((c) => c.id === id)?.label ?? id; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** TTL choices (`null` = never, the default, D-35). */ +export function ttlChoices( + kind: string | null, +): readonly { readonly value: number | null; readonly label: string }[] { + const base: { value: number | null; label: string }[] = [ + { value: null, label: 'Never' }, + { value: 15 * MINUTE, label: '15 minutes' }, + { value: HOUR, label: '1 hour' }, + { value: 2 * HOUR, label: '2 hours' }, + { value: 6 * HOUR, label: '6 hours' }, + { value: 12 * HOUR, label: '12 hours' }, + { value: DAY, label: '1 day' }, + ]; + if (kind === 'telegram') + return [...base, { value: TELEGRAM_TTL_MAX_MS, label: "47 hours (Telegram's limit)" }]; + return [...base, { value: 2 * DAY, label: '2 days' }, { value: 7 * DAY, label: '7 days' }]; +} + +/** Short human TTL ("2 h", "15 min", "7 d"). */ +export function formatTtl(ms: number): string { + if (ms % DAY === 0) return `${ms / DAY} d`; + if (ms % HOUR === 0) return `${ms / HOUR} h`; + return `${Math.round(ms / MINUTE)} min`; +} + +/** The preset whose categories equal the rule's categories, or `null` (custom). */ +export function presetOf(rules: NotificationChannelRules): string | null { + const current = rules.categories === undefined ? null : [...rules.categories].sort().join(','); + for (const preset of CHANNEL_PRESETS) { + const want = preset.categories === null ? null : [...preset.categories].sort().join(','); + if (want === current) return preset.id; + } + return null; +} + +/** Rules with the preset's categories (other settings kept). */ +export function applyPreset( + rules: NotificationChannelRules, + presetId: string, +): NotificationChannelRules { + const preset = CHANNEL_PRESETS.find((p) => p.id === presetId); + if (preset === undefined) return rules; + const { categories: _categories, ...rest } = rules; + return preset.categories === null ? rest : { ...rest, categories: [...preset.categories] }; +} + +/** One-line summary of what a channel sends ("Needs you, Problems · warn and up · quiet 22:00–07:00"). */ +export function rulesSummary(rules: NotificationChannelRules): string { + const parts: string[] = []; + const preset = presetOf(rules); + const presetLabel = CHANNEL_PRESETS.find((p) => p.id === preset)?.label; + if (presetLabel !== undefined) parts.push(presetLabel); + else if (rules.categories !== undefined) + parts.push(rules.categories.map(categoryLabel).join(', ')); + if (rules.min_severity !== undefined && rules.min_severity !== 'info') { + parts.push(`${rules.min_severity} and up`); + } + if (rules.sessions !== undefined && rules.sessions.length > 0) + parts.push(rules.sessions.join(' ')); + if (rules.quiet_hours !== undefined) { + parts.push(`quiet ${rules.quiet_hours.start}–${rules.quiet_hours.end}`); + } + const images = Object.entries(rules.images ?? {}).filter(([, on]) => on === true); + if (images.length > 0) + parts.push(rules.mask_images === true ? 'masked screenshots' : 'screenshots'); + const ttls = Object.values(rules.ttl_ms ?? {}).filter((v): v is number => typeof v === 'number'); + if (ttls.length > 0) parts.push(`self-destruct ${formatTtl(Math.min(...ttls))}`); + return parts.join(' · '); +} + +/** How BrowserHive is started, for the environment variable instructions. */ +export type LaunchMethod = 'shell' | 'systemd' | 'docker' | 'config'; + +/** Labels of the launch methods. */ +export const LAUNCH_METHODS: readonly { readonly id: LaunchMethod; readonly label: string }[] = [ + { id: 'shell', label: 'Shell' }, + { id: 'systemd', label: 'systemd' }, + { id: 'docker', label: 'Docker' }, + { id: 'config', label: 'Config file' }, +]; + +/** The lines that put `name` in BrowserHive's environment, for one launch method. */ +export function envSnippet(method: LaunchMethod, names: readonly string[]): string { + const vars = names.length > 0 ? names : ['BH_SECRET']; + switch (method) { + case 'shell': + return [ + ...vars.map((n) => `export ${n}=''`), + 'browserhive --admin', + ].join('\n'); + case 'systemd': + return [ + '# sudo systemctl edit browserhive', + '[Service]', + ...vars.map((n) => `Environment="${n}="`), + '# then: sudo systemctl restart browserhive', + ].join('\n'); + case 'docker': + return [ + 'docker run \\', + ...vars.map((n) => ` -e ${n}='' \\`), + ' … browserhive', + '', + '# docker compose: under the service', + 'environment:', + ...vars.map((n) => ` ${n}: \${${n}}`), + ].join('\n'); + case 'config': + return [ + '# Channels read these variables directly; the config file never holds them.', + '# Export them where BrowserHive starts, e.g. in the shell or service manager:', + ...vars.map((n) => `export ${n}=''`), + ].join('\n'); + } +} + +/** Steps of the setup wizard. */ +export const WIZARD_STEPS = ['platform', 'credentials', 'connect', 'rules', 'preview'] as const; +/** One wizard step. */ +export type WizardStep = (typeof WIZARD_STEPS)[number]; + +/** Labels of the wizard steps. */ +export const WIZARD_STEP_LABEL: { readonly [S in WizardStep]: string } = { + platform: 'Platform', + credentials: 'Credentials', + connect: 'Connect', + rules: 'What to send', + preview: 'Preview and test', +}; + +/** The wizard's working copy of a channel (kept in `localStorage` while adding one). */ +export interface ChannelDraft { + readonly v: 1; + readonly kind: AvailableChannelKind | null; + readonly mode: string | null; + readonly name: string; + readonly target: Readonly>; + readonly secretRefs: Readonly>; + readonly rules: NotificationChannelRules; +} + +/** A blank draft. */ +export const EMPTY_DRAFT: ChannelDraft = { + v: 1, + kind: null, + mode: null, + name: '', + target: {}, + secretRefs: {}, + rules: {}, +}; + +/** `localStorage` key of the add-channel draft (spec 04 §12.11.1). */ +export const DRAFT_KEY = 'bh.channelDraft'; + +/** Reads the stored draft; `null` when absent or unreadable. */ +export function readDraft(): ChannelDraft | null { + const raw = readStorage(DRAFT_KEY); + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || (parsed as { v?: unknown }).v !== 1) { + return null; + } + const d = parsed as Partial; + return { + ...EMPTY_DRAFT, + ...d, + target: { ...(d.target ?? {}) }, + secretRefs: { ...(d.secretRefs ?? {}) }, + rules: { ...(d.rules ?? {}) }, + }; + } catch { + return null; + } +} + +/** Stores the draft. */ +export function writeDraft(draft: ChannelDraft): void { + writeStorage(DRAFT_KEY, JSON.stringify(draft)); +} + +/** Forgets the draft (after a save, or "Start over"). */ +export function clearDraft(): void { + removeStorage(DRAFT_KEY); +} + +const TOPIC_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789'; + +/** A hard-to-guess ntfy topic (`bh-` + 12 random characters): on a public server the topic is the password. */ +export function randomTopic(random: () => number = Math.random): string { + let out = 'bh-'; + for (let i = 0; i < 12; i++) out += TOPIC_ALPHABET[Math.floor(random() * TOPIC_ALPHABET.length)]; + return out; +} + +/** A name not used by `taken` (`telegram`, `telegram-2`, …). */ +export function suggestName(kind: string, taken: readonly string[]): string { + const base = kind === 'telegram' ? 'phone' : kind; + if (!taken.includes(base)) return base; + for (let i = 2; i < 100; i++) if (!taken.includes(`${base}-${i}`)) return `${base}-${i}`; + return `${base}-${Date.now() % 1000}`; +} + +/** The draft after choosing a platform: its required secrets named, defaults filled, a preset. */ +export function draftForKind( + draft: ChannelDraft, + kind: AvailableChannelKind, + taken: readonly string[], +): ChannelDraft { + if (draft.kind === kind) return draft; + const spec = CHANNEL_KIND_SPECS[kind]; + const secretRefs: Record = {}; + for (const s of spec.secrets) if (s.required) secretRefs[s.param] = s.suggestedEnv; + const target: Record = {}; + if (kind === 'ntfy') { + target['server'] = NTFY_DEFAULT_SERVER; + target['topic'] = randomTopic(); + } + return { + ...draft, + kind, + mode: spec.defaultMode, + name: draft.name === '' || taken.includes(draft.name) ? suggestName(kind, taken) : draft.name, + target, + secretRefs, + rules: Object.keys(draft.rules).length > 0 ? draft.rules : applyPreset({}, 'needs-me'), + }; +} + +/** The draft of an existing channel (editing). */ +export function draftFromChannel(channel: ChannelView): ChannelDraft { + return { + v: 1, + kind: channel.kind as AvailableChannelKind, + mode: channel.mode, + name: channel.name, + target: { ...channel.target }, + secretRefs: { ...channel.secret_refs }, + rules: { ...channel.rules }, + }; +} + +/** A duplicate of a channel (a free name, same settings). */ +export function duplicateDraft(channel: ChannelView, taken: readonly string[]): ChannelDraft { + const base = draftFromChannel(channel); + let name = `${channel.name}-copy`.slice(0, 32); + for (let i = 2; taken.includes(name) && i < 100; i++) name = `${channel.name}-${i}`.slice(0, 32); + return { ...base, name }; +} + +/** Rules without empty keys (an empty session list means "every session", the absent default). */ +export function cleanRules(rules: NotificationChannelRules): NotificationChannelRules { + const out: Record = {}; + for (const [key, value] of Object.entries(rules)) { + if (value === undefined) continue; + if (Array.isArray(value) && value.length === 0 && key !== 'categories') continue; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const entries = Object.entries(value).filter(([, v]) => v !== undefined && v !== false); + if (key !== 'quiet_hours' && entries.length === 0) continue; + out[key] = key === 'quiet_hours' ? value : Object.fromEntries(entries); + continue; + } + out[key] = value; + } + return out as NotificationChannelRules; +} + +/** `POST /channels` body of a complete draft. */ +export function draftToInput(draft: ChannelDraft): ChannelInput { + return { + name: draft.name, + kind: draft.kind ?? 'webhook', + mode: draft.mode, + target: { ...draft.target }, + secret_refs: { ...draft.secretRefs }, + rules: cleanRules(draft.rules), + }; +} + +/** `PATCH /channels/{id}` body of an edited draft. */ +export function draftToPatch(draft: ChannelDraft): ChannelPatch { + const { kind: _kind, ...rest } = draftToInput(draft); + return rest; +} + +const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; + +/** Problems of a draft (platform config via the shared contract check, plus the name). */ +export function draftProblems(draft: ChannelDraft): ChannelConfigProblem[] { + if (draft.kind === null) return [{ field: 'kind', message: 'Choose a platform.' }]; + const problems = checkChannelConfig({ + kind: draft.kind, + mode: draft.mode, + target: draft.target, + secretRefs: draft.secretRefs, + }); + if (!NAME_RE.test(draft.name)) { + problems.push({ + field: 'name', + message: + 'Use up to 32 lowercase letters, digits and dashes, starting with a letter or digit.', + }); + } + if (draft.kind === 'telegram') { + for (const [category, ttl] of Object.entries(draft.rules.ttl_ms ?? {})) { + if (typeof ttl === 'number' && ttl > TELEGRAM_TTL_MAX_MS) { + problems.push({ + field: `rules.ttl_ms.${category}`, + message: 'Telegram lets a bot delete its messages for 48 hours only: at most 47 h.', + }); + } + } + } + return problems; +} + +/** Problems that block leaving `step`. */ +export function stepProblems(draft: ChannelDraft, step: WizardStep): ChannelConfigProblem[] { + const all = draftProblems(draft); + switch (step) { + case 'platform': + return all.filter((p) => p.field === 'kind' || p.field === 'mode'); + case 'credentials': + return all.filter((p) => p.field.startsWith('secret_refs')); + case 'connect': + return all.filter((p) => p.field.startsWith('target')); + case 'rules': + return all.filter((p) => p.field === 'name' || p.field.startsWith('rules')); + case 'preview': + return all; + } +} + +/** The variables a draft names, required ones first. */ +export function draftEnvNames(draft: ChannelDraft): string[] { + return Object.values(draft.secretRefs).filter((n) => n.trim() !== ''); +} + +/** Whether a URL points at a private or loopback address (the webhook SSRF note). */ +export function isPrivateUrl(url: string): boolean { + let host: string; + try { + host = new URL(url).hostname.replace(/^\[|\]$/g, ''); + } catch { + return false; + } + if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return true; + if (host === '::1' || host.startsWith('fc') || host.startsWith('fd')) return true; + const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(host); + if (m === null) return false; + const a = Number(m[1]); + const b = Number(m[2]); + return ( + a === 10 || + a === 127 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ); +} + +/** The subscribe links for an ntfy topic (the app's deep link and the web page). */ +export function ntfyLinks( + server: string, + topic: string, +): { readonly web: string; readonly app: string } { + const base = server.replace(/\/+$/, ''); + let host = base; + try { + const url = new URL(base); + host = `${url.host}${url.pathname === '/' ? '' : url.pathname}`; + } catch { + // keep the raw value + } + return { web: `${base}/${topic}`, app: `ntfy://${host}/${topic}` }; +} + +/** Whether the ntfy server is the public ntfy.sh (attachments held 3 h on a public server, D-36). */ +export function isPublicNtfy(server: string | undefined): boolean { + return (server ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, '') === NTFY_DEFAULT_SERVER; +} diff --git a/packages/dashboard/src/features/notifications/channels/platforms.tsx b/packages/dashboard/src/features/notifications/channels/platforms.tsx new file mode 100644 index 0000000..4d5f5df --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/platforms.tsx @@ -0,0 +1,117 @@ +/** @module features/notifications/channels/platforms — the platforms the setup offers (available and upcoming), their marks (our own glyphs on a tinted tile, never platform logos), taglines, setup facts and docs anchors */ +import type { AvailableChannelKind } from '@browserhive/contracts/notifications'; +import { ICONS, type IconName } from '@/lib/icons.ts'; +import type { DocsPage } from '@/lib/links.ts'; +import { cn } from '@/lib/utils.ts'; + +/** What the setup says about one platform. */ +export interface PlatformInfo { + readonly kind: AvailableChannelKind; + readonly label: string; + readonly icon: IconName; + /** One line under the name on the platform card. */ + readonly tagline: string; + /** Rough setup time. */ + readonly setup: string; + readonly facts: readonly string[]; + readonly docs: DocsPage; + /** Tailwind classes of the mark tile. */ + readonly tile: string; +} + +/** Available platforms, in the order the setup shows them. */ +export const PLATFORMS: readonly PlatformInfo[] = [ + { + kind: 'telegram', + label: 'Telegram', + icon: 'platformTelegram', + tagline: 'Your own bot messages you, a group or a topic.', + setup: 'About 2 minutes', + facts: ['Screenshots', 'Updates in place', 'Self-destruct up to 47 h'], + docs: 'channelTelegram', + tile: 'bg-platform-telegram-bg text-platform-telegram', + }, + { + kind: 'discord', + label: 'Discord', + icon: 'platformDiscord', + tagline: 'A webhook posts into one channel of your server.', + setup: 'About 30 seconds', + facts: ['Screenshots', 'Updates in place', 'Self-destruct'], + docs: 'channelDiscord', + tile: 'bg-platform-discord-bg text-platform-discord', + }, + { + kind: 'ntfy', + label: 'ntfy', + icon: 'platformNtfy', + tagline: 'Push notifications through ntfy.sh or your own server.', + setup: 'About 1 minute', + facts: ['No account needed', 'Updates in place', 'Self-destruct'], + docs: 'channelNtfy', + tile: 'bg-platform-ntfy-bg text-platform-ntfy', + }, + { + kind: 'webhook', + label: 'Webhook', + icon: 'platformWebhook', + tagline: 'POSTs the notification as signed JSON to your URL.', + setup: 'For your own tools', + facts: ['HMAC signature', 'Full message contract', 'Home Assistant, n8n…'], + docs: 'channelWebhook', + tile: 'bg-platform-webhook-bg text-platform-webhook', + }, +]; + +/** Platforms on the roadmap, shown disabled so users know they are coming. */ +export const UPCOMING_PLATFORMS: readonly { readonly label: string; readonly note: string }[] = [ + { label: 'Slack', note: 'Coming later' }, + { label: 'Pushover', note: 'Coming later' }, + { label: 'Microsoft Teams', note: 'Coming later' }, + { label: 'Email', note: 'Coming later' }, +]; + +/** The info of an available platform (a fallback for unknown kinds). */ +export function platformOf(kind: string): PlatformInfo { + return ( + PLATFORMS.find((p) => p.kind === kind) ?? { + kind: 'webhook', + label: kind, + icon: 'channels', + tagline: '', + setup: '', + facts: [], + docs: 'notificationChannels', + tile: 'bg-muted text-muted-foreground', + } + ); +} + +/** A platform's mark: its glyph on a tinted rounded tile. */ +export function PlatformMark({ + kind, + size = 'md', + className, +}: { + readonly kind: string; + readonly size?: 'sm' | 'md' | 'lg'; + readonly className?: string; +}) { + const info = platformOf(kind); + const Icon = ICONS[info.icon]; + return ( + + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/DiscordMock.tsx b/packages/dashboard/src/features/notifications/channels/preview/DiscordMock.tsx new file mode 100644 index 0000000..a9a963a --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/DiscordMock.tsx @@ -0,0 +1,141 @@ +/** @module features/notifications/channels/preview/DiscordMock — a Discord message drawn from the renderer's webhook request: the sender row with its APP tag, the embed (colour bar from the payload, title, markdown description, inline fields, image, footer) and the button rows (link buttons, and interactive ones in bot mode). Our own CSS; no Discord assets. */ +import type { PlatformRequest } from '@browserhive/contracts/http'; +import { ICONS } from '@/lib/icons.ts'; +import { cn } from '@/lib/utils.ts'; +import { DiscordMarkdown } from './discord-markdown.tsx'; +import { MockAction, MockScreenshot, SenderAvatar } from './MockParts.tsx'; +import { type MockButton, readDiscord } from './read-request.ts'; + +const BUTTON_TONE: { readonly [S in MockButton['style']]: string } = { + primary: 'bg-dc-primary hover:brightness-110', + secondary: 'bg-dc-button hover:brightness-110', + success: 'bg-dc-success hover:brightness-110', + danger: 'bg-dc-danger hover:brightness-110', + link: 'bg-dc-button hover:brightness-110', +}; + +/** `#rrggbb` of a Discord colour integer. */ +export function discordHex(color: number): string { + return `#${Math.max(0, Math.min(0xffffff, color)).toString(16).padStart(6, '0')}`; +} + +/** Props. */ +export interface DiscordMockProps { + readonly request: PlatformRequest; + readonly at: number; + readonly masked: boolean; + /** `bot` shows the BOT tag and interactive buttons as pressable. */ + readonly mode: 'webhook' | 'bot'; +} + +/** The Discord message mock. */ +export function DiscordMock({ request, at, masked, mode }: DiscordMockProps) { + const view = readDiscord(request); + const time = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const External = ICONS.external; + return ( +
    +
    + +
    +
    + BrowserHive + + {mode === 'bot' ? 'Bot' : 'App'} + + Today at {time} + {view.edit ? (edited) : null} +
    + {view.content !== null && view.content !== '' ? ( +
    + +
    + ) : null} + {view.embeds.map((embed) => ( +
    + {embed.title !== null ? ( +

    + {embed.url !== null ? ( + + {embed.title} + + ) : ( + embed.title + )} +

    + ) : null} + {embed.description !== null ? ( +
    + +
    + ) : null} + {embed.fields.length > 0 ? ( +
    + {embed.fields.map((f) => ( +
    +
    {f.name}
    +
    + +
    +
    + ))} +
    + ) : null} + {embed.image !== null ? ( + + ) : null} + {embed.footer !== null || embed.timestamp !== null ? ( +
    + {embed.footer !== null ? {embed.footer} : null} + {embed.footer !== null && embed.timestamp !== null ? ( + + ) : null} + {embed.timestamp !== null ? Today at {time} : null} +
    + ) : null} +
    + ))} + {view.rows.map((row) => ( +
    b.label).join('|')} className="flex flex-wrap gap-2 pt-1"> + {row.map((b) => ( + + {b.label} + {b.style === 'link' ? + ))} +
    + ))} +
    +
    +
    + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/MockParts.tsx b/packages/dashboard/src/features/notifications/channels/preview/MockParts.tsx new file mode 100644 index 0000000..902b59c --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/MockParts.tsx @@ -0,0 +1,98 @@ +/** @module features/notifications/channels/preview/MockParts — pieces shared by the platform mocks: the screenshot stand-in (previews never carry image bytes) and the BrowserHive sender avatar */ +import type { ReactNode } from 'react'; +import { ICONS } from '@/lib/icons.ts'; +import { cn } from '@/lib/utils.ts'; + +/** + * Stands in for an attached screenshot: a small browser frame with page skeleton lines, and black + * bars over the "form fields" when the channel masks them (D-36). + */ +export function MockScreenshot({ + masked, + name, + className, +}: { + readonly masked: boolean; + readonly name: string; + readonly className?: string; +}) { + const Camera = ICONS.toolScreenshot; + return ( +
    +
    + + + + +
    +
    +
    + + + +
    +
    + {[0, 1].map((n) => ( + + ))} + +
    +
    +
    +
    +
    + ); +} + +/** BrowserHive's sender avatar (the brand gradient with a hive glyph). */ +export function SenderAvatar({ className }: { readonly className?: string }) { + return ( + + ); +} + +/** A mock button: a real link for URL buttons, an inert button for act buttons (they work in the chat). */ +export function MockAction({ + url, + className, + children, +}: { + readonly url: string | null; + readonly className: string; + readonly children: ReactNode; +}) { + if (url !== null) { + return ( + + {children} + + ); + } + return ( + + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx b/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx new file mode 100644 index 0000000..a415bf4 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx @@ -0,0 +1,97 @@ +/** @module features/notifications/channels/preview/NtfyMock — an Android notification as the ntfy app shows it, drawn from the renderer's publish request: app row with topic, priority, emoji tags before the title, the message, an attached image and the action buttons. Our own CSS; no ntfy assets. */ +import type { PlatformRequest } from '@browserhive/contracts/http'; +import { ICONS } from '@/lib/icons.ts'; +import { cn } from '@/lib/utils.ts'; +import { MockAction, MockScreenshot } from './MockParts.tsx'; +import { readNtfy, splitNtfyTags } from './read-request.ts'; + +const PRIORITY_LABEL: Readonly> = { + 1: 'min', + 2: 'low', + 3: 'default', + 4: 'high', + 5: 'urgent', +}; + +/** Props. */ +export interface NtfyMockProps { + readonly request: PlatformRequest; + readonly at: number; + readonly masked: boolean; + readonly topic?: string | null; +} + +/** The ntfy notification mock. */ +export function NtfyMock({ request, at, masked, topic }: NtfyMockProps) { + const view = readNtfy(request); + const { emoji, plain } = splitNtfyTags(view.tags); + const time = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const Bell = ICONS.platformNtfy; + const Warn = ICONS.warn; + const shownTopic = view.topic ?? topic ?? 'topic'; + return ( +
    +
    +
    + + + ntfy + + {shownTopic} + + {time} + {view.priority >= 4 ? ( + + + ) : ( + {PRIORITY_LABEL[view.priority]} priority + )} +
    + {view.title !== null ? ( +

    + {emoji.length > 0 ? {emoji.join(' ')} : null} + {view.title} +

    + ) : null} +

    + {view.title === null && emoji.length > 0 ? ( + {emoji.join(' ')} + ) : null} + {view.message} +

    + {plain.length > 0 ? ( +

    Tags: {plain.join(', ')}

    + ) : null} + {view.attachment !== null ? ( + + ) : null} + {view.actions.length > 0 ? ( +
    + {view.actions.map((a) => ( + + {a.label} + + ))} +
    + ) : null} +
    + {view.sequence !== null ? ( +

    + Replaced in place by sequence id {view.sequence} +

    + ) : null} +
    + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.tsx b/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.tsx new file mode 100644 index 0000000..6e30b8f --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.tsx @@ -0,0 +1,137 @@ +/** @module features/notifications/channels/preview/PlatformPreview — renders a `ChannelPreview` as the platform would show it (Telegram, Discord, ntfy mocks, the webhook request), with the renderer's notes and a disclosure of the exact request(s); everything is drawn from `requests`, the same output a real send uses (spec 04 §12.11.1) */ +import type { ChannelPreview, PlatformRequest } from '@browserhive/contracts/http'; +import { useId, useState } from 'react'; +import { JsonView } from '@/components/shared/JsonView.tsx'; +import { ICONS } from '@/lib/icons.ts'; +import { cn } from '@/lib/utils.ts'; +import { DiscordMock } from './DiscordMock.tsx'; +import { NtfyMock } from './NtfyMock.tsx'; +import { TelegramMock } from './TelegramMock.tsx'; + +/** Whether the previewed message carries a masked image. */ +function maskedOf(preview: ChannelPreview): boolean { + return preview.message.blocks.some((b) => b.type === 'image' && b.masked); +} + +function WebhookMock({ request }: { readonly request: PlatformRequest }) { + const headers = { 'Content-Type': 'application/json', ...request.headers }; + return ( +
    +
    + + {request.method} + + {request.path} +
    +
    + {Object.entries(headers).map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} +
    +
    X-BrowserHive-Signature
    +
    sha256=… (when a signing secret is set)
    +
    +
    +
    + +
    +
    + ); +} + +/** Props. */ +export interface PlatformPreviewProps { + readonly preview: ChannelPreview; + /** Chat title shown in the Telegram header (a connected chat). */ + readonly chatTitle?: string | null; + readonly className?: string; + /** Hide the request disclosure (the side-by-side comparison). */ + readonly compact?: boolean; +} + +/** One platform mock for a preview. */ +export function PlatformPreview({ + preview, + chatTitle, + className, + compact = false, +}: PlatformPreviewProps) { + const [showRequest, setShowRequest] = useState(false); + const id = useId(); + const request = preview.requests[0]; + const at = preview.message.at.updated; + const masked = maskedOf(preview); + const Code = ICONS.json; + const Info = ICONS.info; + if (request === undefined) { + return ( +

    + This notification sends nothing on this channel. +

    + ); + } + return ( +
    + {preview.kind === 'telegram' ? ( + + ) : preview.kind === 'discord' ? ( + + ) : preview.kind === 'ntfy' ? ( + + ) : ( + + )} + {!compact && (preview.notes.length > 0 || preview.local_links) ? ( +
      + {preview.local_links ? ( +
    • +
    • + ) : null} + {preview.notes.map((note) => ( +
    • +
    • + ))} +
    + ) : null} + {!compact ? ( +
    +