first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# `backpressure` — bounded send-queue policy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per design doc §11.4, every WebSocket connection MUST have a bounded outbound
queue. This module owns the abstract policy + the evaluator helper that
concrete engines (Bun, future browser shims) call when a connection's queue
depth changes. Centralising the rule prevents engines from drifting from
the documented 256-message default.
The eviction action is a hard close with the documented
`BACKPRESSURE_EVICTED` reason — slow consumers do not get to keep their
slot.
## Features
- `BackpressurePolicy` interface — one knob: `maxQueuedMessages`.
- `DEFAULT_BACKPRESSURE_POLICY` — frozen `{ maxQueuedMessages: 256 }`.
- `evaluateBackpressure(depth, policy?)` returns `"accept"` or `"evict"`.
- `raiseBackpressureEvicted(connectionId)` throws the canonical `WsError`.
## Usage: Basic
```ts
import {
DEFAULT_BACKPRESSURE_POLICY,
evaluateBackpressure,
} from "@suaveplan/api-ws/backpressure";
if (evaluateBackpressure(queue.length) === "evict") {
await connection.close(1013, "backpressure");
}
```
## Usage: Advanced
```ts
import {
evaluateBackpressure,
raiseBackpressureEvicted,
type BackpressurePolicy,
} from "@suaveplan/api-ws/backpressure";
const tightPolicy: BackpressurePolicy = { maxQueuedMessages: 64 };
function enqueue(conn: { id: string; queue: unknown[] }, msg: unknown): void {
conn.queue.push(msg);
if (evaluateBackpressure(conn.queue.length, tightPolicy) === "evict") {
raiseBackpressureEvicted(conn.id);
}
}
```
## API Reference
- `interface BackpressurePolicy { maxQueuedMessages: number }`.
- `const DEFAULT_BACKPRESSURE_POLICY: BackpressurePolicy` (frozen).
- `type BackpressureAction = "accept" | "evict"`.
- `evaluateBackpressure(queuedMessages, policy?): BackpressureAction`.
- `raiseBackpressureEvicted(connectionId): never` — throws.
## Implementation Notes
- The evaluator uses `>` so a depth equal to the cap still accepts; the
cap is the maximum permitted, not the boundary.
- The error message embeds the connection id so operators triaging a
flood of evictions can correlate to a specific slow consumer without
parsing the per-error context.
- The thrown error is unconditional `never` so engines that call it
inside a synchronous `enqueue` path don't need a noreturn shim.

View File

@@ -0,0 +1,81 @@
# `broker` — multi-host fan-out contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A single-host deployment never needs a broker — the `SubscriptionBus`
inside that process already sees every connection. Production deployments
that run multiple host replicas behind a load balancer DO need one: a
broadcast issued on host A has to reach the connections attached to host
B without migrating connections between processes. The `WsBroker` contract
defined here is the seam those broker adapters implement.
Concrete brokers (`api-ws-broker-in-memory` for single-host tests,
`api-ws-broker-redis` for production) plug behind this interface and stay
out of plugin code paths.
## Features
- `BrokerEnvelope` — wire payload (`topic`, `tenant`, `payload`, optional
`originHost`).
- `BrokerHandler` — subscriber callback signature.
- `BrokerSubscription` — disposal handle returned from `subscribe`.
- `WsBroker<Payload>` interface — `publish`, `subscribe`, `dispose`. Each
method may return synchronously or asynchronously so in-memory brokers
stay cheap and Redis-backed brokers can `await` round trips.
## Usage: Basic
```ts
import type { WsBroker } from "@suaveplan/api-ws/broker";
async function fanOut(broker: WsBroker<{ orderId: string }>): Promise<void> {
await broker.publish("orders.created", {
topic: "orders.created",
tenant: tenantId,
payload: { orderId: "o1" },
});
}
```
## Usage: Advanced
```ts
import type { WsBroker, BrokerSubscription } from "@suaveplan/api-ws/broker";
async function bridge(
broker: WsBroker<unknown>,
local: { broadcast: (...args: unknown[]) => void },
): Promise<BrokerSubscription> {
const subscription = await broker.subscribe("orders.created", (envelope) => {
local.broadcast(envelope.topic, {
tenant: envelope.tenant,
payload: envelope.payload,
});
});
return subscription;
}
```
## API Reference
- `interface BrokerEnvelope<Payload> { topic, tenant, payload, originHost? }`.
- `type BrokerHandler<Payload> = (env: BrokerEnvelope<Payload>) => void | Promise<void>`.
- `interface BrokerSubscription { topic; unsubscribe(): void | Promise<void> }`.
- `interface WsBroker<Payload> { publish, subscribe, dispose }`.
## Implementation Notes
- The contract intentionally does not specify reliability semantics. Each
adapter declares its own delivery guarantees (`in-memory` is at-most-
once with FIFO inside the process; `redis` is at-most-once with Redis
pub/sub semantics). Plugins that need stronger guarantees use the
saga / 2PC capabilities elsewhere in the foundation.
- `originHost` is optional so adapters that don't need loop-back
suppression can omit it. Adapters that DO suppress loop-back compare
against the local host's identity at the receive side.
- `dispose` MUST close every subscription cleanly; the conformance
harness pins this invariant against every concrete adapter.

View File

@@ -0,0 +1,73 @@
# `capability-id` — `ws` + `ws.broadcast-cross-tenant`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-types/capabilities` owns the closed `CapabilityId` union for
the whole API foundation. `@suaveplan/api-ws/capability-id` re-exports the
two literals this package contributes (`"ws"` and
`"ws.broadcast-cross-tenant"`) plus a frozen `WS_CAPABILITY_IDS` map so
plugins authoring manifests have a single import to reach for. Type-level
extracts pin the literals so adding a new capability upstream cannot
silently drift this package's view.
The frozen object plus the `expectTypeOf` assertions in the conformance
test together guarantee both compile-time AND run-time identity of the two
capability ids.
## Features
- `WsCapabilityId` literal type extract.
- `WsBroadcastCrossTenantCapabilityId` literal type extract.
- `WS_CAPABILITY_IDS` frozen record (`base`, `crossTenantBroadcast`).
- Re-exports the upstream `CapabilityId` union for convenience.
## Usage: Basic
```ts
import { WS_CAPABILITY_IDS } from "@suaveplan/api-ws/capability-id";
definePlugin({
requires: [WS_CAPABILITY_IDS.base] as const,
websockets: { /* ... */ },
});
```
## Usage: Advanced
```ts
import {
WS_CAPABILITY_IDS,
type WsBroadcastCrossTenantCapabilityId,
} from "@suaveplan/api-ws/capability-id";
function assertCrossTenantOk(caps: ReadonlySet<string>): void {
if (!caps.has(WS_CAPABILITY_IDS.crossTenantBroadcast)) {
throw new Error("missing capability");
}
}
type Cap = WsBroadcastCrossTenantCapabilityId;
const value: Cap = "ws.broadcast-cross-tenant";
void value;
```
## API Reference
- `type WsCapabilityId = "ws"`.
- `type WsBroadcastCrossTenantCapabilityId = "ws.broadcast-cross-tenant"`.
- `const WS_CAPABILITY_IDS: { base: "ws"; crossTenantBroadcast: "ws.broadcast-cross-tenant" }` — frozen.
- Re-exported: `type CapabilityId from "@suaveplan/api-types/capabilities"`.
## Implementation Notes
- The `satisfies` clause on `WS_CAPABILITY_IDS` is the compile-time gate
that fails if either literal is removed from the upstream union, so a
future deprecation in api-types will fail to typecheck here and the
build stops at the contract boundary rather than at an integration site.
- `Extract` keeps the dependency direction one-way — api-ws depends on
api-types, never the reverse. Engines downstream of api-ws inherit the
same extracts.

View File

@@ -0,0 +1,86 @@
# `connection-registry` — multi-index lookup for active connections
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every plugin endpoint that opens a WebSocket connection registers it with a
host-owned `ConnectionRegistry`. The registry is the lookup surface every
other piece of the engine consumes: the subscription bus iterates
connections by `(topic, tenant)`, the per-tenant cap counts by `tenant`,
the heartbeat loop walks the registry to find stale connections. Keeping
the contract here lets future broker-backed registries (Redis, SurrealDB)
swap in without touching plugin code.
This module ships the abstract `ConnectionRegistry` interface AND a
single-host in-memory implementation. The in-memory registry doubles as
the default for the conformance harness and is what every test in the
Stage-1 contract depends on.
## Features
- `ConnectionRegistry` interface — `add`, `remove`, `lookupByTenant`,
`lookupByTopic`, `countByTenant`.
- `InMemoryConnectionRegistry` — O(1) `add` / `remove` / lookups via three
internal maps; idempotent `add` / `remove`; topic bindings dropped
automatically when a connection is removed.
- `recordSubscription(conn, topic, tenant)` and `dropSubscription(...)`
hooks the subscription bus uses to keep the `(topic, tenant)` index in
sync.
## Usage: Basic
```ts
import { InMemoryConnectionRegistry } from "@suaveplan/api-ws/connection-registry";
const registry = new InMemoryConnectionRegistry();
registry.add(connection);
const peersForTenant = registry.lookupByTenant(connection.tenantId);
registry.remove(connection);
```
## Usage: Advanced
```ts
import {
InMemoryConnectionRegistry,
type ConnectionRegistry,
} from "@suaveplan/api-ws/connection-registry";
class TenantCapGate {
constructor(
private readonly registry: ConnectionRegistry,
private readonly maxPerTenant: number,
) {}
accept(conn: { tenantId: TenantId }): boolean {
return this.registry.countByTenant(conn.tenantId) < this.maxPerTenant;
}
}
const registry: ConnectionRegistry = new InMemoryConnectionRegistry();
const gate = new TenantCapGate(registry, 1000);
```
## API Reference
- `interface ConnectionRegistry { add, remove, lookupByTenant, lookupByTopic, countByTenant }`.
- `class InMemoryConnectionRegistry implements ConnectionRegistry`.
- `recordSubscription(connection, topic, tenant)` — wired by
`SubscriptionBus.subscribe`.
- `dropSubscription(connection, topic, tenant)` — wired by `unsubscribe`.
## Implementation Notes
- The three indices (by id, by tenant, by `(topic, tenant)`) stay in sync
through the `add` / `remove` / `recordSubscription` / `dropSubscription`
quartet. `remove` walks the topic index because in practice a
connection's full subscription set isn't carried on the `WsConnection`
contract — engines may keep that state themselves.
- `recordSubscription` silently ignores unknown connections so the
subscription bus can call it without re-checking membership.
- The `(topic, tenant)` key uses `::` as a delimiter; tenant ids that
contain `::` are still distinguishable because the encoding is
prefix-based, not lossy.

View File

@@ -0,0 +1,77 @@
# `connection` — canonical `WsConnection` contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-ws/connection` declares the abstract `WsConnection`
interface every concrete engine (`api-ws-bun`, future browser shims)
implements. Plugins author against this contract; the host wires whichever
engine the deployment requires. This is The type-only foundation —
no runtime, no state, just the interface and its supporting helper types.
The handshake principal is frozen for the connection's lifetime; the
contract intentionally exposes `principal` as a `readonly Principal` so a
plugin cannot mutate it mid-session, and the host emits
`connection.principal_expired` (an api-host concern) when the underlying
session lapses.
## Features
- `WsConnection` interface — id, principal, tenantId, lastEventId, state,
plus `subscribe / unsubscribe / send / close / ack`.
- `WsConnectionState` — `"open" | "closing" | "closed"`.
- `SubscribeOptions` — tenant override and optional `filter` predicate.
- `SubscriptionFilter<Payload>` — `(payload: Payload) => boolean`.
## Usage: Basic
```ts
import type { WsConnection } from "@suaveplan/api-ws/connection";
async function onConnect(conn: WsConnection): Promise<void> {
await conn.subscribe("orders.created", { tenant: conn.tenantId });
}
```
## Usage: Advanced
```ts
import type {
SubscribeOptions,
WsConnection,
} from "@suaveplan/api-ws/connection";
interface OrderEvent { priority: number }
const opts: SubscribeOptions<OrderEvent> = {
tenant: tenantId,
filter: (payload): boolean => payload.priority >= 5,
};
await conn.subscribe("orders.created", opts);
await conn.send({ id: "1", type: "ack", topic: "orders.created" });
conn.ack("e-42");
if (conn.state !== "open") {
return;
}
```
## API Reference
- `interface WsConnection { id, principal, tenantId, lastEventId, state, subscribe, unsubscribe, send, close, ack }`.
- `type WsConnectionState = "open" | "closing" | "closed"`.
- `type SubscriptionFilter<Payload> = (payload: Payload) => boolean`.
- `interface SubscribeOptions<Payload> { tenant?: TenantId; filter?: SubscriptionFilter<Payload> }`.
## Implementation Notes
- `send` accepts a `WsEnvelope` (the wire schema) so engines can apply
the same boundary validation on outbound frames they apply on inbound.
- `subscribe` / `unsubscribe` / `send` / `close` are typed as
`void | Promise<void>` so engines that need to coordinate with a
broker can be async, while in-memory engines can stay synchronous.
- `ack` is synchronous because it only updates the connection's
`lastEventId` cursor; it never blocks on I/O.

View File

@@ -0,0 +1,78 @@
# `envelope` — wire-frame Zod schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-ws/envelope` owns the canonical Zod schema for every
WebSocket frame that flows between the host and a plugin. Engines parse
incoming frames against this schema before the message reaches plugin code,
so unknown top-level keys never leak past the host boundary and every frame
is guaranteed to carry the correlation id and discriminator the
subscription bus, ack pipeline, and reconnect-resume path require.
The shape is deliberately minimal — `id`, `type`, optional `topic`,
optional `payload`, optional `ts` — so plugins can compose their own Zod
schemas on top of `payload` without fighting the wire format.
## Features
- Strict object schema — extra keys are a parse failure, not a silent drop.
- `isWsEnvelope(value): value is WsEnvelope` predicate for ergonomic
narrowing inside engine adapters.
- `parseWsEnvelope(value)` throws the underlying `ZodError` for callers
that want to convert to a `WsError` at the boundary.
- Always uses the default-import form `import z from "zod"` per the
project Zod policy.
## Usage: Basic
```ts
import { isWsEnvelope, parseWsEnvelope } from "@suaveplan/api-ws/envelope";
const frame = parseWsEnvelope(JSON.parse(rawText));
console.log(frame.id, frame.type);
```
## Usage: Advanced
```ts
import { isWsEnvelope, type WsEnvelope } from "@suaveplan/api-ws/envelope";
import { WS_CODES, WsError } from "@suaveplan/api-ws/error-codes";
import z from "zod";
const orderPayload = z.object({ orderId: z.string() });
function handle(raw: unknown): void {
if (!isWsEnvelope(raw)) {
throw new WsError(WS_CODES.HANDSHAKE_FAILED, "invalid envelope");
}
const envelope: WsEnvelope = raw;
if (envelope.type === "orders.ack") {
const payload = orderPayload.parse(envelope.payload);
// narrow + branch...
void payload;
}
}
```
## API Reference
- `wsEnvelopeSchema: z.ZodSchema<WsEnvelope>` — strict object schema.
- `type WsEnvelope = z.infer<typeof wsEnvelopeSchema>`.
- `isWsEnvelope(value: unknown): value is WsEnvelope` — type predicate.
- `parseWsEnvelope(value: unknown): WsEnvelope` — throwing parser.
## Implementation Notes
- `strict()` mode is non-negotiable. Engines that need to allow custom
extensions MUST do so by defining their own discriminated union of
`wsEnvelopeSchema.extend({ ... })` shapes per `type`, not by relaxing the
base schema.
- `payload` is `z.unknown()` rather than `z.any()` so downstream code is
forced to narrow before use.
- The Zod default-import form (`import z from "zod"`) is the only
Bun-safe form per the project policy and is enforced by a repo-wide
grep test.

View File

@@ -0,0 +1,69 @@
# `error-codes` — WS namespace registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-ws` owns the abstract WebSocket contract. Every public failure
path raises a `WsError` carrying a stable, namespaced `WS.*` code so consumers
can branch on the value without parsing messages. This module declares the
eight Stage-1 codes through `defineCodes("WS", [...])` and exposes the
matching `WsError` subclass.
The registry is frozen by `defineCodes` so consumers can rely on the literal
string identity of every code at compile time and at run time.
## Features
- `WS_CODES` frozen registry with one literal per documented failure mode.
- `WsErrorCode` union derived from the registry.
- `WsError` class extending `SuaveplanError<WsErrorCode>` with a public
constructor and a `wsCode` field that narrows correctly inside
`instanceof` branches.
## Usage: Basic
```ts
import { WS_CODES, WsError } from "@suaveplan/api-ws/error-codes";
throw new WsError(WS_CODES.HEARTBEAT_FAILED, "two missed pongs");
```
## Usage: Advanced
```ts
import { WS_CODES, WsError, type WsErrorCode } from "@suaveplan/api-ws/error-codes";
function describe(error: unknown): string {
if (error instanceof WsError) {
const code: WsErrorCode = error.wsCode;
switch (code) {
case WS_CODES.BACKPRESSURE_EVICTED:
return "send queue overflow";
case WS_CODES.CROSS_TENANT_BROADCAST_DENIED:
return "missing ws.broadcast-cross-tenant";
default:
return code;
}
}
return "unknown";
}
```
## API Reference
- `WS_CODES: { readonly HANDSHAKE_FAILED: "WS.HANDSHAKE_FAILED", ... }` — frozen record.
- `type WsErrorCode = (typeof WS_CODES)[keyof typeof WS_CODES]`.
- `class WsError extends SuaveplanError<WsErrorCode>` with
`constructor(code, message, options?)` and a `wsCode: WsErrorCode` field.
## Implementation Notes
- The eight codes mirror the rows of design-doc §11.4 / §11.5 plus the cross-
tenant gate of §11.2 and the broker-unavailability path of §11.3. Adding a
new code requires an OpenSpec delta — the registry is intentionally closed.
- `WsError` widens the base constructor from `protected` to `public` so
callers may construct instances directly. The base class still provides the
redaction, OTel adapter, and cause-chain traversal.

View File

@@ -0,0 +1,85 @@
# `heartbeat` — ping/pong policy + evaluator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every WebSocket connection that the Suaveplan API host accepts is policed
by a heartbeat: the engine pings at a fixed cadence and forcibly closes the
connection once a documented number of consecutive pongs are missed. This
module owns the abstract policy and the stateless evaluator that concrete
engines call from their heartbeat tick. The defaults — 30000ms cadence,
two missed pongs to abort — track design doc §11.4.
The evaluator is intentionally stateless: it consumes scalar measurements
the engine already tracks and returns the next action. State lives with
the engine; the contract lives here.
## Features
- `HeartbeatPolicy` interface (`heartbeatMs`, `maxMissedPongs`).
- `DEFAULT_HEARTBEAT_POLICY` — frozen design-doc defaults.
- `evaluateHeartbeat(msSinceLastPong, consecutiveMissedPongs, policy?)` →
`"ok" | "ping" | "abort"`.
- `raiseHeartbeatFailed(connectionId)` throws the canonical `WsError`.
## Usage: Basic
```ts
import { evaluateHeartbeat } from "@suaveplan/api-ws/heartbeat";
const action = evaluateHeartbeat(msSinceLastPong, missedPongs);
if (action === "ping") sendPing();
if (action === "abort") closeAbnormal();
```
## Usage: Advanced
```ts
import {
evaluateHeartbeat,
raiseHeartbeatFailed,
type HeartbeatPolicy,
} from "@suaveplan/api-ws/heartbeat";
class HeartbeatTicker {
private missed = 0;
private lastPong = Date.now();
constructor(private readonly policy: HeartbeatPolicy) {}
tick(now: number, connectionId: string): void {
const action = evaluateHeartbeat(now - this.lastPong, this.missed, this.policy);
if (action === "ping") {
this.missed += 1;
} else if (action === "abort") {
raiseHeartbeatFailed(connectionId);
}
}
onPong(now: number): void {
this.missed = 0;
this.lastPong = now;
}
}
```
## API Reference
- `interface HeartbeatPolicy { heartbeatMs: number; maxMissedPongs: number }`.
- `const DEFAULT_HEARTBEAT_POLICY: HeartbeatPolicy` (frozen).
- `type HeartbeatAction = "ok" | "ping" | "abort"`.
- `evaluateHeartbeat(msSinceLastPong, consecutiveMissedPongs, policy?): HeartbeatAction`.
- `raiseHeartbeatFailed(connectionId): never`.
## Implementation Notes
- The evaluator checks `consecutiveMissedPongs` before `msSinceLastPong`
so an already-failed connection aborts immediately on the next tick
even if the engine's clock briefly stalls.
- `consecutiveMissedPongs` is the engine's counter — every ping increments
it; every pong resets it. The evaluator does not own that state.
- `raiseHeartbeatFailed` is `never` so callers can use it as a terminal
branch without an explicit unreachable assertion.

View File

@@ -0,0 +1,86 @@
# `reconnect-token` — HMAC envelope for resumable WebSocket sessions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
When the host closes a WebSocket connection cleanly (heartbeat failure,
deploy, transient network issue), it issues a reconnect token the client can
present on a follow-up upgrade request. The host validates the HMAC and
re-attaches the previous subscription set with no plugin code change. This
module owns the abstract payload shape and a Web-Crypto-based sign/verify
pair. State is carried inside the token; the host stores nothing.
The TTL default is 5 minutes, matching design doc §11.5. Anything older is
rejected with `WS.RECONNECT_TOKEN_INVALID` so a stolen token has a hard
upper bound on its usefulness.
## Features
- Strict Zod schema for the payload — `tenant`, `principalId`,
`lastEventId` (nullable), `exp`.
- `signReconnectToken(input, { secret, clock?, ttlMs? })`.
- `verifyReconnectToken(token, { secret, clock? })` — throws on bad
encoding, bad MAC, bad shape, or expired payload.
- Uses Web Crypto (`globalThis.crypto.subtle`) — runs unchanged in Bun,
Node 20+, browser, and worker tiers.
- Constant-time MAC comparison.
## Usage: Basic
```ts
import {
signReconnectToken,
verifyReconnectToken,
} from "@suaveplan/api-ws/reconnect-token";
const secret = crypto.getRandomValues(new Uint8Array(32));
const token = await signReconnectToken(
{ tenant: "t1", principalId: "p1", lastEventId: "e-9" },
{ secret }
);
const payload = await verifyReconnectToken(token, { secret });
```
## Usage: Advanced
```ts
import {
RECONNECT_TOKEN_TTL_MS,
signReconnectToken,
verifyReconnectToken,
} from "@suaveplan/api-ws/reconnect-token";
const clock = (): number => mockNow;
let mockNow = 1_000_000;
const token = await signReconnectToken(
{ tenant: "t1", principalId: "p1", lastEventId: null },
{ secret, clock, ttlMs: RECONNECT_TOKEN_TTL_MS }
);
mockNow += RECONNECT_TOKEN_TTL_MS;
await verifyReconnectToken(token, { secret, clock }); // throws WS.RECONNECT_TOKEN_INVALID
```
## API Reference
- `const RECONNECT_TOKEN_TTL_MS = 300_000`.
- `interface ReconnectTokenPayload { tenant, principalId, lastEventId, exp }`.
- `reconnectTokenPayloadSchema: z.ZodSchema<ReconnectTokenPayload>`.
- `signReconnectToken(input, options): Promise<string>`.
- `verifyReconnectToken(token, options): Promise<ReconnectTokenPayload>` —
throws `WsError(WS.RECONNECT_TOKEN_INVALID)` on every failure mode.
## Implementation Notes
- HMAC-SHA-256 over the canonical JSON of the payload (the exact bytes
signed are the bytes verified — no re-serialisation).
- `base64url` encoding (no padding, `-`/`_` instead of `+`/`/`) keeps the
token URL-safe for the `?reconnectToken=…` query parameter.
- The verifier uses constant-time comparison so a malicious client cannot
recover the MAC byte-by-byte through timing.
- All four failure modes (malformed wire form, bad encoding, bad MAC,
expired) collapse to a single `WS.RECONNECT_TOKEN_INVALID` code so
clients cannot probe for which guard rejected them.

View File

@@ -0,0 +1,103 @@
# `subscription-bus` — typed fan-out with the cross-tenant capability gate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<Payload>` 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<Payload>` 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<Alert>();
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<Payload> { subscribe, unsubscribe, broadcast, asyncIterator }`.
- `interface BusCaller { tenant: TenantId; capabilities: ReadonlySet<CapabilityId> }`.
- `interface BusBroadcastOptions<Payload> { tenant, payload, filter? }`.
- `interface BusSubscribeOptions<Payload> { tenant?, filter? }`.
- `class InMemorySubscriptionBus<Payload> implements SubscriptionBus<Payload>`.
## 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.

View File

@@ -0,0 +1,88 @@
# `testing` — `runWsEngineConformance` harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every concrete WebSocket engine and broker the foundation publishes
(`api-ws-bun`, `api-ws-broker-in-memory`, `api-ws-broker-redis`, future
adapters) MUST pass an identical battery of behavioural tests. This module
ships that battery as a reusable harness so engine authors get the same
verification surface without re-deriving it. The harness covers handshake,
subscribe / broadcast fan-out, backpressure policy, heartbeat policy,
reconnect-token round trip, broker disposal, and the cross-tenant
broadcast capability gate.
It also ships `inMemoryFactory` — a self-contained `(bus, registry,
broker, makeConnection)` quadruple wired against the in-memory primitives
shipped in this package — so the harness self-tests cleanly and engine
authors have an executable reference.
## Features
- `runWsEngineConformance({ label, factory })` — Vitest `describe`/`it`
block driving the seven category tests.
- `WsEngineFactoryResult` — strongly typed quadruple every adapter
returns: `{ bus, registry, broker, makeConnection, dispose }`.
- `inMemoryFactory()` — reference factory used by the package's own
conformance test.
## Usage: Basic
```ts
import { runWsEngineConformance } from "@suaveplan/api-ws/testing";
import { createBunEngine } from "@suaveplan/api-ws-bun";
runWsEngineConformance({
label: "Bun engine",
factory: async () => createBunEngine(),
});
```
## Usage: Advanced
```ts
import {
inMemoryFactory,
runWsEngineConformance,
type WsEngineFactoryResult,
} from "@suaveplan/api-ws/testing";
async function instrumentedFactory(): Promise<WsEngineFactoryResult> {
const base = inMemoryFactory();
return {
...base,
async dispose() {
await base.dispose();
console.log("engine disposed");
},
};
}
runWsEngineConformance({
label: "instrumented",
factory: instrumentedFactory,
});
```
## API Reference
- `interface WsEngineFactoryResult<Payload> { bus, registry, broker, makeConnection, dispose }`.
- `interface WsEngineConformanceOptions<Payload> { label, factory }`.
- `runWsEngineConformance<Payload>(options): void`.
- `inMemoryFactory<Payload>(): WsEngineFactoryResult<Payload>`.
## Implementation Notes
- The harness uses `@suaveplan/testing/runner` (NOT a direct `vitest`
import) so it stays portable between Vitest and Bun-test under the
project's runner conditional-export wrapper.
- `dispose()` runs inside a `finally` block so a failing assertion still
releases the underlying engine resources.
- The cross-tenant test uses a deliberately under-capability `caller`
to assert that engines properly delegate to the bus's capability gate
rather than re-implementing it incorrectly.
- The harness is intentionally additive — engines may layer their own
`describe(...)` blocks alongside it for engine-specific behaviour.