feat(notifications): versioned notification contract and a delivery outbox - #25
Merged
Merged
Conversation
…ions (D-32 to D-39)
…he delivery outbox
…egistry, schema v5 and the redaction invariant
…; notifications guide and changeset
…(restart, orphan recovery)
…r for settlements without one
arg1998
marked this pull request as ready for review
September 27, 2026 16:18
arg1998
added a commit
that referenced
this pull request
Sep 29, 2026
…th publicUrl, screenshots and self-destruct (#27) ## Notifications on your phone (N1) N1 of the notification channels program (plan `scratchpad/notification-channels-design.md`, owner-approved). It builds on N0 (#25): the message contract and the delivery outbox. ### What it does - **Four channels:** a Telegram bot, a Discord webhook, an ntfy topic and a generic webhook. Each is a pure renderer (contract → platform request) plus a transport, behind N0's outbox (retries, `Retry-After`, breaker, coalescing). - **Edits in place, silently.** Resolving an attention request edits the message and removes its buttons. A growing tool-error group edits its count. - **Self-destruct** per channel × category (default never; Telegram capped at 47 h because bots may delete for 48 h only). **Delete when resolved** is available, off by default. Deletes that fall due while BrowserHive is off happen at the next start. - **Screenshots**, opt-in per channel × category: an attention request (CAPTCHAs included), a vault confirmation (taken before the fill; never during one or in a secret window), a crash's last frame. Masked or unmasked, per channel. Never with `recordToolResults=none` or below content level `full`. - **`publicUrl`** (`--publicUrl` / `BROWSERHIVE_PUBLIC_URL` / JSON). All links use it. Its host joins the Host allow-list, and its origin passes the CSRF guard, which fixes proxies that rewrite `Host`. `doctor`, the System page and `GET /system/public-url` check `<publicUrl>/health` against an `instance_id` minted at each start: ok / elsewhere / behind a login / unreachable. Without it, links are labelled "Open on this computer". - **Startup channels:** a flag-only `--notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=…"`, repeatable. Secrets go only as `env:NAME`; an inline secret exits 64 without echoing it, and so does a name clash with a dashboard channel. These channels show read-only in the dashboard. - **API:** `/api/v1/channels` CRUD, pause/resume, test send (rate-limited), a pure preview (the same renderer as a send, secrets shown as variable names), the delivery log with filters and cursor, an env-var set/missing check (never the value), and a Telegram one-tap connect (a `t.me/<bot>?start=<code>` link, then a 2-minute setup-only long poll). Scopes are `channels:read|write`, and a `channels` WS topic carries changes live. - **CLI:** `browserhive channels list | test <name> | preview <name> [--sample]` against a running server. `doctor` checks every channel's variables and `publicUrl`. The banner gains a `Notify` line. - **Dashboard:** Notifications → Channels, with cards (status, last delivery, 24 h counts, pause/resume/edit/duplicate/delete/test) and a 5-step wizard: 1. Platform, with Discord's "What's the difference?" panel drawing webhook vs bot live. 2. Credentials, with launch-specific lines and live set/missing; the draft survives a restart. 3. Connect: Telegram one-tap and QR, or ntfy server/topic and QR. 4. What to send: presets plus advanced options. 5. Preview: near-exact Telegram / Discord / ntfy mocks drawn from the real renderer output, then Save and Send test. The Delivery log is live, with filters and "why wasn't this sent?", and the System page gains a Public address card. ### Setup in short 1. **Telegram:** open @Botfather, send `/newbot`, run `export BH_TELEGRAM_TOKEN=…`, and restart. Then use the wizard's one-tap link. 2. **Discord:** in Channel settings, go to Integrations → Webhooks → New → Copy URL. Run `export BH_DISCORD_WEBHOOK=…` and restart. 3. **ntfy:** install the app and scan the wizard's QR code. On ntfy.sh the topic is the password; for screenshots, prefer a self-hosted server. 4. **Links on the phone:** use `--publicUrl https://…`, for example with Tailscale `serve` or your reverse proxy. The test message's **Open dashboard** is the proof. The full walkthroughs are in `docs/guide/notifications.md`. ### Spikes | Spike | Result | |---|---| | Telegram Rich Messages (Bot API 10.1–10.3) | `sendRichMessage` with `html` (h3, table, footer) plus an inline keyboard was accepted, and so was `editMessageText(rich_message)`. Rendering on phones was not compared. **D-40:** classic HTML for now; two spike messages are left in the test group for you to compare. | | Telegram button URLs | Refused: `localhost`, dotless hosts, IPv6 literals. Accepted: domains and IPv4. A `publicUrl` of `http://localhost:…` failed every send, so it is fixed: links go into the text. | | Discord Components V2 vs embeds | The docs say V2 webhook messages may not carry `files[n]`/embeds, so a webhook V2 message cannot upload a screenshot. We use embeds plus a link-button action row (`with_components=true`). Not verified live; there is no webhook yet. | | ntfy | Checked against the real server `binwiederhier/ntfy:v2.28.0`: sequence-id replace, `DELETE /<topic>/<seq>`, `PUT` upload (only the path form or the header carries the sequence id; the query form is ignored), and a maximum of 3 actions. A server without an attachment cache refuses uploads, so we fall back to text. | ### Verification - **Gates (local):** `bun run check` passes (lint, typecheck, depcruise, unit ≈3 000 server tests plus 384 dashboard tests, and the openapi/docs/db-types checks). `test:goldens`, `build`, `package:check` and the website build pass. `test:integration` gives 67 passed / 2 skipped (the real-ntfy test needs `BHDEV_NTFY_URL`). E2e, run the CI way against the built daemon, gives **11 passed / 1 skipped**: the 9 existing tests plus the new channels journey on desktop and phone. - **Built daemon, against fakes and local ntfy containers:** - A startup webhook channel via the flag delivers HMAC-signed POSTs. - A tool-error group sends, then edits its count, and the intermediate edit is `covered`. - Attention requests take masked and unmasked screenshots; I checked visually that the inputs are blacked out. - An ntfy server without an attachment cache refuses uploads; we fall back to the text alone. - TTL deletes work on ntfy (the `message_delete` event arrives). Resolving after a TTL delete gives `superseded: message_deleted`. - The `publicUrl` check reports `ok`. - An inline-secret flag exits 64 with the exact message and never echoes the value. `BROWSERHIVE_NOTIFICATION_CHANNEL` exits 64 with a hint. A name clash between a startup and a dashboard channel exits 64. - **Real platforms** (your local test bot and group, plus an ntfy.sh topic; Discord only against fakes; I recorded outcomes only): - Telegram and ntfy.sh test sends: OK. - Attention request: Telegram `sendPhoto` with the masked screenshot, and an ntfy.sh upload with the attachment and view actions. - Resolve: both edited in place. - After a 4-minute TTL: Telegram `deleteMessage` and ntfy.sh `DELETE` both succeeded. - `scripts/notify-live.ts`: Telegram ✅, ntfy.sh ✅ (after adding cache polling), Discord skipped. - This caught two bugs, now fixed: Telegram refuses buttons with a localhost `publicUrl`, and ntfy.sh read-back lags a publish. - **Dashboard visual review:** 74 screenshots at 1440 and 768, light and dark, all reviewed: the channels list, every wizard step for every platform, the preview mocks, "What's the difference?", the delivery log and its detail sheet, and the System Public address card in each state. Not attached here because they come from a local run. ### Contract change - **OpenAPI:** - new `listChannels`, `createChannel`, `getChannel`, `updateChannel`, `deleteChannel`, `pauseChannel`, `resumeChannel`, `testChannel`, `previewChannel`, `listDeliveries`, `getDelivery`, `checkChannelEnv`, `startTelegramConnect`, `getTelegramConnect` and `getPublicUrlStatus`; - `HealthResponse.instance_id` (optional); - error codes `CHANNEL_NOT_FOUND`, `CHANNEL_NAME_TAKEN`, `CHANNEL_READ_ONLY`, `CHANNEL_NOT_READY`, `CHANNEL_KIND_UNAVAILABLE`, `CHANNEL_PLATFORM_ERROR` and `DELIVERY_NOT_FOUND`. - **Config:** a new key `publicUrl`, plus the flag-only `--notificationChannel`. `BROWSERHIVE_NOTIFICATION_CHANNEL` and a `notificationChannel` file key fail with a hint. - **WS:** topic `channels` (`channel.changed`, `channel.removed`, `delivery.updated`); the scopes `channels:read` and `channels:write`. - **Contract (`NotificationChannelRules`):** gains `mask_images` (additive; `schema` stays 1). - **Database:** no schema change (N0's v5 tables). - **Goldens:** the WS protocol, help, exports snapshot and OpenAPI goldens are re-blessed after reading the diffs, and there are new renderer goldens under `packages/core/test/goldens/notifications/`. ### Please check on your phone 1. The two spike messages in the Telegram test group, **classic HTML** vs **Rich Message**: which reads better? 2. Telegram: add a channel through the wizard's one-tap link, then **Send test**. Tap **Open dashboard**; it opens only if `publicUrl` is reachable from the phone. 3. Trigger an attention request with screenshots on. Check the photo, the masked fields, and the edit to "Resolved" after you resolve it. After the TTL, check that the message disappears. 4. ntfy: scan the wizard's QR code and check the notification, the tags, the priority and the view actions. 5. Discord (when you have a webhook): an embed with the screenshot, the edit that keeps the image, and the delete. 6. Optionally add the `live-notify` label to this PR to run `notify-live.yml` (it needs your approval of the `notify-live` environment). ### Also in this PR - **The sidebar** now has a Notifications entry. It had none before; the page was reachable only through the bell. - **A new dashboard dependency:** `uqr` (MIT) for QR codes. ### Known and not chased - `integration (macos-latest)` and `integration (windows-latest)` are known flaky/failing (#23, #2). ### Follow-ups for N2 (handoff `scratchpad/notifications-handoff-N1.md`) - Command tokens plus `notification_actions`; mint tokens and pass them to `RenderContext.actToken`. - The Telegram callback long-poll loop, one per bot token and not concurrent with the connect flow. - Discord bot mode: the renderer already draws it; it needs the gateway, bot sends and the picker. - ntfy topic B, after the `http`-action spike. - Allow-lists: the Telegram connect already captures the connecting user's id.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of the five notification PRs (N0 of the plan). It lays the foundations that Telegram, Discord and ntfy build on:
No external channel can be configured yet. With none configured, the only change you can see is that in-app notifications follow what they announce.
Before / after
system.degradednotification moves toresolvedGET /api/v1/notifications, WSnotificationskind,category,severity,state,revision,thread. Additive; old fields and in-app behaviour unchanged.Redactor(registered secrets and credential patterns); URLs lose their query strings. A real leak path is closed.NotificationChannel.send(dto), fire-and-forget, never calledsend/edit/deletereturning platform refs), the in-app inbox on it, a transactional outbox for external channels0005-notification-outbox,compatible: true). Notifications gain 7 columns, backfilled from facts only;message_jsonstays NULL for old rows. New tables:notification_channels,notification_deliveries,notification_channel_messages.browserhive purgeon a database from an older schemaHow it works
Contract (
@browserhive/contracts/notifications, D-32):NotificationMessagewithschema: 1and full-state revisions;blocksbuilt from a tiny inline AST, never markdown;actandopenactions;entitiesandprivacy;The JSON Schema is generated into
docs/reference/notification-message.schema.jsonand checked bygen:docs --check.Producers (pure, table-driven):
revisionForturnsattention.resolved,vault.confirm.resolvedandsystem.recoveredinto revisions of the thread's notification.Pure steps:
scrubMessage(redaction, and clipping to the contract limits);restrictContent(counts < titles < full);degrade(message, capabilities): tables → lists, images dropped or linked, act → open fallback, button cap, plain text, truncation with "… Open in BrowserHive";Outbox (D-34): a notification change and its
notification_deliveriesrows commit in one transaction.NotificationOutboxdrains them:superseded.retry_after. A job isdeadafter 8 attempts or 24 h.sendingare recovered at start.brokenand produces an in-app-onlychannel.brokennotification. There is no degradation: the loop is cut by kind, and a test proves it.infosends become the newest one, with a "you missed N" note.expires_at.Registry:
ChannelRegistrymerges dashboard rows with startup channels. Startup channels are projected read-only, and a name clash isCONFIG_INVALID. It builds adapters through per-kind factories, and none are registered yet. Secrets are env var names only.Startup catch-up: a request settled while nothing listened (orphan recovery at start, or a shutdown) revises its notification after the reconcile. I found this gap during real verification.
OTel:
browserhive.notifications.deliveries{channel_kind,status};notification.deliverspan per platform call.Spec (committed first)
publicUrl;notification_channels.--notificationChannelflag-only grammar, specified for N1;Verification
bun run check: exit 0. 2763 server tests and 344 dashboard tests; openapi, docs and db-types are fresh.test:goldens,buildandpackage:check: exit 0.test:integration: 67 pass.degrade+ content levels;message_json, the in-app row or payload, the channel delivery or the delivery log. It found a real bug: an oversized field made the contract parse throw and would have lost the notification. That is fixed by clipping, plus a safe fallback.origin/main(0.1.3, schema v4), built in a second worktree, produced real notifications over MCP: two tool-error groups, attention requests resolved or rejected from the REST API, and one cancelled.resolved;final;open, with threadtool-errors:<session>;message_jsonNULL.resolved, "Resolved by admin after 2 s", no actions;--sessionLease 1m) →session.reaped;notification_deliveriesrows while no channel exists.notifications caught up revised=1moved the notification to rev 2,resolved, "Rejected after 55 s".send/editjobs,suppressedwithno_adapterorchannel_paused.purge --dryRunof the v4 backup with the v5 binary lists its tables without error.Known and not done
publicUrl, channels page or--notificationChannelparser: those are N1. The flag is fully specified in spec 08 §5.7.Follow-ups for N1
buildOps({ channelFactories }).publicUrlthrough theLinkBuilderport.--notificationChannelparser, handingStartupNotificationChannel[]toChannelRegistry.load().notify-live.yml.The handoff document lists every seam.
Contract change: schema v5 (migration
0005-notification-outbox,compatible: true, somin_reader_versionstays 1); fixturev5.db, goldenschema-v5.json;v1–v4.dbstill upgrade to head and match a fresh install. OpenAPI:Notificationgains 6 required response fields (additive). New public JSON Schemadocs/reference/notification-message.schema.json(NotificationMessageschema 1), and a new contracts subpath@browserhive/contracts/notifications. WS payloads: the same 6 fields onnotification.*. The MCP tool surface is unchanged.