feat(bridges): per-message synthetic actor + admin UI (LC-78 v1) #243

Merged
longjacksonle merged 8 commits from feat/lc-78-bridge-registration-surface into main 2026-05-28 00:57:52 +02:00

Summary

Server-side surface for LC-78 protocol bridges. A registered out-of-process daemon (Matrix-appservice-bridge or similar) authenticates as a bot user with bridge:post + bridge:heartbeat scopes and posts foreign-protocol messages into a lets-chat room as per-message synthetic actors. The daemon itself is documented in docs/protocol-bridges.md, NOT shipped here.

The single load-bearing extension is the per-MESSAGE synthetic actor: LC-74 webhooks and LC-77 email-ingress bind one identity per channel, but a Matrix bridge needs alice@server and bob@server in the same bridged room as distinct actors. The endpoint snapshots foreign_name + kind onto the message row at post time, so the render survives bridge-row removal under stop-new lifecycle. The render resolver gates on bridge_foreign_name.is_some(), not bridge_id, so ON DELETE SET NULL does not flip historical messages back to (unknown).

What ships

  • Schema: bridges (sealed daemon config under LETS_CHAT_SECRET_KEY, two-column AES-GCM convention from imap_inbox_config / vapid_keys), plus messages.bridge_id (FK ON DELETE SET NULL) + snapshotted bridge_foreign_name + bridge_kind.
  • API: POST /api/v1/bridges/{id}/messages (scope bridge:post, 400s any non-null foreign_avatar in v1), POST /api/v1/bridges/{id}/heartbeat (scope bridge:heartbeat, optional {error?}), cursor pagination on GET /api/v1/rooms/{id}/messages (the daemon's initial-sync forcing function: default 50, hard cap 200, next_cursor walks history).
  • MessageActor: new Bridge(BridgeActorMeta { kind, avatar_url }) arm rendered with a violet (via matrix) badge in templates + i18n.
  • Loop-break: every LC-75 outgoing-webhook payload (message.posted / message.edited / message.deleted / reaction.added) now carries an actor block describing the ORIGINAL author. Daemons MUST self-filter on actor.kind == "bridge" && actor.bridge_id == <self> or they cause infinite cross-network amplification. Operator doc warns prominently.
  • Bridge role tier: bots created via the admin UI get role bridge; the ApiAuth::require_not_bridge gate denies them every non-bridge endpoint by default, even with mistakenly-granted messages:write (defense in depth over scope gating). routes_bridge_role_isolation pins the contract.
  • Admin UI: /admin/bridges create / list / remove. Status derived from last_heartbeat_at age vs BRIDGE_HEARTBEAT_STALE_SECS = 90: pending / healthy / stale / errored. Plain remove (stop-new lifecycle: bridges row DELETED, snapshot preserves history).
  • Operator docs: docs/protocol-bridges.md covers API surface, threat model, removal lifecycle, known gaps, and the prominent loop-break warning at the top.

What's deferred

Capability Disposition
Foreign avatars in bridge messages v1 rejects with 400 (LC-78-AVATAR-PROXY token in body). Per-render foreign-homeserver fetch leaks viewer IPs. Proxy-cache lands in a follow-up.
Edits / deletes (either direction) LC-75 fires the events with the bridge actor block; v1 daemon ignores. Pushing edits to Matrix needs a new endpoint that v1 does not ship.
Reactions outbound LC-75 fires reaction.added with actor.kind=user; daemon ignores in v1.
IRC / XMPP daemons bridges.kind is plain TEXT (no CHECK); schema-free defer. Handler currently gates to kind == "matrix".
Identity mapping @alice:matrix.org -> lets-chat user Rejected. Account-linking is an impersonation surface.
Encrypted Matrix rooms Rejected. Key management is its own sub-feature.
Ship a Matrix appservice daemon Out of scope. v1 documents the surface; operator runs a community daemon.

Criterion-owner-deferred decision: stop-new vs delete-history

Criterion 4 ("removes the synthetic actors") was ambiguous. Owner had no preference. Chose stop-new on the principle that the wrong choice made in code (flipping to delete-history later via an additive admin branch) is recoverable; the wrong choice made in data (every removal hard-deletes history) is permanent. Schema is path-agnostic: ON DELETE SET NULL plus snapshotted columns. If a future requirement materializes for strict delete-history, it is a three-line addition to the admin remove handler.

Pre-execution gates resolved

Gate Status
LC-72/73/74/75 prereq audit All three exist (LC-75 better than the ticket assumed: real delivery queue + retry/backoff + 4 event types). LC-74's per-message-actor gap was the one real v1 server feature.
Avatar policy Decision C: reject in v1, nullable column, initials render, proxy deferred.
Bridge bot role Option X: fresh bridge tier, narrowest grant.
RBAC allow-list confirmed Audit confirmed allow-list shape; role addition is mechanical and safe-by-default.
Pagination breaking change blast radius Zero non-test consumers found; safe to ship the bounded contract.
list_messages sharing Forked to list_messages_paginated for the API; web/HTMX list_messages untouched.
Loop-break Persistent bridges.id in the payload + prominent operator-doc warning.

Test plan

  • just test clean (115 binaries pass, 0 failed).
  • just test-saas clean (40 binaries pass, 0 failed; standalone-gated tests correctly skipped).
  • LC-78 surface coverage: routes_api_bridge_messages (7), routes_api_bridge_heartbeat (8), routes_api_pagination (6), routes_bridge_role_isolation (7), outgoing_webhook_actor_payload (4), routes_admin_bridges (8). 40 new tests.
  • No regressions in LC-72 routes_api, LC-73 routes_bots, LC-74 routes_webhooks, LC-75 outgoing_webhooks, LC-77 lc77_webhook_render_fixture, routes_message_edit_history.
  • Phase-24 Category 2 migration-list drift swept: 17 array-form + 1 verbose-form test files brought up to chat/0055. Surfaced as a 500 on send_message_with_attachment once the new messages.bridge_* columns landed in the SELECT.

Verifying as the operator

  1. /admin/bridges -> Register a bridge. Pick room, name the bot, paste daemon config, submit. Copy the one-time token.
  2. Configure your bridge daemon against POST /api/v1/bridges/{id}/messages + POST /api/v1/bridges/{id}/heartbeat. Use the bridge id shown in the admin URL.
  3. Create an outgoing-webhook subscription (/admin/outgoing-webhooks) for whichever events the daemon needs. Implement the loop-break filter on actor.kind == "bridge" && actor.bridge_id == <your id> BEFORE testing on a busy room.
  4. Heartbeat from the daemon. Status moves from pending -> healthy in /admin/bridges.
## Summary Server-side surface for LC-78 protocol bridges. A registered out-of-process daemon (Matrix-appservice-bridge or similar) authenticates as a bot user with `bridge:post` + `bridge:heartbeat` scopes and posts foreign-protocol messages into a lets-chat room as per-message synthetic actors. The daemon itself is documented in `docs/protocol-bridges.md`, NOT shipped here. The single load-bearing extension is the per-MESSAGE synthetic actor: LC-74 webhooks and LC-77 email-ingress bind one identity per channel, but a Matrix bridge needs alice@server and bob@server in the same bridged room as distinct actors. The endpoint snapshots `foreign_name` + `kind` onto the message row at post time, so the render survives bridge-row removal under stop-new lifecycle. The render resolver gates on `bridge_foreign_name.is_some()`, not `bridge_id`, so `ON DELETE SET NULL` does not flip historical messages back to `(unknown)`. ## What ships - **Schema**: `bridges` (sealed daemon config under `LETS_CHAT_SECRET_KEY`, two-column AES-GCM convention from `imap_inbox_config` / `vapid_keys`), plus `messages.bridge_id` (FK ON DELETE SET NULL) + snapshotted `bridge_foreign_name` + `bridge_kind`. - **API**: `POST /api/v1/bridges/{id}/messages` (scope `bridge:post`, 400s any non-null `foreign_avatar` in v1), `POST /api/v1/bridges/{id}/heartbeat` (scope `bridge:heartbeat`, optional `{error?}`), cursor pagination on `GET /api/v1/rooms/{id}/messages` (the daemon's initial-sync forcing function: default 50, hard cap 200, `next_cursor` walks history). - **MessageActor**: new `Bridge(BridgeActorMeta { kind, avatar_url })` arm rendered with a violet `(via matrix)` badge in templates + i18n. - **Loop-break**: every LC-75 outgoing-webhook payload (`message.posted` / `message.edited` / `message.deleted` / `reaction.added`) now carries an `actor` block describing the ORIGINAL author. Daemons MUST self-filter on `actor.kind == "bridge" && actor.bridge_id == <self>` or they cause infinite cross-network amplification. Operator doc warns prominently. - **Bridge role tier**: bots created via the admin UI get role `bridge`; the `ApiAuth::require_not_bridge` gate denies them every non-bridge endpoint by default, even with mistakenly-granted `messages:write` (defense in depth over scope gating). `routes_bridge_role_isolation` pins the contract. - **Admin UI**: `/admin/bridges` create / list / remove. Status derived from `last_heartbeat_at` age vs `BRIDGE_HEARTBEAT_STALE_SECS = 90`: `pending` / `healthy` / `stale` / `errored`. Plain remove (stop-new lifecycle: bridges row DELETED, snapshot preserves history). - **Operator docs**: `docs/protocol-bridges.md` covers API surface, threat model, removal lifecycle, known gaps, and the prominent loop-break warning at the top. ## What's deferred | Capability | Disposition | |---|---| | Foreign avatars in bridge messages | v1 rejects with 400 (`LC-78-AVATAR-PROXY` token in body). Per-render foreign-homeserver fetch leaks viewer IPs. Proxy-cache lands in a follow-up. | | Edits / deletes (either direction) | LC-75 fires the events with the bridge actor block; v1 daemon ignores. Pushing edits to Matrix needs a new endpoint that v1 does not ship. | | Reactions outbound | LC-75 fires `reaction.added` with `actor.kind=user`; daemon ignores in v1. | | IRC / XMPP daemons | `bridges.kind` is plain TEXT (no CHECK); schema-free defer. Handler currently gates to `kind == "matrix"`. | | Identity mapping `@alice:matrix.org` -> lets-chat user | Rejected. Account-linking is an impersonation surface. | | Encrypted Matrix rooms | Rejected. Key management is its own sub-feature. | | Ship a Matrix appservice daemon | Out of scope. v1 documents the surface; operator runs a community daemon. | ## Criterion-owner-deferred decision: stop-new vs delete-history Criterion 4 (`"removes the synthetic actors"`) was ambiguous. Owner had no preference. Chose **stop-new** on the principle that the wrong choice made in code (flipping to delete-history later via an additive admin branch) is recoverable; the wrong choice made in data (every removal hard-deletes history) is permanent. Schema is path-agnostic: `ON DELETE SET NULL` plus snapshotted columns. If a future requirement materializes for strict delete-history, it is a three-line addition to the admin remove handler. ## Pre-execution gates resolved | Gate | Status | |---|---| | LC-72/73/74/75 prereq audit | All three exist (LC-75 better than the ticket assumed: real delivery queue + retry/backoff + 4 event types). LC-74's per-message-actor gap was the one real v1 server feature. | | Avatar policy | Decision C: reject in v1, nullable column, initials render, proxy deferred. | | Bridge bot role | Option X: fresh `bridge` tier, narrowest grant. | | RBAC allow-list confirmed | Audit confirmed allow-list shape; role addition is mechanical and safe-by-default. | | Pagination breaking change blast radius | Zero non-test consumers found; safe to ship the bounded contract. | | `list_messages` sharing | Forked to `list_messages_paginated` for the API; web/HTMX `list_messages` untouched. | | Loop-break | Persistent `bridges.id` in the payload + prominent operator-doc warning. | ## Test plan - [x] `just test` clean (115 binaries pass, 0 failed). - [x] `just test-saas` clean (40 binaries pass, 0 failed; standalone-gated tests correctly skipped). - [x] LC-78 surface coverage: `routes_api_bridge_messages` (7), `routes_api_bridge_heartbeat` (8), `routes_api_pagination` (6), `routes_bridge_role_isolation` (7), `outgoing_webhook_actor_payload` (4), `routes_admin_bridges` (8). 40 new tests. - [x] No regressions in LC-72 `routes_api`, LC-73 `routes_bots`, LC-74 `routes_webhooks`, LC-75 `outgoing_webhooks`, LC-77 `lc77_webhook_render_fixture`, `routes_message_edit_history`. - [x] Phase-24 Category 2 migration-list drift swept: 17 array-form + 1 verbose-form test files brought up to chat/0055. Surfaced as a 500 on `send_message_with_attachment` once the new `messages.bridge_*` columns landed in the SELECT. ## Verifying as the operator 1. `/admin/bridges` -> `Register a bridge`. Pick room, name the bot, paste daemon config, submit. Copy the one-time token. 2. Configure your bridge daemon against `POST /api/v1/bridges/{id}/messages` + `POST /api/v1/bridges/{id}/heartbeat`. Use the bridge id shown in the admin URL. 3. Create an outgoing-webhook subscription (`/admin/outgoing-webhooks`) for whichever events the daemon needs. Implement the loop-break filter on `actor.kind == "bridge" && actor.bridge_id == <your id>` BEFORE testing on a busy room. 4. Heartbeat from the daemon. Status moves from `pending` -> `healthy` in `/admin/bridges`.
operator docs + migration-list drift fix in tests
Some checks failed
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 3s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Failing after 15s
Create release / Create release from merged PR (pull_request) Has been skipped
65038ea11a
Adds docs/protocol-bridges.md with the operator-facing API surface, threat
model, removal lifecycle, and a prominent loop-break warning at the top
that names the daemon's self-filter contract explicitly.

Patches the Phase-24 Category 2 migration-list drift introduced by the two
new chat migrations (0054_bridges, 0055_messages_bridge_actor): 17 array-
form and 1 verbose-form test files were stopping at 0051 and silently
running a stale schema. Adding the new SELECT columns to list_messages
surfaced the drift as a 500 on send_message_with_attachment in
routes_uploads (and would have on any test exercising the room render).
longjacksonle deleted branch feat/lc-78-bridge-registration-surface 2026-05-28 00:57:52 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!243
No description provided.