Initial Commit
This commit is contained in:
202
openspec/archive/2026-05-25-api-ws/specs/api-ws/spec.md
Normal file
202
openspec/archive/2026-05-25-api-ws/specs/api-ws/spec.md
Normal file
@@ -0,0 +1,202 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-ws (api-ws)
|
||||
|
||||
**Package**: `@suaveplan/api-ws`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement carries a `**Verified by:**` line.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: WsConnection contract
|
||||
|
||||
The package SHALL export `WsConnection` with: `id` (ULID), `principal: Principal`, `tenantId: TenantId`, `lastEventId: string | null`, `state: "open" | "closing" | "closed"`, methods `subscribe(topic, opts?)`, `unsubscribe(topic)`, `send(message)`, `close(code, reason?)`, `ack(id)`. The principal MUST be frozen at handshake for the connection's lifetime; token expiry mid-connection fires `connection.principal_expired` (the host emits this; the contract surfaces it).
|
||||
|
||||
#### Scenario: every method present
|
||||
|
||||
- **WHEN** `expectTypeOf<WsConnection>()` is checked
|
||||
- **THEN** every documented method is declared with the documented signature
|
||||
|
||||
**Verified by:** `src/connection/connection.spec-conformance.test.ts::"WsConnection declares every documented method"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `import z from "zod"` everywhere
|
||||
|
||||
Every Zod import MUST use the default form.
|
||||
|
||||
#### Scenario: no named import of z
|
||||
|
||||
- **WHEN** the test greps `import z` from `"zod"`
|
||||
- **THEN** zero matches
|
||||
|
||||
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ConnectionRegistry multi-index
|
||||
|
||||
The package SHALL export `ConnectionRegistry` with O(1) lookup by `tenantId` and by `(topic, tenantId)`. `add`, `remove`, `lookupByTenant(tenant)`, `lookupByTopic(topic, tenant?)`, `countByTenant(tenant)`. `remove` MUST be idempotent (removing an absent connection is a no-op).
|
||||
|
||||
#### Scenario: lookup by tenant returns matching set
|
||||
|
||||
- **WHEN** 3 connections are added for tenant T1 and 2 for tenant T2
|
||||
- **THEN** `lookupByTenant("T1").length === 3` AND `lookupByTenant("T2").length === 2`
|
||||
|
||||
#### Scenario: remove is idempotent
|
||||
|
||||
- **WHEN** `remove(conn)` is called twice
|
||||
- **THEN** the second call returns without throwing
|
||||
|
||||
**Verified by:** `src/connection-registry/connection-registry.test.ts::"lookupByTenant returns matching set"` AND `src/connection-registry/connection-registry.test.ts::"remove is idempotent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SubscriptionBus broadcast with filter
|
||||
|
||||
The package SHALL export `SubscriptionBus.broadcast(topic, { tenant, payload, filter? })` which delivers the payload to every connection registered against `(topic, tenant)` whose optional `filter(payload)` returns true. The payload MUST be encoded once and sent to each matching connection (no per-connection re-encoding).
|
||||
|
||||
#### Scenario: broadcast reaches matching connections
|
||||
|
||||
- **WHEN** two connections subscribe to `orders.created` for tenant T
|
||||
- **THEN** `broadcast("orders.created", { tenant: T, payload })` reaches both
|
||||
|
||||
#### Scenario: filter excludes non-matching connections
|
||||
|
||||
- **WHEN** filter returns `false` for one of two matching connections
|
||||
- **THEN** only the connection where filter returns true receives the payload
|
||||
|
||||
**Verified by:** `src/subscription-bus/subscription-bus.test.ts::"broadcast reaches all matching connections"` AND `src/subscription-bus/subscription-bus.test.ts::"filter predicate excludes non-matching connections"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Cross-tenant broadcast requires explicit capability
|
||||
|
||||
`SubscriptionBus.broadcast(topic, { tenant, payload })` where `tenant` differs from the caller's `principal.tenantId` (or `tenant` is the wildcard "*") MUST throw `WS.CROSS_TENANT_BROADCAST_DENIED` unless the caller's `Caps` includes `"ws.broadcast-cross-tenant"`. Default is deny.
|
||||
|
||||
#### Scenario: same-tenant broadcast succeeds without capability
|
||||
|
||||
- **WHEN** caller's tenant is T AND broadcast is for tenant T
|
||||
- **THEN** broadcast succeeds
|
||||
|
||||
#### Scenario: cross-tenant denied by default
|
||||
|
||||
- **WHEN** caller's tenant is T AND broadcast is for tenant T2 AND `Caps` does NOT include `ws.broadcast-cross-tenant`
|
||||
- **THEN** broadcast throws `WS.CROSS_TENANT_BROADCAST_DENIED`
|
||||
|
||||
**Verified by:** `src/subscription-bus/cross-tenant.test.ts::"same-tenant broadcast succeeds without capability"` AND `src/subscription-bus/cross-tenant.test.ts::"cross-tenant denied without explicit capability"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WsBroker contract
|
||||
|
||||
The package SHALL export `WsBroker` for multi-host fan-out: `publish(topic, envelope)`, `subscribe(topic, handler)`, `dispose()`. The broker MUST NOT migrate connections — it only makes broadcasts visible across host replicas. `dispose()` MUST close every subscription cleanly.
|
||||
|
||||
#### Scenario: every method present
|
||||
|
||||
- **WHEN** `expectTypeOf<WsBroker>()` is checked
|
||||
- **THEN** all three methods are declared
|
||||
|
||||
**Verified by:** `src/broker/broker.spec-conformance.test.ts::"WsBroker declares publish subscribe dispose"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Envelope Zod schema
|
||||
|
||||
The package SHALL export `wsEnvelopeSchema: z.ZodSchema<WsEnvelope>` describing the wire envelope: `{ id: string, type: string, topic?: string, payload?: unknown, ts?: string }`. Unknown top-level keys MUST be rejected.
|
||||
|
||||
#### Scenario: minimal envelope parses
|
||||
|
||||
- **WHEN** `{ id: "x", type: "msg" }` is parsed
|
||||
- **THEN** it succeeds
|
||||
|
||||
#### Scenario: extra key rejected
|
||||
|
||||
- **WHEN** `{ id: "x", type: "msg", extra: 1 }` is parsed
|
||||
- **THEN** Zod reports an issue at `extra`
|
||||
|
||||
**Verified by:** `src/envelope/envelope.test.ts::"minimal envelope parses"` AND `src/envelope/envelope.test.ts::"unknown keys rejected"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Backpressure policy
|
||||
|
||||
The package SHALL export `BackpressurePolicy` with default `maxQueuedMessages = 256`. When a connection's send queue exceeds the cap, the connection MUST receive a `BACKPRESSURE_EVICTED` close frame and be dropped from the registry.
|
||||
|
||||
#### Scenario: queue saturation triggers eviction
|
||||
|
||||
- **WHEN** 257 messages are queued for a connection with default policy
|
||||
- **THEN** the eviction handler fires AND `WS.BACKPRESSURE_EVICTED` is the close code
|
||||
|
||||
**Verified by:** `src/backpressure/backpressure.test.ts::"queue saturation triggers BACKPRESSURE_EVICTED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Heartbeat policy
|
||||
|
||||
The package SHALL export `HeartbeatPolicy` with default `heartbeatMs = 30000`. Two missed pongs MUST trigger `onClose` with code `ABNORMAL`.
|
||||
|
||||
#### Scenario: two missed pongs trigger close
|
||||
|
||||
- **WHEN** a fake clock advances past 2 × heartbeatMs without pong
|
||||
- **THEN** the close handler fires with `code === "ABNORMAL"` AND `WS.HEARTBEAT_FAILED` is emitted
|
||||
|
||||
**Verified by:** `src/heartbeat/heartbeat.test.ts::"two missed pongs trigger ABNORMAL close"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Reconnect-token shape
|
||||
|
||||
The package SHALL export `ReconnectToken` HMAC envelope carrying `{ tenant, principalId, lastEventId, exp }` with default 5-minute TTL. The HMAC verifier MUST reject expired tokens (`WS.RECONNECT_TOKEN_INVALID`) and tokens with bad MAC.
|
||||
|
||||
#### Scenario: valid token verifies
|
||||
|
||||
- **WHEN** a token signed within TTL is verified
|
||||
- **THEN** the verifier returns the parsed payload
|
||||
|
||||
#### Scenario: expired token rejected
|
||||
|
||||
- **WHEN** the fake clock advances past TTL
|
||||
- **THEN** the verifier throws `WS.RECONNECT_TOKEN_INVALID`
|
||||
|
||||
**Verified by:** `src/reconnect-token/reconnect-token.test.ts::"valid token verifies"` AND `src/reconnect-token/reconnect-token.test.ts::"expired token rejected"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CapabilityId augmentation contributes "ws" and "ws.broadcast-cross-tenant"
|
||||
|
||||
The package SHALL augment the `CapabilityId` union to include both ids.
|
||||
|
||||
#### Scenario: both ids assignable to CapabilityId
|
||||
|
||||
- **WHEN** `expectTypeOf<"ws">().toMatchTypeOf<CapabilityId>()` AND `expectTypeOf<"ws.broadcast-cross-tenant">().toMatchTypeOf<CapabilityId>()` are checked
|
||||
- **THEN** both pass
|
||||
|
||||
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"ws and ws.broadcast-cross-tenant are valid CapabilityIds"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error codes registry frozen
|
||||
|
||||
The package SHALL export `WS_CODES` containing exactly: `HANDSHAKE_FAILED`, `PRINCIPAL_EXPIRED`, `BACKPRESSURE_EVICTED`, `HEARTBEAT_FAILED`, `CONNECTION_CAP_EXCEEDED`, `CROSS_TENANT_BROADCAST_DENIED`, `RECONNECT_TOKEN_INVALID`, `BROKER_UNAVAILABLE`.
|
||||
|
||||
#### Scenario: every code reachable
|
||||
|
||||
- **WHEN** the test enumerates `WS_CODES`
|
||||
- **THEN** every code has a production throw site
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every WS_CODES entry thrown by production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness for engines
|
||||
|
||||
The package SHALL export `runWsEngineConformance({ factory, label })`. Every concrete engine + broker pair MUST pass.
|
||||
|
||||
#### Scenario: harness covers handshake, subscribe, broadcast, backpressure, heartbeat, reconnect
|
||||
|
||||
- **WHEN** the harness is run against a stub engine
|
||||
- **THEN** test categories exist for every documented behavior
|
||||
|
||||
**Verified by:** `src/testing/conformance.test.ts::"harness covers handshake subscribe broadcast backpressure heartbeat reconnect"`
|
||||
Reference in New Issue
Block a user