Files
2026-05-27 23:29:53 +01:00

168 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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"`