diff --git a/.changeset/notification-channels.md b/.changeset/notification-channels.md new file mode 100644 index 0000000..4bab79b --- /dev/null +++ b/.changeset/notification-channels.md @@ -0,0 +1,15 @@ +--- +"browserhive": minor +--- + +Notifications on your phone: Telegram, Discord, ntfy and webhook channels, with screenshots, live updates and messages that delete themselves. + +- **Get a message when an agent needs you.** Add a channel under **Notifications → Channels**: a Telegram bot (one-tap connect, no chat id to look up), a Discord webhook, an ntfy topic (scan a QR code with the ntfy app) or a webhook of your own. A wizard shows the exact line to set the token for how you run BrowserHive, checks that it is set, previews the message exactly as it will look, and sends a test. Presets pick what to send (*Needs me now*, *Problems*, *Wrap-ups*); **Advanced** adds minimum severity, session patterns, harness, quiet hours with a time zone and the content level. +- **Messages keep up.** When you resolve an attention request, the chat message is edited in place, silently, and its buttons disappear; a growing group of tool errors updates its count. Every send, edit and delete is in the new **Delivery log**, live, with a sentence for anything that was not sent ("quiet hours", "the platform refused the token"). +- **Screenshots, when you want them.** Off by default, per channel and category: the page when an agent asked for help (CAPTCHAs included), the login page before a vault fill (never during one), a crashed session's last frame. Form fields can be masked. +- **Self-destruct.** Delete messages after a time you choose per category, or once they are resolved. Telegram only allows 48 hours, so its timers stop at 47. +- **Links that open on your phone.** The new `publicUrl` key (`--publicUrl`, `BROWSERHIVE_PUBLIC_URL`) is the address where you reach the dashboard (a Tailscale name, your reverse proxy, a Cloudflare tunnel). Notification links use it, its host is trusted without `allowedHosts`, and the CSRF check accepts it even when your proxy rewrites `Host`. The System page and `browserhive doctor` check that it really reaches this BrowserHive. +- **Your accounts, your tokens.** BrowserHive runs no servers or shared bots. Tokens stay in environment variables; channels store only the variable names, so a database backup never contains one. +- **For servers and containers**, declare channels at startup with `--notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456"` (repeatable; a token typed into the flag is refused). They show in the dashboard with a "from startup" badge. +- **From a terminal:** `browserhive channels list`, `channels test ` and `channels preview `; `browserhive doctor` checks every channel's variables and `publicUrl`. +- New REST endpoints under `/api/v1/channels` (scopes `channels:read`, `channels:write`), `GET /api/v1/system/public-url`, a `channels` WebSocket topic, and `instance_id` in `GET /health`. 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/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/README.md b/docs/README.md index 13dc8ce..8fbf91a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ BrowserHive is a local MCP server that gives AI agents isolated, stealthy Chromi - [Security model](guide/security.md): authentication, bind rules, the vault model, redaction, what is recorded - [Vault](guide/vault.md): Bitwarden setup, folder policies, bindings, confirmations - [Human takeover](guide/attention.md): `request_attention` and the live view -- [Notifications](guide/notifications.md): what is notified, how a notification changes over its life, and how delivery to chat apps works +- [Notifications](guide/notifications.md): notifications on your phone through Telegram, Discord, ntfy or a webhook, with screenshots, self-destruct and a public address for links - [Stealth](guide/stealth.md): what it does, what it does not, ceilings, proxies - [Telemetry](guide/telemetry.md): OpenTelemetry export and a local Grafana stack - [Command line](guide/cli.md): every command and exit code diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 8633e4b..4eeaa31 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -9,6 +9,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 [command] | --help | -h ``` @@ -42,6 +43,25 @@ Output streams: under `--transport stdio`, stdout carries only MCP frames and ev All server flags are in the [configuration reference](../reference/configuration.md). +### Notification channels + +`--notificationChannel` declares a notification channel for this run ([startup channels](notifications.md#startup-channels)). Repeat it for several channels. It is a flag only: there is no environment variable or config-file key for it. + +``` +--notificationChannel ":=,=…" +``` + +A list value joins its items with `+`; a value cannot contain `,` (write `%2C`). Secret parameters are always `env:NAME`, the name of an environment variable (not starting with `BROWSERHIVE_`); a secret written into the flag is refused with exit 64, because process arguments are visible to other users of the machine. + +| Platform | Parameters | +|---|---| +| `telegram` | `name`, `token=env:NAME`, `chat` (a chat id), optional `thread` (a forum topic id) | +| `discord` | `name`, `webhook=env:NAME` (the webhook URL), optional `mode=webhook` | +| `ntfy` | `name`, `topic` (a topic, or `env:NAME`), optional `server` (default `https://ntfy.sh`), `token=env:NAME` | +| `webhook` | `name`, `url` (a URL, or `env:NAME`), optional `secret=env:NAME` (the signing key) | + +Rules, all optional: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info`, `warn`, `error`, `critical`), `sessions` (session name patterns such as `shop-*`), `harness`, `content` (`counts`, `titles`, `full`), `quiet=22:00-07:00` with `tz=Europe/Berlin`, `ttl.=2h` (Telegram at most `47h`), `deleteWhenResolved` (`true`, or a `+` list of categories), `images` (a `+` list of categories; needs `content=full`) and `maskImages=true`. + ## `init` One-time setup, safe to re-run: @@ -68,7 +88,7 @@ One-time setup, safe to re-run: ## `doctor` -Checks Bun, browsers (the bundled Chromium, installed Chrome and Edge, that the configured `defaultChannel` is installed, version drift, managed policies that block automation), Chromium's sandbox for each installed browser (each is launched once), running as root or in a container, data directory permissions and disk space, configuration, port availability, `bw` when the vault is on, the database and pending migrations, the OTLP endpoint when telemetry is on, `maxSessions` against RAM, and config-file permissions when it holds secrets (not when `authTokens` only [references](configuration.md#references) environment variables). It counts the values that came from references and warns about each referenced variable that was not set, so its default is in use. It also warns when the data directory contains an unrecognised data file, which BrowserHive neither reads nor migrates. +Checks Bun, browsers (the bundled Chromium, installed Chrome and Edge, that the configured `defaultChannel` is installed, version drift, managed policies that block automation), Chromium's sandbox for each installed browser (each is launched once), running as root or in a container, data directory permissions and disk space, configuration, port availability, `bw` when the vault is on, the database and pending migrations, the OTLP endpoint when telemetry is on, `maxSessions` against RAM, `publicUrl` (whether it reaches this BrowserHive; see [public address](notifications.md#public-address)), notification channels (each `--notificationChannel` parses and every variable a channel names is set), and config-file permissions when it holds secrets (not when `authTokens` only [references](configuration.md#references) environment variables). It counts the values that came from references and warns about each referenced variable that was not set, so its default is in use. It also warns when the data directory contains an unrecognised data file, which BrowserHive neither reads nor migrates. `--json` prints `[{ check, status, detail }]`. Exit codes: `0` all good, `2` warnings only, `1` a check failed. When the configured browser cannot run sandboxed, the text output ends with what to do on this machine. `--printApparmorProfile` prints an AppArmor profile for the configured browser (for Ubuntu 23.10+) and exits; install it yourself with `sudo tee /etc/apparmor.d/` and `sudo apparmor_parser -r`. @@ -115,6 +135,16 @@ See [Upgrading](upgrading.md). Token commands work on the database directly when the server is stopped, or through the REST API of a running server with `--url ` and an operator credential. +## `channels` + +| Command | Meaning | +|---|---| +| `channels list [--json]` | Every notification channel: platform, status (and "from startup"), where it sends, whether its variables are set, the last delivery and the last 24 hours. | +| `channels test [--json]` | Sends a real test message. Exit `0` when the platform accepted it, `1` with the reason when it did not. | +| `channels preview [--sample ] [--json]` | Prints the platform request a send would make (secrets shown as variable names); sends nothing. Samples: `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`. | + +They talk to the running server: `--url` defaults to the configured host and port, and `--token` (an operator API token) or `--cookie` authenticates. See [Notifications](notifications.md). + ## `version` ``` diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index c347692..a4cb8af 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -81,8 +81,10 @@ A live tail of the server log with level, module, session and trace filters, pau ## System -Version, transport, uptime, bind address, sessions live versus the cap, open attention requests, live views, dashboard connections, default persistence, whether `evaluate` is allowed, vault backend, blocklist, retention, database size and schema version, telemetry endpoint and data directory. A stealth section shows the stealth level, driver, fingerprint and humanize defaults and CAPTCHA mode. **Browsers and sandbox** lists the browsers found on the machine (the bundled Chromium, an installed Google Chrome or Microsoft Edge) with version and path, marks the default one, and shows for each whether it runs inside Chromium's sandbox, with Chrome's own reason when it cannot. **MCP connections** lists the agents connected over MCP, live ones first, then recent ones; select one to see how its harness was recognised, its model and workspace, client and protocol, User-Agent, IP, any conflicting signals and extra labels. A session's Details tab shows its browser version and whether that session ran sandboxed, for live and closed sessions alike. Banners warn when `evaluate` is enabled together with the vault, when disk space is low, or when a subsystem is degraded. The configuration table lists every effective setting with its source (`cli`, `file`, `env`, `default`, `derived`) and the values it shadowed; secrets are shown as redacted. A value the config file reads from an environment variable has a `$NAME` chip under its source (outlined when the variable was not set and the default is used); select it to see the value as written in the file, or, for a secret, just the variable. **Only values from references** narrows the table to those keys, and the filter also matches variable names. See [References](configuration.md#references). +Version, transport, uptime, bind address, sessions live versus the cap, open attention requests, live views, dashboard connections, default persistence, whether `evaluate` is allowed, vault backend, blocklist, retention, database size and schema version, telemetry endpoint and data directory. A stealth section shows the stealth level, driver, fingerprint and humanize defaults and CAPTCHA mode. **Browsers and sandbox** lists the browsers found on the machine (the bundled Chromium, an installed Google Chrome or Microsoft Edge) with version and path, marks the default one, and shows for each whether it runs inside Chromium's sandbox, with Chrome's own reason when it cannot. **MCP connections** lists the agents connected over MCP, live ones first, then recent ones; select one to see how its harness was recognised, its model and workspace, client and protocol, User-Agent, IP, any conflicting signals and extra labels. **Public address** shows `publicUrl` and whether it reaches this BrowserHive (see [public address](notifications.md#public-address)), with **Check again**. A session's Details tab shows its browser version and whether that session ran sandboxed, for live and closed sessions alike. Banners warn when `evaluate` is enabled together with the vault, when disk space is low, or when a subsystem is degraded. The configuration table lists every effective setting with its source (`cli`, `file`, `env`, `default`, `derived`) and the values it shadowed; secrets are shown as redacted. A value the config file reads from an environment variable has a `$NAME` chip under its source (outlined when the variable was not set and the default is used); select it to see the value as written in the file, or, for a secret, just the variable. **Only values from references** narrows the table to those keys, and the filter also matches variable names. See [References](configuration.md#references). ## Notifications Persisted notifications for attention requests, tool errors, crashed sessions and pending vault confirmations, grouped by day. Mark as read or dismiss, individually or all at once. Once an attention request or vault confirmation is settled (in the dashboard, by a timeout, or elsewhere), its row keeps its place and shows the outcome as a small pill (**resolved**, **expired**, or **closed** when the agent stopped waiting), and a toast still on screen for it closes. See [Notifications](notifications.md). + +**Channels** (Notifications → Channels) sends notifications to your phone through Telegram, Discord, ntfy or a webhook: one card per channel with its status, last delivery and 24-hour counts, and an **Add channel** wizard with a live preview and a test message. **Delivery log** lists every send, edit and delete, and says why anything was not sent. See [Channels](notifications.md#channels). diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 5b7b0fa..29b5e8a 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -2,7 +2,12 @@ BrowserHive tells you when something needs you or went wrong: an agent asked for help, a vault fill waits for your approval, a session crashed, tools keep failing, or BrowserHive itself is degraded. Notifications are stored in the database, so they survive reloads and restarts, and they appear in the dashboard's bell, as toasts and on the **Notifications** page. -This page explains what produces a notification, how one changes over its life, and the delivery machinery that sends notifications to chat apps such as Telegram, Discord and ntfy. That delivery is being built in stages: this release lays the foundations, and the first channels arrive next. +BrowserHive can also put them on your phone: through your own Telegram bot, a Discord webhook, an ntfy topic or a webhook of your own. This page explains what produces a notification, how one changes over its life, how to set up each channel, and what leaves your machine. + +- [Channels: set one up in two minutes](#channels) +- [Public address: links that open on your phone](#public-address) +- [Screenshots](#screenshots), [self-destruct](#self-destruct), [startup channels](#startup-channels), [the delivery log](#delivery-log) +- [What leaves your machine](#what-leaves-your-machine) ## What BrowserHive notifies about @@ -28,7 +33,7 @@ Notifications from before this release keep working; they get the new fields, de ## The message contract -Every notification is also a `NotificationMessage`: a small, versioned JSON document with a title and summary, structured blocks (text, facts, lists, tables, images, code), up to five buttons, and what it is about (session, tool, error code). Links in it are dashboard paths. BrowserHive owns this contract; every channel renders it and none reads BrowserHive's internals. The JSON Schema is published at [notification-message.schema.json](../reference/notification-message.schema.json), so you can build your own consumer from the generic webhook channel when it arrives. +Every notification is also a `NotificationMessage`: a small, versioned JSON document with a title and summary, structured blocks (text, facts, lists, tables, images, code), up to five buttons, and what it is about (session, tool, error code). Links in it are dashboard paths. BrowserHive owns this contract; every channel renders it and none reads BrowserHive's internals. The JSON Schema is published at [notification-message.schema.json](../reference/notification-message.schema.json), so you can build your own consumer from the [webhook channel](#webhook). Before a message is stored or sent, BrowserHive removes known secrets and credential-shaped text from every field and strips query strings from URLs, the same redaction the logs get ([security](security.md)). A channel can be set to carry less: only titles and facts, or only counts. @@ -46,6 +51,160 @@ Delivery is designed so that nothing is silently lost and nothing waits on a slo Your own accounts, no servers: BrowserHive never runs a relay or a shared bot. You create your own Telegram bot, Discord webhook or ntfy topic, and every connection goes out from your machine. Tokens stay in environment variables; BrowserHive stores only the variable names, so a database backup never contains a token. +## Channels + +A channel is one place notifications go: a Telegram chat, a Discord channel, an ntfy topic or a URL of yours. Add one in the dashboard under **Notifications → Channels → Add channel**. The wizard walks through five steps: + +1. **Platform.** Telegram, Discord, ntfy or Webhook. +2. **Credentials.** Pick the name of an environment variable that will hold the token, such as `BH_TELEGRAM_TOKEN` (any name that does not start with `BROWSERHIVE_`, which configuration keys use). The wizard shows the exact line for how you run BrowserHive (a shell, systemd, Docker) and a live **set ✓ / missing ✗** check. Set the variable and restart BrowserHive; the wizard keeps your draft across the restart. +3. **Connect.** The platform-specific part, below. +4. **What to send.** Pick a preset (*Needs me now*, *Problems*, *Wrap-ups* or *Everything*) or open **Advanced** for minimum severity, session name patterns, harness, quiet hours with a time zone, the [content level](#what-leaves-your-machine), [screenshots](#screenshots) and [self-destruct](#self-destruct). +5. **Preview and test.** See the message exactly as it will look (drawn from the same code that sends it), save, then **Send test**. The test message has an **Open dashboard** button: tap it on your phone to check that links reach you ([public address](#public-address)). + +Each channel card shows its status, the last delivery and the last 24 hours (sent, failed, filtered). You can pause, resume, edit, duplicate, delete and test it there. There is no limit on channels; every matching channel gets its own copy. + +Secrets are never stored: a channel holds the **name** of the environment variable, BrowserHive reads the value when it starts, and the dashboard only ever says whether it is set. Changing a token is a change to the environment plus a restart. + +### Telegram + +1. In Telegram, open a chat with **@BotFather**, send `/newbot`, choose a display name and a username ending in `bot`. BotFather answers with a token like `123456789:AA…`. +2. Put it in a variable where BrowserHive runs, for example `export BH_TELEGRAM_TOKEN='123456789:AA…'`, and restart BrowserHive. +3. In the wizard's **Connect** step, tap the one-time link (or scan its QR code with your phone). It opens a chat with your bot and sends `/start`. BrowserHive waits up to two minutes for it and fills in the chat by itself. For a group, use **Add to a group** instead, pick the group, and the bot posts there. For a forum topic, send the start link inside that topic. + +Messages use Telegram's HTML formatting. A notification with a screenshot is a photo with a caption (Telegram allows 1 024 characters in a caption; longer messages end with "… Open in BrowserHive"). Buttons are links. When an attention request is resolved, the message is edited in place, silently, and its buttons disappear. + +Telegram lets a bot delete its own messages for **48 hours** only, so self-destruct timers on a Telegram channel go up to 47 hours. Telegram's own auto-delete timer (chat settings → Auto-delete messages) is a good backstop. + +### Discord + +1. In Discord, open the channel's settings: **Edit Channel → Integrations → Webhooks → New Webhook**. Name it, optionally set an avatar, then **Copy Webhook URL**. +2. Put the URL in a variable, for example `export BH_DISCORD_WEBHOOK='https://discord.com/api/webhooks/…'`, and restart BrowserHive. The URL is the secret: anyone who has it can post to your channel. +3. In the wizard, choose **Webhook** mode. There is nothing else to connect. + +Messages are an embed: a coloured bar by severity, the facts as fields, the screenshot as the embed image, and link buttons below. Edits are silent; deletes work at any age. + +**Webhook or bot?** A Discord channel uses one mode. Webhook mode takes thirty seconds and needs no connection, but its buttons can only open links. Bot mode (a Developer Portal application with a bot token) is the only way to press **Approve** or **Reject** right in Discord, and keeps one outbound connection to Discord while BrowserHive runs. Bot mode arrives with act buttons in a later release; the wizard's **What's the difference?** panel shows both message styles side by side. + +### ntfy + +[ntfy](https://ntfy.sh) is a free, open-source push service with apps for Android and iOS. You can use the public server `ntfy.sh` or host your own. + +1. Install the ntfy app on your phone. +2. In the wizard, keep the server (`https://ntfy.sh`) or enter your own, and keep the suggested random topic (for example `bh-7f3kq9x2`) or type one. **On a public server the topic is the password**: anyone who knows it can read your notifications, so keep it long and random. You can also keep it in a variable (`topic` from `BH_NTFY_TOPIC`). +3. Scan the QR code with your phone (or tap **Subscribe** in the app and enter the server and topic). +4. For a protected server or topic, create an access token in ntfy and put it in a variable such as `BH_NTFY_TOKEN`. + +Priority follows severity (a critical notification is urgent). Buttons are "view" actions (at most three). A revision replaces the notification on the phone; a self-destruct deletes it. On ntfy.sh, attachments (screenshots) are stored on the public server for three hours and their links are not documented to be private: for screenshots, a self-hosted ntfy is the better choice. A self-hosted server without an attachment cache refuses uploads; BrowserHive then sends the text alone. + +### Webhook + +The webhook channel POSTs the [message contract](#the-message-contract) itself as JSON, so you can build your own consumer (Home Assistant, n8n, a script): + +```json +{ + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790642672932, + "channel": { "id": "nc-…", "name": "ops" }, + "links": { "take-over": "https://bh.example.net/sessions/…?live=1&takeover=1" }, + "local_links": false, + "message": { "schema": 1, "id": "n-…", "revision": 1, "kind": "attention.requested", "…": "…" } +} +``` + +A revision is POSTed again with `op: "edit"` and a higher `message.revision`; keep the highest. When you set a signing secret (`secret` from a variable such as `BH_WEBHOOK_SECRET`), each request carries `X-BrowserHive-Timestamp` and `X-BrowserHive-Signature: sha256=`, the HMAC-SHA256 of the raw body with your secret. Only `http:` and `https:` URLs are accepted, redirects are followed only on the same scheme and host, and a private address (your LAN) is allowed with a warning, because the request comes from inside your network. + +## Public address + +Links in a notification ("Take over", "Open session") must open somewhere your phone can reach. By default they point at this computer (`http://127.0.0.1:9876/…`) and are labelled **Open on this computer**; they work on the machine running BrowserHive and nowhere else. + +Set `publicUrl` to the address where you made the dashboard reachable, and every link becomes `publicUrl + path`: + +```sh +browserhive --admin --publicUrl https://browserhive.example.net +# or BROWSERHIVE_PUBLIC_URL=https://…, or "publicUrl" in browserhive.config.json +``` + +BrowserHive provides no tunnel or proxy; use what you already have: + +- **Tailscale (nothing exposed to the internet).** Install Tailscale on the computer and your phone, then either browse to the machine's Tailscale name directly (`--host 0.0.0.0 --auth token --publicUrl http://my-box.tail1234.ts.net:9876`) or let Tailscale proxy it with HTTPS: `tailscale serve --bg 9876` and `--publicUrl https://my-box.tail1234.ts.net`. +- **A reverse proxy with your domain** (Caddy, nginx, Traefik) forwarding to `127.0.0.1:9876`, ideally behind an access layer such as Cloudflare Access or your proxy's own login. +- **Cloudflare Tunnel** (`cloudflared tunnel --url http://127.0.0.1:9876`) with Cloudflare Access in front. + +The host of `publicUrl` is trusted automatically: you do not need to add it to `allowedHosts`, and the CSRF check accepts its origin even when your proxy rewrites the `Host` header to the upstream address. Links never carry a token; opening one still needs the dashboard login. BrowserHive warns when `publicUrl` is plain `http:` on a host that is not your own machine. + +**Checking it.** The System page and `browserhive doctor` fetch `/health` and compare an id that changes at every start: + +| Result | Meaning | +|---|---| +| ✓ Points to this BrowserHive | The address reaches this instance. | +| ✗ Points elsewhere | Another server (or another BrowserHive) answered. | +| ! Behind a login | A login page or an access proxy answered, so it cannot be confirmed from here. That is expected with Cloudflare Access. | +| ! Not reachable from this machine | Nothing answered from here. It may still work from outside, for example behind a router without hairpin NAT. | + +The final proof is the **Open dashboard** button of a test message, tapped on your phone. + +## Screenshots + +A notification can carry a screenshot of what the agent was looking at. Screenshots are **off** by default and switched on per channel and category (in the wizard's **Advanced** step). They are taken for three things only: + +- **An attention request**, CAPTCHA hand-offs included: a picture of the page when the agent asked for help. +- **A vault fill waiting for confirmation**: the login page and the site being filled, taken **before** the fill starts (the fill waits for your approval). BrowserHive never takes a screenshot during or after a fill, or while a secret is being typed. +- **A crashed session**: its last stored screenshot, if it has one. + +Screenshots need the content level **full**, and BrowserHive never takes them when `recordToolResults` is `none`. **Mask form fields** (on by default when you enable screenshots) blacks out inputs, text areas and menus before the picture is taken; a crash's stored frame cannot be masked, so a masking channel gets no crash screenshot. Screenshots are JPEG files in the data directory, readable only by you, and are removed after 7 days. + +## Self-destruct + +No chat platform lets a bot set a timer on a message, so BrowserHive deletes its messages itself: + +- **Delete after** a time you choose per category (for example: needs-you after 2 hours, problems after 1 day, reports never). The default is never. +- **Delete when resolved**, per category, off by default: the message goes away once the request is resolved. + +The deadline is stored with the message, so a deletion that fell due while BrowserHive was stopped happens at the next start. Telegram only allows deleting within 48 hours; a Telegram message that became older while BrowserHive was off is logged as `could_not_delete: too_old`. Deleting removes the message for everyone in the chat, but it cannot take back a lock-screen preview someone already saw. + +## Startup channels + +For a server or a container you can declare channels on the command line; they exist from the first start without any clicking. Repeat the flag for several channels: + +```sh +export BH_TG_TOKEN='123456789:AA…' +browserhive --admin \ + --notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456789" \ + --notificationChannel "ntfy:name=pager,topic=env:BH_NTFY_TOPIC,categories=needs-you+problems,min=warn" +``` + +- Secrets are always written `env:NAME`. A token typed into the flag is refused (exit 64), because other users of the machine can read process arguments. +- Rules use the same names as the dashboard: `categories`, `min`, `sessions`, `harness`, `content`, `quiet=22:00-07:00` with `tz`, `ttl.needs-you=2h`, `deleteWhenResolved`, `images`, `maskImages`. Every parameter is listed in the [command-line guide](cli.md#notification-channels). +- The flag has no environment-variable or config-file spelling. Startup channels appear in the dashboard with a **from startup** badge. You can pause them there, but you edit them by changing the flag and restarting. A startup channel whose name a dashboard channel already uses stops the start with an error, so neither silently wins. + +## Delivery log + +**Notifications → Delivery log** lists every send, edit and delete, live: time, channel, notification, revision, status, attempts and how long the platform took. Filter by channel, status, operation or kind. Open a row to see the notification's journey on every channel and the message exactly as that channel was shown it. Every row that was not delivered says why in a sentence: filtered by the channel's rules, quiet hours, the channel was paused, the platform refused the token, the message had been deleted in the chat, and so on. The same answers are in `GET /api/v1/channels/deliveries`. + +From a terminal: + +```sh +browserhive channels list # status, target, variables, last delivery, 24 h counts +browserhive channels test phone # a real test message; exit 1 when the platform refused it +browserhive channels preview phone --sample vault-confirm # the request a send would make; sends nothing +``` + +These talk to the running server (`--url`, with `--token` for an operator API token or `--cookie`), like `browserhive admin tokens --url`. + +## What leaves your machine + +A channel sends notifications to a service you chose, so each one has a **content level**: + +| Level | What it sends | +|---|---| +| `counts` | The kind of event, a count and the session name. Nothing typed by an agent, no page addresses. | +| `titles` (default) | Adds the title, the summary and the facts (session, tool, error code, page address without query string). | +| `full` | Adds the agent's own words (the attention reason), longer details, and allows [screenshots](#screenshots). | + +At every level, BrowserHive first removes registered secrets and credential-shaped text and strips query strings and fragments from URLs ([security](security.md)). Channel tokens never reach a log line, the database or the delivery log. + ## 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. diff --git a/docs/guide/security.md b/docs/guide/security.md index 25a9af6..b81079e 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -11,7 +11,7 @@ Binding a non-loopback address (for example `--host 0.0.0.0`) is **refused** unl - `--auth token` is set, or - `--allowInsecureBind` is set, which you should only do on a network you fully control. The startup banner then shows a red warning. -The refusal is [`INSECURE_BIND_REFUSED`](../reference/errors.md#INSECURE_BIND_REFUSED) with exit code 3. BrowserHive does not terminate TLS; for remote access, put a reverse proxy with TLS in front and list it in `--trustedProxies` so client addresses and `X-Forwarded-Proto` are honoured. Requests whose `Host` header names anything other than loopback, the bind address or an entry of `--allowedHosts` are rejected (DNS rebinding), so add the public name the proxy forwards, for example `--allowedHosts browserhive.example.com`. The port is never compared. +The refusal is [`INSECURE_BIND_REFUSED`](../reference/errors.md#INSECURE_BIND_REFUSED) with exit code 3. BrowserHive does not terminate TLS; for remote access, put a reverse proxy with TLS in front and list it in `--trustedProxies` so client addresses and `X-Forwarded-Proto` are honoured. Requests whose `Host` header names anything other than loopback, the bind address or an entry of `--allowedHosts` are rejected (DNS rebinding), so add the public name the proxy forwards, for example `--allowedHosts browserhive.example.com`. The port is never compared. Setting `--publicUrl` (the address you reach the dashboard at, used for [notification links](notifications.md#public-address)) trusts its host the same way and also accepts its origin in the CSRF check, which a proxy that rewrites `Host` needs. ## Authentication @@ -71,6 +71,7 @@ Failures are returned as a status and reason (`origin_mismatch`, `not_authorized - **Pixels are not redacted.** The live view and screenshots show exactly what the browser shows. Screenshot tracing skips frames while a redaction window is open, but operators are trusted with the live view. - Every secret BrowserHive creates or handles (seed password, tokens, cookies, vault session tokens, OTLP headers) is registered with a redactor that scrubs logs, database rows, WebSocket frames, MCP results and OTLP exports. That includes secrets the config file reads from environment variables (`{env:CI_TOKEN}`, see [References](configuration.md#references)): the variable's name is shown so you know what to set, never its value or the file's text around it. - **Notifications are redacted before they are stored or sent.** Every text a notification copies from an event (an agent's attention reason, a page address, an error) goes through the same redactor, and URLs lose their query strings. A notification channel keeps its tokens in environment variables; BrowserHive stores only the variable names, so a database backup never contains one. See [Notifications](notifications.md). +- **Channels send only what you allow.** Each channel has a content level (counts, titles or full); screenshots are off by default, need `full`, are never taken during a vault fill and can mask form fields; the webhook channel can sign its requests. Links in notifications never carry a token. See [what leaves your machine](notifications.md#what-leaves-your-machine). ## What is recorded 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/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/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/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/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/banner.ts b/packages/browserhive/src/composition/banner.ts index 23ef88a..7a70279 100644 --- a/packages/browserhive/src/composition/banner.ts +++ b/packages/browserhive/src/composition/banner.ts @@ -43,6 +43,12 @@ export interface BannerFacts { readonly agentToken: { readonly principalId: string; readonly token: string } | null; /** Browser binary missing (degraded, not fatal). */ readonly browserMissing: boolean; + /** Notification channels and where their links point (shown when either is configured). */ + readonly notifications?: { + readonly channels: number; + readonly startup: number; + readonly publicUrl: string | null; + }; } /** Label column width (`Dashboard` + gap). */ @@ -117,6 +123,16 @@ export function renderBanner(facts: BannerFacts, options: { readonly color: bool : `${facts.vault.backend} (${facts.vault.unlocked ? 'unlocked' : 'locked'})`, ), ); + const notify = facts.notifications; + if (notify !== undefined && (notify.channels > 0 || notify.publicUrl !== null)) { + const count = + notify.channels === 0 + ? 'no channels' + : `${notify.channels} channel${notify.channels === 1 ? '' : 's'}${notify.startup > 0 ? ` (${notify.startup} from startup)` : ''}`; + const links = + notify.publicUrl === null ? 'links open on this computer' : `links → ${notify.publicUrl}`; + lines.push(row('Notify', `${count} · ${links}`)); + } const notes: string[] = []; for (const line of facts.shadowLines) notes.push(` ${line}`); if (facts.browserMissing) { 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/src/composition/phases/ready.ts b/packages/browserhive/src/composition/phases/ready.ts index 9038f04..48e8ac6 100644 --- a/packages/browserhive/src/composition/phases/ready.ts +++ b/packages/browserhive/src/composition/phases/ready.ts @@ -55,6 +55,11 @@ export async function bannerFacts(ctx: BootContext): Promise { adminPassword: domain.seeds.adminPassword, agentToken: domain.seeds.agentToken, browserMissing: !domain.browserInstalled, + notifications: { + channels: domain.channels.channels().length, + startup: domain.channels.channels().filter((c) => c.record.source === 'startup').length, + publicUrl: config.publicUrl ?? null, + }, }; } 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 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/browserhive/test/cli/helpers.ts b/packages/browserhive/test/cli/helpers.ts index c1bdb33..26df60c 100644 --- a/packages/browserhive/test/cli/helpers.ts +++ b/packages/browserhive/test/cli/helpers.ts @@ -1,10 +1,12 @@ /** @module test/cli/helpers — fake `CliDeps` for the CLI suites: in-memory filesystem and config fs, scripted probes, fake storage, captured streams */ + import { dirname } from 'node:path'; import type { Channel } from '@browserhive/contracts/enums'; import type { ConfigFs } from '@browserhive/core/config'; import type { FileStat, FileSystem } from '@browserhive/core/ports/file-system'; import type { HostEnvironment } from '@browserhive/core/ports/host-environment'; import type { ProcessRunner, ProcessRunResult } from '@browserhive/core/ports/process-runner'; +import type { UrlProbeResult } from '@browserhive/core/runtime'; import type { DetectedBrowser, SandboxEnvironment, @@ -151,6 +153,8 @@ export interface StorageState { resets: number; opened: { readOnly: boolean; migrate: boolean; owner: string }[]; closed: number; + /** Configured notification channels. */ + channels: { name: string; kind: string; source: string; secretRefs: Record }[]; } /** 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/browserhive/test/cli/plan.test.ts b/packages/browserhive/test/cli/plan.test.ts index d28693e..f4a0c67 100644 --- a/packages/browserhive/test/cli/plan.test.ts +++ b/packages/browserhive/test/cli/plan.test.ts @@ -152,6 +152,26 @@ describe('planCli precedence', () => { }); }); + it('--notificationChannel: parsed for serve, an inline secret is 64 without echoing it', () => { + const ok = plan(['--notificationChannel', 'ntfy:name=pager,topic=bh-x'], {}); + if (ok.kind !== 'run' || ok.invocation.command !== 'serve') throw new Error('expected serve'); + expect(ok.invocation.startupChannels.map((c) => c.name)).toEqual(['pager']); + expect(ok.invocation.channelWarnings).toHaveLength(1); + const secret = `1234:${'z'.repeat(35)}`; + const refused = exitOf( + plan(['--notificationChannel', `telegram:name=phone,token=${secret},chat=1`]), + ); + expect(refused).toEqual({ + code: 64, + text: "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.", + }); + expect(refused.text).not.toContain('zzzz'); + expect(exitOf(plan([], { BROWSERHIVE_NOTIFICATION_CHANNEL: 'x' })).text).toContain( + 'declared with the --notificationChannel flag', + ); + expect(commandOf(plan(['doctor', '--notificationChannel', 'bogus']))).toBe('doctor'); + }); + it('stray flags win over invalid config values', () => { const { text } = exitOf(plan(['--all', '--port', 'abc'])); expect(text).toContain('--all only applies'); diff --git a/packages/browserhive/test/composition/boot.test.ts b/packages/browserhive/test/composition/boot.test.ts index 116c5b1..c5d1110 100644 --- a/packages/browserhive/test/composition/boot.test.ts +++ b/packages/browserhive/test/composition/boot.test.ts @@ -160,6 +160,48 @@ describe('bootServer', () => { expect(existsSync(lock)).toBe(false); }); + it('projects startup channels, shows them in the banner, and stops on a name clash', async () => { + const channel = { + name: 'pager', + kind: 'ntfy' as const, + mode: null, + target: { topic: 'bh-test' }, + secret_refs: {}, + rules: {}, + }; + const input = { + ...bootInputFor(dir.path, { admin: true, publicUrl: 'https://bh.example.net' }), + startupChannels: [channel], + }; + const server = await bootServer(input); + expect(input.output.out.join('\n')).toContain( + 'Notify 1 channel (1 from startup) · links → https://bh.example.net', + ); + await server.stop(); + // A dashboard channel with the same name: the start refuses (D-39). + const quiet = { + child: () => quiet, + isLevelEnabled: () => false, + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + }; + const storage = await openStorageForCli({ + dataDir: dir.path, + readOnly: false, + migrate: false, + logger: quiet, + }); + const row = (await storage.repos.notificationChannels.list())[0]; + if (row === undefined) throw new Error('startup channel was not projected'); + await storage.repos.notificationChannels.upsert({ ...row, source: 'db' }); + await storage.close(); + const clash = { ...bootInputFor(dir.path), startupChannels: [channel] }; + await expect(bootServer(clash)).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); + }); + it('prints the banner through output once ready, with the seed secrets on first start', async () => { const input = bootInputFor(dir.path, { admin: true, auth: 'token' }); const server = await bootServer(input); 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/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/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/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/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/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-registry.ts b/packages/core/src/app/notifications/channel-registry.ts index 1c670c2..76bab81 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}. */ @@ -82,6 +87,7 @@ export class ChannelRegistry { }, { message: `notification channel '${spec.name}' is defined by --notificationChannel and in the dashboard. Rename one of them.`, + publicMessage: `notification channel '${spec.name}' is defined by --notificationChannel and in the dashboard. Rename one of them.`, }, ); } @@ -158,7 +164,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 +177,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/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts new file mode 100644 index 0000000..a6247a4 --- /dev/null +++ b/packages/core/src/app/notifications/channel-service.ts @@ -0,0 +1,1053 @@ +/** @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(); + // 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: named(r.path), + encoding: r.encoding, + 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 { + 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( + 'Links open only on this computer. To open them from your phone, set publicUrl to the address where you reach this dashboard.', + ); + } + if (!caps.actButtons && (sample === 'attention' || sample === 'vault-confirm')) { + notes.push('Approve and Reject open BrowserHive, where you answer the request.'); + } + 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/images.test.ts b/packages/core/src/app/notifications/images.test.ts new file mode 100644 index 0000000..e9abf05 --- /dev/null +++ b/packages/core/src/app/notifications/images.test.ts @@ -0,0 +1,82 @@ +/** @module app/notifications/images.test — the per-channel screenshot rule (D-36): which variant a channel sees, and which variants a notification is captured with. */ +import { describe, expect, it } from 'bun:test'; +import type { Block, NotificationMessage } from '@browserhive/contracts/notifications'; +import { applyImageRule, imageVariants, wantsImages } from './images.ts'; +import { sampleMessage } from './samples.ts'; + +const image = (ref: string, masked: boolean): Block => ({ + type: 'image', + ref, + alt: 'page', + captured_at: 1, + masked, + path: '/sessions/x', +}); + +function withImages(...blocks: Block[]): NotificationMessage { + const base = sampleMessage('attention'); + return { + ...base, + blocks: [...base.blocks, ...blocks], + privacy: { level: 'full', has_image: true }, + }; +} + +const refs = (m: NotificationMessage) => + m.blocks.flatMap((b) => (b.type === 'image' ? [b.ref] : [])); + +describe('applyImageRule', () => { + const both = withImages(image('nimg-plain', false), image('nimg-mask', true)); + + it('drops every image unless the category is on at level full', () => { + expect(refs(applyImageRule(both, {}))).toEqual([]); + expect(refs(applyImageRule(both, { images: { 'needs-you': true } }))).toEqual([]); + expect(refs(applyImageRule(both, { content: 'full', images: { problems: true } }))).toEqual([]); + expect(applyImageRule(both, {}).privacy.has_image).toBe(false); + }); + + it('gives a masking channel the masked variant only, others the unmasked one', () => { + const on = { content: 'full', images: { 'needs-you': true } } as const; + expect(refs(applyImageRule(both, { ...on, mask_images: true }))).toEqual(['nimg-mask']); + expect(refs(applyImageRule(both, on))).toEqual(['nimg-plain']); + expect(applyImageRule(both, on).privacy.has_image).toBe(true); + }); + + it('a non-masking channel falls back to the masked frame; a masking one never sees an unmasked frame', () => { + const on = { content: 'full', images: { 'needs-you': true } } as const; + expect(refs(applyImageRule(withImages(image('nimg-mask', true)), on))).toEqual(['nimg-mask']); + expect( + refs(applyImageRule(withImages(image('nimg-plain', false)), { ...on, mask_images: true })), + ).toEqual([]); + }); + + it('leaves a message without images untouched', () => { + const plain = sampleMessage('tool-errors'); + expect(applyImageRule(plain, {})).toBe(plain); + }); +}); + +describe('imageVariants', () => { + it('asks for a masked capture when a wanting channel masks, unmasked when one does not', () => { + const on = { content: 'full' as const, images: { 'needs-you': true } }; + expect(imageVariants([], 'needs-you')).toEqual({ masked: false, unmasked: false }); + expect( + imageVariants( + [ + { status: 'active', rules: { ...on, mask_images: true } }, + { status: 'active', rules: on }, + ], + 'needs-you', + ), + ).toEqual({ masked: true, unmasked: true }); + expect(imageVariants([{ status: 'paused', rules: on }], 'needs-you')).toEqual({ + masked: false, + unmasked: false, + }); + expect(imageVariants([{ status: 'active', rules: on }], 'problems')).toEqual({ + masked: false, + unmasked: false, + }); + expect(wantsImages({ images: { 'needs-you': true } }, 'needs-you')).toBe(false); + }); +}); 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/index.ts b/packages/core/src/app/notifications/index.ts index 3efa94c..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, @@ -72,3 +100,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/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.test.ts b/packages/core/src/app/notifications/notification-service.test.ts index 0d91d3f..997a8f6 100644 --- a/packages/core/src/app/notifications/notification-service.test.ts +++ b/packages/core/src/app/notifications/notification-service.test.ts @@ -449,3 +449,71 @@ describe('NotificationService startup catch-up', () => { expect(await service.reconcileRequests({ get: async (id) => settled.get(id) ?? null })).toBe(0); }); }); + +describe('screenshots (D-36)', () => { + function shotSetup(options: { + readonly enabled?: boolean; + readonly variants?: { masked: boolean; unmasked: boolean }; + readonly slow?: boolean; + }) { + const repo = new InMemoryNotificationRepository(); + const calls: string[] = []; + const service = new NotificationService({ + repo, + bus: new RecordingEventBus(), + clock: new FakeClock(), + ids: new FakeIdGenerator(), + logger: new CollectingLogger(), + screenshots: { + enabled: options.enabled ?? true, + timeoutMs: 20, + variants: () => options.variants ?? { masked: true, unmasked: true }, + snapshots: { + capture: async (sessionId, { masked }) => { + calls.push(`capture:${sessionId}:${masked ? 'masked' : 'plain'}`); + if (options.slow === true) await new Promise((r) => setTimeout(r, 200)); + return { ref: masked ? 'nimg-mask' : 'nimg-plain', capturedAt: 7 }; + }, + lastFrame: async (sessionId) => { + calls.push(`last:${sessionId}`); + return { ref: 'nimg-last', capturedAt: 3 }; + }, + }, + }, + }); + const images = (id: string) => { + const message = NotificationMessage.parse(JSON.parse(repo.rows.get(id)?.messageJson ?? '{}')); + return message.blocks.flatMap((b) => (b.type === 'image' ? [[b.ref, b.masked]] : [])); + }; + return { service, calls, images }; + } + + it('captures the variants the channels want for an attention request', async () => { + const { service, calls, images } = shotSetup({}); + const [dto] = await service.produce(attentionCreated('a-000000000001', 'takeover')); + expect(calls).toEqual([`capture:${SESSION}:plain`, `capture:${SESSION}:masked`]); + expect(images(dto?.notification_id ?? '')).toEqual([ + ['nimg-plain', false], + ['nimg-mask', true], + ]); + }); + + it('uses the last stored frame for a crash, never masked', async () => { + const { service, calls, images } = shotSetup({ variants: { masked: true, unmasked: false } }); + const [dto] = await service.produce(sessionClosed('crash')); + expect(calls).toEqual([`last:${SESSION}`]); + expect(images(dto?.notification_id ?? '')).toEqual([['nimg-last', false]]); + }); + + it('captures nothing when no channel wants it, when disabled, or when it times out', async () => { + const none = shotSetup({ variants: { masked: false, unmasked: false } }); + await none.service.produce(attentionCreated('a-000000000001', 'notify')); + expect(none.calls).toEqual([]); + const off = shotSetup({ enabled: false }); + await off.service.produce(vaultConfirmCreated('a-000000000009', 'github')); + expect(off.calls).toEqual([]); + const slow = shotSetup({ slow: true, variants: { masked: false, unmasked: true } }); + const [dto] = await slow.service.produce(attentionCreated('a-000000000002', 'notify')); + expect(slow.images(dto?.notification_id ?? '')).toEqual([]); + }); +}); 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/app/notifications/public-url.test.ts b/packages/core/src/app/notifications/public-url.test.ts new file mode 100644 index 0000000..57223b6 --- /dev/null +++ b/packages/core/src/app/notifications/public-url.test.ts @@ -0,0 +1,159 @@ +/** @module app/notifications/public-url.test — the `publicUrl` check outcomes (spec 08 §5.8), the checker's cache, the host/origin helpers and the link builders (D-37). */ +import { describe, expect, it } from 'bun:test'; +import type { UrlProbeResult } from '../../ports/notification-channel.ts'; +import { createLocalLinkBuilder, createPublicLinkBuilder, linkBuilderFor } from './links.ts'; +import { + classifyPublicUrlProbe, + isInsecurePublicUrl, + PublicUrlChecker, + publicUrlHost, + publicUrlOrigin, +} from './public-url.ts'; + +const json = (status: number, body: unknown): UrlProbeResult => ({ + kind: 'response', + status, + contentType: 'application/json', + location: null, + body: JSON.stringify(body), +}); +const health = (id?: string) => ({ + status: 'ready', + version: '0.2.0', + ...(id !== undefined && { instance_id: id }), +}); + +describe('classifyPublicUrlProbe', () => { + it('ok when this instance answers, elsewhere when another one does', () => { + expect(classifyPublicUrlProbe(json(200, health('me')), 'me').outcome).toBe('ok'); + expect(classifyPublicUrlProbe(json(503, health('me')), 'me').outcome).toBe('ok'); + expect(classifyPublicUrlProbe(json(200, health('other')), 'me').outcome).toBe('elsewhere'); + expect(classifyPublicUrlProbe(json(200, health()), 'me').outcome).toBe('elsewhere'); + }); + + it('without a running server a BrowserHive answer is ok but unconfirmed', () => { + const verdict = classifyPublicUrlProbe(json(200, health('x')), null); + expect(verdict.outcome).toBe('ok'); + expect(verdict.detail).toContain('confirm'); + }); + + it('login for redirects, 401/403/407 and HTML pages', () => { + const redirect: UrlProbeResult = { + kind: 'response', + status: 302, + contentType: null, + location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login', + body: '', + }; + expect(classifyPublicUrlProbe(redirect, 'me')).toMatchObject({ + outcome: 'login', + statusCode: 302, + }); + expect(classifyPublicUrlProbe(redirect, 'me').detail).toContain('team.cloudflareaccess.com'); + for (const status of [401, 403, 407]) { + expect(classifyPublicUrlProbe(json(status, {}), 'me').outcome).toBe('login'); + } + const html: UrlProbeResult = { + kind: 'response', + status: 200, + contentType: 'text/html; charset=utf-8', + location: null, + body: 'Sign in', + }; + expect(classifyPublicUrlProbe(html, 'me').outcome).toBe('login'); + }); + + it('unreachable for network errors, elsewhere for other servers', () => { + const verdict = classifyPublicUrlProbe({ kind: 'error', detail: 'ECONNREFUSED' }, 'me'); + expect(verdict.outcome).toBe('unreachable'); + expect(verdict.detail).toContain('hairpin'); + expect(classifyPublicUrlProbe(json(404, { error: 'nope' }), 'me').outcome).toBe('elsewhere'); + }); +}); + +describe('PublicUrlChecker', () => { + it('reports unset without probing', async () => { + let probes = 0; + const checker = new PublicUrlChecker({ + publicUrl: undefined, + localUrl: () => 'http://127.0.0.1:9876', + instanceId: 'me', + probe: async () => { + probes++; + return json(200, health('me')); + }, + clock: { now: () => 0, sleep: async () => undefined }, + }); + expect(await checker.status(true)).toMatchObject({ + configured: false, + outcome: 'unset', + url: null, + local_url: 'http://127.0.0.1:9876', + host_trusted: false, + }); + expect(probes).toBe(0); + }); + + it('probes /health, caches a minute, refreshes on demand', async () => { + let now = 1_000; + const urls: string[] = []; + const checker = new PublicUrlChecker({ + publicUrl: 'https://bh.example.net', + localUrl: () => 'http://127.0.0.1:9876', + instanceId: 'me', + probe: async (url) => { + urls.push(url); + return json(200, health('me')); + }, + clock: { now: () => now, sleep: async () => undefined }, + }); + expect(await checker.status()).toMatchObject({ outcome: 'ok', checked_at: 1_000 }); + now += 30_000; + await checker.status(); + expect(urls).toEqual(['https://bh.example.net/health']); + await checker.status(true); + now += 61_000; + await checker.status(); + expect(urls).toHaveLength(3); + }); + + it('turns a throwing probe into unreachable', async () => { + const checker = new PublicUrlChecker({ + publicUrl: 'https://bh.example.net', + localUrl: () => 'x', + instanceId: 'me', + probe: async () => { + throw new Error('boom'); + }, + clock: { now: () => 0, sleep: async () => undefined }, + }); + expect((await checker.status()).outcome).toBe('unreachable'); + }); +}); + +describe('host, origin and links', () => { + it('extracts the trusted host and origin', () => { + expect(publicUrlHost('https://BH.example.net:8443/bh')).toBe('bh.example.net'); + expect(publicUrlHost('http://[::1]:9876')).toBe('::1'); + expect(publicUrlOrigin('https://bh.example.net:8443/bh')).toBe('https://bh.example.net:8443'); + expect(publicUrlHost(undefined)).toBeNull(); + }); + + it('warns about plain http on a public host only', () => { + expect(isInsecurePublicUrl('http://bh.example.net')).toBe(true); + expect(isInsecurePublicUrl('http://localhost:9876')).toBe(false); + expect(isInsecurePublicUrl('https://bh.example.net')).toBe(false); + expect(isInsecurePublicUrl(undefined)).toBe(false); + }); + + it('builds public links under a path prefix, local ones against the listener', () => { + expect(createPublicLinkBuilder('https://bh.example.net/bh').url('/sessions/x?live=1')).toBe( + 'https://bh.example.net/bh/sessions/x?live=1', + ); + const local = createLocalLinkBuilder(() => 'http://127.0.0.1:9876/'); + expect(local.local).toBe(true); + expect(local.url('/notifications')).toBe('http://127.0.0.1:9876/notifications'); + expect(linkBuilderFor('https://a.example.net', () => 'x').local).toBe(false); + expect(linkBuilderFor(undefined, () => 'http://h').url('/p')).toBe('http://h/p'); + }); +}); 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/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); +} 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/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..8cedd64 --- /dev/null +++ b/packages/core/src/infra/notifications/http.ts @@ -0,0 +1,273 @@ +/** @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. */ +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 = serializeError(err).message; + 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..13c82f4 --- /dev/null +++ b/packages/core/src/infra/notifications/index.ts @@ -0,0 +1,156 @@ +/** @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, + telegramAcceptsUrl, + 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..f85499b --- /dev/null +++ b/packages/core/src/infra/notifications/ntfy.ts @@ -0,0 +1,373 @@ +/** @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: 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: true, + 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'); + }, + }); + + /** Publishes the text of a binary request as JSON, keeping its sequence id (no screenshot). */ + function publishText(path: string, request: RenderedRequest): Promise { + 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, + ); + } + + 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); + // The screenshot is gone (pruned): publish the text alone, keeping the sequence id. + if (image === null) return publishText(path, request); + try { + return await 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, + ); + } catch (err) { + // A self-hosted ntfy without an attachment cache (or with a smaller size limit) refuses + // uploads: the notification still goes out, without its screenshot. + if ( + err instanceof ChannelSendError && + err.code === 'rejected' && + /attachment/i.test(err.message) + ) { + return publishText(path, request); + } + throw err; + } + } + 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..bee0482 --- /dev/null +++ b/packages/core/src/infra/notifications/telegram.ts @@ -0,0 +1,576 @@ +/** @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 = linksAsText(links)
+          ? 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 = linksAsText(links) ? 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;
+}
+
+/**
+ * Whether Telegram accepts `url` in a URL button or an ``: it refuses hosts without a dot
+ * (`localhost`, a bare machine name) and IPv6 literals ("Wrong HTTP URL"), and accepts domains and
+ * IPv4 addresses (checked against the Bot API on 2026-09-28).
+ */
+export function telegramAcceptsUrl(url: string): boolean {
+  let host: string;
+  try {
+    host = new URL(url).hostname;
+  } catch {
+    return false;
+  }
+  if (host.startsWith('[')) return false;
+  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes('.');
+}
+
+/** Links go into the text instead of buttons: local links, or a base Telegram would refuse. */
+function linksAsText(links: LinkBuilder): boolean {
+  return links.local || !telegramAcceptsUrl(links.url('/'));
+}
+
+function localLinks(message: NotificationMessage, links: LinkBuilder): Frag {
+  const resolved = openLinks(message, links);
+  if (resolved.length === 0) return EMPTY;
+  return lines([
+    wrap('b', plain(links.local ? `🖥 ${LOCAL_LINKS_LABEL}` : '🔗 Links', 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 (!linksAsText(links)) 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..207247f
--- /dev/null
+++ b/packages/core/src/infra/notifications/url-probe.ts
@@ -0,0 +1,68 @@
+/** @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';
+
+/** 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`
+          : serializeError(err).message.replace(/https?:\/\/\S+/g, '');
+      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 } };
+    },
+  };
+}
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/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/middleware/public-url-trust.test.ts b/packages/core/src/interface/http/middleware/public-url-trust.test.ts
new file mode 100644
index 0000000..1f365e2
--- /dev/null
+++ b/packages/core/src/interface/http/middleware/public-url-trust.test.ts
@@ -0,0 +1,59 @@
+/** @module interface/http/middleware/public-url-trust.test — `publicUrl` is trusted by the host guard and the origin guard (spec 03 §2, D-37): a reverse proxy that rewrites `Host` to the upstream address still passes the CSRF check. */
+import { describe, expect, it } from 'bun:test';
+import { Hono } from 'hono';
+import { CollectingLogger } from '../../../../test/helpers/collecting-logger.ts';
+import type { HttpEnv } from '../env.ts';
+import { errorHandler } from './error-handler.ts';
+import { hostGuard } from './host-guard.ts';
+import { originGuard } from './origin-guard.ts';
+
+function app(trusted: readonly string[], allowedHosts: readonly string[]) {
+  const hono = new Hono();
+  hono.use('*', hostGuard({ host: '127.0.0.1', allowedHosts }));
+  hono.use('*', originGuard({ trustedOrigins: trusted }));
+  hono.onError(errorHandler(new CollectingLogger()));
+  hono.post('/x', (c) => c.json({ ok: true }));
+  return hono;
+}
+
+const post = (hono: Hono, headers: Record) =>
+  hono.request('http://127.0.0.1:9876/x', { method: 'POST', headers });
+
+describe('publicUrl trust', () => {
+  it('accepts the public origin on a proxied request whose Host is the upstream address', async () => {
+    const hono = app(['https://bh.example.net'], ['bh.example.net']);
+    const res = await post(hono, {
+      host: '127.0.0.1:9876',
+      origin: 'https://bh.example.net',
+      cookie: 'x=1',
+    });
+    expect(res.status).toBe(200);
+  });
+
+  it('accepts the public host itself and still refuses other origins', async () => {
+    const hono = app(['https://bh.example.net'], ['bh.example.net']);
+    expect(
+      (await post(hono, { host: 'bh.example.net', origin: 'https://bh.example.net' })).status,
+    ).toBe(200);
+    expect(
+      (await post(hono, { host: '127.0.0.1:9876', origin: 'https://evil.example', cookie: 'x=1' }))
+        .status,
+    ).toBe(403);
+  });
+
+  it('without publicUrl the proxied origin is refused', async () => {
+    const hono = app([], []);
+    expect(
+      (
+        await post(hono, {
+          host: '127.0.0.1:9876',
+          origin: 'https://bh.example.net',
+          cookie: 'x=1',
+        })
+      ).status,
+    ).toBe(403);
+    expect(
+      (await post(hono, { host: 'bh.example.net', origin: 'https://bh.example.net' })).status,
+    ).toBe(421);
+  });
+});
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/ports/notification-channel.ts b/packages/core/src/ports/notification-channel.ts
index 71bee41..ca53840 100644
--- a/packages/core/src/ports/notification-channel.ts
+++ b/packages/core/src/ports/notification-channel.ts
@@ -119,3 +119,144 @@ 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;
+
+/** 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/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,
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/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/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) { 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--) { 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/packages/core/test/notifications/adapters.test.ts b/packages/core/test/notifications/adapters.test.ts new file mode 100644 index 0000000..8d35304 --- /dev/null +++ b/packages/core/test/notifications/adapters.test.ts @@ -0,0 +1,469 @@ +/** @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, + telegramAcceptsUrl, + telegramRenderer, + 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('telegram button URLs', () => { + it('accepts domains and IPv4, refuses dotless hosts and IPv6 literals (Bot API behaviour)', () => { + expect(telegramAcceptsUrl('https://bh.example.net/x')).toBe(true); + expect(telegramAcceptsUrl('http://100.101.102.103:9876/x')).toBe(true); + expect(telegramAcceptsUrl('http://127.0.0.1:9876/x')).toBe(true); + expect(telegramAcceptsUrl('http://localhost:9876/x')).toBe(false); + expect(telegramAcceptsUrl('http://mybox:9876/x')).toBe(false); + expect(telegramAcceptsUrl('http://[::1]:9876/x')).toBe(false); + }); + + it('puts links in the text when publicUrl is a host Telegram refuses', () => { + const links = { local: false, url: (path: string) => `http://localhost:9876${path}` }; + const [request] = telegramRenderer.render( + { ...delivery('test', TELEGRAM_CAPABILITIES), links }, + { mode: null, target: { chat_id: '1' }, op: 'send', ref: null, actToken: () => 'x' }, + ); + expect(request?.body['reply_markup']).toBeUndefined(); + expect(String(request?.body['text'])).toContain('🔗 Links'); + }); +}); + +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('falls back to the text when the server refuses attachments (self-hosted, no cache)', async () => { + fakes.script('ntfy:PUT', { + status: 400, + body: { code: 40014, http: 400, error: 'invalid request: attachments not allowed' }, + }); + const channel = ntfy(); + const { ref } = await channel.send( + delivery('attention', NTFY_CAPABILITIES, { image: 'unmasked' }), + ); + const [put, post] = fakes.of('ntfy'); + expect(put?.method).toBe('PUT'); + expect([post?.method, post?.path]).toEqual(['POST', '/']); + expect(post?.json).toMatchObject({ + sequence_id: 'n-sample000001', + title: 'Attention requested', + }); + expect(ref['sequence_id']).toBe('n-sample000001'); + }); + + 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/channel-service.test.ts b/packages/core/test/notifications/channel-service.test.ts new file mode 100644 index 0000000..d819348 --- /dev/null +++ b/packages/core/test/notifications/channel-service.test.ts @@ -0,0 +1,359 @@ +/** @module test/notifications/channel-service.test — the channels API service (spec 03 §4.8.1): views without secret values, CRUD and its refusals, read-only startup channels, pause/resume, the test send through a real adapter against the fakes, the pure preview, the delivery log and its cursor, the env check and the Telegram connect flow. */ +import { afterAll, beforeAll, 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 { ChannelService } from '../../src/app/notifications/channel-service.ts'; +import { createPublicLinkBuilder } from '../../src/app/notifications/links.ts'; +import { CHANNEL_RENDERERS, channelFactories } from '../../src/infra/notifications/index.ts'; +import { AppError } from '../../src/kernel/errors/app-error.ts'; +import type { TelegramSetup } from '../../src/ports/notification-channel.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, InMemoryUnitOfWork } from '../helpers/in-memory-repos.ts'; +import { RecordingEventBus } from '../helpers/recording-event-bus.ts'; + +const fakes = new FakePlatforms(); +beforeAll(() => fakes.start()); +afterAll(() => fakes.stop()); + +const ENV: Record = { + BH_TELEGRAM_TOKEN: `1234:${'a'.repeat(35)}`, + BH_HOOK_SECRET: 'h'.repeat(32), +}; + +interface Kit { + readonly service: ChannelService; + readonly registry: ChannelRegistry; + readonly repos: InMemoryRepositories; + readonly bus: RecordingEventBus; + readonly clock: FakeClock; + readonly started: string[]; +} + +async function kit(options: { readonly startup?: boolean } = {}): Promise { + const repos = new InMemoryRepositories(); + const clock = new FakeClock(); + const ids = new FakeIdGenerator(); + const logger = new CollectingLogger(); + const bus = new RecordingEventBus(); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + env: (name) => ENV[name], + factories: channelFactories({ images: { read: async () => null } }), + }); + await registry.load( + options.startup === true + ? [ + { + name: 'boot', + kind: 'ntfy', + mode: null, + target: { server: fakes.ntfyServer, topic: 'bh-boot' }, + secret_refs: {}, + rules: {}, + }, + ] + : [], + ); + const started: string[] = []; + const telegram: TelegramSetup = { + botUsername: async () => 'bh_test_bot', + waitForStart: async (_token, code) => { + started.push(code); + return { + chat: { id: '-1001234', title: 'Ops', type: 'supergroup', threadId: null }, + user: { id: '42', name: 'Amir' }, + }; + }, + }; + const service = new ChannelService({ + repos, + uow: new InMemoryUnitOfWork(repos), + registry, + renderers: CHANNEL_RENDERERS, + links: createPublicLinkBuilder('https://bh.example.net'), + clock, + ids, + logger, + bus, + env: (name) => ENV[name], + telegram, + schedule: () => undefined, + }); + return { service, registry, repos, bus, clock, started }; +} + +const code = (fn: () => unknown) => { + try { + fn(); + } catch (err) { + return err instanceof AppError ? err.code : 'other'; + } + return 'none'; +}; +const codeOf = async (promise: Promise) => { + try { + await promise; + } catch (err) { + return err instanceof AppError ? err.code : 'other'; + } + return 'none'; +}; + +beforeEach(() => { + fakes.requests.length = 0; +}); + +describe('ChannelService writes', () => { + it('creates a channel whose view names variables but never holds a value', async () => { + const { service, bus } = await kit(); + const view = await service.create({ + name: 'phone', + kind: 'telegram', + target: { chat_id: '-1001234567890', chat_title: 'Ops' }, + secret_refs: { token: 'BH_TELEGRAM_TOKEN' }, + rules: {}, + }); + expect(view).toMatchObject({ + name: 'phone', + source: 'db', + status: 'active', + ready: true, + problem: null, + target_hint: 'Ops (…7890)', + secrets: [{ param: 'token', env: 'BH_TELEGRAM_TOKEN', set: true }], + }); + expect(JSON.stringify(view)).not.toContain('aaaa'); + expect(bus.published.some((e) => e.name === 'channel.changed')).toBe(true); + }); + + it('reports a missing variable as the problem and refuses a test send', async () => { + const { service } = await kit(); + const view = await service.create({ + name: 'team', + kind: 'discord', + target: {}, + secret_refs: { webhook: 'BH_UNSET_WEBHOOK' }, + rules: {}, + }); + expect(view.ready).toBe(false); + expect(view.problem).toContain('BH_UNSET_WEBHOOK'); + expect(await codeOf(service.test(view.channel_id))).toBe('CHANNEL_NOT_READY'); + }); + + it('refuses secret values, reserved names, Telegram TTLs above 47 h, bot mode and taken names', async () => { + const { service } = await kit(); + const base = { name: 'x', kind: 'telegram' as const, target: { chat_id: '1' } }; + expect( + await codeOf(service.create({ ...base, secret_refs: { token: '123:abc' }, rules: {} })), + ).toBe('VALIDATION_FAILED'); + expect( + await codeOf( + service.create({ ...base, secret_refs: { token: 'BROWSERHIVE_TOKEN' }, rules: {} }), + ), + ).toBe('VALIDATION_FAILED'); + expect( + await codeOf( + service.create({ + ...base, + secret_refs: { token: 'BH_TELEGRAM_TOKEN' }, + rules: { ttl_ms: { 'needs-you': 48 * 3600_000 } }, + }), + ), + ).toBe('VALIDATION_FAILED'); + expect( + await codeOf( + service.create({ + name: 'bot', + kind: 'discord', + mode: 'bot', + target: {}, + secret_refs: { webhook: 'BH_W' }, + rules: {}, + }), + ), + ).toBe('CHANNEL_KIND_UNAVAILABLE'); + expect( + await codeOf( + service.create({ + name: 'shots', + kind: 'ntfy', + target: { topic: 't' }, + secret_refs: {}, + rules: { images: { 'needs-you': true } }, + }), + ), + ).toBe('VALIDATION_FAILED'); + await service.create({ + name: 'dup', + kind: 'ntfy', + target: { topic: 't' }, + secret_refs: {}, + rules: {}, + }); + expect( + await codeOf( + service.create({ + name: 'dup', + kind: 'ntfy', + target: { topic: 'u' }, + secret_refs: {}, + rules: {}, + }), + ), + ).toBe('CHANNEL_NAME_TAKEN'); + }); + + it('keeps startup channels read-only but lets them pause and resume', async () => { + const { service, registry } = await kit({ startup: true }); + const boot = registry.channels()[0]?.record.channelId ?? ''; + expect(await codeOf(service.update(boot, { rules: {} }))).toBe('CHANNEL_READ_ONLY'); + expect(await codeOf(service.remove(boot))).toBe('CHANNEL_READ_ONLY'); + expect((await service.pause(boot)).status).toBe('paused'); + expect((await service.resume(boot)).status).toBe('active'); + }); + + it('edits and deletes a dashboard channel', async () => { + const { service, registry } = await kit(); + const view = await service.create({ + name: 'pager', + kind: 'ntfy', + target: { server: fakes.ntfyServer, topic: 'bh-a' }, + secret_refs: {}, + rules: {}, + }); + const edited = await service.update(view.channel_id, { + name: 'pager-2', + rules: { min_severity: 'error' }, + }); + expect(edited).toMatchObject({ name: 'pager-2', rules: { min_severity: 'error' } }); + await service.remove(view.channel_id); + expect(registry.channels()).toHaveLength(0); + expect(code(() => service.preview({ channel_id: view.channel_id, sample: 'test' }))).toBe( + 'CHANNEL_NOT_FOUND', + ); + }); +}); + +describe('ChannelService test send, preview and the delivery log', () => { + it('sends a real test message and records it in the log', async () => { + const { service, bus } = await kit(); + const view = await service.create({ + name: 'hook', + kind: 'webhook', + target: { url: fakes.webhookUrl }, + secret_refs: { secret: 'BH_HOOK_SECRET' }, + rules: {}, + }); + const result = await service.test(view.channel_id); + expect(result.ok).toBe(true); + expect(result.delivery).toMatchObject({ + status: 'sent', + reason: 'test', + notification_kind: 'test', + channel_name: 'hook', + }); + const [post] = fakes.of('webhook'); + const body = post?.json as { message: { title: string }; links: Record }; + expect(body.message.title).toBe('BrowserHive test message'); + expect(body.links['open-dashboard']).toBe('https://bh.example.net/notifications/channels'); + expect(bus.published.some((e) => e.name === 'delivery.updated')).toBe(true); + const page = await service.deliveries({ limit: 10 }); + expect(page.items.map((d) => d.status)).toEqual(['sent']); + }); + + it('reports a platform refusal as a failed test, dead in the log', async () => { + const { service } = await kit(); + fakes.script('webhook', { status: 401, body: { error: 'no' } }); + const view = await service.create({ + name: 'hook', + kind: 'webhook', + target: { url: fakes.webhookUrl }, + secret_refs: {}, + rules: {}, + }); + const result = await service.test(view.channel_id); + expect(result).toMatchObject({ ok: false, error: { code: 'auth' } }); + expect(result.delivery).toMatchObject({ status: 'dead', reason: 'auth' }); + }); + + it('previews a draft and a saved channel with variable names in place of secrets', async () => { + const { service } = await kit(); + const draft = service.preview({ kind: 'discord', sample: 'attention' }); + expect(draft.requests[0]?.path).toContain('{BH_DISCORD_WEBHOOK}'); + expect(draft.notes.some((n) => n.includes('Approve and Reject open BrowserHive'))).toBe(true); + const saved = await service.create({ + name: 'pager', + kind: 'ntfy', + target: { server: 'https://ntfy.example.net' }, + secret_refs: { topic: 'BH_TOPIC_VAR' }, + rules: { content: 'full', images: { 'needs-you': true } }, + }); + const preview = service.preview({ channel_id: saved.channel_id, sample: 'attention' }); + expect(JSON.stringify(preview.requests)).toContain('{BH_TOPIC_VAR}'); + expect(preview.requests[0]?.file).toEqual({ + name: 'screenshot.jpg', + content_type: 'image/jpeg', + }); + expect(preview.message.privacy.has_image).toBe(true); + const bot = service.preview({ kind: 'discord', mode: 'bot', sample: 'attention' }); + expect(bot.capabilities.act_buttons).toBe(true); + expect(fakes.requests).toHaveLength(0); + }); + + it('pages the log newest first with an opaque cursor', async () => { + const { service } = await kit(); + const view = await service.create({ + name: 'hook', + kind: 'webhook', + target: { url: fakes.webhookUrl }, + secret_refs: {}, + rules: {}, + }); + for (let i = 0; i < 3; i++) await service.test(view.channel_id); + const first = await service.deliveries({ limit: 2 }); + expect(first.items.map((d) => d.seq)).toEqual([3, 2]); + expect(first.nextCursor).not.toBeNull(); + const second = await service.deliveries({ limit: 2, cursor: first.nextCursor ?? '' }); + expect(second.items.map((d) => d.seq)).toEqual([1]); + expect(second.nextCursor).toBeNull(); + const detail = await service.delivery(1); + expect(detail.message?.kind).toBe('test'); + expect(await codeOf(service.delivery(99))).toBe('DELIVERY_NOT_FOUND'); + expect(await codeOf(service.deliveries({ limit: 2, cursor: 'bm9wZQ' }))).toBe( + 'VALIDATION_FAILED', + ); + }); +}); + +describe('ChannelService env check and Telegram connect', () => { + it('says whether variables are set, never what they hold', async () => { + const { service } = await kit(); + expect(service.env(['BH_TELEGRAM_TOKEN', 'BH_NOPE'])).toEqual([ + { name: 'BH_TELEGRAM_TOKEN', set: true }, + { name: 'BH_NOPE', set: false }, + ]); + }); + + it('builds the one-tap links and captures the chat and the person who connected it', async () => { + const { service, started } = await kit(); + const start = await service.telegramConnect('BH_TELEGRAM_TOKEN'); + expect(start.bot_username).toBe('bh_test_bot'); + expect(start.link).toBe(`https://t.me/bh_test_bot?start=${started[0]}`); + expect(start.group_link).toContain('startgroup='); + await Promise.resolve(); + await Promise.resolve(); + expect(service.telegramConnectStatus(start.connect_id)).toMatchObject({ + status: 'connected', + chat: { id: '-1001234', title: 'Ops', type: 'supergroup' }, + user: { id: '42', name: 'Amir' }, + }); + expect(await codeOf(service.telegramConnect('BH_NOPE'))).toBe('CHANNEL_NOT_READY'); + expect(code(() => service.telegramConnectStatus('unknownid1'))).toBe('NOT_FOUND'); + }); +}); 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); + } + }); +}); diff --git a/packages/core/test/persistence/conformance-notifications.test.ts b/packages/core/test/persistence/conformance-notifications.test.ts index 6672ac9..9d5aab2 100644 --- a/packages/core/test/persistence/conformance-notifications.test.ts +++ b/packages/core/test/persistence/conformance-notifications.test.ts @@ -295,6 +295,62 @@ for (const [name, open] of adapters) { ).toEqual(['n-info00000001']); }); + it('deliveries: filter the log by op and notification kind; per-channel stats', async () => { + await r.notifications.insert( + notification({ notificationId: 'n-tool00000001', kind: 'tool.errors', thread: 'x' }), + ); + await r.notificationDeliveries.enqueue([ + job(), + job({ revision: 2, op: 'edit' }), + job({ notificationId: 'n-tool00000001' }), + job({ notificationId: 'n-tool00000001', revision: 2, op: 'edit', status: 'suppressed' }), + ]); + const rows = [...(await r.notificationDeliveries.list({ limit: 10 }))].reverse(); + const [send, edit, toolSend, toolEdit] = rows; + if ( + send === undefined || + edit === undefined || + toolSend === undefined || + toolEdit === undefined + ) + throw new Error('rows'); + expect((await r.notificationDeliveries.list({ ops: ['edit'] })).map((d) => d.seq)).toEqual([ + toolEdit.seq, + edit.seq, + ]); + expect( + (await r.notificationDeliveries.list({ kinds: ['tool.errors'] })).map( + (d) => d.notificationId, + ), + ).toEqual(['n-tool00000001', 'n-tool00000001']); + await r.notificationDeliveries.claim(send.seq, 200); + await r.notificationDeliveries.finish(send.seq, { status: 'sent', updatedAt: 300 }); + await r.notificationDeliveries.claim(toolSend.seq, 200); + await r.notificationDeliveries.finish(toolSend.seq, { + status: 'dead', + reason: 'auth', + updatedAt: 400, + }); + const stats = await r.notificationDeliveries.stats(250); + expect(stats).toEqual([ + { + channelId: 'nc-000000000001', + sent: 1, + failed: 1, + suppressed: 0, + pending: 1, + lastAt: 400, + lastStatus: 'dead', + }, + ]); + expect((await r.notificationDeliveries.stats(1_000))[0]).toMatchObject({ + sent: 0, + failed: 0, + pending: 1, + lastAt: 400, + }); + }); + it('channel messages: upsert, first of a thread, TTL due once, expiry and delete marks', async () => { const message = { channelId: 'nc-000000000001', 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.test.tsx b/packages/dashboard/src/features/notifications/channels/ChannelsPage.test.tsx new file mode 100644 index 0000000..b385516 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/ChannelsPage.test.tsx @@ -0,0 +1,158 @@ +/** @module features/notifications/channels/ChannelsPage.test — channel cards from captured API responses: startup badge (read-only, no menu), a broken channel with Resume and retry, a missing variable (never a value), Send test with the result inline, delete confirm, the publicUrl hint, live `channel.changed` / `channel.removed`, the empty state, axe clean */ +import { describe, expect, it } from 'bun:test'; +import type { ChannelView } from '@browserhive/contracts/http'; +import { ChannelsResponse, PublicUrlStatus } from '@browserhive/contracts/http'; +import { CAPTURED } from '../../../../test/fixtures/channels.ts'; +import { expectNoA11yViolations } from '../../../../test/helpers/axe.ts'; +import { renderPage } from '../../../../test/helpers/page-harness.tsx'; +import { act, fireEvent, screen, waitFor, within } from '../../../../test/helpers/render.tsx'; +import { ChannelsPage, sortChannels } from './ChannelsPage.tsx'; + +const LIST = ChannelsResponse.parse(CAPTURED.channels); +const PUBLIC = PublicUrlStatus.parse({ + ...CAPTURED.publicUrl, + configured: false, + url: null, + outcome: 'unset', +}); +const byName = (name: string): ChannelView => { + const found = LIST.data.find((c) => c.name === name); + if (found === undefined) throw new Error(`fixture lacks ${name}`); + return found; +}; + +function mount(routes: Record = {}) { + return renderPage({ + path: '/notifications/channels', + component: ChannelsPage, + url: '/notifications/channels', + routes: { + 'GET /channels': LIST, + 'GET /system/public-url': PUBLIC, + ...routes, + }, + }); +} + +/** A card by its channel name (a plain DOM lookup: role queries over many cards are slow in happy-dom). */ +function findCard(name: string): HTMLElement | null { + const heading = [...document.querySelectorAll('article h2')].find((h) => h.textContent === name); + return heading?.closest('article') ?? null; +} + +function card(name: string): HTMLElement { + const found = findCard(name); + if (found === null) throw new Error(`no card ${name}`); + return found; +} + +/** Waits until `check` holds, polling with real timers. */ +async function until(check: () => boolean, timeoutMs = 3000): Promise { + const started = performance.now(); + while (!check()) { + if (performance.now() - started > timeoutMs) throw new Error('until timed out'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +describe('ChannelsPage', () => { + it('sorts channels by name', () => { + expect(sortChannels(LIST.data).map((c) => c.name)).toEqual( + [...LIST.data.map((c) => c.name)].sort((a, b) => a.localeCompare(b)), + ); + }); + + it('renders every channel with its state and is accessible', async () => { + const view = mount(); + await until(() => findCard('family') !== null); + // Startup channel: badge, no Edit/Duplicate/Delete menu. + const pager = card('pager'); + expect(within(pager).getByText('from startup')).toBeDefined(); + expect(within(pager).queryByRole('button', { name: /More actions/ })).toBeNull(); + // A channel with a missing variable shows the name and "missing", never a value. + const ha = card('home-assistant'); + expect(within(ha).getByText('BH_WEBHOOK_SECRET')).toBeDefined(); + expect(within(ha).getByText('is missing')).toBeDefined(); + expect( + within(ha) + .getByRole('button', { name: /Send test/ }) + .hasAttribute('disabled'), + ).toBe(true); + // The public address hint. + expect(screen.getByText('Links in notifications open on this computer only')).toBeDefined(); + await expectNoA11yViolations(view.container); + }); + + it('shows a broken channel with its error and Resume and retry', async () => { + const view = mount({ + [`POST /channels/${byName('old-hook').channel_id}/resume`]: { + channel: { ...byName('old-hook'), status: 'active', failure_count: 0 }, + }, + }); + await until(() => findCard('old-hook') !== null); + const hook = card('old-hook'); + expect(within(hook).getByText(/Paused after 5 failures/)).toBeDefined(); + fireEvent.click(within(hook).getByRole('button', { name: /Resume and retry/ })); + await waitFor(() => + expect(view.requests.some((r) => r.path.endsWith('/resume') && r.method === 'POST')).toBe( + true, + ), + ); + await until(() => card('old-hook').textContent?.includes('Pause') === true); + }); + + it('sends a test and shows the result inline', async () => { + const family = byName('family'); + mount({ + [`POST /channels/${family.channel_id}/test`]: { + ok: false, + delivery: null, + error: { code: 'auth', message: 'Telegram refused the token (401)' }, + }, + }); + await until(() => findCard('family') !== null); + fireEvent.click(within(card('family')).getByRole('button', { name: /Send test/ })); + expect(await within(card('family')).findByText(/Test failed/)).toBeDefined(); + expect(within(card('family')).getByText('Telegram refused the token (401)')).toBeDefined(); + }); + + it('asks before deleting and removes the card', async () => { + const phone = byName('phone'); + mount({ [`DELETE /channels/${phone.channel_id}`]: { ok: true } }); + await until(() => findCard('phone') !== null); + await act(async () => { + fireEvent.click( + within(card('phone')).getByRole('button', { name: /More actions for phone/ }), + ); + }); + const item = await screen.findByRole('menuitem', { name: /Delete/ }); + await act(async () => { + fireEvent.click(item); + }); + const confirm = await screen.findByRole('button', { name: 'Delete channel' }); + await act(async () => { + fireEvent.click(confirm); + }); + await until(() => findCard('phone') === null); + }); + + it('follows channel.changed and channel.removed', async () => { + const view = mount(); + await until(() => findCard('family') !== null); + await until(() => view.sockets.length === 1); + view.connect(); + view.emit('channels', { + type: 'channel.changed', + channel: { ...byName('family'), status: 'paused' }, + }); + await until(() => card('family').textContent?.includes('paused') === true); + view.emit('channels', { type: 'channel.removed', channel_id: byName('team').channel_id }); + await until(() => findCard('team') === null); + }); + + it('explains channels when there are none', async () => { + mount({ 'GET /channels': { data: [], now: 1 } }); + expect(await screen.findByText('Get notified on your phone')).toBeDefined(); + expect(screen.getByRole('link', { name: /Add a channel/ })).toBeDefined(); + }); +}); 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/coverage.test.ts b/packages/dashboard/src/features/notifications/channels/coverage.test.ts new file mode 100644 index 0000000..bd791cf --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/coverage.test.ts @@ -0,0 +1,40 @@ +/** @module features/notifications/channels/coverage.test — spec 04 principle 2 for the notification channel API: every channels / public-url operation is called through `useApi()` somewhere in the dashboard */ +import { describe, expect, it } from 'bun:test'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const SRC = resolve(import.meta.dir, '../../..'); +const OPERATIONS = [ + 'listChannels', + 'createChannel', + 'previewChannel', + 'listDeliveries', + 'getDelivery', + 'checkChannelEnv', + 'startTelegramConnect', + 'getTelegramConnect', + 'getChannel', + 'updateChannel', + 'deleteChannel', + 'pauseChannel', + 'resumeChannel', + 'testChannel', + 'getPublicUrlStatus', +] as const; + +function sources(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return sources(path); + return /\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; + }); +} + +describe('channel operations have a surface', () => { + it('calls every operation through the typed client', () => { + const text = sources(SRC) + .map((p) => readFileSync(p, 'utf8')) + .join('\n'); + for (const op of OPERATIONS) expect(text.includes(`api.${op}(`), op).toBe(true); + }); +}); 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..49880a6 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/discord-shots.ts @@ -0,0 +1,33 @@ +/** @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..335038f --- /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.test.ts b/packages/dashboard/src/features/notifications/channels/model.test.ts new file mode 100644 index 0000000..f2bce0f --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/model.test.ts @@ -0,0 +1,142 @@ +/** @module features/notifications/channels/model.test — the pure channel logic: presets and summaries, TTL choices with the Telegram cap, draft defaults and problems, the API body, launch snippets, subscribe links and the private-address note */ +import { describe, expect, it } from 'bun:test'; +import { TELEGRAM_TTL_MAX_MS } from '@browserhive/contracts/notifications'; +import { + applyPreset, + cleanRules, + draftForKind, + draftProblems, + draftToInput, + draftToPatch, + EMPTY_DRAFT, + envSnippet, + isPrivateUrl, + isPublicNtfy, + ntfyLinks, + presetOf, + randomTopic, + rulesSummary, + stepProblems, + suggestName, + ttlChoices, +} from './model.ts'; + +describe('presets', () => { + it('maps categories to presets and back', () => { + expect(presetOf({ categories: ['needs-you'] })).toBe('needs-me'); + expect(presetOf({ categories: ['problems', 'needs-you'] })).toBe('problems'); + expect(presetOf({})).toBe('everything'); + expect(presetOf({ categories: ['system'] })).toBeNull(); + expect(applyPreset({ min_severity: 'warn', categories: ['system'] }, 'everything')).toEqual({ + min_severity: 'warn', + }); + }); + + it('summarises rules in one line', () => { + expect( + rulesSummary({ + categories: ['needs-you', 'problems'], + min_severity: 'error', + quiet_hours: { start: '22:00', end: '07:00' }, + images: { 'needs-you': true }, + mask_images: true, + ttl_ms: { 'needs-you': 7_200_000 }, + }), + ).toBe('Problems · error and up · quiet 22:00–07:00 · masked screenshots · self-destruct 2 h'); + expect(rulesSummary({ categories: ['system', 'reports'] })).toBe('System, Reports'); + }); +}); + +describe('TTL', () => { + it('caps Telegram at 47 hours and offers days elsewhere', () => { + const tg = ttlChoices('telegram').map((c) => c.value); + expect(Math.max(...tg.filter((v): v is number => v !== null))).toBe(TELEGRAM_TTL_MAX_MS); + expect(ttlChoices('ntfy').some((c) => c.value === 7 * 86_400_000)).toBe(true); + expect(ttlChoices('discord')[0]).toEqual({ value: null, label: 'Never' }); + }); + + it('refuses a Telegram TTL above 47 hours', () => { + const draft = { + ...draftForKind(EMPTY_DRAFT, 'telegram', []), + target: { chat_id: '1' }, + rules: { ttl_ms: { 'needs-you': 48 * 3_600_000 } }, + }; + expect(draftProblems(draft).map((p) => p.field)).toEqual(['rules.ttl_ms.needs-you']); + }); +}); + +describe('drafts', () => { + it('names the required secrets, fills ntfy defaults and suggests a free name', () => { + const tg = draftForKind(EMPTY_DRAFT, 'telegram', ['phone']); + expect(tg.secretRefs).toEqual({ token: 'BH_TELEGRAM_TOKEN' }); + expect(tg.name).toBe('phone-2'); + expect(tg.rules).toEqual({ categories: ['needs-you'] }); + const ntfy = draftForKind(EMPTY_DRAFT, 'ntfy', []); + expect(ntfy.target['server']).toBe('https://ntfy.sh'); + expect(ntfy.target['topic']).toMatch(/^bh-[a-z2-9]{12}$/); + expect(ntfy.name).toBe('push'); + expect(suggestName('discord', ['team', 'team-2'])).toBe('team-3'); + }); + + it('reports problems per step', () => { + const d = draftForKind(EMPTY_DRAFT, 'webhook', []); + expect(stepProblems(d, 'credentials')).toEqual([]); + expect(stepProblems(d, 'connect').map((p) => p.field)).toEqual(['target.url']); + expect(stepProblems({ ...d, name: 'Bad Name' }, 'rules').map((p) => p.field)).toEqual(['name']); + expect(stepProblems(EMPTY_DRAFT, 'platform').map((p) => p.field)).toEqual(['kind']); + }); + + it('builds the API bodies without empty rule keys', () => { + const d = { + ...draftForKind(EMPTY_DRAFT, 'discord', []), + rules: { categories: ['needs-you' as const], sessions: [], images: { problems: false } }, + }; + expect(cleanRules(d.rules)).toEqual({ categories: ['needs-you'] }); + expect(draftToInput(d)).toEqual({ + name: 'team', + kind: 'discord', + mode: 'webhook', + target: {}, + secret_refs: { webhook: 'BH_DISCORD_WEBHOOK' }, + rules: { categories: ['needs-you'] }, + }); + expect('kind' in draftToPatch(d)).toBe(false); + }); + + it('draws random topics from a readable alphabet', () => { + expect(randomTopic(() => 0)).toBe('bh-aaaaaaaaaaaa'); + }); +}); + +describe('snippets and links', () => { + it('writes the lines for each launch method, never a value', () => { + for (const method of ['shell', 'systemd', 'docker', 'config'] as const) { + const text = envSnippet(method, ['BH_TELEGRAM_TOKEN']); + expect(text).toContain('BH_TELEGRAM_TOKEN'); + expect(text).toContain(''); + } + expect(envSnippet('systemd', ['A'])).toContain('Environment="A='); + expect(envSnippet('docker', ['A'])).toContain('-e A='); + }); + + it('builds ntfy subscribe links and spots ntfy.sh', () => { + expect(ntfyLinks('https://ntfy.sh/', 'bh-x')).toEqual({ + web: 'https://ntfy.sh/bh-x', + app: 'ntfy://ntfy.sh/bh-x', + }); + expect(isPublicNtfy(undefined)).toBe(true); + expect(isPublicNtfy('https://ntfy.example.net')).toBe(false); + }); + + it('recognises private webhook targets', () => { + for (const url of [ + 'http://192.168.1.5:8123/api/webhook/x', + 'http://localhost/x', + 'http://10.0.0.1', + ]) { + expect(isPrivateUrl(url)).toBe(true); + } + expect(isPrivateUrl('https://hooks.example.net/x')).toBe(false); + expect(isPrivateUrl('not a url')).toBe(false); + }); +}); 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..ee08dd3 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/model.ts @@ -0,0 +1,455 @@ +/** @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; +} + +/** First name suggestions per platform. */ +const NAME_SUGGESTION: Readonly> = { + telegram: 'phone', + discord: 'team', + ntfy: 'push', + webhook: 'hook', +}; + +/** A name not used by `taken` (`telegram`, `telegram-2`, …). */ +export function suggestName(kind: string, taken: readonly string[]): string { + const base = NAME_SUGGESTION[kind] ?? kind; + if (!taken.includes(base)) return base; + let i = 2; + while (taken.includes(`${base}-${i}`)) i++; + return `${base}-${i}`; +} + +/** 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..520cdef --- /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..8cfc218 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/NtfyMock.tsx @@ -0,0 +1,100 @@ +/** @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; + /** A later revision: say that it replaces the first notification. */ + readonly revised?: boolean; +} + +/** The ntfy notification mock. */ +export function NtfyMock({ request, at, masked, topic, revised = false }: 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 && revised ? ( +

    + Replaces the first notification in place (sequence id{' '} + {view.sequence}) +

    + ) : null} +
    + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.test.tsx b/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.test.tsx new file mode 100644 index 0000000..79b85c4 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.test.tsx @@ -0,0 +1,108 @@ +/** @module features/notifications/channels/preview/PlatformPreview.test — each platform mock draws from real renderer output (captured previews): Telegram HTML with the masked screenshot, Discord embed with APP/BOT tag and bot-mode act buttons, ntfy title/tags/actions, the webhook request; the request disclosure; axe clean */ +import { describe, expect, it } from 'bun:test'; +import { ChannelPreview } from '@browserhive/contracts/http'; +import { CAPTURED } from '../../../../../test/fixtures/channels.ts'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { fireEvent, render, screen } from '../../../../../test/helpers/render.tsx'; +import { PlatformPreview } from './PlatformPreview.tsx'; +import { readDiscord, readNtfy, readTelegram } from './read-request.ts'; + +const P = { + telegramPhoto: ChannelPreview.parse(CAPTURED.previews.telegramPhoto), + telegramResolved: ChannelPreview.parse(CAPTURED.previews.telegramResolved), + discord: ChannelPreview.parse(CAPTURED.previews.discord), + discordBot: ChannelPreview.parse(CAPTURED.previews.discordBot), + ntfy: ChannelPreview.parse(CAPTURED.previews.ntfy), + ntfyImage: ChannelPreview.parse(CAPTURED.previews.ntfyImage), + webhook: ChannelPreview.parse(CAPTURED.previews.webhook), +}; + +function first(p: ChannelPreview) { + const r = p.requests[0]; + if (r === undefined) throw new Error('no request'); + return r; +} + +describe('request readers', () => { + it('reads the Telegram photo request and its keyboard', () => { + const view = readTelegram(first(P.telegramPhoto)); + expect(view.photo).not.toBeNull(); + expect(view.html).toContain(''); + const resolved = readTelegram(first(P.telegramResolved)); + expect(resolved.silent || resolved.edit || resolved.html.length > 0).toBe(true); + }); + + it('reads Discord embeds (multipart payload_json included) and button styles', () => { + const view = readDiscord(first(P.discord)); + expect(view.embeds.length).toBe(1); + expect(view.embeds[0]?.color).not.toBeNull(); + const bot = readDiscord(first(P.discordBot)); + expect(bot.rows.flat().some((b) => b.url === null)).toBe(true); + }); + + it('reads ntfy JSON and query-field publishes', () => { + const view = readNtfy(first(P.ntfy)); + expect(view.title).not.toBeNull(); + expect(view.priority).toBeGreaterThanOrEqual(1); + const parsed = readNtfy({ + method: 'PUT', + path: '/bh-topic/n-1', + encoding: 'binary', + body: { + title: 'T', + message: 'M', + tags: 'warning,cam', + priority: 'high', + actions: 'view, Open, https://x.y; view, Two, https://a.b', + }, + headers: {}, + file: { name: 'screenshot.jpg', content_type: 'image/jpeg' }, + }); + expect(parsed).toMatchObject({ + topic: 'bh-topic', + priority: 4, + tags: ['warning', 'cam'], + attachment: 'screenshot.jpg', + }); + expect(parsed.actions.map((a) => a.label)).toEqual(['Open', 'Two']); + }); +}); + +describe('PlatformPreview', () => { + it('draws the Telegram chat with the masked screenshot', async () => { + const view = render(); + expect(screen.getByText('Family ops')).toBeDefined(); + expect(screen.getByLabelText(/form fields masked/)).toBeDefined(); + expect(view.container.querySelector('[data-platform="telegram"]')).not.toBeNull(); + await expectNoA11yViolations(view.container); + }); + + it('draws both Discord modes: APP with links, BOT with act buttons', async () => { + const webhook = render(); + expect(screen.getByText('App')).toBeDefined(); + await expectNoA11yViolations(webhook.container); + webhook.unmount(); + render(); + expect(screen.getByText('Bot')).toBeDefined(); + expect(screen.getAllByRole('button').length).toBeGreaterThan(0); + }); + + it('draws the ntfy notification with its actions and attachment', async () => { + const view = render(); + expect(view.container.querySelector('[data-platform="ntfy"]')).not.toBeNull(); + expect(screen.getByLabelText(/Attached screenshot/)).toBeDefined(); + await expectNoA11yViolations(view.container); + }); + + it('shows the webhook request and discloses the raw requests', () => { + render(); + expect(screen.getByText('POST')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: /Show the request/ })); + expect(screen.getByRole('button', { name: /Hide the request/ })).toBeDefined(); + }); + + it('shows the publicUrl note once', () => { + render(); + expect(screen.queryAllByText(/publicUrl/).length).toBeLessThanOrEqual(1); + }); +}); 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..38c6c8c --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/PlatformPreview.tsx @@ -0,0 +1,147 @@ +/** @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 { type ReactNode, 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. */ + +/** A note with the config key `publicUrl` (when present) rendered as code. */ +function withCodeTerms(note: string): ReactNode { + const at = note.indexOf('publicUrl'); + if (at < 0) return note; + return ( + <> + {note.slice(0, at)} + publicUrl + {note.slice(at + 'publicUrl'.length)} + + ); +} + +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' ? ( + 1} + /> + ) : ( + + )} + {!compact && preview.notes.length > 0 ? ( +
      + {preview.notes.map((note) => ( +
    • +
    • + ))} +
    + ) : null} + {!compact ? ( +
    +
    ; + return
    {renderTokens(parseInline(line), key)}
    ; + })} + + ); +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/read-request.ts b/packages/dashboard/src/features/notifications/channels/preview/read-request.ts new file mode 100644 index 0000000..b1adb50 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/read-request.ts @@ -0,0 +1,304 @@ +/** @module features/notifications/channels/preview/read-request — tolerant readers that turn a renderer's `PlatformRequest` (JSON, multipart fields or query fields; objects or JSON strings) into what each platform mock draws. Pure; unknown shapes degrade to empty values instead of throwing. */ +import type { PlatformRequest } from '@browserhive/contracts/http'; + +type Json = Readonly>; + +function isRecord(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A value that may arrive as JSON text (multipart fields are strings). */ +function parsed(value: unknown): unknown { + if (typeof value !== 'string') return value; + const t = value.trim(); + if (!(t.startsWith('{') || t.startsWith('['))) return value; + try { + return JSON.parse(t); + } catch { + return value; + } +} + +function str(value: unknown): string | null { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return null; +} + +function arr(value: unknown): readonly unknown[] { + const v = parsed(value); + return Array.isArray(v) ? v : []; +} + +/** One button of a message. */ +export interface MockButton { + readonly label: string; + /** A link button's URL; `null` for an act (callback) button. */ + readonly url: string | null; + /** `primary`, `danger`, `success`, `secondary` or `link`. */ + readonly style: 'primary' | 'danger' | 'success' | 'secondary' | 'link'; +} + +/** What the Telegram mock draws. */ +export interface TelegramView { + readonly html: string; + readonly photo: { readonly name: string } | null; + readonly rows: readonly (readonly MockButton[])[]; + readonly silent: boolean; + readonly reply: boolean; + readonly edit: boolean; + readonly method: string; +} + +/** Reads a Telegram `sendMessage` / `sendPhoto` / edit request. */ +export function readTelegram(request: PlatformRequest): TelegramView { + const body = request.body; + const text = str(body['text']) ?? str(body['caption']) ?? ''; + const markup = parsed(body['reply_markup']); + const keyboard = isRecord(markup) ? arr(markup['inline_keyboard']) : []; + const rows = keyboard.map((row) => + arr(row).flatMap((b): MockButton[] => { + if (!isRecord(b)) return []; + const label = str(b['text']) ?? ''; + const url = str(b['url']); + return [{ label, url, style: url === null ? 'secondary' : 'link' }]; + }), + ); + const method = request.path.replace(/^\//, ''); + return { + html: text, + photo: + request.file !== null || method === 'sendPhoto' || method === 'editMessageCaption' + ? { name: request.file?.name ?? 'screenshot.jpg' } + : null, + rows: rows.filter((r) => r.length > 0), + silent: body['disable_notification'] === true || body['disable_notification'] === 'true', + reply: body['reply_parameters'] !== undefined || body['reply_to_message_id'] !== undefined, + edit: method.startsWith('edit'), + method, + }; +} + +/** One Discord embed field. */ +export interface DiscordField { + readonly name: string; + readonly value: string; + readonly inline: boolean; +} + +/** One Discord embed. */ +export interface DiscordEmbed { + readonly title: string | null; + readonly url: string | null; + readonly description: string | null; + /** RGB integer from the payload, or `null`. */ + readonly color: number | null; + readonly fields: readonly DiscordField[]; + readonly image: string | null; + readonly footer: string | null; + readonly timestamp: string | null; +} + +/** What the Discord mock draws. */ +export interface DiscordView { + readonly content: string | null; + readonly embeds: readonly DiscordEmbed[]; + readonly rows: readonly (readonly MockButton[])[]; + readonly edit: boolean; + readonly attachment: string | null; +} + +const DISCORD_STYLE: Readonly> = { + 1: 'primary', + 2: 'secondary', + 3: 'success', + 4: 'danger', + 5: 'link', +}; + +/** Reads a Discord webhook execute/edit request (JSON, or multipart with `payload_json`). */ +export function readDiscord(request: PlatformRequest): DiscordView { + const payload = parsed(request.body['payload_json']); + const body: Json = isRecord(payload) ? payload : request.body; + const embeds = arr(body['embeds']).flatMap((e): DiscordEmbed[] => { + if (!isRecord(e)) return []; + const image = isRecord(e['image']) ? str(e['image']['url']) : null; + const footer = isRecord(e['footer']) ? str(e['footer']['text']) : null; + return [ + { + title: str(e['title']), + url: str(e['url']), + description: str(e['description']), + color: typeof e['color'] === 'number' ? e['color'] : null, + fields: arr(e['fields']).flatMap((f): DiscordField[] => + isRecord(f) + ? [ + { + name: str(f['name']) ?? '', + value: str(f['value']) ?? '', + inline: f['inline'] === true, + }, + ] + : [], + ), + image, + footer, + timestamp: str(e['timestamp']), + }, + ]; + }); + const rows = arr(body['components']).map((row) => + isRecord(row) + ? arr(row['components']).flatMap((b): MockButton[] => { + if (!isRecord(b)) return []; + const style = + typeof b['style'] === 'number' + ? (DISCORD_STYLE[b['style']] ?? 'secondary') + : 'secondary'; + return [{ label: str(b['label']) ?? '', url: str(b['url']), style }]; + }) + : [], + ); + return { + content: str(body['content']), + embeds, + rows: rows.filter((r) => r.length > 0), + edit: request.method.toUpperCase() === 'PATCH', + attachment: request.file?.name ?? null, + }; +} + +/** One ntfy action button. */ +export interface NtfyAction { + readonly label: string; + readonly url: string | null; + readonly kind: string; +} + +/** What the ntfy mock draws. */ +export interface NtfyView { + readonly topic: string | null; + readonly title: string | null; + readonly message: string; + readonly priority: number; + readonly tags: readonly string[]; + readonly click: string | null; + readonly actions: readonly NtfyAction[]; + readonly attachment: string | null; + readonly sequence: string | null; +} + +const PRIORITY_NAMES: Readonly> = { + min: 1, + low: 2, + default: 3, + high: 4, + max: 5, + urgent: 5, +}; + +function header(request: PlatformRequest, ...names: readonly string[]): string | null { + for (const [key, value] of Object.entries(request.headers)) { + if (names.includes(key.toLowerCase())) return value; + } + return null; +} + +function field(request: PlatformRequest, key: string, ...headers: readonly string[]): unknown { + const value = request.body[key]; + return value !== undefined ? value : header(request, ...headers); +} + +/** `view, Open, https://…, clear=true; http, …` → actions. */ +function parseActionText(text: string): NtfyAction[] { + return text + .split(';') + .map((a) => a.split(',').map((p) => p.trim())) + .filter((parts) => parts.length >= 2) + .map((parts) => ({ + kind: parts[0] ?? 'view', + label: parts[1] ?? '', + url: parts[2] !== undefined && !parts[2].includes('=') ? parts[2] : null, + })); +} + +/** Reads an ntfy publish (JSON on `/`, or a file body with fields as query parameters or headers). */ +export function readNtfy(request: PlatformRequest): NtfyView { + const rawPriority = field(request, 'priority', 'x-priority', 'priority', 'prio', 'p'); + const priority = + typeof rawPriority === 'number' + ? rawPriority + : typeof rawPriority === 'string' + ? (PRIORITY_NAMES[rawPriority] ?? (Number(rawPriority) || 3)) + : 3; + const rawTags = field(request, 'tags', 'x-tags', 'tags', 'tag', 'ta'); + const tags = Array.isArray(rawTags) + ? rawTags.map(String) + : typeof rawTags === 'string' + ? rawTags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : []; + const rawActions = parsed(field(request, 'actions', 'x-actions', 'actions', 'action')); + const actions = Array.isArray(rawActions) + ? rawActions.flatMap((a): NtfyAction[] => + isRecord(a) + ? [{ kind: str(a['action']) ?? 'view', label: str(a['label']) ?? '', url: str(a['url']) }] + : [], + ) + : typeof rawActions === 'string' + ? parseActionText(rawActions) + : []; + const pathTopic = request.path.replace(/^\/+/, '').split('/')[0] ?? ''; + return { + topic: str(request.body['topic']) ?? (pathTopic === '' ? null : pathTopic), + title: str(field(request, 'title', 'x-title', 'title', 't')), + message: str(field(request, 'message', 'x-message', 'message', 'm')) ?? '', + priority: Math.min(5, Math.max(1, priority)), + tags, + click: str(field(request, 'click', 'x-click', 'click')), + actions, + attachment: + request.file?.name ?? + str(field(request, 'filename', 'x-filename', 'filename')) ?? + (field(request, 'attach', 'x-attach', 'attach') !== undefined && + field(request, 'attach', 'x-attach', 'attach') !== null + ? 'attachment' + : null), + sequence: str(field(request, 'sequence_id', 'x-sequence-id')), + }; +} + +/** ntfy tag short codes BrowserHive uses, as the emoji the app shows before the title. */ +export const NTFY_EMOJI: Readonly> = { + information_source: 'ℹ️', + warning: '⚠️', + rotating_light: '🚨', + sos: '🆘', + white_check_mark: '✅', + heavy_check_mark: '✔️', + x: '❌', + lock: '🔒', + camera: '📷', + bell: '🔔', + robot: '🤖', + hourglass: '⌛', + wave: '👋', + test_tube: '🧪', +}; + +/** Splits ntfy tags into the emoji shown before the title and the plain tags listed below it. */ +export function splitNtfyTags(tags: readonly string[]): { + readonly emoji: readonly string[]; + readonly plain: readonly string[]; +} { + const emoji: string[] = []; + const plain: string[] = []; + for (const tag of tags) { + const e = NTFY_EMOJI[tag]; + if (e !== undefined) emoji.push(e); + else plain.push(tag); + } + return { emoji, plain }; +} diff --git a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts new file mode 100644 index 0000000..3ecf598 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.test.ts @@ -0,0 +1,79 @@ +/** @module features/notifications/channels/preview/telegram-html.test — the Telegram HTML subset parser: tags and aliases, links, expandable quotes, entities, and malformed markup kept as text */ +import { describe, expect, it } from 'bun:test'; +import { decodeEntities, parseTelegramHtml, tgText } from './telegram-html.ts'; + +describe('parseTelegramHtml', () => { + it('parses the supported tags, aliases and attributes', () => { + const nodes = parseTelegramHtml( + '⚠️ Attention now\nxidOpen
    quote
    ', + ); + expect(nodes.map((n) => (n.type === 'el' ? n.tag : 'text'))).toEqual([ + 'text', + 'b', + 'text', + 'b', + 'text', + 'i', + 'code', + 'a', + 'blockquote', + ]); + const link = nodes[7]; + expect(link?.type === 'el' && link.href).toBe('https://bh.example.net/s?live=1'); + const quote = nodes[8]; + expect(quote?.type === 'el' && quote.expandable).toBe(true); + }); + + it('decodes entities and never turns text into markup', () => { + expect(tgText(parseTelegramHtml('a <script> & b 'c' 😀'))).toBe( + "a '); + expect(nodes).toEqual([{ type: 'text', text: '' }]); + }); + + it('keeps stray and unclosed tags readable', () => { + expect(tgText(parseTelegramHtml('x
    y'))).toBe('x
    y'); + const unclosed = parseTelegramHtml('bold'); + expect(unclosed[0]?.type === 'el' && unclosed[0].tag).toBe('b'); + }); + + it('reads pre/code languages and spoilers', () => { + const [pre] = parseTelegramHtml('
    {}
    '); + const code = pre?.type === 'el' ? pre.children[0] : undefined; + expect(code?.type === 'el' && code.language).toBe('json'); + const [spoiler] = parseTelegramHtml('s'); + expect(spoiler?.type === 'el' && spoiler.tag).toBe('spoiler'); + }); + + it('decodes the named entity subset', () => { + expect(decodeEntities('" &unknown;')).toBe('" &unknown;'); + }); +}); + +describe('tg-time', () => { + it('keeps the unix time of a tag', () => { + const [node] = parseTelegramHtml('14:13 UTC'); + expect(node?.type === 'el' && node.tag).toBe('time'); + expect(node?.type === 'el' && node.unix).toBe(1_700_000_000); + }); +}); + +describe('discord timestamps', () => { + it('parses as a time token', async () => { + const { parseInline } = await import('./discord-markdown.tsx'); + expect(parseInline('since ok')).toEqual([ + { t: 'text', v: 'since ' }, + { t: 'time', unix: 1_700_000_000, style: 't' }, + { t: 'text', v: ' ok' }, + ]); + expect(parseInline('**b** `c` [x](https://a.b) \\*')).toEqual([ + { t: 'b', c: [{ t: 'text', v: 'b' }] }, + { t: 'text', v: ' ' }, + { t: 'code', v: 'c' }, + { t: 'text', v: ' ' }, + { t: 'link', label: [{ t: 'text', v: 'x' }], href: 'https://a.b' }, + { t: 'text', v: ' *' }, + ]); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts new file mode 100644 index 0000000..acf7fa6 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/preview/telegram-html.ts @@ -0,0 +1,177 @@ +/** @module features/notifications/channels/preview/telegram-html — parses the HTML subset of Telegram's `parse_mode: HTML` into a small tree the mock renders as React nodes; never `innerHTML`. Unknown tags and malformed markup stay visible as text, as Telegram would refuse them. */ + +/** A node of the parsed message. */ +export type TgNode = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'el'; + readonly tag: TgTag; + readonly href?: string; + readonly expandable?: boolean; + readonly language?: string; + /** ``: the moment, shown in the reader's time zone. */ + readonly unix?: number; + readonly children: readonly TgNode[]; + }; + +/** Tags Telegram supports in HTML mode (aliases folded). */ +export type TgTag = + | 'b' + | 'i' + | 'u' + | 's' + | 'code' + | 'pre' + | 'a' + | 'blockquote' + | 'spoiler' + | 'time' + | 'emoji'; + +const ALIASES: Readonly> = { + b: 'b', + strong: 'b', + i: 'i', + em: 'i', + u: 'u', + ins: 'u', + s: 's', + strike: 's', + del: 's', + code: 'code', + pre: 'pre', + a: 'a', + blockquote: 'blockquote', + 'tg-spoiler': 'spoiler', + 'tg-time': 'time', + 'tg-emoji': 'emoji', +}; + +const NAMED: Readonly> = { + lt: '<', + gt: '>', + amp: '&', + quot: '"', + apos: "'", + nbsp: ' ', +}; + +/** Decodes the HTML entities Telegram accepts (named subset and numeric). */ +export function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => { + if (body.startsWith('#x') || body.startsWith('#X')) { + const code = Number.parseInt(body.slice(2), 16); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + if (body.startsWith('#')) { + const code = Number.parseInt(body.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + return NAMED[body.toLowerCase()] ?? match; + }); +} + +interface Frame { + readonly tag: TgTag | null; + readonly name: string; + readonly attrs: Readonly>; + readonly children: TgNode[]; +} + +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + for (const m of raw.matchAll(/([a-zA-Z_:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) { + const name = (m[1] ?? '').toLowerCase(); + const value = m[2] ?? m[3] ?? m[4]; + attrs[name] = value === undefined ? true : decodeEntities(value); + } + return attrs; +} + +function pushText(frame: Frame, text: string): void { + if (text === '') return; + const last = frame.children.at(-1); + if (last !== undefined && last.type === 'text') { + frame.children[frame.children.length - 1] = { type: 'text', text: last.text + text }; + } else { + frame.children.push({ type: 'text', text }); + } +} + +function close(frame: Frame): TgNode { + if (frame.tag === null) return { type: 'text', text: '' }; + const base = { type: 'el' as const, tag: frame.tag, children: frame.children }; + if (frame.tag === 'a') { + const href = frame.attrs['href']; + return { ...base, ...(typeof href === 'string' && { href }) }; + } + if (frame.tag === 'blockquote') + return { ...base, expandable: frame.attrs['expandable'] !== undefined }; + if (frame.tag === 'time') { + const unix = Number(frame.attrs['unix']); + return Number.isFinite(unix) ? { ...base, unix } : base; + } + if (frame.tag === 'code') { + const cls = frame.attrs['class']; + if (typeof cls === 'string' && cls.startsWith('language-')) { + return { ...base, language: cls.slice('language-'.length) }; + } + } + return base; +} + +/** + * Parses Telegram HTML into nodes. `` is a spoiler; tags outside the + * subset, stray closing tags and unclosed tags are kept as literal text. + */ +export function parseTelegramHtml(html: string): readonly TgNode[] { + const root: Frame = { tag: null, name: '#root', attrs: {}, children: [] }; + const stack: Frame[] = [root]; + const top = () => stack[stack.length - 1] ?? root; + const re = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^<>]*?)?)\s*(\/?)>/g; + let last = 0; + for (let m = re.exec(html); m !== null; m = re.exec(html)) { + pushText(top(), decodeEntities(html.slice(last, m.index))); + last = m.index + m[0].length; + const closing = m[1] === '/'; + const name = (m[2] ?? '').toLowerCase(); + const attrs = parseAttrs(m[3] ?? ''); + let tag: TgTag | undefined = ALIASES[name]; + if (name === 'span' && attrs['class'] === 'tg-spoiler') tag = 'spoiler'; + if (name === 'span' && closing) { + const open = [...stack].reverse().find((f) => f.name === 'span'); + if (open !== undefined) tag = 'spoiler'; + } + if (tag === undefined) { + pushText(top(), m[0]); + continue; + } + if (!closing) { + stack.push({ tag, name, attrs, children: [] }); + continue; + } + const index = stack.map((f) => f.name).lastIndexOf(name); + if (index <= 0) { + pushText(top(), m[0]); + continue; + } + while (stack.length > index) { + const frame = stack.pop(); + if (frame === undefined) break; + const node = close(frame); + top().children.push(node); + } + } + pushText(top(), decodeEntities(html.slice(last))); + while (stack.length > 1) { + const frame = stack.pop(); + if (frame === undefined) break; + top().children.push(close(frame)); + } + return root.children; +} + +/** The plain text of nodes (tests, the ntfy-like fallbacks). */ +export function tgText(nodes: readonly TgNode[]): string { + return nodes.map((n) => (n.type === 'text' ? n.text : tgText(n.children))).join(''); +} diff --git a/packages/dashboard/src/features/notifications/channels/search.ts b/packages/dashboard/src/features/notifications/channels/search.ts new file mode 100644 index 0000000..03d82a4 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/search.ts @@ -0,0 +1,37 @@ +/** @module features/notifications/channels/search — URL search of the channel wizard (`step`, `kind`) and the delivery log (`channel`, `status`, `op`, `kind`, `notification`, `seq`, `page`, `ps`) (spec 04 §1, §12.11.1) */ +import { + NotificationDeliveryOp, + NotificationDeliveryStatus, + NotificationKind, +} from '@browserhive/contracts/enums'; +import { AvailableChannelKind } from '@browserhive/contracts/notifications'; +import { z } from 'zod'; +import { csvParam, pageParam, pageSizeParam, TABLE_SEARCH_DEFAULTS } from '@/lib/search/table.ts'; +import { WIZARD_STEPS } from './model.ts'; + +/** Wizard search (`/notifications/channels/new`, `/notifications/channels/$channelId`). */ +export const wizardSearch = z.object({ + step: z.enum(WIZARD_STEPS).optional().catch(undefined), + kind: AvailableChannelKind.optional().catch(undefined), + /** A channel id to copy settings from ("Duplicate"). */ + from: z.string().max(80).optional().catch(undefined), +}); +/** Wizard search. */ +export type WizardSearch = z.infer; + +/** Delivery log search. */ +export const deliveryLogSearch = z.object({ + channel: z.string().max(80).optional().catch(undefined), + status: csvParam(NotificationDeliveryStatus), + op: csvParam(NotificationDeliveryOp), + kind: csvParam(NotificationKind), + notification: z.string().max(80).optional().catch(undefined), + /** The delivery whose detail sheet is open. */ + seq: z.coerce.number().int().positive().optional().catch(undefined), + page: pageParam, + ps: pageSizeParam, +}); +/** Delivery log search. */ +export type DeliveryLogSearch = z.infer; +/** Defaults omitted from the URL. */ +export const DELIVERY_LOG_DEFAULTS = { ...TABLE_SEARCH_DEFAULTS } as const; diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx new file mode 100644 index 0000000..9098e8e --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.test.tsx @@ -0,0 +1,160 @@ +/** @module features/notifications/channels/wizard/ChannelWizardPage.test — the add-channel wizard: platform choice, the variable's live missing → set state (never a value), the draft in localStorage, Telegram one-tap connect (link + QR, then the chat and the allow-list owner), rules (Telegram TTL capped at 47 h, screenshots need Full, masking on by default), preview from the renderer and Save; axe clean */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { ChannelPreview } from '@browserhive/contracts/http'; +import { CAPTURED } from '../../../../../test/fixtures/channels.ts'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { type RecordedRequest, renderPage } from '../../../../../test/helpers/page-harness.tsx'; +import { act, fireEvent } from '../../../../../test/helpers/render.tsx'; +import { DRAFT_KEY, readDraft } from '../model.ts'; +import { wizardSearch } from '../search.ts'; +import { NewChannelPage } from './ChannelWizardPage.tsx'; + +const PREVIEW = ChannelPreview.parse(CAPTURED.previews.telegramPhoto); +const EXPIRES = 1_700_000_000_000 + 110_000; + +async function until(check: () => boolean, timeoutMs = 3000): Promise { + const started = performance.now(); + while (!check()) { + if (performance.now() - started > timeoutMs) throw new Error('until timed out'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +const text = () => document.body.textContent ?? ''; + +function mount(url: string, options: { envSet?: () => boolean } = {}) { + let polls = 0; + const created: unknown[] = []; + const view = renderPage({ + path: '/notifications/channels/new', + component: NewChannelPage, + validateSearch: (s) => wizardSearch.parse(s), + url, + routes: { + 'GET /channels': { data: [], now: 1 }, + 'GET /channels/env': (req: RecordedRequest) => { + polls += 1; + const names = (req.query.get('names') ?? '').split(','); + return { vars: names.map((name) => ({ name, set: options.envSet?.() ?? true })) }; + }, + 'POST /channels/preview': PREVIEW, + 'POST /channels/telegram/connect': { + connect_id: 'cx-demo-1234', + bot_username: 'my_browserhive_bot', + link: 'https://t.me/my_browserhive_bot?start=bh-4f9k2m', + group_link: 'https://t.me/my_browserhive_bot?startgroup=bh-4f9k2m', + expires_at: EXPIRES, + }, + 'GET /channels/telegram/connect/cx-demo-1234': { + status: 'connected', + chat: { id: '-1001234567890', title: 'Family ops', type: 'supergroup', thread_id: null }, + user: { id: '42', name: 'Amir' }, + error: null, + expires_at: EXPIRES, + }, + 'POST /channels': (req: RecordedRequest) => { + created.push(req.body); + const body = req.body as { name: string }; + return { + status: 201, + body: { + channel: { + ...CAPTURED.channels.data[0], + name: body.name, + channel_id: 'nc-newchannel1', + }, + }, + }; + }, + }, + }); + return { ...view, created, polls: () => polls }; +} + +const click = async (el: Element) => { + await act(async () => { + fireEvent.click(el); + }); +}; +const button = (label: RegExp) => { + const found = [...document.querySelectorAll('button')].find((b) => + label.test(b.textContent ?? ''), + ); + if (found === undefined) throw new Error(`no button ${label}`); + return found; +}; + +afterEach(() => localStorage.removeItem(DRAFT_KEY)); + +describe('channel wizard', () => { + it('walks Telegram: platform, credentials, connect, rules', async () => { + let set = false; + const view = mount('/notifications/channels/new', { envSet: () => set }); + await until(() => text().includes('Where should notifications go?')); + await expectNoA11yViolations(view.container); + const telegram = document.querySelector('input[value="telegram"]'); + if (telegram === null) throw new Error('no telegram radio'); + await click(telegram); + expect(readDraft()?.kind).toBe('telegram'); + await click(button(/Continue/)); + + // Credentials: the suggested variable, missing until the server sees it. + await until(() => text().includes('Where to get it')); + expect((document.querySelector('input[placeholder="BH_…"]') as HTMLInputElement).value).toBe( + 'BH_TELEGRAM_TOKEN', + ); + await until(() => text().includes('missing')); + expect(text()).toContain('BH_TELEGRAM_TOKEN is not set yet'); + set = true; + await until(() => text().includes('set') && !text().includes('is not set yet'), 6000); + await click(button(/Continue/)); + + // Connect: one-tap link, then the captured chat and the allow-list owner. + await until(() => text().includes('Connect a chat in one tap')); + await click(button(/Create the connect link/)); + await until(() => text().includes('Connected to Family ops')); + expect(readDraft()?.target['chat_id']).toBe('-1001234567890'); + expect(readDraft()?.rules.allow_list).toEqual(['42']); + await click(button(/Continue/)); + + // Rules: Telegram TTL stops at 47 h; screenshots need Full and mask by default. + await until(() => text().includes('What should reach you here?')); + if (!text().includes('Categories')) await click(button(/Advanced/)); + await until(() => text().includes('Self-destruct')); + expect(text()).toContain('47 hours'); + const shots = [...document.querySelectorAll('[role="switch"]')]; + expect(text()).toContain('Screenshots need the Full content level.'); + const full = document.querySelector('input[value="full"]'); + if (full === null) throw new Error('no full radio'); + await click(full); + await until(() => text().includes('Attach a screenshot')); + expect(shots.length).toBeGreaterThan(0); + }, 30_000); + + it('previews the draft and saves it', async () => { + localStorage.setItem( + DRAFT_KEY, + JSON.stringify({ + v: 1, + kind: 'telegram', + mode: null, + name: 'phone', + target: { chat_id: '-1001234567890', chat_title: 'Family ops' }, + secretRefs: { token: 'BH_TELEGRAM_TOKEN' }, + rules: { categories: ['needs-you'] }, + }), + ); + const view = mount('/notifications/channels/new?step=preview'); + await until(() => document.querySelector('[data-platform="telegram"]') !== null); + await expectNoA11yViolations(view.container); + await click(button(/Save channel/)); + await until(() => text().includes('phone is saved')); + expect(view.created[0]).toMatchObject({ + name: 'phone', + kind: 'telegram', + secret_refs: { token: 'BH_TELEGRAM_TOKEN' }, + }); + expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); + expect(text()).toContain('Send a real test'); + }, 20_000); +}); diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx new file mode 100644 index 0000000..50757f5 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx @@ -0,0 +1,584 @@ +/** @module features/notifications/channels/wizard/ChannelWizardPage — the add-channel wizard (`/notifications/channels/new`, draft kept in `localStorage` so it survives the restart a new variable needs) and the channel page (`/notifications/channels/$channelId`, the same steps prefilled; read-only with a "from startup" notice for startup channels): a step rail, the step, and Back / Continue / Save (spec 04 §12.11.1) */ +import type { ChannelView } from '@browserhive/contracts/http'; +import type { + AvailableChannelKind, + NotificationChannelRules, +} from '@browserhive/contracts/notifications'; +import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Callout } from '@/components/shared/Callout.tsx'; +import { ErrorState } from '@/components/shared/ErrorState.tsx'; +import { PageHeader } from '@/components/shared/PageHeader.tsx'; +import { StatusDot } from '@/components/shared/StatusBadge.tsx'; +import { Button, buttonVariants } from '@/components/ui/button.tsx'; +import { Skeleton } from '@/components/ui/skeleton.tsx'; +import { Spinner } from '@/components/ui/spinner.tsx'; +import { isAppError, toAppError } from '@/lib/api/errors.ts'; +import { ICONS } from '@/lib/icons.ts'; +import { CHANNEL_STATUS } from '@/lib/status-registry.ts'; +import { cn } from '@/lib/utils.ts'; +import { NotificationsNav } from '../../NotificationsNav.tsx'; +import { + useChannel, + useChannelActions, + useChannelEnv, + useChannels, + useCreateChannel, + useUpdateChannel, +} from '../api.ts'; +import { + type ChannelDraft, + clearDraft, + draftEnvNames, + draftForKind, + draftFromChannel, + draftProblems, + draftToInput, + draftToPatch, + duplicateDraft, + EMPTY_DRAFT, + readDraft, + stepProblems, + WIZARD_STEP_LABEL, + WIZARD_STEPS, + type WizardStep, + writeDraft, +} from '../model.ts'; +import { PlatformMark, platformOf } from '../platforms.tsx'; +import type { WizardSearch } from '../search.ts'; +import type { EnvState } from './fields.tsx'; +import { StepConnect } from './StepConnect.tsx'; +import { StepCredentials } from './StepCredentials.tsx'; +import { StepPlatform } from './StepPlatform.tsx'; +import { StepPreview } from './StepPreview.tsx'; +import { StepRules } from './StepRules.tsx'; + +const STEP_HINT: { readonly [S in WizardStep]: string } = { + platform: 'Pick where notifications should go.', + credentials: 'Keep the secret in an environment variable; BrowserHive stores only its name.', + connect: 'Tell BrowserHive exactly where to deliver.', + rules: 'Name the channel and choose what it receives.', + preview: 'See exactly what arrives, then save and send a test.', +}; + +/** The step rail (vertical on wide screens, a compact progress line on narrow ones). */ +function StepRail({ + step, + reachable, + done, + onStep, +}: { + readonly step: WizardStep; + readonly reachable: (s: WizardStep) => boolean; + readonly done: (s: WizardStep) => boolean; + readonly onStep: (s: WizardStep) => void; +}) { + const Check = ICONS.check; + const index = WIZARD_STEPS.indexOf(step); + return ( + + ); +} + +/** Everything a wizard needs besides where the draft comes from. */ +interface WizardProps { + readonly draft: ChannelDraft; + readonly setDraft: (update: (d: ChannelDraft) => ChannelDraft) => void; + readonly step: WizardStep; + readonly onStep: (step: WizardStep) => void; + readonly taken: readonly string[]; + readonly channel: ChannelView | null; + readonly readOnly: boolean; + readonly saving: boolean; + readonly saveError: unknown; + readonly onSave: () => void; + readonly savedId: string | null; + readonly title: ReactNode; + readonly headerActions?: ReactNode; + readonly notice?: ReactNode; +} + +function Wizard({ + draft, + setDraft, + step, + onStep, + taken, + channel, + readOnly, + saving, + saveError, + onSave, + savedId, + title, + headerActions, + notice, +}: WizardProps) { + const [attempted, setAttempted] = useState>(new Set()); + const names = draftEnvNames(draft); + const env = useChannelEnv(names, step === 'credentials' || step === 'connect'); + const envState = useCallback( + (name: string): EnvState => env.data?.vars.find((v) => v.name === name)?.set ?? null, + [env.data], + ); + const problems = stepProblems(draft, step); + const errors = useMemo(() => { + if (!attempted.has(step)) return {}; + return Object.fromEntries(problems.map((p) => [p.field, p.message])); + }, [attempted, step, problems]); + const index = WIZARD_STEPS.indexOf(step); + const next = WIZARD_STEPS[index + 1]; + const prev = WIZARD_STEPS[index - 1]; + const editing = channel !== null; + const reachable = (s: WizardStep) => { + if (editing || savedId !== null) return true; + const i = WIZARD_STEPS.indexOf(s); + return WIZARD_STEPS.slice(0, i).every((earlier) => stepProblems(draft, earlier).length === 0); + }; + const done = (s: WizardStep) => stepProblems(draft, s).length === 0 && draft.kind !== null; + const serverErrors = + saveError !== null && saveError !== undefined && isAppError(saveError) ? saveError : null; + const ArrowLeft = ICONS.arrowLeft; + const ArrowRight = ICONS.arrowRight; + const Save = ICONS.check; + + const goNext = () => { + setAttempted((a) => new Set([...a, step])); + if (problems.length > 0 || next === undefined) return; + onStep(next); + }; + + const updateTarget = (patch: Readonly>) => + setDraft((d) => { + const target: Record = { ...d.target }; + for (const [k, v] of Object.entries(patch)) { + if (v === null) delete target[k]; + else target[k] = v; + } + return { ...d, target }; + }); + const updateSecret = (param: string, value: string | null) => + setDraft((d) => { + const secretRefs: Record = { ...d.secretRefs }; + if (value === null) delete secretRefs[param]; + else secretRefs[param] = value; + return { ...d, secretRefs }; + }); + + const body = (() => { + switch (step) { + case 'platform': + return ( + setDraft((d) => draftForKind(d, kind, taken))} + onMode={(mode) => setDraft((d) => ({ ...d, mode }))} + /> + ); + case 'credentials': + return ( + + ); + case 'connect': + return ( + + setDraft((d) => ({ + ...d, + rules: user === null ? d.rules : { ...d.rules, allow_list: [user.id] }, + })) + } + /> + ); + case 'rules': + return ( + setDraft((d) => ({ ...d, name }))} + onRules={(rules: NotificationChannelRules) => setDraft((d) => ({ ...d, rules }))} + /> + ); + case 'preview': + return ( + + ); + } + })(); + + const saveLabel = editing ? 'Save changes' : 'Save channel'; + const allProblems = draftProblems(draft); + return ( +
    + } + /> + {notice} +
    + +
    +
    + {draft.kind !== null ? : null} +

    {WIZARD_STEP_LABEL[step]}

    + {draft.kind !== null && step !== 'platform' ? ( + + {platformOf(draft.kind).label} + {draft.name !== '' ? ` · ${draft.name}` : ''} + + ) : null} +
    +
    {body}
    + {serverErrors !== null && step === 'preview' ? ( +
    + + {serverErrors.code === 'CHANNEL_NAME_TAKEN' + ? `Another channel is already called ${draft.name}. Go back to What to send and pick another name.` + : serverErrors.message !== serverErrors.title + ? serverErrors.message + : (serverErrors.hint ?? '')} + +
    + ) : null} + {step === 'preview' && savedId === null && !readOnly && allProblems.length > 0 ? ( +
    + +
      + {allProblems.map((p) => ( +
    • {p.message}
    • + ))} +
    +
    +
    + ) : null} +
    + {prev !== undefined ? ( + + ) : ( + + Cancel + + )} +
    + {next !== undefined ? ( + + ) : readOnly ? null : savedId !== null ? ( + + Done + + ) : ( + + )} +
    +
    +
    +
    +
    + ); +} + +function useStepNavigation(fallback: WizardStep) { + const search = useSearch({ strict: false }) as WizardSearch; + const navigate = useNavigate(); + const step = search.step ?? fallback; + const onStep = useCallback( + (next: WizardStep) => + void navigate({ + to: '.', + search: (prev: Record) => ({ ...prev, step: next }), + }), + [navigate], + ); + return { step, onStep, search }; +} + +/** `/notifications/channels/new`. */ +export function NewChannelPage() { + const { step, onStep, search } = useStepNavigation('platform'); + const channels = useChannels(); + const taken = useMemo(() => (channels.data?.data ?? []).map((c) => c.name), [channels.data]); + const [draft, setDraftState] = useState(() => readDraft() ?? EMPTY_DRAFT); + const [savedId, setSavedId] = useState(null); + const create = useCreateChannel(); + const applied = useRef(false); + + const setDraft = useCallback((update: (d: ChannelDraft) => ChannelDraft) => { + setDraftState((d) => { + const next = update(d); + writeDraft(next); + return next; + }); + }, []); + + // `?from=` (Duplicate) and `?kind=` prefill the draft once the channel list is known. + useEffect(() => { + if (applied.current || channels.data === undefined) return; + applied.current = true; + const source = + search.from === undefined + ? undefined + : channels.data.data.find((c) => c.channel_id === search.from); + if (source !== undefined) setDraft(() => duplicateDraft(source, taken)); + else if (search.kind !== undefined) + setDraft((d) => draftForKind(d, search.kind ?? 'webhook', taken)); + }, [channels.data, search.from, search.kind, taken, setDraft]); + + const save = () => + create.mutate(draftToInput(draft), { + onSuccess: (result) => { + setSavedId(result.channel.channel_id); + clearDraft(); + }, + }); + + const Restart = ICONS.undo; + const hasDraft = draft.kind !== null; + return ( + { + clearDraft(); + setDraftState(EMPTY_DRAFT); + onStep('platform'); + }} + > +