# Capability Spec Delta: api-ws-bun (api-ws-bun) **Package**: `@suaveplan/api-ws-bun` **Status**: PROPOSED > Every requirement carries a `**Verified by:**` line. ## ADDED Requirements ### Requirement: Implements the abstract WebSocket engine contract The package SHALL export `createBunWsEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-ws`. The conformance suite `runWsEngineConformance` MUST pass against this adapter. #### Scenario: conformance suite passes - **WHEN** `runWsEngineConformance("bun", createBunWsEngine)` runs - **THEN** every invariant passes **Verified by:** `src/engine/bun-ws-engine.spec-conformance.test.ts::"createBunWsEngine passes runWsEngineConformance"` --- ### Requirement: Handshake freezes the principal for the connection lifetime At handshake the engine SHALL run per-request pipeline steps 1–7 (auth + tenant resolution + capability scoping) and bind the resolved `principal` and `tenantId` to the `Connection`. The principal MUST be immutable for the connection's lifetime. Mid-connection token expiry MUST emit `connection.principal_expired` on the host event bus; the plugin's `onClose` MUST then be invokable to drop the connection cleanly. `WS_BUN_PRINCIPAL_EXPIRED` is the code emitted with that event. #### Scenario: principal bound at handshake survives subsequent token rotation - **WHEN** the handshake resolves principal P AND the underlying token rotates server-side - **THEN** `conn.principal` still reads P AND `connection.principal_expired` fires once the original token's expiry passes **Verified by:** `src/principal/principal-expiry.test.ts::"principal frozen at handshake; expiry emits connection.principal_expired"` --- ### Requirement: Per-connection backpressure with default 256-message queue The engine SHALL enforce a per-connection send queue capped at `maxQueuedMessages` (default 256). When the queue would exceed the cap, the connection MUST receive a `BACKPRESSURE_EVICTED` close frame AND `onClose` fires with `code: WS_BUN_BACKPRESSURE_EVICTED`. The eviction MUST be observable via `ws.backpressure_evicted_total` counter. #### Scenario: queue overflow evicts the connection - **WHEN** `maxQueuedMessages: 4` AND 5 sends are issued before the client reads any - **THEN** the connection receives a `BACKPRESSURE_EVICTED` close frame AND `ws.backpressure_evicted_total` increments by 1 **Verified by:** `src/backpressure/backpressure.test.ts::"queue overflow evicts connection with BACKPRESSURE_EVICTED"` --- ### Requirement: Heartbeat with default 30000ms interval and 2-missed-pong drop The engine SHALL send a ping frame every `heartbeatMs` (default 30000). Two consecutive missed pongs (default `missedPongLimit: 2`) MUST be treated as a dead connection — `onClose` fires with `code: ABNORMAL` AND `ws.heartbeat_failures_total` increments. #### Scenario: 2 missed pongs drops the connection - **WHEN** the client never responds to pings AND `missedPongLimit: 2` - **THEN** after the 2nd missed-pong window, the connection is dropped AND `ws.heartbeat_failures_total` increments by 1 **Verified by:** `src/heartbeat/heartbeat.test.ts::"2 missed pongs drops the connection with code ABNORMAL"` --- ### Requirement: Per-tenant connection cap The engine SHALL track active connections per tenant and reject new handshakes when a tenant's count is `>= maxConnectionsPerTenant` (default 1000). Rejection returns HTTP 429 AND `WS_BUN_TENANT_CAP_EXCEEDED`. #### Scenario: 1001st connection from same tenant rejected with 429 - **WHEN** `maxConnectionsPerTenant: 1000` AND tenant T already has 1000 active connections - **THEN** the 1001st handshake returns 429 AND `WS_BUN_TENANT_CAP_EXCEEDED` **Verified by:** `src/limits/tenant-cap.test.ts::"1001st connection from same tenant rejected with WS_BUN_TENANT_CAP_EXCEEDED"` --- ### Requirement: Reconnect tokens with HMAC signing and 5-minute TTL On `onClose` the engine SHALL issue a reconnect token: HMAC-signed (key from `reconnectTokenSecret`), 5-minute TTL (`reconnectTokenTtlMs` default 300000), payload `{ tenant, principalId, lastEventId }`. A client reconnecting with `?reconnectToken=...` MUST be validated; on success the engine resumes subscriptions automatically and the plugin's `onConnect` runs in `resume` mode with the pre-restored subscription set. Invalid token → `WS_BUN_RECONNECT_TOKEN_INVALID`. Expired token → `WS_BUN_RECONNECT_TOKEN_EXPIRED`. Replayed token (already consumed) → `WS_BUN_RECONNECT_TOKEN_INVALID`. #### Scenario: valid token resumes subscriptions - **WHEN** the client reconnects with a valid token within 5 minutes - **THEN** the resumed connection has the same subscription set as before close #### Scenario: expired token is rejected - **WHEN** the client reconnects 6 minutes after close - **THEN** the handshake fails with `WS_BUN_RECONNECT_TOKEN_EXPIRED` #### Scenario: tampered token is rejected - **WHEN** the client modifies the token's payload before reconnecting - **THEN** HMAC validation fails AND the handshake fails with `WS_BUN_RECONNECT_TOKEN_INVALID` **Verified by:** `src/reconnect/reconnect-token.test.ts::"valid token resumes subscriptions in resume mode"` AND `src/reconnect/reconnect-token.test.ts::"expired token rejected with WS_BUN_RECONNECT_TOKEN_EXPIRED"` AND `src/reconnect/reconnect-token.test.ts::"tampered token rejected with WS_BUN_RECONNECT_TOKEN_INVALID"` --- ### Requirement: Reconnect event-buffer storage default The engine SHALL provide a per-`(tenant, topic)` event buffer used during reconnect-resume to replay events with id `> lastEventId`. Storage MUST follow `reconnectEventBufferStrategy`: - `"auto"` (default): when a Redis broker (`api-ws-broker-redis`) is wired into the host, the buffer lives in Redis; otherwise it lives in-memory on the local host. This pins the design doc §11.5 paragraph 4 default ("Redis when the broker is Redis, in-memory otherwise") — explicitly resolving the §14 open question for the foundation's shipping default. - `"memory"`: always in-memory regardless of broker (single-host deployments). - `"broker"`: always broker-backed; requires a broker capable of supporting the buffer (currently only `api-ws-broker-redis`); refuses with `WS_BUN_RECONNECT_BUFFER_MISS` at boot if no compatible broker is wired. #### Scenario: auto resolves to broker when Redis broker is wired - **WHEN** `reconnectEventBufferStrategy: "auto"` AND `api-ws-broker-redis` is wired - **THEN** the engine uses the broker-backed buffer #### Scenario: auto resolves to memory when no broker is wired - **WHEN** `reconnectEventBufferStrategy: "auto"` AND no broker is wired (single-host) - **THEN** the engine uses the in-memory buffer #### Scenario: broker strategy without a compatible broker refuses at boot - **WHEN** `reconnectEventBufferStrategy: "broker"` AND no compatible broker is wired - **THEN** boot fails with `WS_BUN_RECONNECT_BUFFER_MISS` **Verified by:** `src/reconnect/event-buffer.test.ts::"auto resolves to broker when Redis broker wired"` AND `src/reconnect/event-buffer.test.ts::"auto resolves to memory when no broker wired"` AND `src/reconnect/event-buffer.test.ts::"broker strategy without compatible broker refuses at boot with WS_BUN_RECONNECT_BUFFER_MISS"` --- ### Requirement: Subscription bus with cross-tenant capability gate `conn.subscribe(topic, { tenant?, filter? })` SHALL register the connection against the host's subscription bus, indexed by `(topic, tenant)`. `bus.broadcast(topic, { tenant, payload })` MUST fan out only to matching connections AND run optional per-connection `filter` predicates. Cross-tenant fan-out (a `bus.broadcast` whose `tenant` value differs from the broadcasting plugin's tenant) SHALL require the plugin to declare the `ws.broadcast-cross-tenant` capability; absence → refuse + audit log emitted. #### Scenario: tenant-scoped broadcast delivered only to matching tenant - **WHEN** a broadcast for tenant T fires AND connections C1 (tenant T) and C2 (tenant U) are subscribed - **THEN** C1 receives the payload AND C2 does not #### Scenario: cross-tenant broadcast without capability is refused - **WHEN** a plugin without `ws.broadcast-cross-tenant` issues `bus.broadcast(topic, { tenant: "other" })` - **THEN** the broadcast is refused AND an audit log entry is emitted **Verified by:** `src/registry/connection-registry.test.ts::"tenant-scoped broadcast delivered only to matching tenant"` AND `src/registry/connection-registry.test.ts::"cross-tenant broadcast without capability refused and audit-logged"` --- ### Requirement: OTel surface The engine SHALL emit every instrument listed in design §11.7: - `ws.handshake.duration_ms` histogram `{ plugin, path }`. - `ws.connections` observable gauge `{ plugin, tenant }`. - `ws.messages_in_total` counter `{ plugin, path }`. - `ws.messages_out_total` counter `{ plugin, path }`. - `ws.broadcasts_total` counter `{ topic, tenant, recipients }`. - `ws.backpressure_evicted_total` counter `{ plugin }`. - `ws.heartbeat_failures_total` counter `{ plugin }`. #### Scenario: handshake emits duration histogram - **WHEN** a connection completes its handshake - **THEN** an in-memory `MeterProvider` observes one observation in `ws.handshake.duration_ms` #### Scenario: broadcast emits broadcasts_total - **WHEN** a tenant-scoped broadcast is issued with 3 recipients - **THEN** `ws.broadcasts_total{recipients=3}` increments by 1 **Verified by:** `src/instruments/instruments.test.ts::"handshake emits ws.handshake.duration_ms"` AND `src/instruments/instruments.test.ts::"broadcast emits ws.broadcasts_total with recipient count"`