Initial Commit
This commit is contained in:
167
openspec/specs/api-ws-bun/spec.md
Normal file
167
openspec/specs/api-ws-bun/spec.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<!-- 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"`
|
||||
Reference in New Issue
Block a user