# `subscription-bus` — typed fan-out with the cross-tenant capability gate _(no submodules)_ ## Purpose `SubscriptionBus` is the broadcast surface every plugin uses to fan a payload out to every subscribed connection on the host. It owns the `(topic, tenant)` lookup against the registry, the per-connection filter predicate, and the cross-tenant capability gate documented in design doc §11.2. This module exposes the abstract `SubscriptionBus` contract and ships `InMemorySubscriptionBus` — the single-host default used by the conformance harness. The bus encodes each broadcast into a `WsEnvelope` exactly once and pushes the same encoded frame to every matching connection; per-connection filters can drop the delivery without re-encoding the payload. ## Features - Generic `SubscriptionBus` interface with `subscribe`, `unsubscribe`, `broadcast`, and `asyncIterator`. - `BusCaller` — identity (tenant + capabilities) used to enforce the `ws.broadcast-cross-tenant` capability gate. - `BusSubscribeOptions` and `BusBroadcastOptions` carry the optional tenant override and `filter` predicate. - `InMemorySubscriptionBus` — single-host default; keeps subscriptions indexed by `(topic, tenant)` and integrates with the registry's `recordSubscription` / `dropSubscription` hooks. - `asyncIterator(topic, { tenant? })` — `for await (const event of …)` consumption for plugins that prefer pull-style. ## Usage: Basic ```ts import { InMemorySubscriptionBus } from "@suaveplan/api-ws/subscription-bus"; const bus = new InMemorySubscriptionBus<{ orderId: string }>(); bus.subscribe(connection, "orders.created"); bus.broadcast( { tenant: connection.tenantId, capabilities: new Set() }, "orders.created", { tenant: connection.tenantId, payload: { orderId: "o1" } }, ); ``` ## Usage: Advanced ```ts import { InMemorySubscriptionBus, type BusCaller, } from "@suaveplan/api-ws/subscription-bus"; interface Alert { priority: number } const bus = new InMemorySubscriptionBus(); bus.subscribe(highPriorityConn, "alerts", { filter: (alert) => alert.priority >= 5, }); const caller: BusCaller = { tenant: orgA, capabilities: new Set(["ws.broadcast-cross-tenant"]), }; bus.broadcast(caller, "alerts", { tenant: orgB, payload: { priority: 9 }, }); const iterator = bus.asyncIterator("alerts", { tenant: orgA }); for await (const evt of iterator) { console.log(evt.payload.priority); break; } await iterator.return?.(); ``` ## API Reference - `interface SubscriptionBus { subscribe, unsubscribe, broadcast, asyncIterator }`. - `interface BusCaller { tenant: TenantId; capabilities: ReadonlySet }`. - `interface BusBroadcastOptions { tenant, payload, filter? }`. - `interface BusSubscribeOptions { tenant?, filter? }`. - `class InMemorySubscriptionBus implements SubscriptionBus`. ## Implementation Notes - The cross-tenant gate fires when `opts.tenant !== caller.tenant` AND the caller is missing `ws.broadcast-cross-tenant`. Same-tenant broadcasts never need the capability. - The bus and the registry share the `(topic, tenant)` indexing key format so a future audit pass can cross-check the two without a schema gymnastic. - The `asyncIterator` is unbounded — callers MUST `await iterator.return?.()` (or rely on `for await` to do so when the loop exits) to release the bus's listener slot. - Internal envelope ids are produced from the topic + a timestamp + a short random suffix; engines that need stronger guarantees can wrap the bus and override `send` in their own adapter.