# ADR-001: Protocol Versioning & Compatibility * **Date**: 2025-11-22 * **Status**: Accepted * **Applies to**: SBP * **Tags**: protocol, frames, handshake ## Context * `@sideband/protocol` is the foundation for all transports and runtimes. Breaking wire changes ripple through every package. * Current implementation encodes frames in a binary envelope with a JSON handshake. Version fields exist (`PROTOCOL_VERSION = "1"`) but policy was undocumented. * We need predictable rules for adding fields, handling unknown data, and reacting to version skew. ## Decision * Version label: `"sideband/1"` (`PROTOCOL_ID`). Version is asserted during handshake; all frames on a connection share the same major version. * Negotiation: no downgrade/upgrade; v1 endpoints accept only `version === "1"` and `protocol === "sideband"`. Future negotiation, if needed, will happen via a new major version handshake. * Compatibility rules for v1: * Additive only: new optional fields, new `ControlOp`, new capability strings are allowed without a major bump. * Unknown fields/caps/metadata MUST be ignored, not rejected. * **Frame header flags** (byte 1): bit 0 signals timestamp presence; bits 1–7 reserved and must be zero. Non-zero reserved bits close the connection. See docs/protocols/sbp/wire-format.md. * **FrameId** (16 bytes): opaque entropy with no semantic bits or structure. Decoders MUST treat as raw binary and MUST NOT interpret any bits. Validation is length-only (must be exactly 16 bytes). * Mandatory field changes (renames/removals) require a new major version. * Error behavior on skew: * If `protocol` or `version` is unsupported, send `ErrorFrame{code=UnsupportedVersion}` then close. * If a frame is malformed or uses a reserved bit/type, send `ErrorFrame{code=ProtocolViolation}` or `InvalidFrame` then close. * Deprecation: v1 features may be deprecated via docs/caps but remain accepted until v2 exists; removal happens only in a new major version. ## Consequences * Implementations can evolve v1 by adding optional data without breaking peers. * Tests must include cases where unknown `caps`/metadata are ignored and reserved bits cause rejection. * Transports should surface version errors early (during handshake) to avoid partial session setup. * **v1 Timestamp Feature**: Timestamps (flag bit 0) are an integrated v1 feature. All implementations MUST handle the timestamp bit, even if unused. Senders MAY include timestamps on any frame; receivers simply skip them if not used. * Future major versions will introduce a new handshake version string and may reuse the same framing with different semantics; coexistence requires either separate listeners or a negotiator shim. --- --- url: /adr/002-naming-matrix.md --- # ADR-002: Naming Matrix for Protocol Types * **Date**: 2025-11-22 * **Status**: Accepted * **Tags**: protocol, transport, runtime ## Context * Names for ids, timestamps, frame kinds, and wire keys have drifted across packages, which makes interop harder and confuses new contributors. * We need a single source of truth that pins type names, property names, and wire-level keys, including how higher-level SDK concepts map onto protocol frames. * The matrix should be concise enough to live near specs and code but explicit enough to guide future changes and AI-assisted edits. ## Decision * Canonicalize on **Peer** as the intrinsic identity, using `PeerId` for type names and `peerId` for properties. * Adopt the following naming matrix as the canonical reference for protocol-level types, wire keys, and SDK-facing naming. * Wire keys (`t`, `id`, `peerId`, `caps`, `s`, `b`) are internal to encode/decode; public TypeScript types expose descriptive names. * Use these names in code, docs, tests, and public APIs; deviations require an ADR update. **Rationale**: `PeerId` avoids collision with Node.js ecosystem terminology and aligns with modern P2P standards (libp2p, IPFS, WebRTC). `NodeId` created semantic ambiguity in browser contexts and confused process identity with network-visible peer identity. ### 1. Identity, timestamps, correlation | Domain concept | Type / Enum name | Field / Property | Wire key | Notes / invariants | | -------------------- | ---------------- | ---------------- | -------- | -------------------------------------------------------- | | Peer identity | `PeerId` | `peerId` | `peerId` | Stable identity of a peer node; survives reconnects | | Connection identity | `ConnectionId` | `connectionId` | - | Transient transport link identity; new per TCP/WebSocket | | Session identity | `SessionId` | `sessionId` | - | Optional higher-level session across reconnects | | Frame identity | `FrameId` | `frameId` | `id` | 16 opaque bytes; identifies this frame instance on wire | | Correlation identity | `CorrelationId` | `correlationId` | - | **Reserved for v2.** | | Trace identity (req) | `TraceId` | `traceId` | - | Optional, spans multi-frame flows | > `frameId` — In v1, used for request/response correlation and ACK linkage. Always present on every frame (16 bytes, binary); auto-generated at construction to eliminate defensive checks in runtime/RPC layers. > `correlationId` — Reserved for v2 when explicit tracing or multi-hop flows require separate correlation semantics. > Helper: `generateFrameId()` creates a 128-bit (16 bytes) FrameId via `crypto.getRandomValues()`. Human-readable representation (for logs/JSON) uses `frameIdToHex()` → lowercase hex (32 chars). ### 2. Frames and variants | Domain concept | Type / Enum name | Field / Property | Wire key | Notes | | ------------------ | ---------------- | ----------------------------------- | -------- | -------------------------------------------------- | | Frame discriminant | `FrameKind` | `kind` | `t` | Enum: `Control`, `Message`, `Ack`, `Error` | | Base frame | `Frame` | - | - | Union of all frame variants | | Control frame | `ControlFrame` | `kind: FrameKind.Control` | `t=0` | Handshake / Ping / Pong / Close | | Message frame | `MessageFrame` | `kind: FrameKind.Message` | `t=1` | Carries routable application data (RPC + pub/sub)¹ | | Ack frame | `AckFrame` | `kind: FrameKind.Ack`; `ackFrameId` | `t=2` | Acknowledges another frame² | | Error frame | `ErrorFrame` | `kind: FrameKind.Error` | `t=3` | Represents protocol or app errors | > 1. Message frames carry routable application data (RPC + events). The peer SDK works with frames directly — there is no separate wrapper type. > 2. `ackFrameId` references the target's `frameId`. ### 3. Control operations and payloads | Domain concept | Type / Enum name | Field / Property | Wire key | Notes | | ----------------- | ------------------ | ---------------- | -------- | ------------------------------------- | | Control op | `ControlOp` | `op` | `c` | `Handshake`, `Ping`, `Pong`, `Close` | | Control data | - | `data` | - | Optional opaque binary | | Handshake payload | `HandshakePayload` | - | - | Contains `peerId`, `caps`, `metadata` | > `c` is scoped to `ControlFrame` payloads only; never used in other frame types. > `data` is an optional binary blob used for control frame metadata or handshake extensions. ### 4. Message frame and subjects | Domain concept | Type / Enum name | Field / Property | Wire key | Notes | | -------------------- | ---------------- | ----------------- | -------- | --------------------------------------------- | | Routing key | `Subject` | `subject` | `s` | UTF-8 string; see channel subject rules below | | Raw message bytes | - | `data` | `b` | `Uint8Array`; wire is opaque bytes | | Wire message (frame) | `MessageFrame` | `subject`, `data` | `t=1` | Routable, identity-bearing unit on the wire | **Channel subject rules** (see ADR-006 for full RPC envelope spec): | Subject | Purpose | Dispatch by | Semantics | | -------- | --------------------------- | -------------- | ---------------- | | `rpc` | All RPC request/response | `envelope.m` | Request/Response | | `event` | All fire-and-forget events | `envelope.e` | Notification | | `stream` | Streaming (reserved for v2) | — | (future) | | `app/*` | Vendor-specific / custom | Subject/custom | Custom | Note: `rpc`, `event`, and `stream` are exact-match channels. The `app/` prefix supports arbitrary sub-paths. Subjects are transport-level mux keys; method and event identity MUST live in the envelope (`m`, `e`), not in the subject. ### 5. Errors | Domain concept | Type / Enum name | Field / Property | Wire key | Notes | | ------------------- | ---------------- | -------------------- | -------- | --------------------------------------------- | | Error code enum | `ErrorCode` | `code` | - | Protocol range 1000–1099; RPC range 1100–1199 | | RPC error code enum | `RpcErrorCode` | `code` | - | RPC-layer codes in `@sideband/rpc` | | Error frame details | `ErrorFrame` | `message`, `details` | - | Optional `details` (binary); see note below | > `details` is an optional binary/structured payload (e.g. JSON, CBOR) carried alongside the error message. Application errors use code range 2000+. ### 6. Capabilities and metadata | Domain concept | Type / Enum name | Field / Property | Wire key | Notes | | ----------------- | ---------------- | ---------------- | -------- | ------------------------------------------------ | | Capabilities list | - | `capabilities` | `caps` | `string[]`, feature flags or protocol extensions | | Peer metadata | - | `metadata` | - | `Record` namespaced keys | ### 7. Protocols and packages | Layer | Protocol | Abbreviation | Package | Notes | | -------------- | ------------------------ | ------------ | --------------------------- | ------------------------------------ | | App Framing | Sideband Protocol | **SBP** | `@sideband/protocol` | Topology-agnostic application frames | | Relay Session | Sideband Relay Protocol | **SBRP** | `@sideband/secure-relay` | E2EE tunnel via relay server | | Direct Session | Sideband Direct Protocol | **SBDP** | `@sideband/direct` (future) | P2P session via DTLS or similar | > **SBP** is the base protocol used by all session layers. SBRP wraps SBP frames with E2EE for relay mode; SBDP (future) will secure SBP frames for direct P2P. | Package | Notes | | ------------------------ | ---------------------------------------- | | `@sideband/transport` | Shared interfaces, no env-specific logic | | `@sideband/transport-ws` | WebSocket transport (Browser/Node/Bun) | | `@sideband/rpc` | Typed RPC envelope layer over SBP frames | | `@sideband/runtime` | Peer lifecycle, routing, subscriptions | | `@sideband/peer` | High-level publish/subscribe/RPC API | ### 8. RPC and pub/sub (higher level) | Domain concept | Type / Enum name | Field / Property | Notes | | -------------------- | -------------------- | ---------------------------------- | ---------------------------------------------------- | | RPC envelope (union) | `RpcEnvelope` | `t` discriminant | Discriminated union in `MessageFrame.data` (ADR-006) | | RPC request | `RpcRequest` | `t: "r"`, `m`, `cid`, `p?` | Method + params; `cid` = frame's `frameId` | | RPC success response | `RpcSuccessResponse` | `t: "R"`, `cid`, `result?` | Correlation via `cid` (ADR-010) | | RPC error response | `RpcErrorResponse` | `t: "E"`, `cid`, `code`, `message` | `data?` for structured error details | | RPC response (union) | `RpcResponse` | — | `RpcSuccessResponse \| RpcErrorResponse` | | RPC notification | `RpcNotification` | `t: "N"`, `e`, `d?` | Fire-and-forget; no `cid` | ### 9. Helper API verbs (for AI assistants) | Operation | Function name | Notes | | ---------------------- | ---------------------- | ---------------------------------------------------- | | Generate frame ID | `generateFrameId` | `() -> FrameId` (16-byte binary) | | Frame ID to hex | `frameIdToHex` | `(FrameId) -> string` (32-char lowercase) | | Frame ID from hex | `frameIdFromHex` | `(string) -> FrameId` (validates 32 hex) | | Encode frame to bytes | `encodeFrame` | `Frame -> Uint8Array` | | Decode bytes to frame | `decodeFrame` | `ArrayBufferView -> Frame` | | Create message frame | `createMessageFrame` | `(subject, data, opts?) -> MessageFrame` | | Create error frame | `createErrorFrame` | `(code, message, details?, opts?) -> ErrorFrame` | | Create handshake frame | `createHandshakeFrame` | `(data: Uint8Array, opts?) -> HandshakeControlFrame` | ### AI usage hints (suitable as code comments) > * Use `Frame` / `FrameKind` / `kind` for protocol-level variants. > * Use `peerId` for stable peer identity; `connectionId` is per link, `sessionId` spans reconnects. > * Use `frameId` to identify frames; `cid` in RPC envelopes for request/response correlation (ADR-010). > * Map wire fields (`t`, `id`, `peerId`, `caps`, `s`, `b`) only inside encode/decode; never expose them in public TS types. ## Consequences * All packages align on shared names and wire keys with **PeerId** as the intrinsic identity, reducing ambiguity in docs, code, and AI-assisted edits. * Eliminates naming collision with Node.js ecosystem and aligns with libp2p/IPFS/WebRTC conventions. * Every frame has a `frameId` created by `generateFrameId()` at construction. This eliminates defensive `frameId === undefined` checks throughout runtime and RPC layers, improving code clarity and reducing bugs. * Future additions (new frame kinds, error codes, capability keys) must extend this matrix and update the ADR. * Tests and examples should assert the public property names while keeping wire keys scoped to codecs. * Adding `connectionId`/`sessionId` is optional but recommended when distinguishing links or resumable sessions; codecs should keep those out-of-band unless explicitly encoded. --- --- url: /adr/003-control-frame-invariants.md --- # ADR-003: Control Frame Type Invariants * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: SBP * **Tags**: protocol, frames, type-safety * **Relates to**: ADR-002 (naming matrix) ## Context The four control operations (`Handshake`, `Ping`, `Pong`, `Close`) have different data semantics: Handshake requires data, Ping/Pong forbid it, Close allows it optionally. A single generic `ControlFrame` with optional `data?: Uint8Array` allows invalid states at compile time (e.g., `{op: Ping, data: [...]}`) and invites unstructured metadata creep. ## Decision Use a **discriminated union of control frame types** (discriminated by `op`) to encode data invariants at the type level: * `HandshakeControlFrame`: `data` is required. * `PingControlFrame` / `PongControlFrame`: `data` is forbidden (`data?: undefined`). * `CloseControlFrame`: `data` is optional. The `ControlFrame` type is the union of these four variants. Factory functions (`createHandshakeFrame`, `createPingFrame`, etc.) and type guards (`isHandshakeFrame`, etc.) enforce invariants at construction and narrowing. ### Type Definitions Each variant is a separate interface; the union prevents invalid combinations: ```ts export interface HandshakeControlFrame { kind: FrameKind.Control; op: ControlOp.Handshake; data: Uint8Array; // required } export interface PingControlFrame { kind: FrameKind.Control; op: ControlOp.Ping; data?: undefined; // forbidden } export interface PongControlFrame { kind: FrameKind.Control; op: ControlOp.Pong; data?: undefined; // forbidden } export interface CloseControlFrame { kind: FrameKind.Control; op: ControlOp.Close; data?: Uint8Array; // optional } export type ControlFrame = | HandshakeControlFrame | PingControlFrame | PongControlFrame | CloseControlFrame; ``` ### Factory Functions and Type Guards * Factory functions (`createHandshakeFrame(data, opts)`, `createPingFrame(opts)`, `createCloseFrame(reason?, opts)`) enforce invariants at construction. * Type guards (`isHandshakeFrame(frame)`, `isPingFrame(frame)`, etc.) enable safe narrowing downstream. ## Consequences ### Benefits * **Compile-time correctness**: TypeScript prevents invalid combinations (e.g., `{op: Ping, data: [...]}`). * **Self-documenting**: Type invariants are explicit; no need to read codec or comments. * **Prevents extension creep**: Adding ad-hoc metadata to `data` is no longer tempting; new control ops require explicit enum values and types. * **Codec exhaustiveness**: Missing a case in encode/decode raises a compiler error. * **Wire-compatible**: No format changes. ### Trade-offs * Four frame types instead of one (minor boilerplate). * Callers must use factory functions; no ad-hoc object literals. ## Future Extensions New control ops must: 1. Add an enum value to `ControlOp`. 2. Define a new interface with explicit data semantics. 3. Add the variant to the `ControlFrame` union. 4. Update codec and add factory/type guard. Do not add unstructured data to existing control ops. --- --- url: /adr/004-binary-frameid.md --- # ADR-004: Binary FrameId Representation * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: SBP * **Tags**: protocol, breaking-change, wire-format ## Context Initial wire format treated `FrameId` as UTF-8 text with space-padding to 16 bytes. This created: * **Semantic confusion**: Random bytes as text with padding/trimming logic * **Interop fragility**: UTF-8 handling differs across runtimes * **Entropy underutilization**: Original spec used 12 bytes, encoded as 16-char hex * **Developer friction**: Forced string ops on opaque data; humans need hex anyway ## Decision `FrameId` is 16 opaque bytes (128 bits) generated via `crypto.getRandomValues()`. **Wire:** 16 raw bytes, no encoding or padding, no semantic bits or structure. **Type:** `FrameId = Uint8Array & { __brand: "FrameId" }` **Validation:** `asFrameId()` enforces exactly 16 bytes. No bit-level validation; FrameId is treated as pure entropy. **Key distinction:** Frame header flags (byte 1) are separate from FrameId. FrameId has no semantic bits or structure. **Human representation:** Helpers for logs/JSON: * `frameIdToHex(id) → string` (32-char lowercase hex) * `frameIdFromHex(hex) → FrameId` (validates format) ## Consequences * **Breaking wire change**: Pre-1.0, acceptable. * **Simpler codec**: No padding/trimming logic; just read/write 16 bytes. * **Collision safety**: 128-bit entropy is astronomically safe. * **Clarity**: No accidental string operations on random data. * **Consistency**: All runtimes read the same 16 bytes. --- --- url: /adr/005-transport-abi.md --- # ADR-005: Transport ABI Package * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: Architecture * **Tags**: architecture, transport ## Context The project architecture requires a `@sideband/transport` package as the ABI layer between protocol and concrete implementations. The initial architecture inadvertently mixed transport concerns into `@sideband/protocol` via a low-level callback-based `RawTransport` interface. ## Decision Created `@sideband/transport` as a dedicated, transport-agnostic ABI layer: **Core interfaces:** * `Transport`: Entry point for `connect()` (client) and optional `listen()` (server) * `TransportConnection`: Bidirectional channel with `send()`, `close()`, and `inbound: AsyncIterable` * `TransportEndpoint`: Branded string for connection targets (format is transport-specific) **Dependency rule:** * `@sideband/transport` depends only on `@sideband/protocol` (imports `ConnectionId`) * All concrete transports (`transport-ws`) depend on `@sideband/transport` * `@sideband/runtime` depends on `@sideband/transport`, not on concrete implementations **Key constraint:** No I/O, codec, or environment-specific logic. Transport is purely an interface contract. ## Rationale * **Layered isolation**: Concrete transports depend on the ABI, not vice versa; enables swappable implementations * **Testability**: LoopbackTransport reference implementation serves as both documentation and test harness * **Async iterables**: Inbound stream via `AsyncIterable` enables backpressure, composition, and natural error handling * **Stateless types**: `TransportEndpoint` and `ConnectionId` are transport-agnostic, avoiding implicit coupling ## Specification Documents Normative behavioral semantics are defined in the transport specification: * `docs/protocols/transport/abi.md` — Extended connection lifecycle and semantics * `docs/protocols/transport/errors.md` — Error taxonomy and classification * `docs/protocols/transport/websocket.md` — WebSocket-specific rules * `docs/protocols/transport/conformance.md` — Conformance test matrix ## Key Types The specification documents extend the core interfaces with additional types: * **ConnectionState** — Finite state machine: `connecting`, `open`, `closing`, `closed` * **TransportError** — Structured error with `kind: TransportErrorKind` for classification * **TransportErrorKind** — Error classification (see `transport/errors.md` for full taxonomy) * **CloseInfo** — Details about connection closure: `graceful`, `closeCode`, `reason`, `error` * **CloseOptions** — Options for graceful shutdown: `closeCode`, `reason` * **ConnectOptions** — Connection options: `timeoutMs`, `signal` (transport-specific specs may extend) ## Consequences * This ADR establishes the interface contract and architectural boundaries * The specification documents provide normative behavioral semantics that implementations must follow * Conformance tests validate that concrete transports adhere to both the ABI and behavioral specs * Updates to connection lifecycle or error handling require spec changes, not ADR amendments --- --- url: /adr/006-rpc-envelope.md --- # ADR-006: Canonical RPC Envelope over MessageFrame * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: RPC * **Tags**: rpc, protocol-usage, type-safety ## Context `MessageFrame` is intentionally generic (subject + opaque bytes) to keep the protocol minimal and stable. However, RPC users currently parse subject conventions manually and re-implement correlation and error handling in userland, violating "hard to misuse APIs." The question is where to fix this: at the protocol layer (new frame kinds) or at the RPC layer (canonical envelope structure). ## Decision 1. **Keep `MessageFrame` unchanged** – the one and only application-level payload frame. 2. **Define a canonical RPC envelope** carried in `MessageFrame.data`: Request, Success Response, Error Response, Notification. Encoded as JSON (v1) or CBOR (v2+). 3. **Enforce channel subject validation**: all messages must use channel subjects (`rpc`, `event`, `stream`) or the `app/` prefix. Invalid subjects → `ProtocolViolation`. 4. **Zero protocol wire changes.** RPC semantics live at the `@sideband/rpc` layer, not the wire. ### Channel Subjects (Mandatory, Runtime-Validated) All `MessageFrame.subject` values must be exact channel matches or use the `app/` prefix: | Subject | Purpose | Dispatch by | Semantics | | -------- | --------------------------- | -------------- | ---------------- | | `rpc` | All RPC request/response | `envelope.m` | Request/Response | | `event` | All fire-and-forget events | `envelope.e` | Notification | | `stream` | Streaming (reserved for v2) | — | (future) | | `app/*` | Vendor-specific (fallback) | Subject/custom | Custom semantics | **Channel subjects model:** `rpc`, `event`, and `stream` are exact-match channels. The subject identifies the message *class*, not the specific method or event. Method/event dispatch happens via envelope fields (`m` for RPC methods, `e` for event names). Benefits: * Single source of truth (envelope owns semantics) * No subject/envelope mismatch possible * Simpler subject cardinality Subjects outside these channels (or not prefixed with `app/`) are rejected at runtime with `ProtocolViolation`. ### Envelope Schema Request (`t: "r"`): method name + optional params. Success Response (`t: "R"`): optional result. Error Response (`t: "E"`): code + message + optional data. Notification (`t: "N"`): event name + optional data. Correlation is explicit: responses copy the request's `cid` (correlation ID) field. The `cid` field is set to the request frame's own `frameId` and copied unchanged by the server into all response envelopes. See ADR-010 for the complete correlation design rationale. Full schema at `docs/protocols/rpc/envelope.md`. ## Consequences * **Zero wire changes**: protocol stays minimal and stable. * **Type-safe RPC**: branded types + runtime validation enforce correctness. * **Predictable, hard-to-misuse**: subjects and envelopes validated at runtime. * **Respects layering**: RPC semantics belong above the protocol, not in it. * **Future-proof**: envelope extensible for v2 (CBOR, streaming, etc.) without protocol bumps. ## Rationale for Rejection of Alternatives **New frame kinds (Request/Response/Notification)**: Pushes RPC semantics into the protocol, violating the minimal charter and hardening patterns that should remain flexible. **JSON-RPC 2.0**: Verbose, opaque to the protocol, no benefit over a canonical envelope at the RPC layer. --- --- url: /adr/007-immutable-frame-types.md --- # ADR-007: Immutable Decoded Frame Types * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: SBP * **Tags**: protocol, frames, type-safety * **Relates to**: ADR-002, ADR-003 ## Context Decoded `Frame` types are currently mutable, allowing downstream code to reassign `kind`, `subject`, `data` after `decodeFrame()`. This violates "predictable, hard-to-misuse APIs" and "correctness first" principles—frame mutation can corrupt RPC correlation, routing, and middleware state. The protocol library is the single source of truth for wire values; decoded frames must be immutable facts. ## Decision All public frame types and their decoded instances are deeply readonly via TypeScript's `readonly` keyword on all properties and `Readonly` for binary data. Factory functions and `decodeFrame()` return immutable variants. Internal codec may use mutable temporaries during construction. ## Consequences * **Eliminates mutation bugs**: TypeScript prevents reassignment of any frame property or binary data at compile time. * **Guarantees referential stability**: Middleware, RPC handlers, and routing logic can safely pass frames without fear of corruption. * **Zero runtime cost**: `readonly` is TS-only; no performance overhead. * **Breaking change for mutating code**: Code that reassigns frame properties will fail type check. This is intentional—correct usage never mutates decoded frames. ## Rationale Immutability is the standard for protocol message structures across Protocol Buffers, WebRTC, and similar systems. Decoded frames represent facts from the wire and should not be reassigned downstream. This change prevents subtle bugs while maintaining full expressivity: callers that need mutable state can explicitly copy via `new Uint8Array(frame.data)`. --- --- url: /adr/008-subject-namespace-validation.md --- # ADR-008: Enforce Channel Subject Validation at the Protocol Layer * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: SBP * **Tags**: protocol, validation, correctness, conformance ## Context ADR-006 mandates that all `MessageFrame.subject` values use exact-match channel subjects (`rpc`, `event`, `stream`) or the `app/` prefix. The protocol layer did not enforce this contract. The wire codec accepted any string; validation only existed in `@sideband/rpc` (optional). This violated ADR-006 and the principle of correctness-first design. ## Decision Move subject validation into `@sideband/protocol` — the only package owning the wire-format contract. 1. **Add branded `Subject` type**: Prevents accidental misuse at the TypeScript level. 2. **Add `asSubject()` validator**: Runtime validation enforcing: * 1–256 UTF-8 bytes (measured correctly, not code units) * Valid channel (`rpc`, `event`, `stream` exact match, or `app/` prefix) * No null bytes or empty strings * Throws `ProtocolError(ProtocolViolation)` on violation 3. **Update `MessageFrame.subject`**: Change from `string` to `Subject` type. 4. **Add `createMessageFrame()` factory**: User-facing API that validates subject at construction. 5. **Update `decodeFrame()`**: Validate subject on wire decode. Invalid subjects → fatal protocol violation → connection close. 6. **Update RPC layer**: Re-export protocol validator as `asRpcSubject()`. Remove duplicate validation. ## Technical Details **UTF-8 byte length**: Validator measures via `TextEncoder` (not JavaScript `.length`, which counts code units). Correctly handles multi-byte characters. **Channel list extensibility**: Stored as const arrays (`RESERVED_CHANNELS` for exact matches, `RESERVED_PREFIXES` for prefix-based), enabling future extensions (e.g., `signal`) without API breakage. Update the relevant array, release minor version. **Error semantics**: Invalid subjects are fatal. On send: synchronous throw. On receive: `ProtocolError(ProtocolViolation)` → `ErrorFrame` → close connection. ## Consequences | Area | Impact | | ------------------- | ---------------------------------------------------------------------------------- | | **Correctness** | Every frame on the wire provably conforms to ADR-006. No bypass paths. | | **API safety** | Invalid subjects rejected at construction time, not silently sent or received. | | **Breaking change** | Pre-1.0 acceptable. Raw `MessageFrame` construction will fail type-check or throw. | | **Performance** | Negligible: one `TextEncoder` + prefix check per message. | ## Alternatives Considered and Rejected * **Validate only in RPC**: Higher layers bypass; transports/tools emit invalid frames. * **Validate only on send**: Malicious peers can flood with invalid subjects (DoS). * **Validate only on decode**: Invalid frames constructed internally; bugs in middlewares. * **Configurable prefix set**: Foot-gun; violates "one obvious way" principle. --- --- url: /adr/009-runtime-peer-lifecycle.md --- # ADR-009: Runtime Session Lifecycle * **Date**: 2025-01-11 * **Status**: Accepted * **Affects**: Runtime ## Context The runtime must manage connections throughout their lifecycle. However, Sideband supports multiple protocol modes: * **SBP (direct)**: Simple handshake-first exchange * **SBRP (relay)**: E2EE with HandshakeInit/Accept, TOFU identity pinning, key derivation A single hardcoded lifecycle won't age well. We need an architecture that: 1. Separates transport (bytes) from session (secure channel) from routing (dispatch) 2. Makes protocol-specific negotiation pluggable 3. Distinguishes logical peer identity from physical session 4. Allows per-transport retry policies ## Decision ### 1. Three-Layer Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Router │ │ (dispatches frames to handlers) │ └────────────────────────┬────────────────────────────────┘ │ MessageFrame ┌────────────────────────┴────────────────────────────────┐ │ Session │ │ (active channel, protocol-negotiated) │ │ │ │ ┌─────────────────┐ ┌─────────────────────────┐ │ │ │ SbpNegotiator │ OR │ Custom Negotiator │ │ │ │ (handshake) │ │ (user-provided) │ │ │ └─────────────────┘ └─────────────────────────┘ │ └────────────────────────┬────────────────────────────────┘ │ Uint8Array ┌────────────────────────┴────────────────────────────────┐ │ Transport │ │ (connects, sends/receives bytes) │ └─────────────────────────────────────────────────────────┘ ``` **Boundaries:** | Layer | Responsibility | Does NOT handle | | --------- | --------------------------------------- | -------------------------- | | Transport | Connection, byte streams | Framing, security, routing | | Session | Negotiation, encryption, session state | Message dispatch, handlers | | Router | Handler registry, dispatch, correlation | Connection management | ### 2. Peer vs Session These are distinct concepts: | Concept | Type | Meaning | Lifetime | | ------------ | ------------------- | --------------------------------------------------------- | -------------------------------- | | **Peer** | `Peer` | Logical remote entity | Long-lived, survives reconnects | | **Session** | `Session` | Active channel to peer | Transient, replaced on reconnect | | **peerId** | `PeerId` | Routing label | Stable per peer | | **identity** | `VerifiedIdentity?` | Cryptographic identity (e.g., Ed25519 pubkey fingerprint) | Set by negotiator, used for TOFU | In SBRP, TOFU pins the daemon's identity key, not the peerId. The runtime exposes both: ```ts interface Session { peerId: PeerId; // Routing label (from handshake) identity?: VerifiedIdentity; // Cryptographic identity (from negotiator) state: SessionState; // Observable lifecycle state channel: SessionChannel; // I/O channel (raw or wrapped) sendFrame(frame: Frame): Promise; // Preferred: type-safe, encodes frame sendRaw(data: Uint8Array): Promise; // Advanced: raw bytes (escape hatch) } // VerifiedIdentity is negotiator-specific type VerifiedIdentity = { type: "ed25519"; fingerprint: string; // Hex of pubkey hash }; ``` Note: `state` is intentionally exposed for observability (logging, UI indicators). Application code SHOULD NOT branch on internal states like `retry-wait`; use events instead. **Ownership invariant:** > A `Peer` owns zero or more `Session` instances over time. > At most one `Session` may be active per peer. The `@sideband/peer` SDK exposes a `Peer` interface (see ADR-013) that manages session lifecycle internally. The runtime `Session` is a lower-level building block. ### 3. Session States ``` ┌─────────────┐ │ Idle │◄────────────────────┐ └──────┬──────┘ │ │ connect() │ terminate() / fatal ▼ │ ┌─────────────┐ │ ┌──────►│ Connecting │─────────────────────┤ │ └──────┬──────┘ │ │ │ transport open │ │ ▼ │ │ ┌─────────────┐ │ │ │ Negotiating │─────────────────────┤ │ └──────┬──────┘ │ │ │ negotiation complete │ │ ▼ │ │ ┌─────────────┐ │ retry │ │ Active │─────────────────────┘ │ └──────┬──────┘ │ │ transport error (retryable) │ ▼ │ ┌─────────────┐ └───────┤ RetryWait │ └─────────────┘ ``` **State definitions:** | State | Meaning | | ------------- | ------------------------------------------------------------------------ | | `idle` | No session. Initial and terminal state. | | `connecting` | Transport `connect()` in progress. | | `negotiating` | Transport open, negotiator establishing channel (handshake, E2EE, auth). | | `active` | Ready for message exchange. | | `retry-wait` | Waiting before retry attempt (if retry enabled). | ### 4. Pluggable Negotiators Negotiators handle protocol-specific session establishment: ```ts interface Negotiator { /** Establish session after transport opens */ negotiate(conn: TransportConnection): Promise; /** Protocol-specific close; MUST be idempotent */ terminate(conn: TransportConnection, options?: CloseOptions): Promise; /** Classify an error as fatal or retryable */ classifyError(error: Error): "fatal" | "retryable"; } interface NegotiationResult { peerId: PeerId; identity?: VerifiedIdentity; capabilities: string[]; metadata: Record; channel?: SessionChannel; // Optional session-layer channel (see SessionChannel type) } // SessionChannel is a session-layer abstraction that implements TransportConnection. // It MAY wrap an underlying transport with encryption or other session-specific processing. type SessionChannel = TransportConnection; // v2 extension for session resume (not in v1) interface ResumableNegotiator extends Negotiator { resume( conn: TransportConnection, token: Uint8Array, ): Promise; } ``` **Built-in negotiators:** | Negotiator | Protocol | Behavior | | --------------- | -------- | ------------------------------------------------ | | `SbpNegotiator` | SBP | Handshake frame exchange, capability negotiation | > **SBRP Integration**: The runtime does NOT include an `SbrpNegotiator`. SBRP is a session-layer E2EE protocol with complex cryptography; its primitives live in `@sideband/secure-relay`. SBRP can integrate via: > > 1. **Custom Negotiator with `channel`**: Implement a negotiator that performs SBRP key exchange and returns a `NegotiationResult.channel` — a wrapped `TransportConnection` that encrypts outbound frames and decrypts inbound frames. The runtime uses this channel transparently. > 2. **Transport Adapter**: Embed SBRP logic in a custom transport that exposes decrypted SBP frames directly. Simpler but couples session and transport layers. > > See `docs/protocols/stack.md` "Session Output Contract" for the normative interface. **SBP negotiation sequence:** ``` Client Server │──────── Handshake{peerId} ─────────►│ │◄─────── Handshake{peerId} ──────────│ │ [Active] │ ``` For SBRP negotiation sequences, see `docs/protocols/sbrp/` and `@sideband/secure-relay`. ### 5. Error Classification The **negotiator** classifies errors as fatal or retryable via `classifyError()`. The runtime never hardcodes fatality—this keeps security-sensitive decisions in the negotiator where they belong. **Typical classifications (examples, not rules):** | Error type | Typical classification | Notes | | ------------------------------ | ---------------------- | --------------------------------------- | | Transport connect failure | Retryable | Network transient | | Negotiation timeout | Negotiator decides | Could be transient or MITM interference | | Protocol violation (bad frame) | Fatal | Per SBP spec | | Identity mismatch (TOFU) | Fatal | Security-critical | | Unsupported protocol version | Fatal | Per SBP spec | | Application error | Retryable | Non-fatal to transport | **Invariants:** * Negotiators MUST classify `ProtocolViolation` and `UnsupportedVersion` as fatal per SBP spec * `"fatal"` means "terminate this session and do not retry"—it does **not** necessarily imply a protocol violation (e.g., identity mismatch is fatal but not a protocol error) * Other classifications are negotiator-specific ### 6. Retry Policy Retry is **per-session** and **opt-in**: ```ts interface RetryPolicy { mode: "never" | "on-error"; // Default: "never" initialDelayMs: number; // Default: 1000 maxDelayMs: number; // Default: 30000 maxAttempts: number; // Retry attempts (excludes initial); 0 = unlimited jitter: number; // Default: 0.2 (20%) } ``` **Backoff formula:** ``` delay = min(initial * 2^attempt, max) * (1 + random(-jitter, +jitter)) ``` **Retry behavior:** 1. Transport error in `active` → transition to `retry-wait` 2. Wait backoff delay 3. Transition to `connecting`, attempt new connection 4. On success: full re-negotiation (no session resume in v1) 5. On max attempts: transition to `idle`, emit `closed` event **Attempt counting:** `maxAttempts` counts only retry attempts, not the initial connection. With `maxAttempts: 5`, a total of 6 connection attempts occur (1 initial + 5 retries). The attempt counter resets on successful transition to `active`. **Retry invariants:** * When retrying, a new Session replaces the previous Session for the same Peer; retries never create parallel sessions * Calling `terminate()` cancels any pending retries and transitions immediately to `idle` **Channel and retry semantics:** If a negotiator returns a wrapped `channel`, channel-level errors (e.g., decryption failure, session expiry) are surfaced to the runtime as session errors and classified via `Negotiator.classifyError()`. Retries always: 1. Close the existing channel and transport 2. Re-establish a fresh transport connection 3. Re-invoke `negotiate()` to obtain a new channel The runtime does not attempt to "resume" a wrapped channel; each retry creates an entirely new session. ### 7. Session Events ```ts interface SessionEvents { connecting: { endpoint: string }; negotiating: { transport: TransportConnection }; active: { peerId: PeerId; capabilities: string[] }; retrying: { attempt: number; delayMs: number; lastError: Error }; closed: { reason: string; graceful: boolean; fatal: boolean }; // Security events (from negotiator) identity_established: { identity: VerifiedIdentity; trusted: boolean }; identity_mismatch: { expected: VerifiedIdentity; received: VerifiedIdentity }; } ``` The `active` event carries negotiation results, not the session itself — subscribers already hold the session reference. This keeps events serializable and avoids circular references. Note: `identity_established` (not `identity_verified`) because TOFU is trust establishment, not cryptographic verification. The `trusted` flag indicates whether this identity was previously pinned. ### 8. Protocol-Aware Termination Termination follows a strict ordering to ensure proper cleanup: ```ts async function terminate(options?: CloseOptions): Promise { // 1. Protocol-level signaling via negotiator (uses raw transport) await negotiator.terminate(transport, options); // 2. Close session channel (if distinct from transport) if (channel !== transport) { await channel.close(options); } // 3. Close underlying transport await transport.close(options); // 4. Transition to idle state = "idle"; } ``` **Close ordering invariant:** > When a session provides a `SessionChannel` distinct from the underlying transport, the runtime MUST close the channel before closing the transport. This ensures session-layer cleanup (e.g., encryption state, pending operations) completes while the transport is still available. **Negotiator.terminate() scope:** `Negotiator.terminate()` is responsible only for **protocol-level signaling** over the underlying transport (e.g., sending SBP Close frames). Session-layer resource cleanup MUST be handled by closing the `SessionChannel`. The negotiator receives the raw transport, not the wrapped channel. **Idempotency invariants:** * `terminate()` MUST be idempotent (safe to call multiple times) * Negotiators MUST tolerate already-closed transports * Close errors are logged but do not throw **SbpNegotiator.terminate():** ```ts // Send Close frame (best-effort; ignores errors if transport already closed) const closeFrame = createCloseFrame(reasonBytes); await conn.send(encodeFrame(closeFrame)); ``` For SBRP termination semantics, see `docs/protocols/sbrp/state-machine.md`. ### 9. Session Resumption Semantics Runtime core does not require resumable sessions. Terminology: | Term | Meaning | | ----------------------- | ----------------------------------------------------------------------- | | **Transport reconnect** | WebSocket drops and reconnects (pure transport layer) | | **Session resume** | Reuse same cryptographic state without new handshake (SBRP daemon only) | | **Client reconnect** | Always requires new token + full handshake | **v1 behavior:** * Client reconnects always create fresh sessions * SBRP daemons MAY implement resumable sessions (gated by `Signal(ready)` state machine) * Non-SBRP negotiators may ignore resume entirely * Pending RPC requests are rejected on disconnect; application handles retransmission SBRP implementations (whether via custom negotiator or transport adapter) MUST implement the SBRP pause/pending/resume state machine when supporting resumable daemon sessions. See `docs/protocols/sbrp/state-machine.md`. Session resume can be extended to clients in v2 via `ResumableNegotiator` interface. ## Alternatives Considered | Alternative | Why Rejected | | ---------------------------------------- | ---------------------------------------------------------------------- | | Single handshake model for all protocols | SBRP and SBP have fundamentally different handshakes | | Global retry policy | Different transports have different reliability characteristics | | peerId as security identity | Conflates routing with authentication; SBRP TOFU pins keys, not peerId | | Session resume in v1 | Adds complexity; v1 focuses on correctness | ## Consequences * **Protocol isolation**: SBP and SBRP don't leak into each other * **Future-proof**: SBDP or new protocols slot in as new negotiators * **Clear security model**: `identity` is distinct from `peerId` * **Testable**: Each layer can be tested independently * **No hidden magic**: Retry is opt-in, termination is explicit ## References * ADR-005 (Transport ABI) * ADR-013 (Peer SDK Core Design Decisions) * `docs/protocols/sbp/behavior.md` (handshake-first rule) * `docs/protocols/sbrp/` (E2EE handshake, TOFU) --- --- url: /adr/010-rpc-correlation-cid.md --- # ADR-010: Explicit RPC Correlation via `cid` in the Envelope * **Date**: 2025-11-23 * **Status**: Accepted * **Applies to**: RPC * **Tags**: rpc, correctness, future-proofing, layering ## Context The RPC envelope spec incorrectly referenced a non-existent `MessageFrame.ackFrameId` field for correlation. The attempted fix—reusing the request's `frameId` on the response—violates the invariant that **`frameId` is sender-local unique**, making multi-hop and observability impossible. We need explicit correlation that works in single-hop, streaming, fan-out, relay, and offline scenarios. ## Decision 1. **`frameId` semantics remain pure**: sender-local unique per wire format spec. No reuse by receivers. 2. **Add explicit `cid` (correlation ID) field to the RPC envelope payload**. Envelope schema (inside `MessageFrame.data`): ```ts interface RpcRequest { t: "r"; m: string; p?: unknown; cid: FrameId; } interface RpcSuccess { t: "R"; cid: FrameId; result?: unknown; } interface RpcError { t: "E"; cid: FrameId; code: number; message: string; data?: unknown; } interface RpcNotification { t: "N"; e: string; d?: unknown; } // no cid ``` 3. **Correlation model**: Requests set `cid` to their request frame's `frameId`. Responses copy that `cid` unchanged. Runtime matches on `cid`, not `frameId`. 4. **No wire format changes.** Envelope payload only; backward compatible. ## Updated Specification See updated `docs/protocols/rpc/envelope.md` for the complete envelope specification with corrected correlation section. ## Consequences * **Streaming RPC**: Multiple responses per request (same `cid`, different `frameId`s) * **Multi-hop / relays**: `cid` end-to-end; `frameId` hop-local * **Observability**: `frameId` uniqueness invariant preserved * **Future evolution**: Envelope payload can evolve independently of wire format ## Rationale * **Layering**: Correlation is explicit in RPC envelope; `frameId` stays pure at wire layer * **Industry-standard**: Used by NATS, CloudEvents, Kafka, gRPC * **Minimal cost**: 16 bytes per RPC request/response * **Minimal surface**: No new fields on frames; existing invariants preserved ## Alternatives Considered | Alternative | Why Rejected | | ----------------------------------- | --------------------------------------------------------------- | | Reuse request `frameId` on response | Breaks sender-local uniqueness; impossible for multi-hop/relays | | Use `AckFrame` for RPC responses | Conflates transport ACK with app semantics | | Embed correlation in subject | Brittle; limited length; creates awkward subject names | ## References * `docs/protocols/rpc/envelope.md` (correlation model) * ADR-006 (RPC envelope – correlation section updated) * ADR-002 (naming matrix) --- --- url: /adr/011-runtime-message-routing.md --- # ADR-011: Runtime Message Routing * **Date**: 2025-01-11 * **Status**: Accepted * **Affects**: Runtime ## Context The runtime needs a message routing layer between the session (which delivers decoded frames) and application logic (which handles RPC, events, and custom protocols). The existing specs define subject namespaces (ADR-006, ADR-008) and RPC envelope structure (ADR-010), but do not specify: 1. How handlers are registered and matched 2. Dispatch ordering when multiple handlers match 3. Error propagation from handlers **Scope:** This ADR defines the `Router` in `@sideband/runtime` — a lower-level building block for subject-based message dispatch. `@sideband/peer` provides higher-level `rpc.handle()` and `events.on()` APIs with its own internal dispatch; it does not use the Router. The Router is intended for advanced use cases: custom `app/` prefix protocols, or building alternative SDKs that need subject-level routing. Key design goals: * **Predictable**: Deterministic dispatch ordering * **Composable**: Works for RPC, events, and custom protocols * **Extensible**: Custom prefixes without hacks * **Safe**: Hard to accidentally misuse (e.g., frameId reuse) ## Decision ### 1. Router API The Router manages handler registration and dispatch: ```ts interface Router { /** Register handler for exact subject match */ route( subject: string, handler: MessageHandler, options?: RouteOptions, ): Unsubscribe; /** Register handler for subject prefix */ routePrefix( prefix: string, handler: MessageHandler, options?: RouteOptions, ): Unsubscribe; /** Remove all handlers for a subject */ unroute(subject: string): void; /** Clear all handlers */ clear(): void; } interface RouteOptions { mode?: "exclusive" | "broadcast"; // Default: inferred from prefix } type Unsubscribe = () => void; type MessageHandler = (msg: InboundMessage) => Promise | void; ``` ### 2. InboundMessage (not MessageContext) The handler receives an `InboundMessage` with clear separation between general messaging and RPC: ```ts interface InboundMessage { // Core message data readonly subject: Subject; readonly payload: Uint8Array; readonly peerId: PeerId; readonly session: Session; // Low-level frame access (read-only) readonly frame: Readonly; /** Send a response message (generates fresh frameId) */ send(subject: Subject, data: Uint8Array): Promise; /** RPC context (only present for `rpc` channel with valid envelope) */ readonly rpc?: RpcContext; } interface RpcContext { readonly method: string; readonly params: unknown; readonly cid: FrameId; /** Reply with success result */ reply(result?: unknown): Promise; /** Reply with error */ error(code: number, message: string, data?: unknown): Promise; } ``` **Key design:** * `send()` is always available (generates fresh frameId internally) * `rpc` is only present when envelope decodes successfully * `frame` is read-only to prevent accidental frameId reuse; exposed for diagnostics and tracing only—application logic SHOULD NOT depend on frame internals * `rpc.reply()` and `rpc.error()` handle correlation automatically ### 3. Subject Validation Policy Subject prefixes are **policy-driven**, not type-locked: ```ts interface SubjectPolicy { /** Exact-match channels (default: ["rpc", "event"]) */ allowedChannels: string[]; /** Channels that are reserved/rejected (default: ["stream"]) */ reservedChannels: string[]; /** Allowed prefixes (default: ["app/"]) */ allowedPrefixes: string[]; /** Custom classifier for dispatch semantics */ classify?(subject: string): SubjectKind; } type SubjectKind = "rpc" | "event" | "custom" | "reserved"; ``` **Validation order:** 1. Check `reservedChannels` → reject with `ErrorFrame{code: 1003}` (UnsupportedFeature) 2. Check `allowedChannels` (exact match) or `allowedPrefixes` (prefix match) → reject with `ErrorFrame{code: 1002}` (InvalidFrame) if no match 3. Run `classify()` if provided → determines dispatch semantics **Precedence:** `reservedChannels` always takes precedence over `allowedChannels`. If a channel appears in both, it is reserved. **Classification invariant:** If `classify()` returns `"rpc"`, the message MUST contain a valid RPC envelope; otherwise the message is rejected with `ErrorFrame{code: 1002}` (InvalidFrame: malformed payload). This prevents semantic spoofing where non-RPC messages bypass envelope validation. **Default validation:** | Subject | Kind | Default mode | Behavior | | -------- | ---------- | ------------ | ---------------------------- | | `rpc` | `rpc` | `exclusive` | Single handler, must reply | | `event` | `event` | `broadcast` | Fan-out, no reply | | `stream` | `reserved` | — | Reject with ErrorFrame(1003) | | `app/*` | `custom` | `broadcast` | User-defined semantics | Note: `rpc`, `event`, and `stream` are exact-match channels. `app/` is a prefix supporting arbitrary sub-paths. **Future extensibility:** Reserved channels (e.g., `stream`) may be reclassified by negotiators in future protocol versions. V2 negotiators can advertise streaming capability and override the default reserved status. **Extensibility:** ```ts // Allow custom prefixes for app protocols const router = createRouter( {}, { allowedChannels: ["rpc", "event"], reservedChannels: ["stream"], allowedPrefixes: ["app/", "debug/", "admin/"], }, ); router.routePrefix("debug/", debugHandler); router.routePrefix("admin/", adminHandler, { mode: "exclusive" }); ``` ### 4. Dispatch Ordering When multiple handlers match, dispatch follows deterministic rules: **Matching priority:** 1. Exact match handlers (highest priority) 2. Prefix handlers (longest prefix first) **Within each bucket:** * Registration order (first registered, first called) **Invocation:** | Mode | Behavior | | ----------- | ------------------------------------------- | | `exclusive` | First matching handler only; others ignored | | `broadcast` | All matching handlers invoked sequentially | **Why sequential broadcast?** Handlers are awaited sequentially—the next handler is invoked only after the previous one resolves. This ensures deterministic side-effects. Concurrent invocation would introduce race conditions and make debugging harder. If parallel execution is needed, handlers can spawn their own async tasks. **Example (for `app/` prefix subjects):** ```ts router.route("app/metrics/cpu", handlerA); // exact router.routePrefix("app/metrics/", handlerB); // prefix (longer) router.routePrefix("app/", handlerC); // prefix (shorter) // Incoming: "app/metrics/cpu" // Dispatch order: handlerA (exact), then handlerB (longer prefix), then handlerC // For broadcast mode: all three called in order // For exclusive mode: only handlerA called ``` Note: For `rpc` and `event` channels, dispatch is determined by envelope fields (`m` for RPC methods, `e` for event names), not by subject matching. The example above applies to `app/` prefix subjects and any custom prefixes. ### 5. RPC Dispatch Rules For subjects classified as `rpc`: 1. Decode RPC envelope from payload 2. If envelope decoding fails → emit `ErrorFrame{code: 1002}` (InvalidFrame: malformed payload) 3. If no handler → reply `RpcError{code: 1101, message: "Method not found"}` 4. If handler doesn't reply within timeout → reply `RpcError{code: 1103, message: "Handler timeout"}` 5. If handler throws → reply `RpcError{code: 2000, message: error.message}` (default mapper) **Note:** The frame itself is valid; only the payload (envelope) is malformed. This is a non-fatal error per SBP `InvalidFrame` semantics—the connection remains open; only the specific request fails. **Timeout configuration:** ```ts interface RouterConfig { rpcTimeoutMs: number; // Default: 30000 (30s) } ``` ### 6. Event Dispatch Rules For the `event` channel: 1. Decode envelope (expect `RpcNotification`) 2. If decode fails → log warning, drop (no response) 3. Fan-out to all matching handlers (broadcast mode) 4. Handler errors → log warning, continue to next handler 5. No reply expected; `msg.rpc` is undefined ### 7. Error Propagation Error handling is **configurable** via error mapper: ```ts interface RouterConfig { /** Map handler errors to RPC error responses */ errorMapper?: (error: Error, msg: InboundMessage) => RpcErrorPayload; } interface RpcErrorPayload { code: number; message: string; data?: unknown; } ``` **Default error mapper:** ```ts const defaultErrorMapper = (error: Error): RpcErrorPayload => ({ code: 2000, // Application error range message: error.message, }); ``` **Custom error mapper example:** ```ts const router = createRouter({ errorMapper: (error, msg) => { if (error instanceof ValidationError) { return { code: 2001, message: "Validation failed", data: error.details }; } if (error instanceof NotFoundError) { return { code: 2002, message: "Resource not found" }; } return { code: 2000, message: error.message }; }, }); ``` **Error code ranges** (see `docs/protocols/error-codes.md`): | Range | Owner | Examples | | --------- | -------------- | -------------------------------------------- | | 1000–1099 | SBP (protocol) | `InvalidFrame`, `ProtocolViolation` | | 1100–1199 | RPC (runtime) | `UnsupportedMethod` (1101), `Timeout` (1103) | | 2000+ | Application | User-defined | ### 8. Handler Scoping The Router is a standalone object created via `createRouter()`. It has no built-in concept of session scoping — all handlers registered on a Router instance live until explicitly unsubscribed or `clear()` is called. Session-scoped behavior is the caller's responsibility: ```ts const router = createRouter(); // Long-lived handler router.routePrefix("app/metrics/", metricsHandler); // Session-scoped: unsubscribe when session closes const unsub = router.route("app/session/state", sessionHandler); session.on("closed", () => unsub()); ``` For RPC and events, prefer the higher-level `peer.rpc.handle()` and `peer.events.on()` APIs from `@sideband/peer`, which handle lifecycle automatically. The Router API is for custom `app/` protocols or building alternative SDKs. ### 9. Preventing frameId Misuse The API makes it hard to accidentally reuse frameId: ```ts // WRONG: Exposing frame for modification ctx.frame.frameId; // This would allow reuse // RIGHT: Read-only frame, send() generates new frameId msg.send(subject, data); // Always fresh frameId // RIGHT: RPC reply uses cid for correlation, fresh frameId for response msg.rpc.reply(result); // cid from request, new frameId ``` Implementation: ```ts async function send(subject: Subject, data: Uint8Array): Promise { const frame = createMessageFrame(subject, data); // Generates new frameId await session.send(encodeFrame(frame)); } async function rpcReply(result: unknown): Promise { const envelope: RpcSuccess = { t: "R", cid: this.cid, result }; const frame = createMessageFrame(this.subject, encodeRpcEnvelope(envelope)); await session.send(encodeFrame(frame)); } ``` ## Alternatives Considered | Alternative | Why Rejected | | --------------------------------- | ------------------------------------------------------------------ | | Type-locked `SubjectPrefix` union | Prevents custom prefixes; forces workarounds via `app/` | | `reply()` always available | Confusing for events; RPC-specific methods belong in `rpc` context | | Concurrent dispatch for all modes | Unpredictable ordering; sequential is safer default | | Glob/regex patterns | Over-engineering for v1; prefix matching covers 95% of cases | ## Consequences * **Predictable dispatch**: Exact > prefix, registration order, documented rules * **Safe API**: `send()` always generates fresh frameId; `frame` is read-only * **Extensible prefixes**: Policy-driven, not type-locked * **Clear RPC boundary**: `msg.rpc` only present when appropriate * **Flexible error handling**: Custom mappers for domain-specific codes * **Layered**: `@sideband/peer` provides high-level RPC/events APIs; the Router serves advanced use cases and custom protocols ## References * ADR-006 (RPC envelope, channel subjects) * ADR-008 (channel subject validation) * ADR-009 (session lifecycle) * ADR-010 (RPC correlation via `cid`) * ADR-013 (peer SDK design — uses its own dispatch, not the Router) * `docs/protocols/error-codes.md` (error code ranges) --- --- url: /adr/012-websocket-transport-design.md --- # ADR-012: WebSocket Transport Design Decisions * **Date**: 2025-01-13 * **Status**: Accepted * **Applies to**: @sideband/transport-ws * **Tags**: transport, websocket, api-design ## Context The `@sideband/transport-ws` package implements the Transport ABI (ADR-005) for WebSocket connections. Several API design decisions required choosing between alternatives with different tradeoffs for usability, security, and correctness. This ADR documents the key decisions and rejected alternatives for future reference. ## Architectural Invariants These constraints are non-negotiable and apply to all decisions below: 1. **Opaque payloads**: Transport MUST treat message payloads as opaque bytes. No SBP/SBRP/RPC semantics at this layer. This preserves layer boundaries and future-proofs alternative session stacks. 2. **Topology-agnostic**: Transport behavior MUST NOT branch on relay vs direct topology. Only endpoint URLs and options differ. Runtime stays topology-agnostic. 3. **No reconnection**: Transport only reports state, close, and errors. Reconnection and backoff policies belong in `@sideband/runtime` or `@sideband/peer`. This avoids hidden policies and keeps transport minimal. ## Decisions ### 1. Single entry point vs separate browser/node exports **Options**: * A) Single `wsTransport()` with runtime detection * B) Separate `@sideband/transport-ws/browser` and `/node` imports **Chosen**: A (single entry point) **Rationale**: Simpler for users. Bundlers tree-shake unused code anyway. The `platform` override enables testing browser code in Node. Conditional exports in package.json still provide optimal bundles. ### 2. WebSocket subprotocol enforcement default **Options**: * A) Enforce by default (`requireSelection: true`) * B) Pass-through by default (`requireSelection: false`) **Chosen**: B (pass-through by default) **Rationale**: Transport layer should be generic. WebSocket subprotocol enforcement is Sideband-specific policy that belongs in application configuration, not transport defaults. This keeps transport-ws usable for non-Sideband WebSocket connections without fighting defaults. Sideband applications explicitly opt-in: `{ subprotocols: { offer: ["sideband.v1"], requireSelection: true } }`. > **Note**: WebSocket subprotocols (`Sec-WebSocket-Protocol`) are distinct from application protocols (SBP/SBRP). Transport handles WebSocket-level negotiation; application protocol negotiation happens at the SBP layer. ### 3. Auth mode in browser **Options**: * A) Default to query (browsers can't set headers anyway) * B) Throw if mode not specified (force explicit choice) * C) Silently fall back to query **Chosen**: B (throw if not specified) **Rationale**: Query params are visible in logs, URLs, and browser history. Developers must consciously accept this security tradeoff. Silent fallback (C) hides the problem. Default to insecure (A) is wrong. ### 4. Send buffer overflow behavior **Options**: * A) Reject `send()` call with `buffer_overflow` * B) Close connection * C) Block until buffer drains **Chosen**: A (reject with `buffer_overflow`) **Rationale**: Rejection is explicit and recoverable. Connection close is surprising and unrecoverable. Blocking violates non-blocking `send()` semantics in ABI. Caller can check `pendingSendBytes` for proactive backpressure. Using dedicated `buffer_overflow` kind (vs `transport_failure`) enables distinct handling in retry logic. ### 5. Inbound buffer overflow behavior **Options**: * A) Drop messages silently * B) Close connection with error * C) Apply backpressure to sender **Chosen**: B (close connection) **Rationale**: Silent drops violate message delivery guarantees. WebSocket has no native backpressure mechanism for inbound data. Closing is the only safe option to prevent OOM. **Normative rule**: On inbound buffer overflow, implementations MUST close the connection with close code 1011 ("Internal Error") and `TransportError(kind: "buffer_overflow")`. ### 6. Origin validation with missing Origin header **Options**: * A) Reject connections without Origin * B) Allow connections without Origin **Chosen**: B (allow) **Rationale**: Origin is browser-only. Non-browser clients (CLI tools, other servers) legitimately don't send it. Rejecting would break CLI-to-daemon and service-to-service use cases. Origin is for DNS rebinding protection, not authentication. **Default policy**: * Localhost endpoints → `"localhost"` (allow only localhost origins) * Non-localhost endpoints → `"any"` (allow all origins) > **Warning**: The `"any"` default is intentionally permissive for development. Production servers SHOULD configure `originPolicy` explicitly based on their security requirements. ### 7. Endpoint normalization **Options**: * A) Full canonicalization (sort query params, normalize ports, trailing slashes) * B) Minimal validation (scheme check, strip hash only) **Chosen**: B (minimal) **Rationale**: ABI says endpoint is opaque, for diagnostics only. Canonicalization creates false expectation that endpoints can be compared for equality. Different URLs should remain different endpoints. ### 8. Server WebSocket subprotocol selection **Options**: * A) Fixed list only (`offer: ["sideband.v1"]`), select first match * B) Callback for custom logic (`select: (offered) => ...`) * C) Both: list for common case, callback for advanced **Chosen**: C (both) **Rationale**: Most servers just need a list. But version negotiation ("prefer v2, fall back to v1") or custom logic (logging, metrics) needs a callback. Callback is proactive (called during handshake), not reactive (after selection). ## Close Code Semantics Implementations MUST use these WebSocket close codes: | Code | Condition | TransportErrorKind | | ---- | --------------------------------------- | ------------------- | | 1000 | Graceful close | (none) | | 1003 | Text frame received | `transport_failure` | | 1009 | Single message exceeds `maxMessageSize` | `message_too_large` | | 1011 | Inbound buffer overflow | `buffer_overflow` | **Graceful close**: Close code 1000 resolves the `closed` promise with `{ graceful: true }` and no `TransportError`. All other codes resolve with `{ graceful: false, error: TransportError(...) }`. **1006 heuristic**: Code 1006 (abnormal closure) provides no reason. Implementations MUST use traffic history to disambiguate: * 1006 + no bytes exchanged → `connection_refused` (connection failed before establishment) * 1006 + bytes exchanged → `abnormal_close` (connection dropped mid-session) This heuristic requires internal traffic counters (`ioBytes: { sent, received }`). The counters need not be public API, but MUST be tracked for correct error classification. ## Non-goals Explicitly out of scope for `@sideband/transport-ws`: * **Reconnection / backoff** → `@sideband/runtime` or `@sideband/peer` * **Connection pooling** → `@sideband/peer` * **Protocol framing (SBP/SBRP)** → `@sideband/protocol`, `@sideband/secure-relay` * **Authentication verification** → Relay server / `@sideband/runtime` * **Message serialization** → `@sideband/protocol` * **Heartbeat policy** → `@sideband/runtime` Transport is minimal: it connects, sends bytes, receives bytes, and closes. Everything else belongs in higher layers. ## Consequences * API is simple for common cases (`wsTransport()` + `wsEndpoint()`) while allowing advanced configuration * Security-sensitive choices (auth mode, origin validation) require explicit opt-in * Transport remains generic and reusable for non-Sideband WebSocket connections * Error handling is predictable: `buffer_overflow` is recoverable on send, fatal on receive * Close codes are deterministic and testable ## Related * ADR-005: Transport ABI Package * `docs/protocols/transport/websocket.md`: WebSocket-specific behavioral spec * `docs/protocols/transport/errors.md`: Error taxonomy including `buffer_overflow` * `docs/protocols/transport/conformance.md`: Conformance test matrix --- --- url: /adr/013-peer-sdk-design.md --- # ADR-013: Peer SDK Core Design Decisions * **Date**: 2026-02-24 * **Status**: Accepted * **Affects**: SDK, Runtime, Transport ## Context `@sideband/peer` is the primary consumer-facing SDK wrapping the runtime and transport layers. Several design choices have cross-layer normative implications — they constrain implementation across `@sideband/secure-relay`, `@sideband/runtime`, and `@sideband/peer`. This ADR formalizes the two most architecturally significant decisions from the RFC development cycle. ## Decisions ### 1. SBRP Negotiator Role Separation **Options:** * A) Single `sbrpNegotiator(options: SbrpNegotiatorOptions)` with all options in one bag * B) Discriminated union: `sbrpNegotiator({ role: "client" | "daemon", ...options })` * C) Separate factories per role **Chosen:** C (separate factories) ```typescript /** Client-side SBRP (browser or CLI connecting to a daemon) */ function relayClientNegotiator(options: SbrpClientOptions): Negotiator; /** Daemon-side SBRP (service accepting relay connections) */ function relayDaemonNegotiator(options: SbrpDaemonOptions): Negotiator; ``` **Rationale:** `SbrpNegotiatorOptions` mixes client-only fields (TOFU pinning, `onFirstConnection`, `trustPolicy`, `controlPlaneUrl`) with daemon-only fields (`serverIdentity`, `resumable`, `pauseBufferLimitBytes`). Options A and B allow invalid combinations to compile and fail only at runtime — violating the "hard to misuse" design goal. Separate factories give each role a dedicated, focused type with unambiguous autocomplete and error messages. **Note:** The single `sbrpNegotiator()` function was abandoned during design — it was never shipped. No migration/deprecation path is needed. ### 2. NATS-Style Event Pattern Syntax **Decision:** Pub/sub event names and subscription patterns use NATS-style dot-separated syntax. **Grammar:** ```text event_name = segment ("." segment)* segment = token | "*" | ">" token = 1*SAFE_CHAR SAFE_CHAR = ALPHA | DIGIT | "-" | "_" ``` **Wildcards** (subscription patterns only; invalid in literal event names): | Wildcard | Meaning | | -------- | ------------------------------------------------------- | | `*` | Matches exactly one segment | | `>` | Matches one or more segments; MUST be the final segment | **Constraints:** * Max length: 255 bytes UTF-8 * Case-sensitive: `User.Created` ≠ `user.created` * No empty segments, no leading/trailing dots * `**` is explicitly forbidden (this is not a glob syntax) * `*` and `>` must each occupy an entire segment **Normative rules:** 1. Patterns are validated at `onPattern()` call time. 2. Invalid patterns throw `PeerError` with code `invalid_pattern`. 3. `**` MUST be rejected with an error directing the user to `>`. **Rationale:** NATS semantics are proven in production messaging systems, have a simple non-regex matching implementation, and are familiar to pub/sub developers. Explicit prohibition of `**` prevents confusion with filesystem glob patterns. ### 3. Server-only export split (`@sideband/peer/server`) `listen()` and server-side types are exported from the `@sideband/peer/server` subpath, not from the package root. **Rationale:** `listen()` imports Node/Bun server APIs. Including it in the root bundle would pull these into browser builds. Isolating it under `/server` keeps the main entry point tree-shakeable and safe for browser consumers without requiring conditional imports or polyfills. ## Consequences * `@sideband/peer` exposes `relayClientNegotiator` and `relayDaemonNegotiator`. They live in peer (not secure-relay) because peer already depends on runtime for `Negotiator`/`SbpNegotiator`, while secure-relay stays a pure crypto library with zero runtime dependencies. * `@sideband/peer` validates event patterns client-side via `onPattern()` / `validatePattern()`. * All documentation and examples MUST use `>` (not `**`) for multi-segment wildcard patterns. * Server-side consumers import from `@sideband/peer/server`; browser consumers import from `@sideband/peer`. ## References * `docs/sdk/peer.md` §6.4, §6.8 — Peer SDK RFC (SBRP negotiator and pub/sub API) * ADR-008: Channel Subject Validation * ADR-006: RPC Envelope * ADR-014: Peer SDK Session Signal Handling --- --- url: /adr/014-peer-session-signals.md --- # ADR-014: Peer SDK Session Signal Handling * **Date**: 2026-02-25 * **Status**: Accepted * **Affects**: SDK, Runtime ## Context SBRP relay sessions can be paused mid-flight when the relay daemon loses its upstream connection. The protocol emits control signals (`session_paused`, `session_resumed`, `session_ended`, `session_pending`) to drive lifecycle transitions. The Peer SDK must surface these to application code without leaking relay internals. Three design problems arise: 1. **How do signals cross the negotiator boundary?** Negotiators are stateless factories; adding an event emitter to the `Negotiator` interface would couple the runtime to specific protocols. 2. **What is `paused` from the SDK consumer's perspective?** Is it a distinct state or an implementation detail of the connection layer? 3. **What does `session_ended` mean, and does it differ between client and server peers?** ## Decision ### 1. Signal propagation via `subscribeSignals` on `NegotiationResult` ```ts interface NegotiationResult { // ... subscribeSignals?: (handler: (signal: SessionSignal) => void) => () => void; } ``` Returning an unsubscribe function gives the SDK precise lifecycle control: on each `negotiate()` call, `PeerImpl` intercepts `subscribeSignals`, wires the handler, and tears it down on session close or reconnect — re-subscribing fresh on retry. A callback option on `Negotiator` was rejected because it gives no mechanism to swap handlers across reconnects. ### 2. `paused` as a first-class Peer state `paused` is visible in the public `PeerState` union. Hiding it would force consumers to infer pause indirectly from failed sends, which is worse DX and harder to reason about. **Behavior during `paused`:** | Operation | Behavior | | --------------- | ---------------------------------------------------------------------- | | `sendRaw()` | Rejects immediately with `PeerError("session_paused")` | | RPC calls | Policy-dependent: reject if `onDisconnect: "fail"`, queue if `"pause"` | | Event publishes | Always buffered; flushed on `session_resumed` | `sendRaw` rejects because it bypasses the SDK's queuing layer — callers own flow control. Events always buffer because dropping them during pause is silent data loss. RPC queuing is opt-in (`connectionPolicy.onDisconnect: "pause"`) because callers must acknowledge the memory trade-off. **Event emission:** * `sessionPaused` fires on entering `paused`. * `sessionResumed` fires on returning to `active` from `paused`. * `connected` does NOT re-fire on resume — only on a fresh connection (non-`paused` → `active`). * `disconnected` fires whenever the peer leaves `{active, paused}` for any other state. ### 3. `session_ended` semantics differ by peer role `session_ended` means the remote client left the relay; the link to the daemon remains open. **Client peer (`createPeer`):** terminate the current session; let the retry policy decide whether to reconnect. Calling `disconnect()` would move permanently to `closed` — wrong, because the signal does not mean "this peer is gone forever". **Accepted peer (`listen`):** call `disconnect()` directly. Accepted peers have no retry semantics and exist only for the duration of an inbound connection. ### 4. `session_pending` is a no-op `session_pending` means the daemon reconnected to the relay but has not confirmed readiness. It triggers no state transition; if currently `paused`, peers remain `paused`. Only `session_resumed` returns the peer to `active`. ## Invariants * Signals are processed only from `active` or `paused`; signals during `connecting`, `negotiating`, or `reconnecting` are dropped. * `subscribeSignals` unsubscribe MUST be called before establishing a new subscription (enforced by `runLoop`). * Client peers (`PeerImpl`) MUST NOT call `disconnect()` on `session_ended` — session termination must go through the session manager so the retry policy applies. ## Consequences * Control signals (SBRP) remain invisible to the runtime core; `SessionSignal` is a generic type with no protocol-specific fields. * Consumers observe a clean `paused` → `active` round-trip with automatic event and optional RPC resume — no manual retry plumbing needed. * Non-SBRP negotiators returning no `subscribeSignals` are unaffected; `paused` is never entered. ## References * ADR-009: Runtime Session Lifecycle * ADR-013: Peer SDK Core Design Decisions * `packages/runtime/src/session/types.ts` — `NegotiationResult`, `SessionSignal` * `packages/peer/src/peer.ts` — `handleSessionSignal`, `transition` * `packages/peer/src/listen.ts` — `ConnectedPeerImpl.handleSessionSignal` --- --- url: /adr/015-p2p-direct-protocol.md --- # ADR-015: P2P Direct Protocol (SBDP) * **Date**: 2026-02-25 * **Status**: Accepted * **Affects**: Protocol, SDK, Transport ## Context SBRP provides E2EE sessions via a relay server. This works for the primary use case (browser ↔ daemon behind NAT) but introduces relay latency and requires relay infrastructure. Two scenarios call for a direct session layer (SBDP): 1. **Same-network access** — daemon and client are on the same LAN; relay adds unnecessary round-trip latency. 2. **Direct daemon-to-daemon** — two server-side peers in the same datacenter or VPC do not benefit from relay. The existing architecture was designed to accommodate SBDP without changes to SBP or higher layers. This ADR records the key design decisions before specification work begins, so that speculative P2P design is not driven by premature implementation. ## Decisions ### 1. Defer until relay is stable SBDP will not be specified until the hosted relay (SBRP) is production-stable and user demand for direct sessions is validated. The architecture is ready; the implementation is intentionally deferred. **Rationale:** Premature P2P complexity risks polluting the negotiator contract and transport ABI before they are fully validated in production. The relay path gives real data on session lifecycle edge cases that SBDP will inherit. ### 2. Noise XX (mutual authentication) — not Noise NK SBRP uses asymmetric authentication: the daemon has an identity keypair; the client has no persistent identity. SBDP requires mutual authentication because both sides are peers with equal standing. **Chosen protocol:** Noise XX — both sides authenticate their Noise static keypairs (X25519) during the handshake. If Sideband Ed25519 identities are used, they MUST be cryptographically bound to the Noise static key (binding details deferred to the SBDP specification). **Consequences:** * Both parties must provision Noise static keypairs before a direct session. * TOFU pinning applies to both directions independently. * MITM detection works in both directions. * `sbdpNegotiator()` requires a Noise static keypair from both sides (unlike `relayClientNegotiator` where the client has no persistent identity). ### 3. Transport is pluggable; no single P2P transport is mandated SBDP is a session layer that wraps SBP frames — it does not mandate a specific P2P transport. | Context | Transport | | ----------------- | ----------------------------------------- | | Browser ↔ browser | WebRTC DataChannel | | Browser ↔ daemon | WebRTC DataChannel (or QUIC if supported) | | Daemon ↔ daemon | QUIC, raw TCP | Each transport implements `TransportConnection` (see ADR-005). SBDP does not inspect which transport is in use. ### 4. NAT traversal via relay-assisted ICE signaling Browser-based P2P requires ICE/STUN/TURN for NAT traversal. The preferred strategy is to use the existing SBRP relay connection only for ICE candidate exchange (signaling), then transition to a direct WebRTC DataChannel once the hole punch succeeds. * **Happy path**: relay forwards ICE candidates → direct DataChannel connected → Noise XX handshake completes over the direct transport → SBRP connection closed. SBRP MUST NOT be closed before the direct SBDP session is cryptographically active. * **Fallback**: if direct DataChannel cannot be established (symmetric NAT), remain on relay (SBRP). The transition is modeled as a negotiator-managed channel switch (new `SessionChannel`), not as a new public peer state. No user API change is needed; the active channel is an internal concern of the negotiator/runtime boundary. ### 5. Peer discovery is out of scope for the SDK SBDP assumes the remote peer's address (or ICE candidates) are known. How peers discover each other is an application concern: * **Local network**: mDNS / Bonjour, explicit IP configuration * **Same datacenter**: service registry, DNS SRV * **Internet**: rendezvous server, relay-assisted Discovery APIs will not be added to `@sideband/peer`. Applications resolve addresses and pass them to the transport. ## Invariants * SBDP MUST treat SBP frames as opaque bytes — no inspection of subjects or frame content. * Both peers in an SBDP session MUST provision Noise static keypairs (X25519) before the handshake begins. * TOFU pinning MUST be applied in both directions. * The transport ABI (ADR-005) is unchanged; SBDP introduces no new transport primitives. ## Alternatives Considered **Noise NK / NX**: One-sided authentication like SBRP. Rejected because SBDP peers have symmetric roles — forcing one side to be anonymous weakens the security model without simplifying the API. **DTLS**: Widely deployed but not available as a WebCrypto primitive in browsers. Would require a WASM implementation or platform-specific code path, complicating the transport abstraction. **QUIC for browsers**: Not yet widely available via WebTransport in all targets. Deferred to a later transport implementation; SBDP does not depend on it. ## Consequences * `@sideband/sbdp` will be a new package (parallel to `@sideband/secure-relay`) when implemented. * `sbdpNegotiator()` will live in `@sideband/peer` (same pattern as SBRP negotiators, see ADR-013). * `@sideband/transport-webrtc` will be a separate package implementing the transport ABI for WebRTC. * No changes to SBP, runtime, router, or RPC layers. * Mutual TOFU means the daemon/server side will need an `identityKeyStore` to persist and validate inbound client keys — a new requirement compared to SBRP where clients are anonymous. * Relay-assisted ICE signaling requires a coordination message type in the relay protocol — this will be specified as an SBRP extension when SBDP design begins. ## References * ADR-005: Transport ABI * ADR-009: Runtime Session Lifecycle (negotiator contract) * ADR-013: Peer SDK Design (negotiator factory pattern) * ADR-014: Session Signal Handling (pause/resume semantics for SBRP) * `docs/protocols/sbdp/index.md`: SBDP protocol stub * `docs/protocols/sbdp/design.md`: SBDP design document * `docs/protocols/sbrp/`: Reference implementation for session layer pattern --- --- url: /adr/016-relay-server-design.md --- # ADR-016: Relay Server Design * **Date**: 2026-02-25 * **Status**: Accepted * **Affects**: Runtime, SDK, Transport ## Context The hosted Sideband relay is the infrastructure that enables browser ↔ daemon communication through NAT. It is the bridge between `relayClientNegotiator` (browser/client) and `relayDaemonNegotiator` (local daemon). Without a relay endpoint, the E2EE value proposition is theoretical. The relay must: 1. Accept WebSocket connections from daemons and clients. 2. Pair them by `daemonId` and session parameters. 3. Forward binary frames between them without decryption. 4. Handle disconnects, session cleanup, and pause/resume signaling. The platform is built on Cloudflare Workers. Any relay design must operate within Workers constraints: no persistent memory between requests, stateless execution model by default. ## Decision ### 1. Cloudflare Durable Objects — one DO per `daemonId` Each active daemon routing shard (one daemon + N clients) is owned by a Durable Object keyed by `daemonId`. The DO holds WebSocket connections and forwards frames between paired endpoints. **Rationale:** Durable Objects are the only Cloudflare-native primitive that provides: * Persistent in-memory state across WebSocket lifetime * Co-location of related connections within a single execution context * Atomic session lifecycle management without external coordination Alternatives considered and rejected: | Option | Reason rejected | | ------------------------- | ----------------------------------------------------------------- | | KV / R2 for state | No push semantics; polling is incompatible with WebSocket latency | | Global Workers state | Stateless; no connection affinity | | External Redis / Postgres | Adds infrastructure dependency and egress latency | ### 2. Frame forwarding is opaque (relay never decrypts) The relay forwards raw binary WebSocket messages without parsing SBRP frame content. Session key material never exists on the relay — only the two endpoints (daemon and client) hold session keys. **Rationale:** This is a security invariant of SBRP (see `docs/protocols/sbrp/threat-model.md`). Violating it would undermine the core E2EE promise. The relay identifies sessions by `daemonId` and validated token claims — it never reads frame payloads. ### 3. Authentication before WebSocket upgrade All connections authenticate before the WebSocket handshake is completed. Authentication is handled in the edge Worker (not the Durable Object) to prevent unauthenticated connections from consuming DO resources. **Auth model:** * **Daemon connections**: Long-lived presence token (JWT, `role=daemon`) issued by the control plane, scoped to a `daemonId` and signed with the relay's EdDSA key. Validated at WebSocket upgrade; the DO trusts the pre-validated claims. * **Client connections**: Short-lived session token (JWT, `role=client`, TTL ≤120s) scoped to a `daemonId` + `sid`. Issued by the control plane after the user authenticates. ### 4. Token-based session routing Connection routing uses a fixed endpoint with token-derived routing: ```text wss://{region}.relay.sideband.cloud?token= ``` The routing key (`daemonId`) is derived exclusively from validated token claims (`did`). It MUST NOT be duplicated in the URL path — the token is the single source of truth for `daemonId`, `role`, and `sid`. This eliminates URL-token mismatch validation, reduces metadata leakage in logs, and removes client-side URL construction complexity. The edge Worker validates the JWT before upgrade, extracts `did`, and routes to the Durable Object keyed by `daemonId`. No database lookup is needed on every frame — the DO ID is deterministic from the token's `did` claim. Token delivery: * **Query parameter**: `?token=` — universally compatible (browser, Bun, Node.js). All `@sideband/cloud` connections use this form. Bun's native WebSocket constructor does not support custom upgrade headers, so daemons running on Bun must use query-param token delivery. * **Authorization header**: `Authorization: Bearer ` — supported for Node.js `ws`-based transports; not usable from Bun or browsers. Security note: query-string tokens MUST be short-lived and redacted from logs/analytics. ### 5. Pause/resume signaling When a client connection drops, the relay emits `Control(session_ended)` to the daemon; the daemon's `ConnectedPeer` transitions to `closed` (no pause semantics for client drops — clients are ephemeral from the relay's perspective). When the daemon connection closes unexpectedly, the DO emits `Control(session_paused)` to affected clients (relay-originated control), which maps to `SessionSignal{ type: "session_paused" }` in the SDK (see ADR-014). During `paused`, the relay does not promise payload buffering; SDK-side buffering policy (`connectionPolicy`, `eventPolicy`) remains authoritative. After daemon reconnect, the relay emits `Control(session_pending)` and only emits `Control(session_resumed)` after daemon `Signal(ready)` (see `docs/protocols/sbrp/state-machine.md`). If the session is terminated (e.g., daemon deregisters or auth expires), the relay emits `Control(session_ended)`. The client terminates the current session; retry behavior follows the client's retry policy (see ADR-014). ## Invariants * Relay MUST NOT inspect SBRP frame payloads. * Session pairing MUST be deterministic from `daemonId` alone (no random sharding). * Unauthenticated WebSocket upgrades MUST be rejected at the edge Worker before reaching the DO. * A DO instance is authoritative for exactly one `daemonId`; multiple DOs for the same `daemonId` MUST NOT be allowed. ## Consequences * `sideband-platform/packages/relay/` — new package implementing the Durable Object and edge Worker routing logic. * `apps/api/` — adds WebSocket upgrade endpoint routed to the relay DO. * `db/` — adds relay session and API key tables. * Relay package has no dependency on `@sideband/peer`. Frame routing decisions come from authenticated connection context (`daemonId`, role, token claims), not SBP payload parsing. * Cloudflare Durable Object namespace must be declared in `wrangler.toml`. * `packages/cloud/` (`@sideband/cloud`) — new SDK package that wraps `@sideband/peer` with automatic presence token renewal, relay session fetching, and daemon session demultiplexing. All `@sideband/cloud` connections use `?token=` in URL for universal Bun/browser/Node.js compatibility. See `docs/guide/e2ee.md` for usage. ## References * ADR-009: Runtime Session Lifecycle * ADR-014: Peer SDK Session Signal Handling * ADR-015: P2P Direct Protocol (SBDP) — relay-assisted ICE signaling extension noted there * `docs/protocols/sbrp/state-machine.md`: SBRP session states forwarded by relay * `docs/protocols/sbrp/threat-model.md`: Security invariants the relay must uphold * `docs/protocols/sbrp/control-codes.md`: Relay notification codes (pause, resume signals) --- --- url: /adr/017-cloud-sdk-design.md --- # ADR-017: Cloud SDK Design * **Date**: 2026-02-27 * **Status**: Accepted * **Affects**: Runtime, SDK ## Context `relay.sideband.cloud` embeds a time-limited JWT in every WebSocket URL (`?token=`). `PeerOptions.endpoint` is a static string — reconnects reuse it unchanged, causing 401 rejections when the token expires. Daemon presence tokens also expire (1 hour) with no renewal mechanism, and users had to decode the JWT `sid` claim manually to construct relay URLs. ## Decision ### 1. `Negotiator.getConnectionParams()` — dynamic endpoint per attempt A new optional method on the `Negotiator` interface in `@sideband/runtime`: ```typescript interface NegotiatorConnectionParams { endpoint?: string; // overrides PeerOptions.endpoint for this attempt headers?: Record; // extra headers (Node.js ws only) } interface Negotiator { getConnectionParams?(): Promise; // ... } ``` Called before each connect attempt; result scoped to that attempt (no caching). If it throws, the attempt fails and the peer retries per `retryPolicy`. Runs in sequence before `negotiate()` for the same attempt — implementors may store transient state (e.g., a fetched token) between the two calls without data races. **Why not `endpoint` factory in `PeerOptions`?** The negotiator that resolves an endpoint also knows how to authenticate against it. A separate factory in `PeerOptions` has no synchronization guarantee with negotiator state. ### 2. `@sideband/cloud` — separate package SaaS URLs and deployment-specific logic are isolated in `@sideband/cloud`. Core packages (`@sideband/peer`, `@sideband/runtime`) contain no references to `*.sideband.cloud`. Entry points: * `connect(opts)` — cloud client; two auth modes (see §3) * `listen(opts)` — cloud daemon; outbound relay connection with session demultiplexing **Why not `@sideband/peer/cloud` subpath?** SaaS URLs don't belong in the open-source core — they'd appear in bundle output and lock the core to a specific hosted product. **Why not merge into `@sideband/peer`?** The daemon path pulls in `@sideband/transport-ws` and a relay-specific mux loop, bloating browser bundles and conflating the generic SDK with a specific hosted deployment model. ### 3. `connect()` auth modes — account vs. Quick Connect `connect()` accepts two mutually exclusive auth modes (discriminated union at the type level): **Account path** (`{ daemonId, getAccessToken }`): standard persistent sessions. A fresh relay session is fetched from `api.sideband.cloud` on every connect attempt (relay rejects reused sessionIds with 409 — treated as retryable ghost-socket collision here). `daemonId` is known upfront. Reconnects automatically on transport drops. **Quick Connect path** (`{ quickConnectCode }`): one-shot bootstrap using a short-lived code as the sole credential. `redeemQuickConnectCode()` is called once — it returns the relay URL, a session JWT, and the `daemonId` resolved server-side. After redeem, `qcRedeemed` is set to `true` and any further `getConnectionParams()` call throws `PeerError(InvalidState)` (fatal), preventing silent re-redeem attempts. **Why consume-first?** The server atomically transitions the code to `redeemed` before checking whether the daemon is online (there is no race-free pre-check). A 409 response means the code is already burned and the daemon was offline — retrying would produce a misleading 404. The SDK classifies 409 as fatal in QC mode so callers surface the true error immediately: "get a new code." **Why is QC non-reconnecting?** QC codes are single-use by design. Persisting a session after the initial bootstrap is the account path's responsibility; QC serves the "no-login" first-contact scenario. Attempting auto-reconnect with a burned code would stall until `maxAttempts` is exhausted. ### 4. `RelayDaemonTransport` — inverted transport for daemon relay Daemons do not bind a local port. The relay multiplexes frames from multiple client sessions onto a single outbound WebSocket, each tagged by `SessionID`. `RelayDaemonTransport` implements `Transport.listen()` by connecting outbound and demultiplexing incoming frames by `SessionID` into virtual `RelayVirtualConn` instances. ```text relay.sideband.cloud │ one outbound WebSocket per daemon │ SBRP frames multiplexed by SessionID ↓ RelayDaemonTransport.listen() │ HandshakeInit (new SID) → create RelayVirtualConn → handler(vconn) │ known SID → route frame to existing RelayVirtualConn │ SID=0 Control → rate_limited: continue; other: reconnect ↓ relayDaemonNegotiator per session → ConnectedPeer → onConnection(peer) ``` `peerListen()` is unchanged — it sees a stream of `TransportConnection` objects regardless of whether they come from a local server or relay mux. Slow consumers are terminated (backpressure). Consecutive malformed frames trigger reconnect with backoff (circuit breaker against log storms on protocol version mismatch). `listen()` returns `CloudServer` (extends `PeerServer`) with three additional members that are only available after the first relay connection resolves: ```typescript interface CloudServer extends PeerServer { readonly daemonId: string; // `did` claim extracted from the presence token readonly relayUrl: string; // e.g. "wss://relay.sideband.cloud" createQuickConnect(opts?: { ttlSeconds?: number }): Promise<{ code: string; // short-lived QC code url: string; // ready-to-use connect URL expiresAt: string; // ISO 8601 }>; } ``` `daemonId` and `relayUrl` are read from the presence token on first connect, so they are never stale even if the daemon ID was omitted from `ListenOptions`. ## Invariants * `getConnectionParams()` MUST run before `negotiate()` for every attempt, in sequence. * `@sideband/peer` and `@sideband/runtime` MUST NOT reference any `*.sideband.cloud` URLs. * In QC mode, `getConnectionParams()` MUST NOT be called a second time after a successful redeem. * 409 from the relay in account mode is retryable; in QC mode it is fatal (code is burned). ## References * ADR-013: Peer SDK Core Design Decisions * ADR-016: Relay Server Design * `packages/runtime/src/session/types.ts` — `Negotiator`, `NegotiatorConnectionParams` * `packages/cloud/src/connect.ts` — `CloudClientNegotiator` * `packages/cloud/src/listen.ts` — `RelayDaemonTransport`, `RelayVirtualConn`, `runMux`, `CloudServer` --- --- url: /adr/018-daemon-capabilities.md --- # ADR-018: Daemon Built-in Capabilities and `$sideband/*` Namespace * **Date**: 2026-03-07 * **Status**: Accepted * **Affects**: SDK, CLI ## Context The connect page needs something meaningful to render when a peer connects. Before this PR the daemon had only two methods (`$sideband/echo`, `$sideband/info`) with no introspection surface and no way for a UI to discover what a daemon can do. Three design questions arose: 1. **How should a UI discover which RPC methods exist?** — enumerating all handlers vs. a dedicated introspection protocol. 2. **How should user-facing features (stats, future file browser) be advertised?** — a free-form capability bag vs. a fixed schema. 3. **Where does method introspection belong in the SDK?** — a separate introspection API vs. a method on `RpcInterface`. ## Decision ### 1. `$sideband/*` — reserved namespace for platform-provided handlers All handlers registered by the Sideband platform (CLI built-ins, SDK infrastructure) use the `$sideband/` prefix. User-registered methods MUST NOT use this prefix. This makes the platform surface unambiguous and lets UIs apply consistent treatment (e.g., grouping, filtering) without heuristics. ### 2. Two-tier introspection: infrastructure vs. capabilities **Infrastructure handlers** (`$sideband/rpc.list`, `$sideband/rpc.describe`) are always available on every daemon, CLI or programmatic. They are universal and are NOT listed in `capabilities` — their presence is implicit. **Capabilities** (`stats`, future `fs`) represent user-facing functional areas that may or may not be present. They are advertised explicitly in `$sideband/info`: ```typescript // $sideband/info response { daemonId: string; name: string; version: string; capabilities: Record; // { stats: {}, fs: { root, write } … } } ``` The key design invariant: **`rpc.list` is the truth; `rpc.describe` is optional display metadata; `capabilities` is the user-feature contract.** Rationale for the separation: * `rpc.list` returns all registered methods — including user-defined ones. The UI uses it for the full method set. * `capabilities` is a curated, stable API for feature detection. A UI checking `capabilities.stats` is more robust than checking whether `$sideband/stats` appears in `rpc.list`. * `rpc.describe` is sparse metadata for human display. It is intentionally not filtered against `rpc.list` — undescribed methods appear in `rpc.list` but not in `rpc.describe`, which is correct and expected. ### 3. `listMethods()` on `RpcInterface` Method introspection is placed on `RpcInterface` rather than a separate API: ```typescript interface RpcInterface { listMethods(): string[]; // Returns registered handler names, sorted lexicographically. } ``` **Rationale:** `$sideband/rpc.list` must work for every daemon — CLI and programmatic. Placing `listMethods()` on `RpcInterface` means any daemon, regardless of how it was created, exposes the same introspection surface. A separate top-level introspection API would require daemon authors to opt in explicitly; an `RpcInterface` method is always there. ### 4. Stats subscriptions are session-scoped `$sideband/stats.start` subscriptions are not resumed across reconnects. The client must call `stats.start` again after a new session is established. Rationale: resuming a subscription requires the daemon to track per-client state across sessions, which adds complexity and creates implicit cleanup obligations. Explicit restart is simpler, predictable, and keeps the daemon stateless between sessions. ## Invariants * `$sideband/*` prefix is reserved for platform use. User handlers MUST NOT use it. * `rpc.list` and `rpc.describe` MUST NOT appear in `capabilities`. * `listMethods()` returns a sorted array — clients may rely on lexicographic order for stable display. * Stats subscriptions are terminal at session end — not resumed on reconnect. ## Consequences * UI can use `capabilities` for feature detection and `rpc.list` + `rpc.describe` for the RPC explorer. * New CLI capabilities (e.g., `fs`) follow the same pattern: register handlers, return `{ fs: }` from the handler registrar, merge into `$sideband/info`. * Programmatic daemons that want `rpc.list` to work correctly call `registerRpcMeta()` themselves; it is opt-in, not automatic. ## References * ADR-013: Peer SDK Core Design Decisions * `packages/cli/src/handlers/rpc-meta.ts` — `registerRpcMeta()` * `packages/cli/src/handlers/stats.ts` — `registerStatsHandlers()` * `packages/peer/src/types.ts` — `RpcInterface.listMethods()` --- --- url: /adr/_template.md --- # ADR-NNN: \[Short Title] * **Date**: YYYY-MM-DD * **Status**: \[Proposed | Accepted | Deprecated | Superseded] * **Affects**: \[Protocol | Runtime | SDK | Transport | Repo] ## Context \[Describe the problem or situation requiring a decision. Be concise: What is the issue? Why now? Include relevant facts, constraints, or requirements.] ## Decision \[State the chosen solution clearly. Explain why this option was selected. Include any key trade-offs.] ## Invariants \[Optional: List non-negotiable rules introduced by this ADR — constraints that implementations MUST uphold. Omit section if none.] ## Alternatives Considered \[Optional: Briefly list other options evaluated, with pros/cons if relevant.] ## Consequences \[Outline expected impacts: benefits, risks, costs. How does this affect code, maintenance, or future decisions?] ## References \[Links to related docs, code, or other ADRs.] --- --- url: /adr.md --- # Architecture Decision Records This section documents significant architectural decisions for the Sideband project. Each ADR captures the context, decision, and consequences of a choice that affects the codebase. ## Index | ADR | Title | Status | | ----------------------------------------------------- | ------------------------------------- | -------- | | [001](./001-protocol-versioning-and-compatibility.md) | Protocol Versioning and Compatibility | Accepted | | [002](./002-naming-matrix.md) | Naming Matrix for Protocol Types | Accepted | | [003](./003-control-frame-invariants.md) | Control Frame Invariants | Accepted | | [004](./004-binary-frameid.md) | Binary FrameId | Accepted | | [005](./005-transport-abi.md) | Transport ABI | Accepted | | [006](./006-rpc-envelope.md) | RPC Envelope | Accepted | | [007](./007-immutable-frame-types.md) | Immutable Frame Types | Accepted | | [008](./008-subject-namespace-validation.md) | Channel Subject Validation | Accepted | | [009](./009-runtime-peer-lifecycle.md) | Runtime Session Lifecycle | Accepted | | [010](./010-rpc-correlation-cid.md) | RPC Correlation with CID | Accepted | | [011](./011-runtime-message-routing.md) | Runtime Message Routing | Accepted | | [012](./012-websocket-transport-design.md) | WebSocket Transport Design Decisions | Accepted | | [013](./013-peer-sdk-design.md) | Peer SDK Core Design Decisions | Accepted | | [014](./014-peer-session-signals.md) | Peer SDK Session Signal Handling | Accepted | | [015](./015-p2p-direct-protocol.md) | P2P Direct Protocol (SBDP) | Accepted | | [016](./016-relay-server-design.md) | Relay Server Design | Accepted | | [017](./017-cloud-sdk-design.md) | Cloud SDK Design | Accepted | | [018](./018-daemon-capabilities.md) | Daemon Built-in Capabilities | Accepted | ## Creating a new ADR Copy [\_template.md](./_template.md) and follow the format. Number ADRs sequentially. --- --- url: /protocols/sbrp/control-codes.md --- # Control Codes > **Authority**: Primary (Reference)\ > **Purpose**: Catalog of control code values, names, and flags. Behavioral semantics defined in [state-machine.md](./state-machine.md).\ > **Stability Note**: This document exists to stabilize numeric code assignments. Once the control code set is frozen, this catalog MAY be inlined into state-machine.md as an appendix. ## Code Ranges Control frames (`0x20`) are relay-originated only. Ranges enable category discrimination: | Range | Category | Description | | --------------- | -------------- | ------------------------------ | | `0x01xx` | Authentication | Token and authorization errors | | `0x02xx` | Routing | Daemon discovery and presence | | `0x03xx` | Session | Session lifecycle errors | | `0x04xx` | Wire format | Protocol violations | | `0x05xx` | Reserved | Future use | | `0x06xx` | Internal | Relay internal errors | | `0x07xx–0x08xx` | Reserved | Future use | | `0x09xx` | Throttling | Traffic control (varies: N/T) | | `0x0Axx–0x0Fxx` | Reserved | Future use | | `0x10xx` | Session state | Non-terminal state transitions | | `0x11xx–0x1Fxx` | Reserved | Future state notifications | | `0xE0xx` | SDK-only | Never transmitted on wire | ## Wire Codes (Relay → Endpoint) **Legend**: T=terminal (closes WebSocket), N=non-terminal. SID: 0=connection-level, S=session-specific. ### Authentication (0x01xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | -------------- | --- | --- | -------------------------- | ------------------------ | | 0x0101 | `unauthorized` | T | 0 | Invalid or expired token | Token validation failure | | 0x0102 | `forbidden` | T | 0 | Valid token, access denied | User doesn't own daemon | ### Routing (0x02xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | ------------------ | --- | --- | -------------------- | ------------------------------- | | 0x0201 | `daemon_not_found` | T | S | Unknown daemon ID | Daemon doesn't exist | | 0x0202 | `daemon_offline` | T | S | Daemon not connected | Client connects, daemon offline | ### Session (0x03xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | ------------------- | --- | --- | ------------------ | --------------------------------------- | | 0x0301 | `session_not_found` | T | S | Unknown session ID | Frame references invalid session | | 0x0302 | `session_expired` | T | S | Session terminated | Grace expired, Signal(close), or policy | ### Wire Format (0x04xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | -------------------- | --- | --- | --------------------------- | ------------------------------------------ | | 0x0401 | `malformed_frame` | T | 0 | Invalid header structure | Header parse failure, truncated frame | | 0x0402 | `payload_too_large` | T | 0 | Exceeds 64KB limit | Payload length > MAX\_PAYLOAD\_SIZE | | 0x0403 | `invalid_frame_type` | T | 0 | Unknown type byte | Type byte not in defined set | | 0x0404 | `invalid_session_id` | T | 0 | SessionID invalid for frame | Session-bound with 0, or Ping/Pong with >0 | | 0x0405 | `disallowed_sender` | T | S | Wrong direction for frame | Client sends Signal, peer sends Control | ### Internal (0x06xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | ---------------- | --- | --- | ---------------------- | --------------------------------------------------------------------------- | | 0x0601 | `internal_error` | T | 0 | Relay internal failure | Connection-fatal relay failure; reconnect may succeed on a healthy instance | ### Throttling (0x09xx) | Code | Name | T/N | SID | Meaning | When Emitted | | ------ | -------------- | --- | --- | ----------------- | --------------------- | | 0x0901 | `rate_limited` | N | 0 | Too many requests | Message rate exceeded | | 0x0902 | `backpressure` | T | 0 | Send buffer full | Receiver too slow | Rate limiting (`rate_limited`) is non-terminal and connection-level — the connection stays open and the sender should back off. Backpressure (`backpressure`) is terminal — the slow consumer is closed because silently dropping forwarded frames would corrupt the E2EE sequence stream. ### Session State (0x10xx) | Code | Name | T/N | SID | Direction | Meaning | | ------ | ----------------- | --- | --- | -------------- | ----------------------------------------- | | 0x1001 | `session_paused` | N | S | Relay → Client | Daemon disconnected; traffic suspended | | 0x1002 | `session_resumed` | N | S | Relay → Client | Daemon reconnected and ready | | 0x1003 | `session_ended` | N | S | Relay → Daemon | Client disconnected; cleanup session | | 0x1004 | `session_pending` | N | S | Relay → Client | Daemon reconnected; awaiting ready signal | ## Endpoint Codes (SDK Only) These codes exist for SDK error handling and logging. They are never transmitted in Control frames. | Code | Name | Detected by | Meaning | | ------ | ---------------------- | ----------- | ------------------------------------- | | 0xE001 | `identity_key_changed` | Client | TOFU key mismatch; possible MITM | | 0xE002 | `handshake_failed` | Client | Signature verification failed | | 0xE003 | `handshake_timeout` | Either | 30s handshake deadline exceeded | | 0xE004 | `decrypt_failed` | Either | ChaCha20-Poly1305 decrypt/auth failed | | 0xE005 | `sequence_error` | Either | Sequence outside replay window | **Reserved ranges for endpoint codes:** * `0xE0xx`: SBRP v1 SDK codes * `0xE1xx–0xEFxx`: Future SDK versions * `0xF0xx–0xFFxx`: Vendor/application-specific ## Interpreting T/N and SID **Terminality (T/N)**: * **T (Terminal)**: Relay sends Control frame then closes WebSocket. Client should not retry on same connection. * **N (Non-terminal)**: Connection remains open. Client may wait, retry, or take appropriate action. **SessionID scope (SID)**: * **0**: Connection-level error; use SessionID=0 in frame header. * **S**: Session-specific; use the relevant non-zero SessionID in frame header. --- --- url: /guide/concepts.md --- # Core Concepts This page explains the mental model behind Sideband. You don't need to understand all of it to get started — the [quick start](index.md) gets you running in five lines — but understanding the concepts helps when you hit edge cases. *** ## Peers A **Peer** is a long-lived connection endpoint. You create one, connect it, and use it until you disconnect. One peer = one logical connection. ```typescript const peer = createPeer({ endpoint: "ws://localhost:8080" }); await peer.connect(); // ... use peer await peer.disconnect(); ``` On the server side, `listen()` accepts incoming connections. Each accepted connection is an `ConnectedPeer` — the same abstraction, but with no `connect()` (the server doesn't initiate). *** ## Sessions and Negotiators A **session** is what happens when the transport connects. Before any application messages flow, both sides run a **negotiator** — a protocol handshake that establishes identity, capabilities, and optionally an encrypted channel. The default negotiator (`SBP`) just exchanges peer IDs and capabilities. The SBRP negotiator adds E2EE on top: same API, different negotiator option. ```typescript // Plain (no encryption) createPeer({ endpoint: "ws://..." }); // E2EE via relay (SBRP) createPeer({ endpoint: relayUrl, // from POST /api/sessions negotiator: relayClientNegotiator({ daemonId, sessionToken, identityKeyStore, }), }); ``` Everything above the negotiator — RPC, events, reconnection — is identical regardless of which session layer is active. *** ## Subjects and Channels Every message frame carries a **subject** — a routing label that determines how the runtime dispatches it: | Subject | Semantics | | -------- | --------------------------------------------------------- | | `rpc` | Request/response — exactly one handler, exactly one reply | | `event` | Fire-and-forget — all matching handlers called | | `stream` | Reserved (v2) | | `app/*` | Custom application protocols (user-defined) | `rpc` and `event` are the two active built-in channels. `stream` is reserved for future use. The `app/*` namespace is open for application-defined protocols. Method and event names live inside the frame payload (the RPC envelope), not in the subject. The subject identifies the message class; the envelope identifies the operation. *** ## State Machine A peer moves through states in a defined order. Understanding states matters when you handle errors or want to react to connection changes. ```text idle → connecting → negotiating → active ↑ ↓ └──── reconnecting ────────┘ active ↔ paused (SBRP only: relay lost upstream) any → closed (terminal) ``` Reconnection always goes through the full `connecting → negotiating → active` cycle — session state is re-established, not resumed from mid-handshake. | State | Meaning | | -------------- | ---------------------------------------------------------------------------------------- | | `idle` | Not connected. Initial state. | | `connecting` | Transport connect in progress. | | `negotiating` | Transport open; handshake in progress. | | `active` | Ready to send and receive. | | `paused` | SBRP session pause — daemon disconnected; relay holds WebSocket, client buffers locally. | | `reconnecting` | Waiting before retry attempt. | | `closed` | Terminal. No further transitions. | `peer.ready` is `true` only in `active`. `peer.connected` is `true` in both `active` and `paused`. *** ## RPC RPC follows a request/response pattern. Each call is correlated by a `cid` (correlation ID) assigned at send time. With `onDisconnect: "pause"`, the call intent is buffered across reconnections and sent with a fresh `cid` once the session is restored. See the [RPC guide](rpc.md). *** ## Events Events are fire-and-forget notifications. Subscribers use NATS-style dot-separated names with `*` (one segment) and `>` (one or more segments) wildcards. See the [Events guide](events.md). *** ## E2EE and Identity In SBRP mode, the daemon has a persistent Ed25519 identity keypair. The client pins the daemon's public key fingerprint on first connection (TOFU — Trust On First Use). Subsequent connections verify against the pinned key; a mismatch is fatal (not retryable). The relay never sees plaintext — all frame content is ChaCha20-Poly1305 encrypted end-to-end. See the [E2EE guide](e2ee.md) for setup. --- --- url: /protocols/sbrp/wire-crypto.md --- # Cryptography and Wire Format > **Authority**: Primary (Normative)\ > **Purpose**: Cryptographic construction, key derivation, and binary frame layout for SBRP. ## 1. Cryptographic Construction ### 1.1 Primitives | Purpose | Primitive | Notes | | -------------------- | ----------------- | ------------------------ | | Identity signing | Ed25519 | Long-lived daemon key | | Key exchange | X25519 | Ephemeral per session | | Symmetric encryption | ChaCha20-Poly1305 | All message payloads | | Key derivation | HKDF-SHA256 | Session key derivation | | Hashing | SHA-256 | Signature payloads, salt | ::: tip Why Ed25519 for identity? X25519 is for key exchange only. Without signatures, the relay could substitute keys during handshake and perform MITM. Ed25519 signatures ensure the client can cryptographically verify it's talking to the real daemon, not a relay-in-the-middle. ::: ### 1.2 Actors and Authentication SBRP uses two-tier authentication: the **control plane** handles user/daemon registration and session brokering; the **data plane** (relay) validates tokens for WebSocket connections. **User**: Authenticates to control plane via OAuth, passkeys, or session-based auth. Obtains relay tokens for WebSocket connections. Can register multiple daemons and connect from multiple devices. **Daemon**: Registers with control plane via API key. Has Ed25519 identity keypair (signs handshakes). Identity public key is registered with control plane; clients fetch it from there. **Client**: Authenticates to control plane via user session. Generates ephemeral X25519 keys per connection. Verifies daemon signatures using locally pinned identity key (TOFU). ### 1.3 E2EE Handshake After WebSocket connection, client and daemon perform authenticated key exchange using binary frames. ```text Client Relay Daemon │ │ │ │ [WebSocket connected] │ │ │ │ │── HandshakeInit (0x01) ►│── forward ─────────────►│ │ [32B X25519 key] │ │ ← clientEphemeralPublicKey │ │ │ │ │ │ Daemon: │ │ │ 1. Generate X25519 ephemeral │ │ │ 2. Sign ephemeral with Ed25519 │ │ │ │◄───────────────────────────── HandshakeAccept (0x02) │ │ [32B Ed25519 key] │ ← identityPublicKey (TOFU) │ │ [32B X25519 key] │ ← daemonEphemeralPublicKey │ │ [64B signature] │ ← Ed25519 signature │ │ │ │ Client: │ │ │ 1. Verify signature │ │ │ 2. Derive shared secret│ │ │ │ │ │◄══════════ Encrypted frames (0x03) ══════════════►│ │ (relay sees opaque binary only) │ ``` Future versions MAY support client-held identity keys for cryptographic user authentication; SBRP v1 does not require this. ### 1.4 Signature Verification Daemon signs its ephemeral public key with context binding: ```text signaturePayload = SHA256( "sbrp-v1-handshake" || daemonId || clientEphemeralPublicKey || daemonEphemeralPublicKey ) signature = Ed25519.sign(identityPrivateKey, signaturePayload) ``` **String encoding:** Context strings (`"sbrp-v1-handshake"`, `"sbrp-v1-transcript"`) and `daemonId` MUST be UTF-8 encoded with no BOM or length prefix before concatenation. Client verifies using **pinned** identity key (see [index.md](./index.md#tofu-identity-pinning)): ```text valid = Ed25519.verify(pinnedIdentityPublicKey, signaturePayload, signature) ``` If verification fails, abort handshake immediately. **Handshake timeout:** Handshake SHOULD complete within 30 seconds. Client measures from WebSocket open; daemon measures from receiving `HandshakeInit`. Implementations MUST abort with `handshake_timeout` if the timeout expires. **Why this prevents MITM:** 1. Client uses locally-pinned identity key (TOFU), not freshly-fetched key 2. Relay doesn't have the daemon's Ed25519 private key 3. Signature binds to specific daemonId, preventing cross-daemon confusion 4. Signature binds to specific clientEphemeralPublicKey, preventing replay **Scope note:** Relay-region or relay-origin binding belongs to token validation in [authentication.md](./authentication.md), not the handshake signature payload. ### 1.5 Key Derivation Both parties compute: ```text sharedSecret = X25519(myEphemeralPrivate, peerEphemeralPublic) // Transcript hash binds session keys to the authenticated handshake transcriptHash = SHA256( "sbrp-v1-transcript" || daemonId || clientEphemeralPublicKey || daemonEphemeralPublicKey || signature ) sessionKeys = HKDF-SHA256( ikm: sharedSecret, salt: transcriptHash, info: "sbrp-session-keys", len: 64 ) clientToDaemon = sessionKeys[0:32] daemonToClient = sessionKeys[32:64] ``` **Design rationale:** * HKDF info parameter MUST be the protocol-defined constant "sbrp-session-keys" * Transcript hash as salt binds keys to this specific authenticated session * Including signature ensures derived keys are tied to verified identity * Directional keys prevent reflection attacks * Transcript context string provides domain separation ## 2. Runtime Cryptographic Invariants ### 2.1 Encrypted Messages All application messages after handshake use binary `Encrypted` frames (type `0x03`). The encrypted payload structure: ```text nonce (12 bytes) || ciphertext || authTag (16 bytes) ``` ### 2.2 Nonce Construction * Bytes 0-3: Direction (`0x00000001` = client→daemon, `0x00000002` = daemon→client) * Bytes 4-11: Sequence number (big-endian uint64) ### 2.3 Additional Authenticated Data (AAD) * AAD MUST be empty (zero-length byte array) * Context binding is achieved via nonce (direction + sequence) and key derivation (transcript hash includes handshake data) * Implementations MUST NOT use non-empty AAD ### 2.4 Sequence Numbers * Start at 0, increment per message per direction * Sequence number space is 64-bit; replay window is an implementation-defined sliding subset * Receiver MUST use bitmap-based sliding window of at least 64 messages * Receiver SHOULD use window size at least 128 * Receiver MAY use 256 or larger for high-latency or bursty traffic * Messages outside window are rejected * Bitmap approach prevents memory exhaustion from attacker-controlled sequence numbers * Large sequence jumps (beyond window size) MUST be handled in O(1) by resetting the bitmap rather than iterating; failure to do so enables CPU exhaustion attacks ### 2.4.1 Sequence Exhaustion Sequence numbers are **unsigned 64-bit integers** (`uint64`). Nonce uniqueness requires that `seq` MUST NOT wrap while using the same directional traffic key. **Sender:** If `seq` reaches `2^64 − 1`, the sender MUST stop sending encrypted frames for that direction and terminate the session. To continue, endpoints must perform a new handshake to derive fresh keys. **Receiver:** The replay window (§2.4) naturally rejects wrap — any `seq` that implies reset will fall outside the sliding window. Implementations must use correct `uint64` arithmetic to preserve this property. ::: tip Implementation Note In JavaScript/TypeScript, use `bigint` for sequence tracking. The `number` type loses precision beyond 2^53, causing silent bugs in comparison and arithmetic long before exhaustion. ::: ### 2.5 Replay Window Implementation ```typescript // Bitmap-based sliding window (prevents memory DoS) // NOTE: This example shows the algorithm with a minimal 64-message window. // Production implementations SHOULD use ≥128 (e.g., two 64-bit words or Uint8Array). interface ReplayWindow { maxSeen: bigint; // highest accepted sequence bitmap: bigint; // bit i set = (maxSeen - i) was seen } function checkReplay(seq: bigint, window: ReplayWindow): boolean { if (seq > window.maxSeen) { // New high sequence - shift window const shift = seq - window.maxSeen; window.bitmap = shift >= 64n ? 0n : window.bitmap << shift; window.bitmap |= 1n; // mark current as seen window.maxSeen = seq; return true; } const diff = window.maxSeen - seq; if (diff >= 64n) return false; // too old const mask = 1n << diff; if (window.bitmap & mask) return false; // replay window.bitmap |= mask; return true; } ``` ## 3. Wire Format and Frame Layout All relay communication uses binary frames over WebSocket (binary message type). The relay inspects ONLY the frame header for routing decisions; payload content is opaque bytes that the relay MUST NOT interpret. ### 3.1 Frame Structure ```text ┌───────────┬──────────────┬────────────────┬─────────────────────┐ │ Type (1B) │ Length (4B) │ SessionID (8B) │ Payload (0..64KB) │ └───────────┴──────────────┴────────────────┴─────────────────────┘ │ │ │ │ │ │ │ └─ Opaque bytes (handshake or encrypted) │ │ └─ Session ID (big-endian uint64) │ └─ Payload length in bytes (big-endian uint32, excludes header) └─ Frame type (see §3.2) ``` * **Header size:** 13 bytes (fixed) * **Max payload:** 65536 bytes (64 KB) * **Byte order:** Big-endian for all multi-byte integers ### 3.2 Frame Types | Type | Hex | Direction | SessionID | Description | | --------------- | ------ | --------------- | --------- | ---------------------------------------- | | HandshakeInit | `0x01` | Client → Daemon | Required | Client's ephemeral X25519 key | | HandshakeAccept | `0x02` | Daemon → Client | Required | Daemon's identity key + signed ephemeral | | Data | `0x03` | Either | Required | E2EE application payload | | Signal | `0x04` | Daemon → Relay | Required | Session lifecycle command | | Ping | `0x10` | Either | Zero | Keepalive request (connection-scoped) | | Pong | `0x11` | Either | Zero | Keepalive response (connection-scoped) | | Control | `0x20` | Relay → Peer | Varies | Errors and state notifications | **Reserved frame type ranges:** | Range | Purpose | Status | | ----------- | ------------------------------ | ------------------- | | `0x00` | Invalid (undefined) | Reserved | | `0x01–0x0F` | Session-bound endpoint frames | 0x01–0x04 allocated | | `0x10–0x1F` | Connection-scoped frames | 0x10–0x11 allocated | | `0x20–0x2F` | Relay-generated frames | 0x20 allocated | | `0x30–0x7F` | Future protocol extensions | Reserved | | `0x80–0xFF` | Experimental/vendor extensions | Reserved | **SessionID requirements:** * Session-bound frames (`0x01`, `0x02`, `0x03`, `0x04`) MUST have non-zero SessionID * Keepalive frames (`0x10`, `0x11`) MUST have SessionID = 0 (connection-scoped, never forwarded) * Control frames (`0x20`) use non-zero SessionID for session events, zero for connection errors **SessionID scope by Control code:** See [control-codes.md](./control-codes.md) for the authoritative per-code SID column. Connection-level errors (SID=0) have no valid session context; session-specific events (SID=S) use the relevant non-zero SessionID. **Authority boundaries:** * Clients MUST NOT send Signal (`0x04`) or Control (`0x20`) frames * Endpoints MUST NOT send Control (`0x20`) frames * Relay MUST reject disallowed frames with `Control(disallowed_sender)` if header is parseable; if header is malformed or truncated, close WebSocket immediately with `Control(malformed_frame, SessionID=0)` ### 3.3 Payload Formats Payload structure is defined per frame type. These formats are parsed by endpoints, never by relay. **HandshakeInit (`0x01`)** — 32 bytes: ```text ┌─────────────────────────┐ │ initPublicKey (32B) │ X25519 ephemeral public key └─────────────────────────┘ ``` **HandshakeAccept (`0x02`)** — 128 bytes: ```text ┌──────────────────────────┐ │ identityPublicKey (32B) │ Ed25519 identity public key (TOFU) ├──────────────────────────┤ │ acceptPublicKey (32B) │ X25519 ephemeral public key ├──────────────────────────┤ │ signature (64B) │ Ed25519 signature (see §1.4) └──────────────────────────┘ ``` **Data (`0x03`)** — variable length: ```text ┌─────────────────────────┐ │ nonce (12B) │ See §2.2 for nonce construction ├─────────────────────────┤ │ ciphertext (N bytes) │ ChaCha20-Poly1305 encrypted payload ├─────────────────────────┤ │ authTag (16B) │ Poly1305 authentication tag └─────────────────────────┘ ``` Total payload size: 28 + plaintext length. Max plaintext: 65508 bytes (64 KB payload limit − 28 bytes overhead). **Signal (`0x04`)** — 2 bytes (daemon-originated only): ```text ┌─────────────────────────┐ │ signal (1B) │ Signal code (see below) ├─────────────────────────┤ │ reason (1B) │ Reason code (for extensibility) └─────────────────────────┘ ``` Signal codes: | Signal | Value | Meaning | | ------- | ------ | --------------------------------------- | | `ready` | `0x00` | Session state retained, ready to resume | | `close` | `0x01` | Session terminated | Reason codes (universal across all signals): | Reason | Value | Meaning | | ------------ | ------ | ------------------------------- | | `none` | `0x00` | No specific reason (default) | | `state_lost` | `0x01` | Process restart, memory cleared | | `shutdown` | `0x02` | Graceful daemon shutdown | | `policy` | `0x03` | Internal policy denial | | `error` | `0x04` | Internal daemon error | For `ready` signal, reason SHOULD be `0x00` (`none`). For `close` signal, use the appropriate reason. Unknown reason codes MUST be treated as `none` by relay. **Ping (`0x10`)** — 0-8 bytes: ```text ┌─────────────────────────┐ │ payload (0..8B) │ Optional nonce/timestamp for RTT └─────────────────────────┘ ``` Ping is connection-scoped (SessionID = 0). Payload is opaque; recipient copies it to Pong response. **Pong (`0x11`)** — 0-8 bytes: ```text ┌─────────────────────────┐ │ payload (0..8B) │ Copied from corresponding Ping └─────────────────────────┘ ``` Pong is connection-scoped (SessionID = 0). Payload MUST match the triggering Ping. **Control (`0x20`)** — variable length (relay-originated only): ```text ┌─────────────────────────┐ │ code (2B) │ Control code (big-endian uint16, see control-codes.md) ├─────────────────────────┤ │ message (0..N bytes) │ UTF-8 diagnostic message (optional) └─────────────────────────┘ ``` ::: warning Control Message Constraints The `message` field is for diagnostic/logging purposes only. Relay implementations: * MUST NOT include identifiers (daemonId, clientId, sessionId, tokens) in message * SHOULD leave message empty in production * MAY include generic error descriptions for debugging Clients MUST NOT parse message content for behavioral decisions; use only the `code` field. ::: ### 3.4 Control Code Ranges Control frames (`0x20`) are relay-originated only. Endpoint-detected errors (cryptographic failures) are not transmitted as Control frames; endpoints close the connection and log the error locally. Control codes use **ranges** to categorize error types and session states. Ranges enable **category discrimination only**—do not infer terminality or SessionID scope from the range; consult [control-codes.md](./control-codes.md) for the per-code table. | Range | Category | Description | | --------------- | -------------- | ------------------------------ | | `0x01xx` | Authentication | Token and authorization errors | | `0x02xx` | Routing | Daemon discovery and presence | | `0x03xx` | Session | Session lifecycle errors | | `0x04xx` | Wire format | Protocol violations | | `0x05xx` | Reserved | Future use | | `0x06xx` | Internal | Relay internal errors | | `0x07xx–0x08xx` | Reserved | Future use | | `0x09xx` | Throttling | Traffic control (varies: N/T) | | `0x0Axx–0x0Fxx` | Reserved | Future use | | `0x10xx` | Session state | Non-terminal state transitions | | `0x11xx–0x1Fxx` | Reserved | Future state notifications | | `0xE0xx` | SDK-only | Never transmitted on wire | **Terminality is per-code, not per-range.** See [control-codes.md](./control-codes.md) for the complete code table with terminal/non-terminal annotations per code. Terminal codes result in relay closing the WebSocket; non-terminal codes allow the connection to remain open. ## 4. Wire-Level Validation Rules ### 4.1 Relay Behavior The relay MUST: * Parse frame header (13 bytes) to extract type, length, and session ID * Route endpoint frames (`0x01`, `0x02`, `0x03`) by session ID to the paired connection * Handle `Signal` (`0x04`) from daemon: * `Signal(ready)`: send `Control(session_resumed)` to client, resume routing * `Signal(close)`: send `Control(session_expired)` to client, close session pairing * Handle `Ping` (`0x10`) locally: respond with `Pong` copying payload; NEVER forward * Handle `Pong` (`0x11`) locally: update liveness state; NEVER forward * Reject `Control` (`0x20`) received from peers as `disallowed_sender` * Reject `Signal` (`0x04`) received from clients as `disallowed_sender` * Generate `Control` frames as needed * Forward payload bytes of routed frames without modification * Reject frames exceeding max payload size with `Control(payload_too_large)` The relay MUST NOT: * Interpret payload contents of endpoint frames * Parse any structure within encrypted payloads * Buffer, reassemble, or coalesce frames beyond transport requirements * Modify frame bytes in transit * Generate Control messages that leak information derived from encrypted payloads * Include identifiers (daemonId, clientId, sessionId, tokens) in Control message text ::: warning Relay is a Frame Router The relay operates on opaque binary frames. It has no knowledge of handshake semantics, encryption, or application protocols. This separation is intentional and MUST be preserved. ::: ### 4.2 Frame Validation Order When multiple errors apply, relay MUST check in this order and return the first matching error: 1. **Header parse** — If header is malformed or truncated → `malformed_frame`, SID=0 2. **Payload size** — If length exceeds limit → `payload_too_large`, SID=0 3. **Frame type** — If type byte unknown → `invalid_frame_type`, SID=0 4. **SessionID validity** — If session-bound frame has SessionID=0, or Ping/Pong has non-zero SessionID → `invalid_session_id`, SID=0 5. **Frame direction** — If sender not allowed for frame type → `disallowed_sender`, SID=header's SessionID (now validated non-zero for session-bound frames) This order ensures deterministic error selection and correct SID in responses. ## 5. Examples ### 5.1 Complete Handshake ```text Client → Relay → Daemon: Frame: 01 00000020 0000000000000001 <32 bytes initPublicKey> │ │ │ └─ Payload │ │ └─ SessionID = 1 │ └─ Length = 32 └─ Type = HandshakeInit Daemon → Relay → Client: Frame: 02 00000080 0000000000000001 <32 bytes identityPublicKey><32 bytes acceptPublicKey><64 bytes signature> │ │ │ └─ Payload (128 bytes) │ │ └─ SessionID = 1 │ └─ Length = 128 └─ Type = HandshakeAccept Client → Relay → Daemon (first encrypted message): Frame: 03 0000003C 0000000000000001 <12 bytes nonce><32 bytes ciphertext><16 bytes tag> │ │ │ └─ Payload (60 bytes) │ │ └─ SessionID = 1 │ └─ Length = 60 └─ Type = Data Keepalive (connection-scoped): Frame: 10 00000008 0000000000000000 <8 bytes timestamp> │ │ │ └─ Payload (optional RTT nonce) │ │ └─ SessionID = 0 (connection-scoped) │ └─ Length = 8 └─ Type = Ping Daemon → Relay (session resumption): Frame: 04 00000002 0000000000000001 00 00 │ │ │ │ └─ reason = 0x00 (none) │ │ │ └─ signal = ready (0x00) │ │ └─ SessionID = 1 │ └─ Length = 2 └─ Type = Signal ``` ### 5.2 Nonce Encoding Example Client sending first message to daemon: ```text Direction: 0x00000001 (client → daemon) Sequence: 0x0000000000000000 (first message) Nonce (12 bytes): 00 00 00 01 00 00 00 00 00 00 00 00 ``` Daemon sending first response: ```text Direction: 0x00000002 (daemon → client) Sequence: 0x0000000000000000 (first message in this direction) Nonce (12 bytes): 00 00 00 02 00 00 00 00 00 00 00 00 ``` ### 5.3 Control Frame Example Session paused notification: ```text Frame: 20 00000002 0000000000000001 10 01 │ │ │ │ │ │ │ └─ code = 0x1001 (session_paused) │ │ └─ SessionID = 1 │ └─ Length = 2 └─ Type = Control ``` With optional diagnostic message: ```text Frame: 20 00000015 0000000000000001 10 01 "Daemon disconnected" │ │ │ │ └─ UTF-8 message (optional) │ │ │ └─ code = 0x1001 (session_paused) │ │ └─ SessionID = 1 │ └─ Length = 21 (2 bytes code + 19 bytes message) └─ Type = Control ``` --- --- url: /README.md --- # Documentation ## Structure | Layer | Path | Scope | | ----------------- | ----------------- | --------------------------------------------- | | Wire protocols | `protocols/` | Frame encoding, state machines, cryptography | | Runtime contracts | `runtime/` | Session lifecycle, routing, correlation | | SDK design | `sdk/` | Public API surface and design documents | | Decisions | `adr/NNN-slug.md` | Architectural decisions, reference as ADR-NNN | | Architecture | `architecture/` | Package design, repo structure | | Guides | `guide/` | Non-normative tutorials | ## Reference Conventions Use doc references in code comments only when enforcing a non-obvious normative rule. | Source | Reference style | Example | | ---------------- | -------------------------- | -------------------------------- | | ADR | `ADR-NNN` | `// See ADR-004` | | Protocol spec | `docs/protocols/.md` | `// See docs/protocols/stack.md` | | Runtime contract | `docs/runtime/.md` | `// See docs/runtime/session.md` | | SDK contract | `docs/sdk/.md` | `// See docs/sdk/peer.md` | Never restate a normative rule in a comment — reference the authoritative source instead. ## Golden Rule A normative rule MUST appear in exactly one document. `runtime/` and `sdk/` reference `protocols/` — they never restate wire rules. `docs/protocols/stack.md` is the final arbiter on layer boundaries. ## Reference Direction ``` protocols/ ← independent; no upstream dependencies runtime/ ← may reference protocols/ sdk/ ← may reference runtime/ and protocols/ architecture/ ← may reference protocols/ adr/ ← may reference all layers guide/ ← may reference all layers ``` See `docs/protocols/authoring.md` for protocol-specific documentation guidelines. --- --- url: /guide/e2ee.md --- # E2EE Relay (SBRP) By default, Sideband uses the plain SBP handshake — peers exchange IDs and start talking. For production use behind NAT, you want E2EE relay mode: the Sideband relay forwards your messages, but cannot read them. *** ## How it Works ```text Browser (client) ──SBRP──▶ Relay ──SBRP──▶ Daemon ◀────────────────────────────────────────── ``` The relay routes encrypted frames by `daemonId`. Frame content is ChaCha20-Poly1305 encrypted end-to-end — the relay never holds session keys. Identity is verified via Ed25519 signatures; a MITM attack changes the key and fails TOFU verification. See `docs/protocols/sbrp/` for the full protocol specification. *** ## Cloud Relay (relay.sideband.cloud) `@sideband/cloud` handles token management automatically — presence token renewal, relay session fetching, and reconnect logic. ### Daemon The daemon has a persistent identity keypair. Generate it once on first run and persist it. ```typescript import { listen, generateIdentityKeyPair } from "@sideband/cloud"; import * as fs from "node:fs/promises"; const identityPath = ".sideband/identity.json"; async function loadOrCreateIdentity() { try { return JSON.parse(await fs.readFile(identityPath, "utf8")); } catch { const created = generateIdentityKeyPair(); await fs.mkdir(".sideband", { recursive: true }); await fs.writeFile(identityPath, JSON.stringify(created)); return created; } } const server = await listen({ apiKey: process.env.SIDEBAND_API_KEY, // sbnd_dak_... from sideband.cloud identityKeyPair: await loadOrCreateIdentity(), onConnection(peer) { peer.rpc.handle("ping", () => "pong"); }, }); // Presence token renewed automatically on each relay reconnect. ``` ### Client The client connects to the relay using the daemon's ID. On first connection, the client sees the daemon's identity fingerprint and must decide whether to trust it. ```typescript import { connect, createMemoryIdentityKeyStore } from "@sideband/cloud"; const peer = connect({ daemonId: "d_abc123", getAccessToken: () => auth.getSessionToken(), // called on every connect attempt identityKeyStore: createMemoryIdentityKeyStore(), // trustPolicy defaults to "auto" — appropriate for cloud (control plane // already authenticated the daemon via API key at registration) onIdentityMismatch: async ({ expectedFingerprint, receivedFingerprint }) => { return confirm( `Daemon identity changed (${expectedFingerprint} → ${receivedFingerprint}). Trust new key?`, ); }, }); peer.rpc.handle("push", handlePush); // register before connection completes await peer.whenReady(); const result = await peer.rpc.call("ping"); ``` ### Quick Connect (one-shot bootstrap) Quick Connect lets a client connect to a daemon without a user account. The daemon generates a short-lived code via the Sideband API; the client redeems it as the sole credential. ```typescript import { connect, createMemoryIdentityKeyStore } from "@sideband/cloud"; const peer = connect({ quickConnectCode: "abcd-efgh-ijkl", // displayed by the daemon, scanned/pasted by client identityKeyStore: createMemoryIdentityKeyStore(), }); await peer.whenReady(); ``` **Important limitations:** * Codes are **single-use** — the server atomically consumes the code before checking daemon status. A 409 response means the code is burned and the daemon is offline; the client must ask for a new code. * **No reconnection** — if the connection drops, the peer terminates fatally. QC is a first-contact bootstrap; for persistent sessions, transition to the account path after the initial connection. * `daemonId` is resolved from the redeem response — clients do not need to know it upfront. *** ## Self-Hosted Relay For self-hosted relay servers, use `@sideband/peer` directly: ### Daemon ```typescript import { listen } from "@sideband/peer/server"; import { relayDaemonNegotiator } from "@sideband/peer/sbrp"; import { generateIdentityKeyPair } from "@sideband/secure-relay"; const server = await listen({ endpoint: "wss://relay.example.com", negotiator: relayDaemonNegotiator({ daemonId: asDaemonId("my-daemon"), identityKeyPair: identity, }), onConnection(peer) { peer.rpc.handle("ping", () => "pong"); }, }); ``` ### Client ```typescript import { createPeer } from "@sideband/peer"; import { relayClientNegotiator } from "@sideband/peer/sbrp"; // sessionToken from your relay API — contains the sid claim const { relayUrl, token: sessionToken } = await api.createSession({ daemonId: "my-daemon", }); const peer = createPeer({ endpoint: `${relayUrl}?token=${sessionToken}`, negotiator: relayClientNegotiator({ daemonId: asDaemonId("my-daemon"), sessionToken, // sessionId extracted from JWT sid claim automatically identityKeyStore, trustPolicy: "prompt", onFirstConnection: async ({ fingerprint }) => { return confirm(`Trust daemon with fingerprint ${fingerprint}?`); }, onIdentityMismatch: async ({ expectedFingerprint, receivedFingerprint, }) => { return confirm( `Identity changed. Expected ${expectedFingerprint}, received ${receivedFingerprint}. Trust new key?`, ); }, }), }); await peer.connect(); ``` *** ## TOFU Trust Model TOFU — Trust On First Use — means the first connection establishes the trusted identity. All subsequent connections verify against the pinned fingerprint. | Trust policy | Behaviour | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"prompt"` (default) | Calls `onFirstConnection` and `onIdentityMismatch`; throws if callbacks are missing | | `"auto"` | Trusts silently on first contact; pins automatically. On identity mismatch, silently accepts the new key and re-pins — convenient but trades MITM detection for zero-friction rotation | | `"pinned-only"` | Requires the key to already be pinned; throws if not found | With the default `trustPolicy: "prompt"`, an identity mismatch invokes the `onIdentityMismatch` callback. If it returns `true`, the new key is **permanently re-pinned** in the `identityKeyStore`, replacing the old key. If it returns `false`, the connection is closed — the peer transitions to `"closed"`, not `"reconnecting"`. Choose carefully — re-pinning should only happen when the user has out-of-band confirmation that the daemon was intentionally re-keyed. With `trustPolicy: "pinned-only"`, any mismatch is unconditionally fatal — the peer transitions to `"closed"` immediately. This prevents silent MITM attacks without relying on user judgment. *** ## Key Management `identityKeyStore` is an object you provide that persists pinned identity keys. ```typescript interface IdentityKeyStore { get(daemonId: string): Promise; set(daemonId: string, publicKey: Uint8Array): Promise; delete(daemonId: string): Promise; } ``` Minimal file-backed implementation: ```typescript const identityKeyStore: IdentityKeyStore = { async get(daemonId) { try { const data = await fs.readFile(`.sideband/keys/${daemonId}`, null); return new Uint8Array(data); } catch { return null; } }, async set(daemonId, publicKey) { await fs.mkdir(".sideband/keys", { recursive: true }); await fs.writeFile(`.sideband/keys/${daemonId}`, publicKey); }, async delete(daemonId) { await fs.unlink(`.sideband/keys/${daemonId}`).catch(() => {}); }, }; ``` *** ## Session Pause and Resume If the relay loses the daemon's connection (daemon restarts, network blip), the relay signals the client session to pause rather than disconnect. During `"paused"`: * The peer state is `"paused"` — not `"reconnecting"`. * Outbound events are buffered (up to `eventPolicy.maxBufferedEvents`). * RPC calls with `onDisconnect: "pause"` are buffered. * `peer.connected` remains `true`. When the daemon reconnects to the relay, the relay emits a resume signal and the peer transitions back to `"active"` without a full reconnect cycle. *** ## See Also * [Concepts — E2EE and Identity](concepts.md#e2ee-and-identity) * `docs/protocols/sbrp/` — Full SBRP protocol specification * `docs/protocols/sbrp/threat-model.md` — Security model and attacker assumptions * `docs/protocols/sbrp/authentication.md` — TOFU verification detail * `docs/sdk/peer.md` §6.9 — SBRP security model in the RFC --- --- url: /protocols/error-codes.md --- # Error Code Registry > **Authority**: Primary (Normative)\ > **Purpose**: Canonical registry for all error codes across protocol layers. This document is the single source of truth for error codes. Specifications MUST reference this registry; they MUST NOT define codes inline. ## Code Ranges | Range | Owner | Carrier | Purpose | | --------- | -------- | ---------- | -------------------------------- | | 1000–1099 | SBP | ErrorFrame | Frame/handshake/namespace faults | | 1100–1199 | RPC | RpcError | Envelope/method/routing faults | | 1200–1999 | Reserved | — | Future protocol extensions | | 2000+ | App | RpcError | User-defined application errors | Implementations MUST NOT define codes outside their owned range. ## Transport Errors (Local) Transport errors are **local-only** and are **NOT** wire protocol errors. They describe why a local transport operation failed (connection refused, DNS failure, TLS error, timeout, etc.) and are never sent on the wire or carried in `ErrorFrame` or `RpcError`. For the full transport error taxonomy, see [transport/errors.md](./transport/errors.md). **TransportErrorKind values** (reference): | Kind | Retryable | Description | | ----------------------- | --------- | -------------------------------------- | | `connection_refused` | Yes | Server not accepting connections | | `dns_failure` | Yes | DNS resolution failed | | `tls_failure` | No | TLS/SSL handshake or certificate error | | `timeout` | Yes | Connection or operation timed out | | `network_offline` | Yes | Network unavailable | | `abnormal_close` | Yes | Connection dropped unexpectedly | | `message_too_large` | No | Message exceeds size limit | | `policy_violation` | No | CSP, CORS, or browser security policy | | `authentication_failed` | No | Relay-level auth (NOT E2EE auth) | | `aborted` | No | Explicit AbortSignal cancellation | | `subprotocol_mismatch` | No | Subprotocol negotiation failed | | `transport_failure` | Yes | Catch-all for unmapped errors | > **Scope distinction**: > > * **Wire protocol errors** (this document): Sent between peers, carried in `ErrorFrame` or `RpcError` > * **Transport errors** ([transport/errors.md](./transport/errors.md)): Local-only, describe transport layer failures, surfaced via `TransportError` class ## SBP Codes (1000–1099) Carried in `ErrorFrame.code`. These are protocol-level errors. | Code | Name | Fatality | Semantics | | ---- | ------------------ | ---------- | ------------------------------------------------ | | 1000 | ProtocolViolation | Fatal | Generic protocol contract violation | | 1001 | UnsupportedVersion | Fatal | Peer advertised incompatible version | | 1002 | InvalidFrame | Contextual | Frame structure or encoding error | | 1003 | UnsupportedFeature | Non-fatal | Feature reserved or requires capability exchange | **Fatality rules**: * **Fatal**: Receiver MUST close transport after emitting diagnostics * **Non-fatal**: Continue processing subsequent frames * **Contextual**: MAY be fatal if recovery is unsafe; otherwise continue See [SBP Error Model](./sbp/errors.md) for sender/receiver contracts. ## RPC Codes (1100–1199) Carried in `RpcError.code` (envelope `t: "E"`). These are request-scoped errors that MUST NOT trigger transport close. | Code | Name | Semantics | | ---- | ------------------- | ----------------------------------------------- | | 1100 | InvalidEnvelope | Envelope structure or encoding error | | 1101 | UnsupportedMethod | Method not recognized by handler | | 1102 | CorrelationMismatch | Response cid does not match any pending request | | 1103 | Timeout | Request timed out waiting for response | | 1104 | EnvelopeMismatch | Envelope type incompatible with subject prefix | **Key invariant**: Routers MUST NOT use SBP `ErrorFrame` to represent handler timeouts. Timeouts MUST be delivered as `RpcError` responses. ## Application Codes (2000+) User-defined codes for application-specific errors. Carried in `RpcError.code`. | Code | Name | Semantics | | ---- | ---------------- | ------------------------------------ | | 2000 | ApplicationError | Catch-all for unspecified app errors | Applications MAY define stable subcodes above 2000 for domain-specific errors. ## SBRP Control Codes SBRP uses a separate namespace (0x01xx–0x10xx) for relay control signaling. These are NOT error codes and are never carried in `ErrorFrame` or `RpcError`. See [SBRP Control Codes](./sbrp/control-codes.md). ## Adding New Codes New codes require: 1. ADR documenting the need and semantics 2. Update to this registry 3. Update to `@sideband/protocol` or `@sideband/rpc` constants Reserved ranges (1200–1999) are allocated only by ADR. --- --- url: /guide/events.md --- # Events Sideband events are fire-and-forget notifications. Unlike RPC, events have no response — they are dispatched to all matching subscribers. This makes them appropriate for state changes, progress updates, and broadcast notifications. *** ## Emitting Events ```typescript peer.events.emit("task.completed", { taskId: "abc123", duration: 342 }); ``` `emit()` is synchronous. It throws only for invalid event names; send failures are handled asynchronously through `onUnhandledError`. If the peer is not `active`, the event is buffered (up to `eventPolicy.maxBufferedEvents`, default 128; evict-oldest on overflow). On transition to `closed`, buffered events are discarded silently. *** ## Subscribing by Name ```typescript const unsubscribe = peer.events.on("task.completed", (data) => { console.log("Task done:", data.taskId); }); // Later: unsubscribe(); ``` `on()` matches exact event names only. For wildcard matching, use `onPattern()`. *** ## Pattern Subscriptions `onPattern()` uses NATS dot-separated syntax with two wildcards: | Wildcard | Matches | | -------- | ------------------------------------ | | `*` | Exactly one segment | | `>` | One or more segments (must be final) | ```typescript // Matches "task.created", "task.updated", "task.completed" — not "task.step.done" peer.events.onPattern("task.*", (name, data) => { console.log(`Event: ${name}`, data); }); // Matches everything under "sensor." — any depth peer.events.onPattern("sensor.>", (name, data) => { console.log(`Sensor event: ${name}`); }); ``` `onPattern()` returns an idempotent `unsubscribe()` function. *** ## Wildcard Reference ```text event_name = token ("." token)* pattern = segment ("." segment)* segment = token | "*" | ">" token = 1+ alphanumeric / "-" / "_" ``` Valid examples: ``` task.created ← exact match task.* ← matches task.created, task.updated (one segment) task.> ← matches task.created, task.step.done (one or more) sensor.temperature.* ← matches sensor.temperature.reading sensor.> ← matches sensor.temperature, sensor.a.b (not "sensor" alone) ``` Invalid as event names: ``` task.** ← rejected: use > not ** *.created ← rejected: wildcards are not valid in emitted event names task..event ← rejected: empty segment ``` Note: `*.created` is valid as a *subscription pattern* (matches `task.created`, `job.created`, etc.) — it is only rejected when used as an event name in `emit()`. `onPattern()` throws `PeerError{ code: "invalid_pattern" }` synchronously for invalid patterns. *** ## Dispatch Semantics * All matching handlers are called, in registration order. * A handler throwing does not abort dispatch to remaining handlers — the error is forwarded to `peerOptions.onUnhandledError`. * There is no acknowledgement or reply. If you need confirmation, use RPC. *** ## Subscriptions Survive Reconnects Subscriptions are in-process registrations — they are not re-sent to the remote peer on reconnect. This means: * Subscriptions outlive individual sessions. * No re-registration boilerplate on reconnect. * The pattern matching happens locally on every inbound message. *** ## Buffer Behavior During Reconnection Outbound events emitted while `paused` or `reconnecting` are buffered in a capped queue (default 128 events, `eventPolicy.maxBufferedEvents`). When the buffer is full, the oldest event is silently discarded. On transition to `closed`, all buffered events are discarded. *** ## See Also * [Concepts — State Machine](concepts.md#state-machine) * ADR-013: NATS-style event pattern syntax * `docs/protocols/rpc/envelope.md` — Notification wire format (`t: "N"`) * `docs/sdk/peer.md` §6.6 — Events interface RFC --- --- url: /guide.md --- # Getting Started Sideband is a secure communication stack for TypeScript applications. ## Zero-Infrastructure Start The fastest way to get a browser talking to a local daemon — no infrastructure required: ```bash npx sideband ``` This starts a relay-connected daemon and prints a Quick Connect URL (`https://sideband.cloud/connect#qc=...`). Open it in any browser to connect and call RPC methods over E2EE in under 30 seconds. Requires an API key from [sideband.cloud](https://sideband.cloud) — run `npx sideband init --api-key sbnd_dak_...` once to save it. See the [CLI README](https://github.com/sidebandtech/sideband/tree/main/packages/cli) for full usage (`--json` mode, built-in RPC methods, QC renewal). *** ## SDK Installation For integrating Sideband into your own application: ```bash bun add @sideband/peer ``` ## Quick Example ```typescript import { listen } from "@sideband/peer/server"; import { createPeer } from "@sideband/peer"; // Server: register RPC handlers, push events to clients const server = await listen({ endpoint: "ws://0.0.0.0:8080", onConnection(peer) { peer.rpc.handle<{ path: string }, { content: string }>( "file.read", async ({ path }) => ({ content: await Bun.file(path).text() }), ); }, }); // Client: call RPCs, subscribe to events const peer = createPeer({ endpoint: "ws://localhost:8080" }); await peer.connect(); const { content } = await peer.rpc.call<{ content: string }>("file.read", { path: "./README.md", }); ``` ## Guides Recommended order for new users: | Guide | Description | | ------------------------------- | ------------------------------------------------------------- | | [Concepts](concepts.md) | 1) Mental model: peers, sessions, subjects, state machine | | [RPC](rpc.md) | 2) Request/response patterns, typed clients, errors, timeouts | | [Events](events.md) | 3) Fire-and-forget events, NATS pattern subscriptions | | [Server](server.md) | 4) `listen()`, ConnectedPeer, connection management | | [E2EE Relay](e2ee.md) | 5) End-to-end encrypted sessions via SBRP and TOFU | | [Testing](testing.md) | 6) LoopbackTransport and integration testing patterns | | [Self-Hosting](self-hosting.md) | Relay architecture and self-hosted deployment | ## Packages | Package | Description | | ------------------------ | -------------------------------------------------------- | | `@sideband/protocol` | Wire format, frame types, encode/decode | | `@sideband/transport` | Transport interface and shared utilities | | `@sideband/runtime` | Peer lifecycle, routing, subscriptions | | `@sideband/rpc` | Typed RPC layer | | `@sideband/peer` | High-level SDK | | `@sideband/secure-relay` | E2EE relay protocol | | `@sideband/cloud` | `relay.sideband.cloud` integration (`connect`, `listen`) | | `sideband` | CLI — `npx sideband` zero-infrastructure daemon | --- --- url: /runtime/router.md --- # Message Routing > **Authority**: Primary (Behavioral)\ > **Purpose**: Normative behavioral contract for handler registration, dispatch ordering, and error propagation. The router dispatches decoded `MessageFrame` payloads to registered handlers. It has no knowledge of transport connections or session encryption. See ADR-011 for the full normative specification. ## Layer Boundary | Layer | Responsibility | Does NOT handle | | ------- | --------------------------------------- | -------------------------- | | Session | Negotiation, encryption, session state | Message dispatch, handlers | | Router | Handler registry, dispatch, correlation | Connection management | | Handler | Application logic | Frame framing or routing | *** ## Subject Classification Every inbound `MessageFrame` is classified by subject before dispatch: | Subject | Kind | Dispatch mode | | -------- | ---------- | ------------------------------------------------------------- | | `rpc` | `rpc` | `exclusive` — first matching handler only; handler MUST reply | | `event` | `event` | `broadcast` — all matching handlers; no reply | | `stream` | `reserved` | Rejected — `ErrorFrame{code: 1003}` | | `app/*` | `custom` | `broadcast` by default; configurable | Subjects are validated against the canonical subject policy (ADR-008). Subject validation is separate from handler presence: unhandled RPC requests map to RPC "method not found", while unhandled events/custom subjects are dropped after classification. *** ## Dispatch Ordering 1. Exact-match handlers take priority over prefix handlers. 2. Among prefix handlers, longer prefix wins. 3. Within the same bucket, handlers are called in registration order. 4. In `exclusive` mode (RPC), only the first matching handler is invoked. 5. In `broadcast` mode (event, custom), all matching handlers are invoked sequentially. *** ## Handler Registration The runtime `Router` itself is not session-scoped. Handlers stay registered until explicitly unsubscribed or `router.clear()` is called; callers may layer session-scoped cleanup on top. ```typescript // Register for the session lifetime const unsubscribe = router.route("rpc", rpcDispatchHandler); // Prefix match (app/*) const unsubscribePrefix = router.routePrefix("app/", customHandler); // Deregister unsubscribe(); unsubscribePrefix(); ``` Registering the same method twice is allowed at the router level — the `"rpc_method_already_registered"` error is enforced by the `Peer` SDK layer, not the router. *** ## Inbound Message Shape Every handler receives an `InboundMessage`: ```typescript interface InboundMessage { readonly subject: Subject; readonly payload: Uint8Array; readonly peerId: PeerId; readonly session: Session; readonly frame: Readonly; // read-only: frameId must not be reused send(subject: Subject, data: Uint8Array): Promise; // fresh frameId always generated readonly rpc?: RpcContext; // present only when subject === "rpc" and envelope is valid } interface RpcContext { readonly method: string; readonly params: unknown; readonly cid: FrameId; // echo unchanged in response reply(result?: unknown): Promise; error(code: number, message: string, data?: unknown): Promise; } ``` *** ## RPC Dispatch Rules * The `rpc` subject is `exclusive` — only one handler matches per request. * The handler MUST call `rpc.reply()` or `rpc.error()` exactly once. Failing to reply causes the caller to wait until its timeout fires. * If the message has subject `rpc` but an invalid RPC envelope, the router rejects it with `ErrorFrame{code: 1002}` (see `docs/protocols/rpc/`). * `cid` is the request's correlation ID — it MUST be echoed in the response. `frameId` is hop-local and is never used for RPC correlation (see ADR-010). *** ## Event Dispatch Rules * The `event` subject is `broadcast` — all registered handlers receive the message. * Handler errors do not abort dispatch to remaining handlers. Errors are forwarded through the router/runtime error handling pipeline. * No reply is sent for event messages. If a handler calls `send()`, it creates a new independent message, not a response. *** ## Invariants * `send()` always generates a fresh `frameId`. The `frame` field is read-only to prevent accidental frameId reuse (see ADR-004, ADR-007). * `reservedChannels` always take precedence over `allowedChannels`. * Event handler errors are logged and do not abort dispatch to remaining handlers. * Handler lifecycle is explicit (`unsubscribe` / `clear`) unless a higher layer adds session cleanup behavior. * `cid` is echoed unchanged from request to response; `frameId` is hop-local and never reused for correlation. *** ## Full Specification See [ADR-011](../adr/011-runtime-message-routing.md) for the complete normative spec, including the `Router` interface, `SubjectPolicy`, and error propagation details. ## Related Documents * ADR-008: Channel Subject Validation * ADR-010: RPC Correlation via `cid` * ADR-011: Runtime Message Routing (normative) * `docs/protocols/rpc/envelope.md` — RPC wire format * `docs/runtime/session.md` — Session lifecycle (caller-managed integration with router) --- --- url: /architecture/packages.md --- # Package Architecture Package boundaries and dependency direction for the current Sideband workspace. ## Design Principles 1. Core remains topology- and transport-agnostic. 2. Session cryptography is isolated from runtime/transport. 3. Public package APIs prioritize correctness and typing over feature breadth. 4. Higher-level packages compose lower-level ones; lower layers never depend upward. ## Terminology | Term | Context | Meaning | | --------------------- | ------------------------ | ------------------------------------------------------------------ | | Cryptographic session | `@sideband/secure-relay` | Handshake state, traffic keys, sequence/replay state | | Runtime connection | `@sideband/runtime` | Logical link over an attached transport | | Peer session | `@sideband/peer` | SDK-level lifecycle abstraction over runtime + optional negotiator | ## Layered View | Layer | Package(s) | Responsibility | | -------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | Layer 0: Wire Contract | `@sideband/protocol` | Canonical frame/types/constants + codecs | | Layer 1: I/O | `@sideband/transport`, `@sideband/transport-ws` | Transport ABI + WebSocket transport | | Layer 2: Message Semantics | `@sideband/rpc` | Typed RPC envelope/codec model | | Layer 3: Coordination | `@sideband/runtime` | Peer lifecycle, routing, transport attachment | | Layer 4: Session Crypto | `@sideband/secure-relay` | SBRP handshake/encryption/replay protection | | Layer 5: SDK | `@sideband/peer` | User-facing composition of runtime, transport, negotiators | | Tooling | `sideband`, `@sideband/testing` | Developer CLI and test helpers | `@sideband/runtime` and `@sideband/secure-relay` are sibling layers: neither depends on the other. ## Package Reference ### `@sideband/protocol` * Provides: canonical protocol types/constants, frame codecs, type guards. * Does not provide: I/O, transport implementations, cryptography, runtime/session logic. * Runtime dependencies: none. ### `@sideband/transport` * Provides: transport interfaces and shared transport utilities. * Does not provide: concrete network transport stacks, runtime/session logic. * Runtime dependencies: `@sideband/protocol`. ### `@sideband/transport-ws` * Provides: browser + Node/Bun WebSocket transport implementation. * Does not provide: runtime orchestration or cryptographic session behavior. * Runtime dependencies: `@sideband/protocol`, `@sideband/transport`, `ws`. ### `@sideband/rpc` * Provides: typed RPC envelope model and JSON codec helpers. * Does not provide: transport delivery or encryption. * Runtime dependencies: `@sideband/protocol`. ### `@sideband/runtime` * Provides: transport attachment, peer lifecycle, message routing, middleware hooks. * Does not provide: concrete transport implementations or cryptographic session primitives. * Runtime dependencies: `@sideband/protocol`, `@sideband/transport`, `@sideband/rpc`. ### `@sideband/secure-relay` * Provides: SBRP handshake, key derivation, frame codecs, encryption/decryption, replay protection. * Does not provide: WebSocket/network I/O, token issuance/validation, relay server behavior. * Runtime dependencies: `@noble/ciphers`, `@noble/curves`, `@noble/hashes`. ### `@sideband/peer` * Provides: high-level SDK that composes runtime + transport + optional relay negotiators. * Does not provide: base wire contract definitions or low-level crypto primitives. * Runtime dependencies: `@sideband/protocol`, `@sideband/rpc`, `@sideband/runtime`, `@sideband/transport`, `@sideband/transport-ws`. * Optional peer dependency: `@sideband/secure-relay` (for SBRP negotiators). ### `@sideband/cloud` * Provides: relay.sideband.cloud integration — `connect()` (client) and `listen()` (daemon). `listen()` returns `CloudServer` (extends `PeerServer`) with `daemonId`, `relayUrl`, and `createQuickConnect()`. Also exports `renewPresenceToken` and `extractDaemonIdFromToken`. * Does not provide: protocol definitions or cryptographic primitives. * Runtime dependencies: `@sideband/peer`, `@sideband/secure-relay`, `@sideband/transport-ws`. ### `sideband` * Provides: daemon CLI (`npx sideband`) — connects to relay.sideband.cloud and prints a Quick Connect URL for zero-infrastructure remote access. Wraps `@sideband/cloud`'s `listen()`. * Runtime dependencies: `@sideband/cloud`, `@sideband/secure-relay`. ### `@sideband/testing` * Provides: shared test fixtures/fakes/helpers for workspace packages. * Runtime dependencies: package-local only; intended for test-only usage. ## Current Workspace Packages | Package | Role | | ------------------------ | -------------------------------- | | `@sideband/protocol` | Wire contract | | `@sideband/transport` | Transport ABI | | `@sideband/transport-ws` | WebSocket transport | | `@sideband/rpc` | RPC envelope layer | | `@sideband/runtime` | Runtime coordination engine | | `@sideband/secure-relay` | SBRP cryptographic session layer | | `@sideband/peer` | User-facing SDK | | `@sideband/cloud` | relay.sideband.cloud integration | | `sideband` | Daemon CLI (Quick Connect) | | `@sideband/testing` | Test helpers | ## Dependency Direction ```text @sideband/protocol ├─> @sideband/transport ──> @sideband/transport-ws ├─> @sideband/rpc ├─> @sideband/runtime └─> @sideband/peer @sideband/transport ──> @sideband/runtime @sideband/transport ──> @sideband/peer @sideband/rpc ──> @sideband/runtime @sideband/rpc ──> @sideband/peer @sideband/runtime ──> @sideband/peer @sideband/secure-relay (optional peer dependency of @sideband/peer) @sideband/peer ──> @sideband/cloud @sideband/secure-relay ──> @sideband/cloud @sideband/transport-ws ──> @sideband/cloud @sideband/cloud ──> sideband (cli) @sideband/secure-relay ──> sideband (cli) ``` ## Relay Composition (SBRP + SBP) In relay mode, SBRP transports encrypted SBP payloads. Integration ownership is split: 1. `@sideband/transport-ws`: WebSocket transport. 2. `@sideband/secure-relay`: handshake + payload crypto. 3. `@sideband/protocol`: inner application frames. 4. `@sideband/rpc`: request/response envelopes inside protocol messages. 5. `@sideband/peer`: user-facing orchestration. ## Summary * `@sideband/protocol` is the canonical wire contract. * `@sideband/runtime` coordinates transport and message flow. * `@sideband/secure-relay` is cryptographic/session logic only. * `@sideband/peer` composes runtime with optional relay negotiators. --- --- url: /architecture/project-structure.md --- # Project Structure What lives where, who depends on whom, and where to start. ## Layout ```text . ├─ docs/ # ADRs, specs, architecture notes ├─ examples/ # Usage samples (when present) ├─ packages/ # Implementation code │ ├─ protocol/ │ ├─ transport/ │ ├─ runtime/ │ ├─ rpc/ │ ├─ peer/ │ ├─ secure-relay/ # E2EE relay protocol (SBRP) │ ├─ testing/ # Test helpers │ ├─ transport-ws/ # WebSocket transport (browser + Node/Bun) │ ├─ cloud/ # relay.sideband.cloud integration │ └─ cli/ # Daemon CLI (npx sideband) ├─ README.md # Top-level overview ├─ package.json # Workspace root ├─ tsconfig.base.json # Shared TS options └─ tsconfig.json # Root project config ``` Optional root files (not shown): AI helper notes (`CLAUDE.md`), lockfiles (`bun.lock`), lint/format configs. ## Package roles and boundaries `@sideband/protocol` — Sideband Protocol (SBP) • Canonical message/frame types, constants, error codes; light encode/decode helpers. • No I/O or runtime logic. Topology-agnostic (used by both relay and direct modes). • Depends on: none. Used by: all. `@sideband/transport` — Transport ABI + shared utilities\ • Defines the `Transport` interface and common transport types/utilities (e.g., reconnect/backoff helpers, optional loopback transport).\ • No environment-specific code.\ • Depends on: protocol. Used by: runtime, testing, all concrete transports. `@sideband/runtime` — Transport-agnostic runtime • Manages peers, attaches transports, decodes frames, routes messages, correlates requests/responses, middleware hooks. • No concrete transport or UI/framework code. • Depends on: protocol, transport, rpc. Must not depend on concrete transports/cli. `@sideband/rpc` — Typed RPC envelope layer • Maps method names to input/output shapes; request/response/notification helpers and JSON codec. • Depends on: protocol. No transport coupling. `@sideband/peer` — Peer SDK • Friendly API over runtime + injected or default transport; lifecycle utilities; convenience pub/sub/RPC helpers. • Depends on: protocol, rpc, runtime, transport, transport-\* (as chosen). Optional peer dependency: secure-relay. • No protocol definitions or low-level routing. `@sideband/transport-ws` — WebSocket transport • Implements the Transport interface via WebSocket for both browser and Node/Bun. • Conditional exports: `./browser` for browsers, `./node` for Node/Bun server-side. • Depends on: protocol, transport. Must not depend on runtime/rpc/peer. `@sideband/secure-relay` — Sideband Relay Protocol (SBRP) • E2EE handshake, session encryption/decryption, TOFU identity pinning, replay protection. • Enables secure browser↔daemon communication via untrusted relay servers. • Standalone cryptographic layer; no I/O or transport code. • Depends on: none (uses @noble/\* for crypto). Used by: relay implementations, browser/daemon apps. `@sideband/cloud` — relay.sideband.cloud integration • `connect()` (client) and `listen()` (daemon). `listen()` returns `CloudServer` with `daemonId`, `relayUrl`, and `createQuickConnect()` for zero-infrastructure Quick Connect URLs. • Depends on: peer, secure-relay, transport-ws. `sideband` — Daemon CLI • `npx sideband` starts a relay-connected daemon and prints a Quick Connect URL. Wraps `@sideband/cloud`'s `listen()`. • Depends on: cloud, secure-relay; does not define protocol or runtime behavior. `@sideband/testing` — Test scaffolding\ • Central place for fakes, loopback transports, peer fixtures, and shared test helpers.\ • Keeps test utilities out of runtime packages; avoids circular devDeps.\ • Depends on: protocol, transport, and runtime packages as needed. Only imported from tests (never production paths). Dependency flow (allowed edges only): ```mermaid graph TD protocol --> runtime protocol --> rpc rpc --> runtime protocol --> transport transport --> runtime protocol --> transport_ws transport --> transport_ws protocol --> peer transport --> peer rpc --> peer runtime --> peer transport_ws --> peer secure_relay --> peer peer --> cloud secure_relay --> cloud transport_ws --> cloud cloud --> cli secure_relay --> cli ``` ## Dependency policy * **`dependencies`** — anything your published JS imports at runtime. All internal `@sideband/*` imports go here. Each package must be installable in isolation without consumers manually wiring internal pieces. * **`peerDependencies`** — only for host-provided integrations or optional feature adapters the consumer chooses to install (e.g., `@sideband/secure-relay` in peer). * Never use peer dependencies just to deduplicate installs across the monorepo; package managers already deduplicate compatible `^` ranges. ## Conventions * Language: TypeScript; ESM (`type: "module"`). * Each package has its own `package.json` and `tsconfig.json` extending `tsconfig.base.json`. * Tests/config/docs live with the package they describe. * Keep new code within the package that owns the responsibility above; update this file when roles or dependencies change. --- --- url: /protocols/stack.md --- # Protocol Architecture > **Authority**: Primary (Normative)\ > **Purpose**: Layer boundaries, frame wrapping rules, and dependency invariants. Final arbiter when layer specs conflict. This document defines frame wrapping rules, dependency invariants, and layer boundaries. Lower-level details (frame encoding, cryptography, RPC envelopes) are documented in referenced specifications. This document is the final arbiter of layer architecture; conflicts indicate bugs in lower-level specs which MUST be updated to comply. ## Document Conventions * **Normative**: Sections using MUST/SHOULD/MAY define required behavior * **Non-normative**: Examples, notes, and diagrams illustrate but do not constrain implementations ## Layer Stack ```text ┌─────────────────────────────────────────────────────────────────────┐ │ Application / SDK │ │ (@sideband/peer, @sideband/cloud, sideband CLI) │ ├─────────────────────────────────────────────────────────────────────┤ │ RPC Layer │ │ Typed request/response/notification semantics │ │ Lives inside: MessageFrame.data │ ├─────────────────────────────────────────────────────────────────────┤ │ SBP (Sideband Protocol) │ │ Application framing: Control, Message, Ack, Error │ │ Topology-agnostic, no encryption, no transport │ ├─────────────────────────────────────────────────────────────────────┤ │ Session Layer │ │ ├─ SBRP (relay mode): E2EE via relay server │ │ └─ SBDP (direct mode): P2P encryption (future) │ ├─────────────────────────────────────────────────────────────────────┤ │ Transport │ │ Any ordered transport with integrity (WebSocket, QUIC streams) │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Data Flow (Non-normative) When a client sends an RPC request through a relay: ```text 1. Application creates RPC request → { t: "r", m: "getUser", p: {...} } 2. RPC encodes to JSON (default) or CBOR → bytes 3. SBP wraps in MessageFrame → [header][frameId][subject][data] 4. SBRP encrypts complete SBP frame → [nonce][ciphertext][authTag] 5. SBRP wraps in Data frame (0x03) → [type][length][sessionId][encrypted] 6. Transport sends over WebSocket → binary message ``` Encoding is negotiated during handshake via capabilities (default: JSON). The encrypted blob in step 4 contains the full SBP frame bytes (header + frameId + payload). ## Frame Wrapping Rules These rules are normative. Violations indicate implementation bugs. ### Rule 1: SBP Frames Are Application-Visible SBP frames (`ControlFrame`, `MessageFrame`, `AckFrame`, `ErrorFrame`) are the only frames that application code interacts with. Session layers and transports are invisible to applications. ### Rule 2: Session Layers Treat SBP Frames as Opaque Payloads SBRP and SBDP MUST treat the entire SBP frame (including header, frameId, and payload) as an opaque byte sequence. Session layers: * MUST NOT parse SBP frame structure * MUST NOT inspect MessageFrame subjects or data * MUST NOT depend on SBP semantics for routing decisions * MUST NOT rewrite any SBP frame field (including frameId) **SBRP Data frames (type 0x03) carry encrypted SBP frames.** This includes SBP `ControlFrame` (handshake, ping, pong), `MessageFrame` (application data), `AckFrame`, and `ErrorFrame`. After decryption, endpoints deserialize the plaintext using `@sideband/protocol` codecs. SBRP never inspects encrypted payload contents. **Forwarding invariant**: SBRP relays forward encrypted SBP frames without modification; they never terminate SBP links. If a separate component (an SBP proxy) terminates an SBP link and re-originates frames on a new link, it MUST generate new frameIds on the outbound link—this creates two back-to-back SBP sessions, not frame forwarding. ### Rule 3: RPC Envelopes Are Invisible to All Lower Layers RPC envelopes exist only inside `MessageFrame.data`. They are: * Invisible to SBP framing (SBP routes by `subject` but does not interpret `data` contents) * Invisible to session layers (which see encrypted SBP frames) * Invisible to transports (which see binary messages) ### Rule 4: Frame Identity Is Layer-Scoped | Layer | Identity Field | Wire Key | Scope | Reused Across Layers? | | ----- | -------------- | -------- | ------------------------- | --------------------- | | SBP | `frameId` | `id` | Sender-local unique | No | | SBRP | `SessionID` | (header) | Relay routing multiplexer | No | | RPC | `cid` | `cid` | End-to-end correlation | No | **frameId**: Identifies frames at their origin; never rewritten by SBRP relays (see Rule 2). Used for Ack linkage within a single SBP link. Receivers MUST NOT reuse a received frameId in outbound frames. AckFrame generation MAY be performed by the runtime on behalf of the application. **cid**: RPC correlation ID copied from request to response. Preserved end-to-end through relays. Enables multi-hop routing while keeping SBP topology-agnostic. **SessionID**: SBRP-specific uint64 in frame headers for relay multiplexing. Distinct from JWT `sid` claim used for authentication (see Session terminology below). See ADR-010 for detailed rationale on frameId vs cid separation. ## Dependency Invariants These invariants MUST be preserved. Violations require an ADR to justify. ### SBP Dependencies SBP MUST NOT depend on: * Session layer semantics (SBRP, SBDP) * Transport guarantees (ordering, delivery) * RPC envelope structure SBP MAY be used directly over any byte-stream transport without a session layer (e.g., loopback testing, trusted networks). ### RPC Dependencies RPC MUST NOT depend on: * Session layer semantics * Transport guarantees * Encryption status (RPC works identically encrypted or plaintext) RPC depends only on SBP `MessageFrame` for framing. ### Session Layer Dependencies SBRP and SBDP: * MUST NOT inspect SBP payload contents * MUST NOT depend on RPC semantics * MUST NOT initiate SBP frames (application or runtime generates all SBP frames) * MUST treat received SBP frames as opaque and forward unchanged * MAY have their own control messages (e.g., SBRP `RelayNotification` frames) that are session-layer scoped, not SBP-scoped ### Transport Dependencies Transports (`@sideband/transport-*`): * MUST NOT assume SBP semantics * MUST NOT parse any layer above raw bytes * MUST NOT generate frames for any layer ### Session Output Contract (Normative) A session protocol (e.g., SBRP, SBDP) MUST present the runtime with a channel that: 1. Delivers complete, decrypted SBP frames on the inbound path 2. Accepts outbound SBP frames for encryption and transmission This contract may be implemented: * **Via Negotiator**: The `Negotiator.negotiate()` method returns a `NegotiationResult` with an optional `channel` field. If provided, the runtime uses this wrapped connection for all subsequent frame I/O. The negotiator handles key exchange and returns a channel that transparently encrypts/decrypts. * **Via Transport Adapter**: A custom transport embeds session logic internally, exposing decrypted SBP frames directly. This approach is simpler but couples session and transport layers. The runtime MUST NOT depend on which implementation approach is used. Both satisfy the contract. ## Error Code Ownership See [Error Code Registry](./error-codes.md) for the canonical code definitions. | Range | Owner | Carrier | Purpose | | --------- | -------- | ---------- | -------------------------------- | | 1000–1099 | SBP | ErrorFrame | Frame/handshake/namespace faults | | 1100–1199 | RPC | RpcError | Envelope/method/routing faults | | 1200–1999 | Reserved | — | Future protocol extensions | | 2000+ | App | RpcError | User-defined application errors | Implementations MUST NOT define codes outside their owned range. SBRP uses a separate Control code namespace (0x01xx–0x10xx); these are not SBP/RPC error codes. ## Error Scope and Transport Authority Error scope is determined by wire carrier: * **SBP `ErrorFrame` (kind=3)**: Connection-scoped. Receivers apply fatality rules per `sbp/errors.md`. * **RPC `RpcError` (t="E")**: Request-scoped. MUST NOT trigger transport close. ### Escalation Errors that cannot be routed to a request are connection-scoped by definition. If an RPC envelope cannot be parsed or correlated, emit `ErrorFrame`; receivers apply fatality rules per `sbp/errors.md`. If the envelope is valid but the method fails, respond with `RpcError`. ## Terminology Disambiguation ### "Control" at Different Layers Two distinct constructs use "Control" terminology: | Layer | Construct | Wire Type | Encrypted? | Purpose | | ----- | ------------------- | --------- | ---------- | ------------------------------- | | SBP | `ControlFrame` | kind=0 | Yes (E2EE) | Handshake, Ping, Pong, Close | | SBRP | `RelayNotification` | 0x20 | Never | Relay-to-endpoint notifications | These are unrelated: * **SBP ControlFrame**: Application-level protocol control (peer handshake, keepalive). Travels end-to-end inside SBRP Data frames, encrypted. * **SBRP RelayNotification**: Relay-generated notifications (`session_paused`, `rate_limited`, `backpressure`). Sent plaintext from relay to endpoint only. (Wire type 0x20; historically called "Control frame" but renamed to avoid confusion with SBP ControlFrame.) ### "Session" at Different Layers The unqualified term "session" is ambiguous and SHOULD NOT be used in specifications or code. Use these qualified terms: | Context | Qualified Term | Type / Encoding | Meaning | | ------------------- | -------------- | ----------------------- | ----------------------------------------------- | | SBRP wire format | `SessionID` | uint64 (big-endian) | Relay routing multiplexer; wire layer only | | SBRP authentication | `SessionToken` | JWT with `sid` claim | Auth binding; relay validates against SessionID | | SBRP cryptography | `SessionState` | Binary (keys, counters) | E2EE encryption per client; endpoint-only | | SBP | (none) | — | SBP has no session concept | The JWT `sid` claim is the base64url-encoded 8-byte representation of SessionID uint64, enabling relays to validate that client frames' SessionID field matches the token. ### "Handshake" at Different Layers | Layer | Construct | Wire Type | Encrypted? | Purpose | | ----- | --------------------------- | ------------ | ---------- | ---------------------------------------- | | SBP | ControlFrame (op=Handshake) | kind=0, op=0 | Yes (E2EE) | Peer introduction (peerId, capabilities) | | SBRP | HandshakeInit/Accept | 0x01, 0x02 | Never | E2EE key exchange (X25519/Ed25519) | These are unrelated: * **SBP Handshake**: Application-level peer introduction. Sent encrypted end-to-end within SBRP Data frames once a secure SBP-carrying channel is established. * **SBRP Handshake**: Cryptographic key exchange. Sent plaintext during E2EE channel establishment. Relay forwards without inspection. ### "Ping/Pong" at Different Layers | Layer | Construct | Encrypted? | Purpose | Forwarded? | | ----- | ---------------- | ---------- | ----------------------------------- | ---------- | | SBP | `ControlOp.Ping` | Yes (E2EE) | Application-level keepalive | Yes | | SBRP | `Ping` (0x10) | Never | Connection-level liveness detection | Never | SBP Ping/Pong are routable like Messages and encrypted by session layers for end-to-end liveness detection. SBRP Ping/Pong are unencrypted and connection-scoped. ## Session Resumption Session resumption semantics differ by role: | Role | Resumption Support | Behavior | | ------ | ------------------ | --------------------------------------------- | | Client | Not supported | Reconnect requires full handshake (new token) | | Daemon | SBRP only | Resumable via `Signal(ready)` state machine | Runtime core does not require resumable sessions. Specific session layers (e.g., SBRP) MAY define resumable session semantics. When using SBRP, the session implementation (via negotiator-provided channel or transport adapter) MUST implement the SBRP pause/pending/resume state machine; other session implementations MAY ignore resume entirely. ## Registries Canonical sources of truth for protocol constants: | Registry | Location | Purpose | | ---------------- | ------------------------------------------------ | ---------------------------- | | Error codes | [error-codes.md](./error-codes.md) | All SBP/RPC error codes | | Subject prefixes | [rpc/envelope.md](./rpc/envelope.md) | Reserved subject namespaces | | Capability names | [sbp/behavior.md](./sbp/behavior.md) | Handshake capability strings | | SBRP controls | [sbrp/control-codes.md](./sbrp/control-codes.md) | Relay notification codes | Specifications MUST reference registries; they MUST NOT define constants inline. ## v1 Invariants Properties guaranteed in v1 that implementations may rely on: 1. **frameId is always present**: Every SBP frame has a 16-byte frameId, auto-generated at construction. 2. **Session layers are opaque**: Applications never see SBRP/SBDP framing. 3. **Relay is a frame router**: SBRP relay inspects SBRP frame headers (type, length, SessionID) for routing. It never parses encrypted payloads, SBP frames, or RPC envelopes. 4. **Best-effort delivery**: SBP allows senders to request Ack frames, but neither sending nor acknowledging is mandatory. Reconnection may cause retries at higher layers. Applications requiring reliable delivery must implement idempotency via RPC correlation. 5. **No guaranteed ordering**: Message ordering depends on transport. Relays and networks may reorder. Applications requiring strict ordering must add sequence numbers. ## Non-Goals (Explicit Deferrals) These are intentionally not addressed in v1: 1. **Client cryptographic identity**: Clients use ephemeral keys; no persistent client signing keys. 2. **Streaming RPC**: `stream/` subject prefix is reserved but unspecified. 3. **Multi-hop relay chains**: Single relay only. 4. **Cross-session message correlation**: Each session is independent. 5. **SBDP implementation**: Design principles documented; full spec deferred. ## Related Documents | Document | Authority | Purpose | | ------------------------------------------------------------------ | ---------------------- | -------------------------------------- | | [SBP Specification](./sbp/) | Normative (SBP layer) | Frame encoding, wire format, routing | | [SBRP Specification](./sbrp/) | Normative (SBRP layer) | Session crypto, relay responsibilities | | [SBDP Specification](./sbdp/) | Design only (future) | P2P session layer principles | | [RPC Specification](./rpc/) | Normative (RPC layer) | Envelope format, correlation | | [ADR-002: Naming Matrix](../adr/002-naming-matrix.md) | Canonical | Type/field names across all layers | | [ADR-009: Session Lifecycle](../adr/009-runtime-peer-lifecycle.md) | Canonical | Negotiator interface, session states | | [ADR-010: RPC Correlation](../adr/010-rpc-correlation-cid.md) | Canonical | frameId vs cid semantics | | [ADR-011: Message Routing](../adr/011-runtime-message-routing.md) | Canonical | Router, handler dispatch | **Authority hierarchy**: This document defines intended layering and dependencies. Conflicts indicate bugs; lower-level specs MUST be updated to comply. This document is updated only if the architecture itself changes. --- --- url: /protocols/authoring.md --- # Protocol Documentation Guidelines Rules for maintaining consistent, navigable protocol specifications. ## Document Pattern Every protocol MUST have: | Document | Purpose | Authority | | ------------------------------------ | ---------------------------------------------------- | -------------------------- | | `index.md` | Overview, delegation, authority table, reading order | Navigation (non-normative) | | `wire-format.md` or `wire-crypto.md` | Binary encoding, frame structure | Primary | | `behavior.md` or `state-machine.md` | Runtime semantics, state transitions | Primary | | `conformance.md` | Test vectors, validation checklist | Supporting | Optional: `errors.md` (if not delegated), security appendices, extension stubs. ## Authority Banners Every document MUST start with: ```markdown # Document Title > **Authority**: Primary (Normative) | Supporting (Reference) | Navigation (Non-normative) > **Purpose**: One-line description of what this document defines. ``` Use two spaces at the end of a line to create a line break in Markdown. ## Authority Rules 1. **Primary**: Defines canonical rules. Source of truth. Uses MUST/SHOULD/MAY. 2. **Supporting**: References and elaborates. MUST NOT redefine primary content. 3. **Navigation**: Index files only. MUST NOT contain RFC 2119 keywords. **Conflict resolution**: * Same protocol: Primary wins over Supporting * Cross-protocol: `stack.md` governs layer boundaries; lower layer wins * Unresolved: File an issue; do not ship conflicting specs ## Index File Requirements Each protocol's `index.md` MUST include: 1. **Authority banner** (Navigation, non-normative) 2. **Delegation section** — what is inherited vs defined locally 3. **Document Authority table** — maps concerns to Primary/Supporting docs 4. **Recommended Reading Order** — numbered list for implementers Example delegation: ```markdown ## Delegation This protocol delegates: - **Wire format**: Inherits SBP frame structure (see sbp/wire-format.md) - **Error codes**: Reuses SBP errors (1000-1999) This protocol defines: - **Envelope format**: See envelope.md ``` ## Terminology * Define protocol-scoped terms in `index.md` → Local Terminology section * Link to `glossary.md` for cross-protocol terms * Never redefine glossary terms locally ## Stub Documents Mark incomplete documents: ```markdown > **Status: Stub** — This document is non-authoritative until content is added. ``` Stubs MUST NOT contain RFC 2119 keywords (MUST/SHOULD/MAY). ## Cross-Layer Policy `runtime/` and `sdk/` may reference protocol docs via links; they MUST NOT restate or redefine wire-level invariants. See `docs/README.md` for the full authority model and reference direction. ## Content Rules 1. **No duplication**: Define once, reference elsewhere 2. **Clear scope**: Each document has one focused purpose 3. **Cross-references**: Use relative links `[text](./file.md)` 4. **Code examples**: Keep in normative docs, not index files 5. **Diagrams**: Mermaid for sequences, ASCII for frame layouts ## Validation Checklist Before merging protocol changes, verify: * \[ ] All documents have authority banners with purpose line * \[ ] Index files have delegation + authority table + reading order * \[ ] Index files contain no RFC 2119 keywords * \[ ] New terms added to glossary.md or Local Terminology * \[ ] Cross-references use relative paths and resolve correctly * \[ ] Stubs marked with status banner * \[ ] No normative content duplicated across documents ## File Structure ```bash docs/protocols/ ├── authoring.md # This file (guidelines) ├── index.md # Protocol hub + pattern declaration ├── glossary.md # Shared terminology ├── stack.md # Layer boundaries (Primary) ├── / │ ├── index.md # Navigation + delegation │ ├── *.md # Normative/supporting docs ``` --- --- url: /protocols/glossary.md --- # Protocol Glossary > **Authority**: Supporting (Reference)\ > **Purpose**: Canonical definitions for cross-protocol terminology. Terms defined here are authoritative. Protocol-specific index.md files SHOULD link here and MAY include a Local Terminology section for protocol-scoped terms. ## Identity & Session | Term | Definition | | ------------ | ------------------------------------------------------------------- | | PeerId | Stable identity of a peer node; survives reconnects | | SessionId | 8-byte identifier for a relay session (SBRP); bound to token claims | | FrameId | 16 opaque bytes identifying a frame instance on wire | | ConnectionId | Transient transport link identity; new per TCP/WebSocket | ## Cryptographic | Term | Definition | | ------------- | -------------------------------------------------------------------------------------- | | TOFU | Trust-On-First-Use identity pinning; client pins daemon public key on first connection | | Identity Key | Long-lived Ed25519 keypair for daemon authentication | | Ephemeral Key | Per-session X25519 keypair for key exchange | | Session Key | Derived HKDF key for symmetric encryption | | Replay Window | Bitmap-based sliding window for sequence validation | ## Framing | Term | Definition | | --------- | --------------------------------------------------------------------------- | | Frame | Protocol data unit with header + payload; see FrameKind for variants | | FrameKind | Discriminant enum: Control, Message, Ack, Error (SBP) or frame types (SBRP) | | Envelope | RPC structure inside MessageFrame.data; see RPC envelope spec | | Payload | Frame body after the header; semantics depend on frame type | ## Protocol Layers | Term | Definition | | ---- | --------------------------------------------------------------- | | SBP | Sideband Protocol - topology-agnostic application framing | | SBRP | Sideband Relay Protocol - E2EE sessions via relay server | | SBDP | Sideband Direct Protocol - P2P sessions (future) | | RPC | Request/response/notification semantics inside SBP MessageFrame | ## SBRP-Specific | Term | Definition | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | Relay | Routing authority and token validator; not an encryption endpoint | | Client | Session initiator with ephemeral keys; no persistent crypto identity | | Daemon | Long-lived agent with Ed25519 identity; reachable via relay | | Control Frame (SBRP) | Relay-generated notifications (0x20); distinct from SBP ControlFrame | | Signal Frame | Daemon-to-relay session lifecycle commands (ready/close) | | Nonce Monotonicity | Sequence numbers must strictly increase per direction | | Throttling | Traffic control category (0x09xx); covers rate-limiting (non-terminal: back off and retry) and backpressure (terminal: slow consumer closed) | | Backpressure | Terminal relay signal (0x0902) closing a connection whose send buffer is full; distinct from `rate_limited` (0x0901) which is non-terminal | --- --- url: /protocols.md --- # Protocols Sideband is a layered communication stack for browser-to-daemon communication, designed to work reliably behind NAT with end-to-end encryption. > These specifications are implementation-neutral. You may implement them in any language or runtime, including proprietary systems, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) terms. The wire format uses standard binary encoding (little-endian, length-prefixed) and widely supported cryptographic primitives (ChaCha20-Poly1305, X25519, Ed25519). The reference implementation and SDK are TypeScript. ## Architecture **Start here**: [Protocol Architecture](./stack.md) defines layering, frame wrapping rules, and dependency invariants. All specifications below must be consistent with the architecture document. ```text ┌───────────────────────────────────────────────────┐ │ Application / SDK │ ├───────────────────────────────────────────────────┤ │ RPC (semantic envelopes inside MessageFrame.data) │ ├───────────────────────────────────────────────────┤ │ SBP (application framing, routing, correlation) │ ├───────────────────────────────────────────────────┤ │ Session: SBRP (relay E2EE) or SBDP (direct P2P) │ ├───────────────────────────────────────────────────┤ │ Transport (WebSocket, WebRTC, etc.) │ └───────────────────────────────────────────────────┘ ``` | Layer | Protocol | Status | Purpose | | -------------- | -------- | ------ | -------------------------------------- | | App Framing | SBP | v1 | Framing, multiplexing, message routing | | Relay Session | SBRP | Draft | E2EE sessions via relay server | | Direct Session | SBDP | Design | P2P encryption (future) | | Semantic | RPC | v1 | Typed request/response patterns | ::: info RPC envelopes live inside `MessageFrame.data`. Session layers (SBRP/SBDP) encrypt entire SBP frames; they never inspect RPC content. See [architecture](./stack.md) for wrapping rules. ::: ## SBP (Sideband Protocol) Topology-agnostic framing used by all session layers. Defines frame types, message routing, and wire format. [Read the SBP specification →](./sbp/) ## SBRP (Sideband Relay Protocol) Default transport: secure, relay-based sessions with E2EE. The relay never sees plaintext. [Read the SBRP specification →](./sbrp/) ## SBDP (Sideband Direct Protocol) Future: direct P2P when both peers can establish a connection without relay. [Read the SBDP specification →](./sbdp/) ## RPC Typed request/response/notification semantics inside SBP message frames. [Read the RPC specification →](./rpc/) --- --- url: /protocols/sbrp/authentication.md --- # Relay Authentication > **Authority**: Primary (Normative)\ > **Purpose**: Token-based authentication contract between control plane and relay data plane. ## Architecture Separation SBRP separates concerns between two planes: | Plane | Responsibility | | ----------------- | ------------------------------------------------------------- | | **Control Plane** | User auth, daemon registry, session brokering, token issuance | | **Data Plane** | Frame routing, presence, token validation | The control plane issues tokens that grant relay access: short-lived **session tokens** for clients and long-lived **presence tokens** for daemons. The relay validates tokens but NEVER issues them. ## Token Claims Tokens are JWTs signed by the control plane. The relay MUST validate these claims: ```typescript interface RelayTokenClaims { // Standard JWT claims iss: string; // MUST match configured issuer aud: string | string[]; // Relay normalizes to array; MUST contain "sideband-relay" exp: number; // Unix timestamp; issuer SHOULD ≤120s for clients (MUST NOT exceed 300s); daemon tokens SHOULD use TTL ≥1h iat: number; // Issued-at timestamp (REQUIRED; absence or non-number makes TTL check bypass via NaN) jti: string; // Unique token ID; issuer MUST include; relay does not validate (audit only) // Schema version (if present, MUST be 1; absent: accepted) ver?: 1; // Identity sub: string; // Principal: daemon ID (daemon tokens) or user ID (client tokens) role: "daemon" | "client"; // SBRP routing did: string; // Daemon routing target (REQUIRED for both roles) sid?: string; // Session ID base64url(uint64 ≠ 0); REQUIRED for clients // Capabilities — relay behavior MUST be based on scp; absent treated as []; unknown scopes MUST be ignored scp?: string[]; // Optional region?: string; // Relay region binding (hard fail if mismatch) lim?: { concurrent_sessions?: number }; // Effective cap; absent = plan default; if present, MUST be positive integer ≥ 1 } ``` ## Capability Scopes The `scp` claim lists capabilities granted to the token holder. Relay behavior decisions MUST be based on `scp`; no implicit defaults apply after validation. | Scope | Applicable Role | Description | | ------------------ | --------------- | -------------------------------------------------------------------------- | | `"session:create"` | client | Client may initiate sessions (always present in client tokens) | | `"session:resume"` | daemon | Daemon may resume paused sessions on reconnect (deny-by-default if absent) | **Deny-by-default**: A daemon token without `"session:resume"` in `scp` is treated as non-resumable. **Forward compatibility**: Unknown scopes MUST be ignored. Relay implementations MUST NOT reject tokens containing unrecognized scope strings. The relay automatically sends `Control(session_expired)` to all paired clients upon daemon reconnect, without waiting for Signal frames. ## Validation Rules The relay MUST validate in this order: **Phase 1 — Header** (unauthenticated, but cheap to check before signature work) 1. Extract token from Authorization header or `?token` query parameter; reject if token length > 4096 or if not exactly 3 dot-separated parts (malformed tokens → 401, not 500) 2. Decode JWT header; reject if `typ` ≠ `"sbrp-relay+jwt"` (missing or wrong value) 3. Extract `kid` from header; reject if missing **Phase 2 — Cryptographic** 4. Verify JWT signature using JWKS from control plane (using `kid`); MUST restrict accepted algorithms to `["EdDSA"]` — prevents algorithm substitution if a non-EdDSA key enters the JWKS pool **Phase 3 — Payload** (all claims are authenticated after this point) 5. Normalize `aud` string→array; verify it contains `"sideband-relay"` 6. Reject `iss` ≠ configured issuer 7. Reject if `iat` or `exp` is missing or not a number (prevents silent NaN bypass in TTL check) 8. Reject `exp` < current time (with ≤30s clock skew tolerance) 9. If `ver` present: reject if ≠ `1` (absent: accept, emit metric) 10. Reject if `role` is missing or not in `{"daemon","client"}` 11. Reject if `did` is missing, not a string, or empty 12. If `role === "client"`: reject if `sub` is missing, not a string, or empty; reject if `sid` is missing, not a string, empty, does not base64url-decode to exactly 8 bytes, or decodes to zero 13. If `region` claim is present: reject if it doesn't match relay's configured region 14. If `role === "client"`: reject if `exp − iat > 300` (warn if `> 120`) 15. If `scp` present: reject if not an array or if any element is not a string 16. If `lim.concurrent_sessions` present: reject if not a positive integer ≥ 1 The relay MUST NOT: * Track `jti` values for revocation or deduplication (expiration handles token lifetime; audit logging is permitted) * Issue tokens under any circumstances * Disconnect established WebSocket connections due to token expiry (tokens are validated at connection time only; session lifetime is managed by application-level mechanisms) ## Key Rotation The control plane publishes signing keys via a standard JWKS endpoint (`/.well-known/jwks.json`). The relay SHOULD: * Cache JWKS for up to 5 minutes * Refresh JWKS when encountering unknown `kid` (key ID) * Support at least 2 concurrent keys for rotation * Verify the selected JWK has `alg: "EdDSA"` (prevents algorithm substitution if future key types are added) ## Connection Flow ```text ┌─────────┐ ┌─────────────┐ ┌─────────┐ │ Client │ │Control Plane│ │ Relay │ └────┬────┘ └──────┬──────┘ └────┬────┘ │ │ │ │ request session │ │ │ {daemonId} │ │ ├────────────────────►│ │ │ │ │ │ {relayUrl, token} │ │ │◄────────────────────┤ │ │ │ │ │ WSS relayUrl?token= │ │ ├─────────────────────┼────────────────────►│ │ │ │ │ │ validate token │ │ │ pair by sid │ │ │ │ │◄════════════════════ E2EE frames ════════►│ ``` The relay endpoint is a fixed path — the routing key (`daemonId`) is derived exclusively from validated token claims, not from the URL: ``` wss://{region}.relay.sideband.cloud?token= ``` Token delivery: * **Query parameter**: `?token=` (browser WebSocket compatibility) * **Authorization header**: `Authorization: Bearer ` (preferred for programmatic clients) ## Session Binding For client connections, the `sid` (session ID) in the token MUST match the `SessionID` field in session-bound frames sent by the client (`0x01`, `0x03`). Note: `0x02` is daemon→client only; `0x04` (Signal) from clients is rejected as `disallowed_sender` per the main SBRP spec. Daemon presence connections (sid omitted) are exempt—they handle multiple sessions via relay-managed pairing. Relay-generated Control frames (`0x20`) and connection-scoped frames (`0x10`, `0x11`) are not subject to token sid matching. **Session ID format:** * Wire format: 64-bit unsigned integer (big-endian in frame header) * JWT `sid` claim: base64url encoding of the 8-byte big-endian uint64 (no padding) * Non-zero: the decoded uint64 MUST NOT equal 0 (i.e., not all 8 bytes are 0x00) ```text Token claims: { sid: "AAALOnPOL_I", role: "client", did: "d_xyz" } │ ▼ base64url decode → 8 bytes → uint64 Frame header: SessionID = 0x00000B3A73CE2FF2 ``` Session IDs MUST be non-zero for session-bound frames (HandshakeInit, HandshakeAccept, Data, Signal). Control frames use non-zero SessionID for session events, zero for connection errors. Ping and Pong frames MUST use SessionID = 0 (connection-scoped). ## Daemon Presence Tokens Daemons connect with a long-lived presence token that has additional constraints: * `role` = `"daemon"` * `sub` = daemon's own ID (same as `did`) * `sid` is omitted (presence-only connection) * `scp`: include `"session:resume"` to enable session resumption (deny-by-default; absent = non-resumable) * Daemon accepts incoming sessions routed by relay **Non-resumable daemons**: When `"session:resume"` is absent from `scp`, the relay automatically sends `Control(session_expired)` to all paired clients upon daemon reconnect, without waiting for `Signal(ready)` or `Signal(close)`. This simplifies v1 implementations that don't need resumption—the daemon doesn't need to track session IDs or send per-session Signal frames on reconnect. When a client initiates a session, the relay validates the client's session token and routes the `HandshakeInit` frame to the daemon. The daemon identifies the session via the `SessionID` field in the frame header and uses this to key per-client state. The daemon trusts the relay to have validated the client's authorization; it does not receive or verify the client's JWT token directly. --- --- url: /sdk/peer.md --- # RFC: @sideband/peer SDK **Status:** Active **Created:** 2026-01-13 **Updated:** 2026-02-25 ## 1. Summary `@sideband/peer` is a high-level SDK providing a developer-friendly API for real-time applications on the Sideband stack. It wraps `@sideband/runtime`, `@sideband/transport-ws`, and `@sideband/secure-relay` with three core capabilities: * **Connection lifecycle** — Connect, reconnect, disconnect with observable state * **RPC** — Type-safe remote procedure calls with correlation and timeouts * **Events** — Fire-and-forget events with subject-based routing **Target Developer:** > Developers who start on localhost and expect to graduate to secure remote access without rewriting their stack. This includes indie developers building local-first tools (AI agents, dev tools, desktop apps) who need browser↔daemon communication today, and secure relay access tomorrow. *** ## 2. Use Cases ### UC1: Local Development (Browser ↔ Local Daemon) ```text Browser (UI) ←—— WebSocket ——→ Local Daemon (localhost:8080) ``` Direct WebSocket on loopback. Fast reconnection on daemon restart. Bidirectional RPC. No E2EE required. ### UC2: Remote Management (Browser ↔ Cloud Daemon via E2EE Relay) ```text Browser ←—— E2EE (SBRP) ——→ Relay Server ←—— E2EE (SBRP) ——→ Cloud Daemon ``` End-to-end encrypted via ChaCha20-Poly1305 over an untrusted relay. TOFU identity pinning. Session pause/resume when daemon temporarily disconnects. ### UC3: Service Mesh (Daemon ↔ Daemon) ```text Daemon A ←—— WebSocket/TCP ——→ Daemon B ``` Server-side listening, multiple concurrent connections. Application-level E2EE optional. *** ## 3. Out of Scope * Data synchronization / CRDTs * Service discovery (peers must know endpoints upfront) * Multi-party group communication (point-to-point only) * Offline-first / message queuing * Streaming RPC * Built-in presence *** ## 4. Design Principles ### 4.1 Layered Architecture | Component | Responsibility | | ------------------ | ------------------------------------------------------- | | **PeerConnection** | Transport lifecycle, reconnection, negotiation | | **RpcClient** | Request/response correlation, typed methods | | **Events** | Fire-and-forget event emission and listening | | **Peer** | Coordinator exposing the three above; no business logic | ### 4.2 Explicit Negotiator Selection The negotiator determines the session protocol (SBP for direct, SBRP for relay). This is security-critical — URL-based auto-detection is deliberately avoided. Security posture must be explicit and auditable. Defaults to `sbpNegotiator()` for direct connections. See ADR-013. ### 4.3 Fail Fast Invalid configuration and programming errors throw synchronously at the call site: ```typescript peer.rpc.handle("user.get", handlerA); peer.rpc.handle("user.get", handlerB); // PeerError{ code: "rpc_method_already_registered" } ``` *** ## 5. Architecture ```text ┌─────────────────────────────────────────────────────────────────┐ │ @sideband/peer │ │ │ │ ┌─────────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ │ PeerConnection │ │ RpcClient │ │ Events │ │ │ │ (lifecycle + │ │ (request/ │ │ (emit/on, │ │ │ │ negotiation) │ │ response) │ │ fire-forget) │ │ │ └────────┬────────┘ └──────┬───────┘ └─────────┬─────────┘ │ │ │ │ │ │ │ ┌────────┴──────────────────┴────────────────────┴──────────┐ │ │ │ Peer │ │ │ │ (coordinator: wires connection + router + correlation) │ │ │ └────────┬──────────────────────────────────────────────────┘ │ │ │ │ │ ┌────────┴─────────────┐ ┌─────────────────────┐ │ │ │ Negotiator │ │ Transport │ │ │ │ (SBP or SBRP) │ │ (WebSocket) │ │ │ └──────────────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ @sideband/runtime @sideband/transport-ws @sideband/secure-relay @sideband/transport @sideband/rpc @sideband/protocol ``` | Component | Responsibility | | ------------------ | ------------------------------------------------ | | **Peer** | Wire components together; expose namespaces | | **PeerConnection** | Transport lifecycle; state machine; reconnection | | **RpcClient** | Correlation; typed clients; timeouts | | **Events** | Subscriptions; client-side filtering | | **Negotiator** | Session establishment (SBP/SBRP) | *** ## 6. API Reference ### 6.1 Factory Functions ```typescript // Core factory function createPeer(options: PeerOptions): Peer; // Server — import from "@sideband/peer/server" (Node/Bun only) function listen(options: ListenOptions): Promise; // Negotiator factories function sbpNegotiator(opts?: { peerId?: string; capabilities?: string[]; handshakeTimeoutMs?: number; }): Negotiator; // relayClientNegotiator / relayDaemonNegotiator — see §10 // Pattern utilities (also exported from the package) function isValidEventName(name: string): boolean; function validatePattern(pattern: string): void; // throws on invalid function matchPattern(pattern: string, name: string): boolean; ``` ### 6.2 PeerOptions ```typescript interface PeerOptions { /** WebSocket endpoint, e.g. "ws://localhost:8080". */ endpoint: string; /** * Session negotiator. Defaults to sbpNegotiator() (plain SBP handshake). * Pass relayClientNegotiator(...) for E2EE relay mode. */ negotiator?: Negotiator; /** * Transport factory. Defaults to wsTransport() (auto-detects platform). * Override for testing or non-WebSocket transports. */ transport?: Transport; /** Local peer ID. Auto-generated if omitted. */ peerId?: string; connectionPolicy?: Partial; rpcPolicy?: Partial; eventPolicy?: Partial; retryPolicy?: Partial; /** * Handler for errors with no other delivery path (e.g. event handler throws). * Defaults to no-op — opt in to logging explicitly. */ onUnhandledError?: (error: Error) => void; } ``` ### 6.3 Policy Objects ```typescript interface ConnectionPolicy { /** * Behavior when the connection drops. * - "fail": RPC calls rejected immediately when peer is not active (default). * - "pause": Unsent calls are buffered up to rpcPolicy.disconnectBufferLimitBytes * (from any non-ready state, including before first connect); overflow → * PeerError{ code: "buffer_overflow" }. Already in-flight calls are still * rejected on disconnect. In-flight calls are not preserved across reconnects; * applications must implement retry or idempotency if required. */ onDisconnect: "fail" | "pause"; } interface RpcPolicy { /** Per-call timeout when RpcCallOptions.timeoutMs is absent. Default: 10_000. */ defaultTimeoutMs: number; /** * Max queued outgoing RPC bytes when onDisconnect === "pause". Default: 65_536 (64 KiB). * Overflow rejects the enqueued call with PeerError{ code: "buffer_overflow" }. */ disconnectBufferLimitBytes: number; } interface EventPolicy { /** * Max events buffered while disconnected. Oldest evicted silently on overflow. * Default: 128. */ maxBufferedEvents: number; } ``` `RetryPolicy` is re-exported from `@sideband/runtime` and governs reconnection backoff. ### 6.4 Peer Interface ```typescript interface Peer { /** Current lifecycle state. */ readonly state: PeerState; /** true when state is "active" or "paused". */ readonly connected: boolean; /** true only when state is "active" (traffic can flow). */ readonly ready: boolean; /** * Promise for the current reconnection cycle. * Created on entering "reconnecting"; undefined otherwise. * disconnect() resolves it with { status: "aborted" }. */ readonly reconnecting: Promise | undefined; readonly rpc: RpcInterface; readonly events: EventsInterface; /** * Initiate connection. Returns a Promise that resolves on first "active". * Legal from "idle" (starts connection), "connecting", or "negotiating" * (returns the in-progress Promise). Throws synchronously from "active", * "paused", "reconnecting", or "closed". * Fatal errors are rejected on the Promise AND emitted via on("error"). */ connect(): Promise; /** * Hard close. Idempotent (no-op if already "closed"). * Resolves reconnecting with { status: "aborted" }. */ disconnect(): Promise; /** * Wait until state === "active". * Resolves immediately if already active. * Rejects immediately with peer_closed if already closed. * Stays pending during "paused" — readiness requires active traffic flow. * Abortable via options.signal. */ whenReady(options?: { signal?: AbortSignal }): Promise; /** Subscribe to a peer lifecycle event. */ on( event: K, handler: (data: PeerEvents[K]) => void, ): Unsubscribe; /** Enables `using peer = createPeer(...)`. Calls disconnect(). */ [Symbol.dispose](): void; /** Enables `await using peer = createPeer(...)`. Awaits disconnect(). */ [Symbol.asyncDispose](): Promise; } ``` ### 6.5 PeerState ```typescript type PeerState = | "idle" // Not connected, not connecting | "connecting" // Transport connection in progress | "negotiating" // Handshake in progress | "active" // Ready; traffic can flow | "paused" // SBRP session pause; session alive, SDK client-side buffering | "reconnecting" // Waiting before next retry | "closed"; // Terminal; no reconnection type ReconnectionOutcome = | { status: "connected" } | { status: "aborted" } | { status: "failed"; error: Error }; ``` **State machine:** ```text idle → connecting → negotiating → active ↔ paused active | paused | connecting | negotiating → reconnecting → connecting → … any → closed (terminal) ``` `"reconnecting"` creates `peer.reconnecting` promise (one per cycle). `"paused"` does not create a `reconnecting` promise — the session is alive. ### 6.6 PeerEvents ```typescript interface PeerEvents { /** Fires on every state transition. */ stateChange: { state: PeerState; previous: PeerState }; /** Fires when entering "active" except when resuming from "paused" (use sessionResumed). */ connected: void; /** Fires when leaving "active" or "paused" toward disconnect/close. */ disconnected: void; /** Fires when entering "reconnecting". */ reconnecting: void; /** Fires when entering "paused" (SBRP session pause). */ sessionPaused: void; /** Fires when resuming from "paused" to "active". */ sessionResumed: void; /** Fires on fatal or unhandled errors. */ error: Error; } ``` ### 6.7 RpcInterface ````typescript interface RpcInterface { /** * Make an RPC call. Rejects with PeerError on timeout, cancellation, or remote error. * Handler errors on the remote side are returned as RPC error responses — the caller * gets a rejection, not a timeout. */ call( method: string, params?: unknown, options?: RpcCallOptions, ): Promise; /** * Like call() but never throws. Returns { ok, value, reconnected }. * reconnected: true means the peer was not in "active" state when the call * was initiated (e.g., buffered before first connect or across a reconnect). */ tryCall( method: string, params?: unknown, options?: RpcCallOptions, ): Promise>; /** * Register a handler for method. Returns Unsubscribe. * Throws synchronously with PeerError{ code: "rpc_method_already_registered" } * if the method already has a handler. * Handler errors are sent back as RPC error responses. * * Generics allow typed handlers without `as` casts: * ```ts * peer.rpc.handle<{ id: string }, User>("user.get", (p) => db.find(p.id)); * ``` */ handle

( method: string, handler: (params: P) => R | Promise, ): Unsubscribe; /** * Returns a typed proxy that maps method keys to typed call functions. * T is an interface whose keys are method names (string literals) and values * are function signatures describing params and return type. */ client(): TypedRpcClient; /** * Returns currently registered RPC handler method names, sorted * lexicographically. Used by platform infrastructure (e.g. $sideband/rpc.list) * and available to programmatic daemons for introspection. See ADR-018. */ listMethods(): string[]; } interface RpcCallOptions { /** Override rpcPolicy.defaultTimeoutMs for this call. */ timeoutMs?: number; /** Aborting rejects with PeerError{ code: "rpc_cancelled" }. */ signal?: AbortSignal; } type TryCallResult = | { ok: true; value: R; reconnected: boolean } | { ok: false; error: PeerError; reconnected: boolean }; /** * T keys are method names; values are function types. * When params is void | undefined the params argument is optional. * * interface Api { * "user.get": (params: { id: string }) => User; * "ping": (params: void) => void; * "noop": () => void; * } */ type TypedRpcClient = { [K in keyof T & string]: T[K] extends (...args: infer Args) => infer R ? Args["length"] extends 0 ? (params?: undefined, options?: RpcCallOptions) => Promise> : [Args[0]] extends [void | undefined] ? (params?: undefined, options?: RpcCallOptions) => Promise> : (params: Args[0], options?: RpcCallOptions) => Promise> : never; }; ```` **RPC behavior under disconnect:** | `onDisconnect` | In-flight (already sent) | Unsent calls (while disconnected) | | -------------- | ------------------------ | ----------------------------------------------------------------- | | `"fail"` | Rejected immediately | Rejected with `not_connected` | | `"pause"` | Rejected immediately | Queued up to `disconnectBufferLimitBytes`; then `buffer_overflow` | When state reaches `"closed"`, all pending/queued calls are rejected with `peer_closed`. ### 6.8 EventsInterface ```typescript interface EventsInterface { /** * Send a fire-and-forget event. Synchronous; returns void. * Throws PeerError{ code: "invalid_pattern" } synchronously on invalid event name. * Outbound events are buffered up to eventPolicy.maxBufferedEvents while * disconnected. Oldest events are evicted silently on overflow. * Events are discarded silently on close. */ emit(eventName: string, data?: unknown): void; /** * Subscribe to an exact event name. Returns idempotent Unsubscribe. * Throws PeerError{ code: "invalid_pattern" } synchronously on wildcard patterns * (use onPattern() for those). Subscriptions survive reconnects. */ on(eventName: string, handler: (data: unknown) => void): Unsubscribe; /** * Subscribe to events matching a NATS-style pattern. * Pattern matching is client-side. * Throws PeerError{ code: "invalid_pattern" } synchronously on invalid pattern. * Returns idempotent Unsubscribe. */ onPattern( pattern: string, handler: (eventName: string, data: unknown) => void, ): PatternSubscription; } type PatternSubscription = Unsubscribe; ``` **Pattern syntax (NATS-style):** | Token | Meaning | Example | | ----- | ---------------------------------------------- | -------------------------------- | | `*` | Exactly one segment | `user.*` → `user.created` | | `>` | One or more trailing segments (final pos only) | `metrics.>` → `metrics.cpu.load` | | `**` | Rejected — use `>` instead | — | Valid characters in segment tokens: `A-Z`, `a-z`, `0-9`, `-`, `_`. Case-sensitive. Max 255 UTF-8 bytes. ### 6.9 ConnectedPeer `ConnectedPeer` is the server-side view of a connection, handed to `ListenOptions.onConnection` after negotiation completes. ```typescript interface ConnectedPeer { readonly state: "active" | "paused" | "closed"; readonly connected: boolean; // state === "active" || state === "paused" readonly ready: boolean; // state === "active" /** Remote peer ID — matches the key in PeerServer.connections. */ readonly peerId: string; readonly rpc: RpcInterface; readonly events: EventsInterface; disconnect(): Promise; whenReady(options?: { signal?: AbortSignal }): Promise; on( event: K, handler: (data: PeerEvents[K]) => void, ): Unsubscribe; [Symbol.dispose](): void; [Symbol.asyncDispose](): Promise; } ``` Key differences from `Peer`: no `connect()`, no `reconnecting`, narrower state union. `disconnect()` is always a hard close even when `state === "paused"`. Starts in `"active"` immediately after `onConnection` is called. ### 6.10 Server Listening ```typescript interface ListenOptions { /** Endpoint to listen on, e.g. "ws://0.0.0.0:8080". */ endpoint: string; /** * Called for each accepted connection after negotiation. * peer is always in "active" state at the time of the callback. */ onConnection: (peer: ConnectedPeer) => void | Promise; /** * Session negotiator. Defaults to sbpNegotiator(). * Pass relayDaemonNegotiator(...) for E2EE relay mode. */ negotiator?: Negotiator; transport?: Transport; peerId?: string; rpcPolicy?: Partial; eventPolicy?: Partial; onUnhandledError?: (error: Error) => void; } interface PeerServer { /** The address the server is listening on. */ readonly address: string; /** All currently-connected accepted peers, keyed by remote PeerId. */ readonly connections: ReadonlyMap; /** * Hard shutdown. Transitions all ConnectedPeer instances to "closed", * severs transports. Idempotent. */ close(): Promise; } ``` *** ## 7. Error Taxonomy ### 7.1 Error Class Hierarchy ```typescript /** Base error for all peer SDK errors. */ class PeerError extends Error { readonly code: PeerErrorCode; readonly details?: Record; } /** RPC-layer errors (timeout, cancellation, handler error). */ class RpcPeerError extends PeerError {} ``` ### 7.2 Error Codes ```typescript const PeerErrorCode = { /** Peer is closed (terminal state). */ PeerClosed: "peer_closed", /** rpc.handle() called for a method that already has a handler. */ RpcMethodAlreadyRegistered: "rpc_method_already_registered", /** RPC call cancelled via AbortSignal. */ RpcCancelled: "rpc_cancelled", /** RPC call exceeded timeoutMs. */ RpcTimeout: "rpc_timeout", /** Remote RPC handler returned an error. */ RpcError: "rpc_error", /** Invalid NATS pattern or event name (onPattern, on, emit). */ InvalidPattern: "invalid_pattern", /** Outbound RPC buffer full (onDisconnect: "pause" only). */ BufferOverflow: "buffer_overflow", /** Peer exists but is not connected or is reconnecting. */ NotConnected: "not_connected", /** Send blocked because the session is temporarily paused by the relay. */ SessionPaused: "session_paused", /** connect() called from a state that does not allow it. */ InvalidState: "invalid_state", /** Operation cancelled via AbortSignal (non-RPC, e.g. whenReady). */ Cancelled: "cancelled", } as const; type PeerErrorCode = (typeof PeerErrorCode)[keyof typeof PeerErrorCode]; ``` Wire-layer numeric codes are available in `error.details.wireCode` when applicable. ### 7.3 Error Handling Patterns ```typescript // Global handler for fatal/unhandled errors peer.on("error", (error) => { logger.error("Peer error", { code: error.code, details: error.details }); }); // RPC with typed catch try { const result = await peer.rpc.call("user.get", { id: "123" }); } catch (error) { if (error instanceof PeerError) { if (error.code === "rpc_timeout") return retry(); if (error.code === "rpc_error") console.warn("Remote error:", error.message); } throw error; } // Non-throwing RPC const result = await peer.rpc.tryCall("user.get", { id: "123" }); if (result.ok) { console.log(result.value); } else { console.error(result.error.code); if (result.reconnected) { // Connection was lost and restored during this call; decide retry based on idempotency } } ``` *** ## 8. Observability `onUnhandledError` (in `PeerOptions` and `ListenOptions`) is the observability hook — it captures errors that have no other delivery path (e.g. event handler throws). A `PeerObserver` interface for OpenTelemetry-style instrumentation is out of scope. *** ## 9. Reconnection Semantics ### 9.1 What Reconnection Does | Behavior | Description | | ---------------------- | ------------------------------------------------------------------------------ | | Transport reconnect | WebSocket closes → SDK reconnects after backoff | | Session re-negotiation | Full SBP/SBRP handshake on each reconnect | | Handler preservation | RPC handlers and subscriptions survive reconnect | | State reset | In-flight RPCs always fail; unsent calls buffer or fail per `connectionPolicy` | ### 9.2 Reconnection Handling ```typescript // Wait for current reconnection cycle if (peer.reconnecting) { const outcome = await peer.reconnecting; if (outcome.status === "connected") { // Retry failed operations } else if (outcome.status === "failed") { showError(outcome.error); } } // RPC with reconnection awareness const result = await peer.rpc.tryCall("save", data); if (!result.ok && result.reconnected) { // Peer was not active when the call was initiated. // Request may or may not have been delivered. // Decide retry based on operation idempotency. } ``` ### 9.3 SBRP Session Pause/Resume In relay mode, when the daemon disconnects from the relay, the peer transitions to `"paused"` — the SBRP session is logically alive but traffic cannot flow. **State model during pause:** * `peer.state === "paused"` (distinct state, not a flag) * `peer.connected === true` (session is alive) * `peer.ready === false` (traffic cannot flow) * `stateChange` event fires (`"active"` → `"paused"`) * `sessionPaused` event fires * RPC calls remain pending (up to limits) if `onDisconnect: "pause"` * RPC calls fail immediately if `onDisconnect: "fail"` (default) **Buffering ownership:** | Layer | Responsibility | | ---------- | ----------------------------------------------------------- | | **SDK** | Client-side buffer (authoritative); enforces all limits | | **Relay** | Opaque forwarding; closes slow consumers via `backpressure` | | **Daemon** | Server-side buffer for paused clients (SBRP daemon config) | **Pause lifecycle:** 1. Relay sends `session_paused` control frame to client 2. SDK transitions `"active"` → `"paused"`; emits `stateChange` and `sessionPaused` 3. Outbound events buffered client-side (up to `eventPolicy.maxBufferedEvents`); unsent RPC calls buffered up to `rpcPolicy.disconnectBufferLimitBytes` if `onDisconnect: "pause"` 4. When daemon reconnects, relay sends `session_resumed` 5. SDK transitions `"paused"` → `"active"`; emits `stateChange` and `sessionResumed` 6. Buffered messages sent; pending RPC calls continue waiting for response If the session expires (daemon does not reconnect in time), the peer transitions to `"reconnecting"` or `"closed"` per policy. *** ## 10. Security Model (SBRP) SBRP (Sideband Bridge Relay Protocol) provides end-to-end encryption over an untrusted relay, built on top of the existing peer lifecycle. ### 10.1 Threat Model | Threat | Mitigation | | ----------------------------- | -------------------------------------------------- | | **Relay reads content** | E2EE via ChaCha20-Poly1305 | | **Relay impersonates daemon** | Ed25519 signatures; TOFU pinning | | **MITM intercepts handshake** | Ephemeral X25519 signed by identity key | | **Replay attacks** | Sequence numbers + 128-bit sliding window | | **Daemon key compromise** | `onIdentityMismatch` callback for user decision | | **Silent auto-accept** | `trustPolicy: "prompt"` requires explicit callback | ### 10.2 What's Encrypted vs. Visible | Data | Encrypted | Visible to Relay | | ---------------------- | --------- | ---------------- | | RPC method names | Yes | No | | RPC parameters/results | Yes | No | | Event names/data | Yes | No | | Message timing | — | Yes | | Message size | — | Yes | | Daemon ID | — | Yes | | Session ID | — | Yes | ### 10.3 Negotiator API ```typescript // Client-side (browser / CLI) function relayClientNegotiator(options: SbrpClientOptions): Negotiator; // Server-side (daemon connecting to relay) function relayDaemonNegotiator(options: SbrpDaemonOptions): Negotiator; ``` Client and daemon roles use distinct negotiators because their handshake responsibilities differ: the client verifies daemon identity via TOFU, while the daemon presents its identity key pair and registers with the relay. See ADR-013. ### 10.4 TOFU Trust Policies | Policy | First Connection | Mismatch | Use Case | | --------------- | ---------------- | ---------------------------------------------- | -------------------- | | `"auto"` | Auto-accept | Silent re-pin | Development only | | `"prompt"` | Require callback | Call `onIdentityMismatch()`, abort if rejected | Production (default) | | `"pinned-only"` | Reject if no pin | Abort | High-security | ```typescript // Development: auto-accept (NOT RECOMMENDED for production) const peer = createPeer({ endpoint: "wss://relay.example.com", negotiator: relayClientNegotiator({ daemonId: "dev-daemon", sessionId, identityKeyStore, trustPolicy: "auto", }), }); // Production: require explicit acceptance const peer = createPeer({ endpoint: "wss://relay.example.com", negotiator: relayClientNegotiator({ daemonId: "prod-daemon", sessionId, identityKeyStore, trustPolicy: "prompt", onFirstConnection: ({ fingerprint }) => { return showConfirmDialog(`Trust daemon ${fingerprint}?`); }, }), }); // High-security: pre-provisioned keys only const peer = createPeer({ endpoint: "wss://relay.example.com", negotiator: relayClientNegotiator({ daemonId: "secure-daemon", sessionId, identityKeyStore, // Must already contain the pinned key trustPolicy: "pinned-only", }), }); ``` ### 10.5 TOFU Lifecycle ```text ┌──────────────────────────────────────────────────────────────────┐ │ First Connection │ │ 1. SDK checks identityKeyStore for pinned key │ │ 2. No pin found: │ │ - "auto": Accept, pin, emit warning │ │ - "prompt": Call onFirstConnection (REQUIRED) │ │ - "pinned-only": Abort with handshake_failed │ │ 3. State transitions to "active" via stateChange event │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ Subsequent Connections │ │ 1. SDK checks identityKeyStore for pinned key │ │ 2. Pin found; compare against daemon's key from handshake │ │ 3a. Match → connection proceeds normally │ │ 3b. Mismatch: │ │ - "pinned-only": Abort (no callback) │ │ - "prompt": Call onIdentityMismatch(), abort if rejected │ │ - "auto": Silent re-pin (no callback) │ └──────────────────────────────────────────────────────────────────┘ ``` *** ## 11. Usage Examples ### 11.1 Local Development **Browser (Client):** ```typescript import { createPeer, sbpNegotiator } from "@sideband/peer"; using peer = createPeer({ endpoint: "ws://localhost:8080", negotiator: sbpNegotiator(), }); const unsub = peer.events.on("file.changed", (data) => { const { path, event } = data as { path: string; event: string }; console.log(`${event}: ${path}`); location.reload(); }); peer.on("error", (error) => { console.error("Peer error:", error.code); }); await peer.connect(); ``` **Local Daemon (Server):** ```typescript import { listen } from "@sideband/peer/server"; import { watch } from "fs"; const server = await listen({ endpoint: "ws://0.0.0.0:8080", onConnection(peer) { peer.rpc.handle<{ path: string }, { content: string }>( "file.read", async ({ path }) => { return { content: await Bun.file(path).text() }; }, ); peer.rpc.handle<{ path: string; content: string }, { success: boolean }>( "file.write", async ({ path, content }) => { await Bun.write(path, content); return { success: true }; }, ); const watcher = watch("./src", { recursive: true }, (event, path) => { peer.events.emit("file.changed", { event, path }); }); peer.on("disconnected", () => watcher.close()); }, }); console.log("Listening on", server.address); ``` ### 11.2 E2EE Relay **Browser (Client):** ```typescript import { createPeer } from "@sideband/peer"; import { relayClientNegotiator, createMemoryIdentityKeyStore, } from "@sideband/peer/sbrp"; // relayUrl and sessionToken from POST /api/sessions const { relayUrl, token: sessionToken } = await api.createSession({ daemonId: "daemon-prod-001", }); const peer = createPeer({ endpoint: relayUrl, // e.g. wss://eu-1.relay.sideband.cloud negotiator: relayClientNegotiator({ daemonId: "daemon-prod-001", sessionToken, identityKeyStore: createMemoryIdentityKeyStore(), trustPolicy: "prompt", onFirstConnection: ({ fingerprint }) => { return showConfirmDialog(`Trust daemon ${fingerprint}?`); }, onIdentityMismatch: ({ expectedFingerprint, receivedFingerprint }) => { return showSecurityDialog({ title: "Security Warning", message: `Daemon identity changed!\nExpected: ${expectedFingerprint}\nReceived: ${receivedFingerprint}`, }); }, }), }); peer.on("sessionPaused", () => showToast("Daemon offline, waiting...")); peer.on("sessionResumed", () => showToast("Daemon reconnected")); await peer.connect(); const status = await peer.rpc.call("system.status"); ``` **Cloud Daemon:** ```typescript import { createPeer } from "@sideband/peer"; import { relayDaemonNegotiator } from "@sideband/peer/sbrp"; const peer = createPeer({ endpoint: "wss://relay.sideband.cloud", negotiator: relayDaemonNegotiator({ daemonId: process.env.DAEMON_ID!, identityKeyPair: await loadIdentityKeyPair("./daemon-identity.key"), }), }); peer.rpc.handle("system.status", () => ({ version: "1.0.0", uptime: process.uptime(), memory: process.memoryUsage(), })); await peer.connect(); console.log("Daemon connected to relay"); ``` *** ## 12. References * [ADR-006: RPC Envelope](../adr/006-rpc-envelope.md) * [ADR-009: Runtime Peer Lifecycle](../adr/009-runtime-peer-lifecycle.md) * [ADR-010: RPC Correlation](../adr/010-rpc-correlation-cid.md) * [ADR-011: Runtime Message Routing](../adr/011-runtime-message-routing.md) * [ADR-012: WebSocket Transport Design](../adr/012-websocket-transport-design.md) * [ADR-013: Peer SDK Design](../adr/013-peer-sdk-design.md) * [Project Structure](../architecture/project-structure.md) * [SBP Protocol](../protocols/sbp/) * [SBRP Protocol](../protocols/sbrp/) * [RPC Spec](../protocols/rpc/) --- --- url: /guide/rpc.md --- # RPC Sideband RPC is request/response — the caller sends a request and awaits a typed result. Correlation is protocol-level (via `cid`); you never manage correlation IDs manually. *** ## Basic Call ```typescript const result = await peer.rpc.call<{ content: string }>("file.read", { path: "./README.md", }); console.log(result.content); ``` `call()` throws `PeerError` if: * The peer is not `active` and `onDisconnect` is `"fail"` (default) * The call times out * The server handler returns an error * The call is cancelled via `AbortSignal` *** ## Typed RPC Client Use `TypedRpcClient` when you control both sides. Define a shared interface, then get a fully-typed proxy — no type assertions, no string method names in call sites. ```typescript interface FilesApi { "file.read": (params: { path: string }) => { content: string }; "file.write": (params: { path: string; content: string }) => void; } // Client side const files = peer.rpc.client(); const { content } = await files["file.read"]({ path: "./README.md" }); ``` *** ## Registering Handlers On the server side (or for bidirectional RPC), register handlers with `rpc.handle()`. Returns an `Unsubscribe` function to deregister. ```typescript const unsubscribe = peer.rpc.handle<{ path: string }, { content: string }>( "file.read", async ({ path }) => ({ content: await Bun.file(path).text() }), ); ``` Handler registration enforces uniqueness per method — registering the same method twice throws `PeerError{ code: "rpc_method_already_registered" }` synchronously. *** ## Error Handling Server-side handler errors are propagated to the caller as `PeerError`. The handler never needs to manually send an error frame — throw or return a rejected promise. ```typescript peer.rpc.handle("file.read", async ({ path }) => { if (!path.startsWith("/safe/")) { throw new Error("Access denied"); } return { content: await Bun.file(path).text() }; }); ``` Caller side: ```typescript try { const result = await peer.rpc.call("file.read", { path: "/etc/passwd" }); } catch (err) { if (err instanceof PeerError) { console.error(err.code, err.message); } } ``` *** ## Timeouts Default timeout is 10 seconds (`rpcPolicy.defaultTimeoutMs`). Override per call: ```typescript const result = await peer.rpc.call("slow.operation", params, { timeoutMs: 30_000, }); ``` Timeout throws `PeerError{ code: "rpc_timeout" }`. *** ## Cancellation via AbortSignal ```typescript const controller = new AbortController(); setTimeout(() => controller.abort(), 5_000); try { const result = await peer.rpc.call("long.op", params, { signal: controller.signal, }); } catch (err) { if (err instanceof PeerError && err.code === "rpc_cancelled") { console.log("User cancelled"); } } ``` Aborting cancels the local promise. If the request is sitting in the disconnect buffer (queued during `reconnecting` or `paused`), it is evicted and will not be sent. If already in-flight, the request is abandoned; the server may still execute and reply (the reply is silently dropped). *** ## tryCall — Reconnection-Aware `tryCall` wraps `call` and also exposes whether the peer was not `active` when the call was made (`reconnected: true`). This covers both first-connect buffering and post-reconnect flushing — useful for telemetry or deciding whether to re-validate server state. ```typescript const outcome = await peer.rpc.tryCall("task.status", { taskId }); if (outcome.ok) { if (outcome.reconnected) { console.log( "Buffered during readiness gap — result may reflect later session state", ); } console.log(outcome.value); } else { console.error(outcome.error.code); } ``` *** ## Buffering During Reconnection With `connectionPolicy.onDisconnect: "pause"`, RPC calls made while the peer is reconnecting are buffered and replayed once the session is restored. Timeouts are wall-clock — a call with a 10 s timeout that sits in the buffer for 15 s will reject with `rpc_timeout` without being sent. Buffer limit is controlled by `rpcPolicy.disconnectBufferLimitBytes` (default 64 KiB). Overflow throws `PeerError{ code: "buffer_overflow" }`. With `onDisconnect: "fail"` (default), calls made while not `active` throw immediately. *** ## See Also * [Concepts — Subjects and Channels](concepts.md#subjects-and-channels) * `docs/protocols/rpc/envelope.md` — Wire format for RPC envelopes * `docs/sdk/peer.md` §6.5 — RPC interface RFC --- --- url: /protocols/rpc/behavior.md --- # RPC Behavior > **Authority**: Primary (Normative)\ > **Purpose**: Request/response semantics, timeouts, correlation, and retry guidance. ## Request/Response Lifecycle 1. Client sends `RpcRequest` with unique `cid` (correlation ID) 2. Server processes request 3. Server sends `RpcSuccess` or `RpcError` with matching `cid` ## Correlation RPC correlation uses `cid` (correlation ID), not SBP `frameId`: * `cid` in requests is the sending frame's `frameId` * `cid` in responses matches the original request's `cid` * Runtime matches on `cid`, not `frameId` This enables relays, proxies, and fan-out without wire format changes. See ADR-010. ## Timeouts Implementations SHOULD: * Set request timeout of 30 seconds by default * Allow per-method timeout configuration * Return `RpcError` with code 1103 (Timeout) on expiry Implementations MUST: * Not assume responses arrive in request order * Handle responses after timeout gracefully (discard) **Key invariant**: Routers MUST NOT use SBP `ErrorFrame` to represent handler timeouts. Timeouts MUST be delivered as `RpcError` responses (code 1103). ## Retry Guidance Implementations MAY retry failed requests: * On transport errors (not on application errors) * With exponential backoff (recommended: 1s, 2s, 4s, max 30s) * With jitter to avoid thundering herd Implementations MUST NOT retry: * After receiving `RpcError` (server explicitly rejected) * Without new `cid` (would confuse correlation) ## Concurrent Requests Multiple requests MAY be in flight simultaneously: * Each request has unique `cid` * Responses may arrive out of order * Implementations SHOULD track pending requests by `cid` ## Notifications `RpcNotification` (`t:"N"`) is fire-and-forget: * MUST use `event` channel (see [envelope.md](./envelope.md#subject-namespacing)) * No `cid` (no correlation) * No response expected * No delivery guarantee beyond transport layer ## Transport Semantics RpcError is request-scoped and MUST NOT trigger transport close. ## Invalid Envelope Handling Error handling differs by subject channel to prevent amplification attacks while preserving diagnostics. ### For `rpc` Channel * If envelope is invalid **but `cid` is extractable**: respond with `RpcError{code: 1100, cid, message}` * If envelope is invalid **and `cid` is not extractable**: emit `ErrorFrame{code: 1002}` and continue (non-fatal) * MUST NOT close connection for a single invalid envelope ### For `event` Channel * If envelope is invalid: MUST drop * MAY log (rate-limited to prevent log DoS) * MUST NOT emit `ErrorFrame` (prevents attacker-triggered error storms) Rationale: Events are fire-and-forget with no reply channel. Emitting `ErrorFrame` for malformed events would allow attackers to amplify traffic. --- --- url: /protocols/rpc/conformance.md --- # RPC Conformance > **Authority**: Supporting (Test specification)\ > **Purpose**: Test vectors and validation checklist for RPC implementations.\ > **Status: Stub** — This document is non-authoritative until content is added.\ > See [tracking issue](https://github.com/anthropics/sideband/issues/XXX) for progress. ## Scope This document will cover: * Envelope encoding round-trip tests * Subject validation tests * Correlation matching tests * Timeout behavior tests * Error handling tests ## Envelope Validation * \[ ] `RpcRequest` MUST have `t: "r"`, `m`, and `cid` * \[ ] `RpcSuccess` MUST have `t: "R"` and `cid` * \[ ] `RpcError` MUST have `t: "E"`, `code`, `message`, and `cid` * \[ ] `RpcNotification` MUST have `t: "N"` and `e` ## Subject-Envelope Validation * \[ ] `t:"r"`, `t:"R"`, `t:"E"` envelopes MUST use `rpc` channel * \[ ] `t:"N"` envelopes MUST use `event` channel * \[ ] Mismatched envelopes MUST be dropped (log recommended) * \[ ] Rejection is non-fatal (continue processing subsequent frames) * \[ ] Subject format rules are validated by SBP (see [SBP Behavior](../sbp/behavior.md#subject-namespace)) ## Error Handling * \[ ] Valid `RpcError` response MUST NOT close transport ## Test Vectors *To be added.* --- --- url: /protocols/rpc/envelope.md --- # RPC Envelope Specification > **Authority**: Primary (Normative)\ > **Purpose**: Defines envelope structure, channel subject rules, and validation for RPC. **Date**: 2025-11-23 **References**: [Protocol Architecture](../stack.md), ADR-010, ADR-006, ADR-002 ## Overview The RPC envelope is a canonical structure carried inside `MessageFrame.data`. It provides type-safe semantics for requests, responses, notifications, and errors without adding new frame kinds to the protocol. Encoded as JSON (v1) or CBOR (v2+). ## Channel Subjects RPC envelope semantics apply to `rpc` and `event` channel subjects. The `MessageFrame.subject` determines which envelope types are valid. | Subject | Allowed `t` | Semantics | | -------- | ------------------- | ------------------------------------------ | | `rpc` | `"r"`, `"R"`, `"E"` | Bidirectional request/response | | `event` | `"N"` | Fire-and-forget notification | | `stream` | — | Reserved (v2); reject per SBP | | `app/*` | — | Vendor-defined; not parsed as RPC envelope | Note: `rpc`, `event`, and `stream` are exact-match channel subjects. Method/event dispatch happens via envelope fields (`m` for RPC methods, `e` for event names). The `app/` prefix supports arbitrary sub-paths. Receivers MUST validate envelope `t` against subject channel for `rpc` and `event` subjects. Mismatched envelopes MUST be dropped; receivers SHOULD log. This is non-fatal; continue processing subsequent frames. Rationale: There is no valid subject to reply on. The sender receives a timeout, which signals failure; the receiver log provides diagnostics. See [SBP Behavior](../sbp/behavior.md#subject-namespace) for subject format validation. ## Envelope Structure ```ts interface RpcRequest { t: "r"; m: string; // method name p?: unknown; // params cid: FrameId; // request's frameId } interface RpcSuccess { t: "R"; cid: FrameId; // matches request.cid result?: unknown; } interface RpcError { t: "E"; cid: FrameId; // matches request.cid code: number; message: string; data?: unknown; // error details } interface RpcNotification { t: "N"; e: string; // event name d?: unknown; // no cid (fire-and-forget) } ``` ## Error Codes RPC defines error codes in the 1100–1199 range: | Code | Name | Semantics | | ---- | ------------------- | ----------------------------------------------- | | 1100 | InvalidEnvelope | Envelope structure or encoding error | | 1101 | UnsupportedMethod | Method not recognized by handler | | 1102 | CorrelationMismatch | Response cid does not match any pending request | | 1103 | Timeout | Request timed out waiting for response | | 1104 | EnvelopeMismatch | Envelope type incompatible with subject channel | Application errors use range 2000+ (user-defined). See [Error Code Registry](../error-codes.md) for the canonical code definitions. ## Encoding **JSON (v1)**: UTF-8 text, undefined fields omitted. **CBOR (v2+)**: Negotiated via handshake capability `"encoding/cbor"`; use if both peers support it, else JSON. ## Correlation Every frame's `frameId` is sender-local unique and MUST NOT be reused by receivers. RPC correlation is explicit in the envelope: * Requests set `cid` to their request frame's `frameId` * Responses copy that `cid` unchanged * Runtime matches on `cid`, not `frameId` This preserves the `frameId` invariant and enables relays, proxies, and fan-out without changes to the wire format. See ADR-010. ## Validation Rules * **Subject**: Must match allowed `t` per [Channel Subjects](#channel-subjects) * **Request**: `t: "r"`, `m` and `cid` required * **Response**: `t: "R"` or `t: "E"` with `code`, `message`, `cid` * **Notification**: `t: "N"`, `e` required; uses `event` channel Unroutable envelope failures (e.g., unparseable payload) escalate to `ErrorFrame` per `stack.md#error-scope-and-transport-authority`. Subject-envelope mismatches are handled per [Subject Namespacing](#subject-namespacing). --- --- url: /protocols/rpc.md --- # RPC Layer > **Authority**: Navigation (Non-normative)\ > **Purpose**: Overview and navigation for RPC semantic layer. RPC is a **semantic layer** carried inside SBP Message frames. It does not define framing, transport, or security—those are handled by SBP and the session layer (SBRP/SBDP). ## Relationship to SBP RPC envelopes are encoded in `MessageFrame.data`. Channel subjects determine envelope semantics: `rpc` for request/response, `event` for notifications. See [envelope.md#subject-namespacing](./envelope.md#subject-namespacing). ## Delegation This protocol delegates: * **Wire format**: Inherits SBP frame structure (see [sbp/wire-format.md](../sbp/wire-format.md)) * **Ordering**: Inherits SBP ordering guarantees (see [sbp/behavior.md](../sbp/behavior.md)) This protocol defines: * **Envelope format**: See [envelope.md](./envelope.md) * **Correlation**: See [behavior.md](./behavior.md) * **Subject namespacing**: See [envelope.md](./envelope.md) * **Error code range**: Defines codes 1100-1199 for envelope errors. ## Errors RPC defines error codes in the 1100–1199 range. See [envelope.md](./envelope.md#error-codes). It operates alongside: * SBP protocol errors (1000–1099) — see [sbp/errors.md](../sbp/errors.md) * Application errors (2000+) — defined per-method See the canonical [Error Code Registry](../error-codes.md) for all assignments. ## Documents | Document | Status | | ---------------------------------- | ---------- | | [envelope.md](./envelope.md) | Normative | | [behavior.md](./behavior.md) | Normative | | [conformance.md](./conformance.md) | Supporting | | [streams.md](./streams.md) | Reserved | ## Document Authority | Concern | Primary | Supporting | | -------------------------- | ---------------------------------- | ---------- | | Envelope structure | [envelope.md](./envelope.md) | — | | Request/response semantics | [behavior.md](./behavior.md) | — | | Correlation | [behavior.md](./behavior.md) | — | | Test specification | [conformance.md](./conformance.md) | — | ## Recommended Reading Order 1. **index.md** (this document) 2. **[envelope.md](./envelope.md)** — wire format and validation 3. **[behavior.md](./behavior.md)** — semantics and timeouts ## Related ADRs * ADR-006: RPC envelope * ADR-010: RPC correlation (cid) --- --- url: /protocols/rpc/streams.md --- # RPC Streaming > **Authority**: Reserved\ > **Purpose**: Streaming RPC semantics for v2.\ > **Status: Stub** — Reserved for v2 streaming support. ## Overview This document is reserved for future streaming RPC semantics. v1 does not support streaming. ## Non-Goals for v1 * Server-sent events (use `event` channel instead) * Bidirectional streaming * Chunked responses ## Design Considerations (Future) When implemented, streaming MAY include: * Stream initiation and termination frames * Backpressure signaling * Stream-scoped correlation * Ordered delivery within streams *No normative specification exists at this time.* --- --- url: /runtime.md --- # Runtime Behavioral Contracts > **Authority**: Navigation (non-normative)\ > **Purpose**: Index and layer boundary for runtime behavioral contracts. The `runtime/` layer defines externally observable behavioral contracts for `@sideband/runtime` — the session lifecycle, message routing, and RPC correlation semantics that peer and application layers depend on. These contracts sit above wire encoding. Wire-level rules remain in [`docs/protocols/`](../protocols/). Runtime docs may reference protocol docs; they MUST NOT restate or redefine wire-level invariants. ## Documents | Document | Scope | Authority | | -------------------------- | ---------------------------------------------------------- | --------- | | [session.md](./session.md) | Session states, negotiators, retry, termination | Primary | | [router.md](./router.md) | Handler registration, dispatch ordering, error propagation | Primary | ## Key Invariants * A `Peer` owns zero or more `Session` instances; at most one may be `Active` per peer in v1. * Session retry is opt-in and per-session; it uses exponential backoff. * `Router` dispatch is deterministic: exact match before prefix, registration order within each bucket. * `cid` (correlation ID) is set by the request and echoed unchanged by the response; the runtime matches on `cid`, never on `frameId`. ## ADRs * [ADR-009](../adr/009-runtime-peer-lifecycle.md) — Session lifecycle design and negotiator interface * [ADR-010](../adr/010-rpc-correlation-cid.md) — RPC correlation via `cid` * [ADR-011](../adr/011-runtime-message-routing.md) — Message routing and dispatch rules --- --- url: /protocols/sbdp/design.md --- # SBDP Design > **Authority**: Informational (Non-normative)\ > **Purpose**: Design decisions and open questions for the SBDP specification.\ > **Status**: Pre-specification — architectural direction is set; wire format and transport bindings are not yet defined. This document records the design rationale and open questions for the Sideband Direct Protocol (SBDP). It is a prerequisite to writing the normative SBDP specification. See ADR-015 for the key architectural decisions. *** ## Goals * Secure, authenticated SBP sessions without relay infrastructure. * Same `Peer` SDK API as relay mode — no user-facing API changes. * Works on local networks and daemon-to-daemon connectivity; handles NAT traversal via relay- assisted ICE signaling as a fallback path. ## Non-Goals * Peer discovery (application responsibility). * Replacing the relay for scenarios where NAT traversal requires it. * New framing or RPC semantics — SBP frames are used unchanged. *** ## Relationship to SBRP SBDP and SBRP are both session layers that wrap SBP frames with encryption. They differ in role symmetry and relay dependency: | Dimension | SBRP | SBDP | | --------------- | ------------------------------------------- | -------------------------------------- | | Relay required | Yes | No | | Client identity | Anonymous | Noise static key (X25519, TOFU pinned) | | Daemon identity | Ed25519 (pinned via TOFU) | Noise static key (X25519, TOFU pinned) | | Handshake | Relay-mediated init/accept | Direct Noise XX exchange | | NAT traversal | Relay handles it | ICE/STUN or relay-assisted fallback | | Pause/resume | Relay signals pause when daemon disconnects | N/A — connection is direct | Both protocols deliver an encrypted `SessionChannel` to the runtime. The runtime and all higher layers (router, RPC, events) are identical regardless of which session layer is active. *** ## Cryptographic Design ### Noise XX The handshake uses [Noise XX](https://noiseprotocol.org/noise.html#interactive-handshake-patterns-fundamental), which provides: * Mutual authentication (both parties authenticate their static keys). * Forward secrecy (ephemeral keys per session). * Implicit MITM protection in both directions. Key material: | Key | Type | Scope | | ----------------- | ----------------- | ----------------------------------------------------------------------------------------------- | | Static keypair | X25519 | Noise static key (TOFU pinned); required for both peers | | Identity keypair | Ed25519 | Optional; if present, MUST be bound to Noise static key for cross-transport identity continuity | | Ephemeral keypair | X25519 | Per-session, discarded after handshake | | Session keys | ChaCha20-Poly1305 | Per-direction, derived from Noise handshake | Cipher suite is fixed (no negotiation) — same design principle as SBRP: one suite, zero negotiation overhead. ### TOFU Identity Pinning Both sides pin each other's static public key on first connection. Subsequent connections verify against the pinned key. Key mismatch is a fatal error — same behavior as SBRP (see `docs/protocols/sbrp/authentication.md`). Unlike SBRP, both peers (including daemon/server implementations) MUST persist pinned keys via an `identityKeyStore`. *** ## Transport Options SBDP is transport-agnostic. The session layer wraps whatever `TransportConnection` is provided: | Scenario | Transport | | ------------------------------- | -------------------------------- | | Browser ↔ daemon (same network) | WebRTC DataChannel | | Browser ↔ daemon (NAT) | WebRTC DataChannel with ICE/STUN | | Daemon ↔ daemon | QUIC, TCP | The transport implements `TransportConnection` from `@sideband/transport` (see ADR-005). *** ## NAT Traversal For browser contexts, WebRTC ICE handles NAT hole-punching. The signaling exchange (SDP and ICE candidates) requires a side channel. Two approaches: **Option A — Out-of-band signaling (simple):** Application code exchanges ICE candidates via any available channel (HTTP, existing SBRP relay, WebSocket). SDK provides ICE candidate serialization helpers; transport assembly is application responsibility. **Option B — Relay-assisted ICE (integrated):** The SBRP relay forwards ICE candidate frames between client and daemon during the upgrade negotiation, then the SBDP session takes over. The relay sees only opaque ICE candidate blobs — no keys. Option B is preferred for the SDK integration path. It requires an SBRP extension to define upgrade signaling message types and ordering. *** ## Session Lifecycle SBDP does not have relay-mediated pause/resume (there is no relay to signal the peer). Instead: * Connection loss maps directly to `reconnecting` in the `Peer` state machine. * Reconnection uses the same retry policy as SBP sessions. * TOFU-pinned keys persist across reconnections; each new handshake MUST re-verify against the pinned key. *** ## Open Questions *These must be resolved before writing the normative specification.* 1. **Noise transcript details**: ADR-015 selected Noise XX, but the spec still needs exact transcript binding rules (capabilities/metadata binding, downgrade resistance markers). 2. **Session resumption**: Should SBDP support resuming an interrupted session (continuing sequence counters) or always create fresh sessions? SBRP punts this to the relay. 3. **Relay upgrade signal format**: What exact message type and encoding carries ICE candidates over SBRP? Needs coordination with the relay server design. 4. **WebRTC fingerprint binding**: WebRTC DTLS requires a certificate, not a raw key. The Noise XX static key and the DTLS certificate are independent unless explicitly bound. Without binding, a transport-layer MITM could observe connection metadata or selectively drop frames even though Noise XX protects payload confidentiality. The spec must define how to bind the DTLS certificate fingerprint to the Noise handshake (e.g., derive from shared entropy or verify against a hash exchanged during Noise XX). 5. **Multi-homed connections**: Can a daemon maintain both an SBRP relay connection and an SBDP direct connection simultaneously, with clients choosing? How does the SDK surface this? 6. **Relay-assisted discovery**: When a client connects to a daemon via relay, the daemon knows its own local addresses. Should the daemon advertise local discovery hints (LAN IP, mDNS name) through the relay session so the client can attempt a direct upgrade on subsequent connections? Without this, SBDP requires out-of-band address configuration, which makes it effectively opt-in-only for technical users. A `ConnectionBundle` or similar mechanism could make direct connections transparent. *** ## Related Documents * [ADR-015](../../adr/015-p2p-direct-protocol.md): Key design decisions * [SBDP Overview](index.md): Stub and relationship to other protocols * [SBRP Specification](../sbrp/): Reference implementation for session layer pattern * [Protocol Stack](../stack.md): Layer boundaries and wrapping rules --- --- url: /protocols/sbp/behavior.md --- # SBP Behavior & Ordering (v1) > **Authority**: Primary (Normative)\ > **Purpose**: Runtime semantics, ordering guarantees, and delivery expectations for SBP. Semantic guarantees and expectations that sit above the raw frame format. Applies across transports; transport-specific notes call out deviations. ## Lifecycle * Handshake first: each peer MUST send a Handshake control frame before any Message/Ack/Error. Receiving non-handshake frames before handshake → `ProtocolViolation` then close. * Liveness: peers MAY send `Ping`; receivers SHOULD respond with `Pong` promptly. Unanswered pings can drive transport-level timeouts. * Close: either side MAY send `Control:Close` and then terminate the transport. ## Subject namespace SBP defines the canonical subject namespace for `MessageFrame.subject`. Higher layers (RPC, pub/sub) interpret payloads but do not own namespace rules. ### Recognized subjects | Subject | Purpose | | -------- | --------------------------- | | `rpc` | RPC request/response | | `event` | Fire-and-forget events | | `stream` | Streaming (reserved for v2) | | `app/*` | Vendor-specific / custom | Note: `rpc`, `event`, and `stream` are exact-match channel subjects. The `app/` prefix supports arbitrary sub-paths. Subjects MUST be non-empty UTF-8 with no NUL characters. Implementations SHOULD limit to 256 bytes; oversize subjects MAY be rejected. ### Validation Implementations MUST validate `MessageFrame.subject` on receipt. If the subject does not begin with a recognized prefix: * Respond with `ErrorFrame{code=1002, message="Invalid subject namespace"}` * Set `id` to the offending frame's `frameId` * Continue processing (non-fatal) If the subject uses a recognized but unsupported prefix (e.g., `stream/` in v1): * Respond with `ErrorFrame{code=1003, message="Unsupported feature: stream/"}` * Set `id` to the offending frame's `frameId` * Continue processing (non-fatal) ### v1 reservations `stream/` is reserved for v2. v1 implementations MUST reject `stream/` subjects as described above. Future capability-gated prefixes follow the same pattern. ## Ordering and delivery * Within a single transport stream (WebSocket or loopback), frames are delivered in send order (reliable, ordered). Transports must preserve order. * No cross-stream ordering: when multiple transports are used concurrently, ordering is per-transport only. * Duplicate tolerance: the protocol does not promise global exactly-once. Implementations MAY resend on reconnect; consumers SHOULD treat `Message` with the same `id` as idempotent or deduplicate at a higher layer. * Ack semantics: `Ack.ackId` confirms receipt (not processing) of the frame whose `id` matches `ackId`. No cumulative or windowed ACKs defined in v1. * Timestamp: informational; does not affect ordering or validity. ## Delivery expectations by transport * Browser WS: ordered, reliable delivery; disconnects may drop in-flight frames. No backpressure signals beyond close/error. * Node/Bun WS: same as browser; server implementations SHOULD cap message size and enforce idle timeouts. * Memory/loopback: ordered, reliable; optional loss simulation MUST be documented by the implementation. ## Ack Policy Ack frames confirm receipt, not processing. Runtime implementations follow these rules: **Default behavior**: * Runtimes MUST NOT generate Ack frames by default **Opt-in configuration** (`acks: "none" | "receipt"`): * `"none"` (default): No automatic Ack generation * `"receipt"`: Send Ack after frame validated and enqueued for delivery **Receipt mode semantics**: * Runtime MUST send Ack after validating frame structure and enqueuing for handler delivery * Runtime MUST NOT wait for handler completion (that's RPC semantics) * Ack confirms "I received and will attempt to process", not "I processed successfully" **Interaction with RPC**: * RPC does not require ACKs for correctness; RPC uses request/response for confirmation * Retries and timeouts are RPC-layer behaviors (code 1103), not Ack-derived * Applications requiring delivery guarantees should use RPC correlation ## State and idempotency * Requests/responses/events share the `Message` frame; correlation (`id`) is the only built-in state tracker. * Consumers SHOULD make request handlers idempotent per `id` when possible; retries may occur after transport drops. * Acks are advisory; higher layers decide whether to retry or treat missing ACKs as failures. ## Security and safety * Size limits: peers SHOULD enforce max frame size (see wire spec) and reject oversize frames with `ProtocolViolation`. * Rate limits: implementations MAY throttle handshakes/pings/error floods; exceeding limits SHOULD close the transport. * Integrity: no built-in MAC; rely on transport security (TLS) and higher-layer auth. Do not trust `peerId` without external verification. * Cross-origin (browser): host apps must configure allowed origins and reject unauthorized WS upgrades; protocol itself is origin-agnostic. ## Observability * Errors: prefer sending `ErrorFrame` before closing when feasible; log both `code` and `message`. * Metrics: count frames by type, error codes, ping/pong latency, handshake failures. --- --- url: /protocols/sbp/conformance.md --- # SBP Conformance Tests (v0.1 plan) > **Authority**: Supporting (Test specification)\ > **Purpose**: Test vectors, fixtures, and validation checklist for SBP implementations.\ > **Status: Stub** — This document is a plan and not a complete conformance suite. Lightweight checklist for cross-implementation compatibility. Use deterministic fixtures; avoid network in golden tests. ## Fixtures * Frame vectors: encode/decode golden cases for each frame kind (with fixed 16-byte `frameId` values), using fixed inputs for `subject`, `body`, `message`, `ackId`, `code`. * Handshake payloads: valid base case; invalid protocol/version; missing `peerId`; oversized metadata. * Error cases: malformed headers (short buffer, bad lengths), invalid frameId length (not 16 bytes), reserved flag bits set, unknown frame kind, incomplete payload for each frame kind. ## Invariants to assert * Round-trip encoding: `decode(encode(frame))` yields the same structure for all supported frames. * Frame ID validity: `frameId` MUST be exactly 16 bytes (opaque binary). Equality checked by byte-wise comparison. * Reserved bits: any non-zero reserved flag bit MUST throw `InvalidFrame`. * Length guards: subject length mismatch and message length mismatch MUST throw `InvalidFrame`. * Handshake validation: wrong protocol/version MUST throw `UnsupportedVersion`; missing required fields MUST throw `InvalidFrame`. * Ack frame ID: must reference exactly 16 bytes from another frame's `frameId`. ## Subject namespace validation * MUST reject unknown subjects (e.g., `foo/bar`) with `ErrorFrame{code=1002}` * MUST reject `stream` in v1 with `ErrorFrame{code=1003}` * MUST set `ErrorFrame.id` to the offending frame's `frameId` * MUST continue processing after rejection (non-fatal) * MUST accept valid channels (`rpc`, `event`) and `app/` prefix * Round-trip: valid subjects route without error ## Negative fuzzing * Feed random/oversized buffers into `decodeFrame`; expect bounded error handling (no crashes/hangs) and `InvalidFrame`. * Bound `ErrorFrame.message` length to implementation limit; ensure rejection path is consistent. * Malformed RPC envelope MUST emit `ErrorFrame`, not `RpcError`. ## Transport-shared expectations * Ordering: encoded frame sequences preserve order after decode. * Unicode: subjects/messages are UTF-8; invalid UTF-8 MUST be rejected when decoded to string fields. * Limits: frames exceeding configured size caps MUST yield `ProtocolViolation` and closure. --- --- url: /protocols/sbp/errors.md --- # SBP Error Model (v1) > **Authority**: Primary (Normative)\ > **Purpose**: Error taxonomy, codes, sender/receiver contracts, and retryability for SBP. Defines canonical error semantics for `@sideband/protocol`, independent of transport. Error frames are part of the public contract; transports MAY also surface transport-native errors (e.g. WebSocket close codes) but must map protocol failures to `ErrorFrame`. ## Error taxonomy SBP defines error codes in the 1000–1099 range: | Code | Name | Fatality | Semantics | | ---- | ------------------ | ---------- | ------------------------------------------------ | | 1000 | ProtocolViolation | Fatal | Generic protocol contract violation | | 1001 | UnsupportedVersion | Fatal | Peer advertised incompatible version | | 1002 | InvalidFrame | Contextual | Frame structure or encoding error | | 1003 | UnsupportedFeature | Non-fatal | Feature reserved or requires capability exchange | Application errors use range 2000+. `ApplicationError` (2000) is a catch‑all; higher-level layers may define stable subcodes via metadata or payload. See [Error Code Registry](../error-codes.md) for the canonical code definitions and RPC-layer codes. ## Error frame shape (binary) * `ErrorFrame` (`t=3`): `code:uint16 LE`, `msgLen:uint32 LE`, `message:UTF-8`, optional opaque `details` (e.g. JSON/CBOR details). * `id` SHOULD mirror the failing frame's `id` when correlating to a request/response; absent otherwise. * `ts` OPTIONAL for diagnostics. ## Contract for senders * Use the narrowest code available; avoid overloading `ApplicationError` for protocol faults. * Message text SHOULD be short and stable enough for logs; do not leak secrets. * On version mismatch during handshake: send `UnsupportedVersion` then close. * On malformed input: send `InvalidFrame` with context in `message`; `details` MAY include a structured schema error. * When closing after a fatal error, prefer sending an `ErrorFrame` before `Control:Close` unless the transport is already unusable. ## Contract for receivers * Treat `ProtocolViolation` and `UnsupportedVersion` as fatal; close the transport after emitting diagnostics. * `InvalidFrame` MAY be fatal or MAY trigger best-effort recovery if the frame boundary is intact; if recovery is unsafe, close. * `UnsupportedFeature` is non-fatal; continue processing subsequent frames. The feature is valid but unavailable in this version or capability set. * `ApplicationError` is non-fatal to the transport; the caller decides whether to retry. * If `id` is present, route the error to the correlated request/stream; otherwise emit as connection-level fault. ## Retryability and mapping * Retryable: typically `ApplicationError` with explicit higher-level hint; protocol spec itself is neutral. * Non-retryable: `UnsupportedVersion`, repeated `ProtocolViolation`, structurally invalid frames. * WebSocket mapping (guidance): * Close with `1002` (protocol error) for `ProtocolViolation`/`InvalidFrame`. * Close with `1003` (unsupported data) for `UnsupportedVersion`. * Preserve `ErrorFrame.message` in close reason when size allows; otherwise emit to logs only. ## Security considerations * Size: receivers MAY cap `ErrorFrame.details` (e.g. ≤ 16 KiB) to prevent abuse. * Untrusted text: treat `message` as untrusted; escape before surfacing in UIs. * Rate limit: throttle repeated errors from a peer to avoid log/CPU DoS. --- --- url: /protocols/sbp/wire-format.md --- # SBP Wire Format (v1) > **Authority**: Primary (Normative)\ > **Purpose**: Defines on-wire encoding, frame structure, and binary format for SBP. Canonical on-wire contract for `@sideband/protocol`. Applies to all transports that carry SBP frames (WS, loopback, future TCP). Values are little-endian unless stated otherwise. **Note**: All v1 implementations MUST support the timestamp flag bit (bit 0), even if unused. Frames with bit 0 set include an 8-byte timestamp after the frame ID. ## Envelope * Version tag: `sideband/1` (see `PROTOCOL_ID`). All frames belong to this version; negotiation happens via the handshake payload, not per-frame. * **Header (fixed 2 bytes)**: `t` (1 byte, `FrameKind`), `flags` (1 byte). * **Important**: Flags are part of the frame header, **not** part of the FrameId. * **Bit 0** (`ts`): timestamp present (1) or absent (0). When set, an 8-byte timestamp follows the frame header before the payload. * **Bits 1–7**: reserved and must be zero in v1. Non-zero reserved bits close the connection. * **Frame ID (`id`)**: **16 raw bytes (128 bits) of opaque entropy. Always present.** Generated via cryptographic randomness. Used for request/response correlation and ACK linkage; auto-generated to simplify runtime logic. No padding, no string encoding, no trimming, no semantic bits. * Decoders MUST treat FrameId as raw binary. * Decoders MUST NOT interpret any bits within FrameId. * Validation is length-only: must be exactly 16 bytes. * **Optional timestamp** (if `flags` bit 0 is set): 8-byte signed int (milliseconds since Unix epoch). * **Payload**: type-specific body (below). ## Frame kinds * Control (`t=0`): first byte `c` (`ControlOp`), rest `data` (opaque). * Handshake (`c=0`): `data` is UTF-8 JSON of `HandshakePayload` (see below). MUST be first frame both peers send. * Ping (`c=1`), Pong (`c=2`): no `data`. Timestamp via `flags` bit 0 is optional and recommended for keepalive latency measurement. * Close (`c=3`): optional UTF-8 reason in `data`. * Message (`t=1`): **A routable, identity-bearing unit of application data.** Structure: `subjectLen` (uint32 LE) + `subject` (UTF-8, routing key) + `data` (opaque bytes). `frameId` always present for correlation; timestamp optional (via `flags` bit 0). Called "Message" (not "Data") because each frame is a distinct, routable entity with optional acknowledgement support—semantically closer to messaging systems than byte streams. Wire keys: `s` (subject), `b` (data). * **Important**: `frameId` is sender-local unique and MUST NOT be reused or copied by the receiver into outbound frames. Each peer generates its own unique `frameId` for every frame it emits. For RPC correlation, see ADR-010 (uses explicit `cid` field in the envelope payload, not `frameId`). * Ack (`t=2`): 16-byte `ackFrameId` (opaque binary, matching a prior frame's `frameId`). No payload data. * Error (`t=3`): `code` (uint16 LE `ErrorCode`) + `msgLen` (uint32 LE) + `message` (UTF-8) + optional `details` (opaque bytes). SHOULD set `frameId` to the failing frame's `frameId` when available. ## Handshake payload (JSON, UTF-8) * Fields (all strings unless noted): * `protocol` (required) = `"sideband"` * `version` (required) = `"1"` * `peerId` (required) unique per peer * `caps?` string\[] of advertised capabilities (e.g. `["rpc", "stream", "compression:gzip"]`) * `metadata?` record of extra hints; keys MUST be namespaced (`"tracing:..."`, `"vendor:..."`). * Rules: * Both sides MUST send a handshake before any Message/Ack/Error frames. * On mismatch of `protocol` or `version`, respond with `ErrorFrame{code=UnsupportedVersion}` then close. * Unknown `caps`/`metadata` keys are ignored (forward-compatible). ## Required vs optional fields (v1) * Required: header bytes, `id` (frameId, always present), frame-specific payload fields; handshake `protocol`, `version`, `peerId`, Message `subject`, Error `code` + `message`. * Optional: `ts`, `caps`, `metadata`, message `data`, error `details`, Close reason. * Reserved: Control ops >3, future frame kinds >3. For flags: bit 0 has defined semantics (timestamp); bits 1–7 are reserved and MUST be zero. ## Size & limits * Recommended maximum frame size: 1 MiB; peers MAY advertise stricter caps via `caps` or out-of-band config. Frames exceeding a peer’s limit SHOULD elicit `ErrorFrame{code=ProtocolViolation}` then close. * Handshake payload SHOULD be ≤ 8 KiB. * Subjects SHOULD be ≤ 256 bytes UTF-8; longer subjects are allowed but may be rejected by implementations. ## Stream semantics on the wire * Requests/responses/events all reuse `Message` frames (`t=1`). RPC correlation is defined at the envelope layer (see RPC envelope spec): `cid` inside `MessageFrame.data` is used for request/response matching. `frameId` remains sender-local unique and is *not* reused by receivers. See ADR-010. * Acks (`t=2`) confirm receipt of the frame whose `frameId` matches `ackFrameId`; no cumulative acking. * Timestamp is advisory and not part of ordering guarantees. ## Extensibility * Additive changes (new optional fields, new `ControlOp`, new `caps` keys) are forward-compatible for v1. New mandatory fields require a new major version. * Future alternative encodings (e.g. CBOR) must still fit this framing; `caps` SHOULD advertise `encoding:cbor` before use. ## Compatibility matrix (v1) * Accept frames where: `t` ∈ \[0..3], reserved flag bits (1–7) are zero, required fields present, `frameId` is exactly 16 bytes. * Bit 0 of flags can be 0 or 1 (controls timestamp presence). * Bits 1–7 MUST be zero. * Ignore: unknown `caps`/`metadata` keys, extra bytes in Error `details`. * Reject/close: unsupported `protocol`/`version`, malformed lengths, unknown frame kind, non-zero reserved flag bits (1–7), frames before handshake, invalid `frameId` length. * **Flag validation**: If any bit 1–7 is set in the flags byte, send `ErrorFrame{code=InvalidFrame}` and close. --- --- url: /protocols/sbrp/conformance.md --- # SBRP Conformance Checklist > **Authority**: Supporting (Test specification)\ > **Purpose**: Testable invariants and verification checklist for SBRP implementations. Use this checklist to verify conformance with SBRP. Requirements reference: * [wire-crypto.md](./wire-crypto.md) — crypto primitives and wire format * [state-machine.md](./state-machine.md) — state transitions and control semantics * [control-codes.md](./control-codes.md) — code values * [authentication.md](./authentication.md) — token validation Items tagged MUST/SHOULD mirror normative requirements. ## Common Crypto (Client + Daemon) * \[ ] MUST use Ed25519 for daemon identity and X25519 for ephemeral key exchange. * \[ ] MUST UTF-8 encode context strings and `daemonId` (no BOM, no length prefix) before concatenation in signature/transcript payloads. * \[ ] MUST sign handshake payload `SHA256("sbrp-v1-handshake" || daemonId || clientEphemeralPublicKey || daemonEphemeralPublicKey)`. * \[ ] MUST compute transcript hash `SHA256("sbrp-v1-transcript" || daemonId || clientEphemeralPublicKey || daemonEphemeralPublicKey || signature)` and use it as HKDF-SHA256 salt. * \[ ] MUST set HKDF `info` to the constant `"sbrp-session-keys"` and `len = 64`. * \[ ] MUST derive directional keys `clientToDaemon` and `daemonToClient`. * \[ ] MUST build nonce as 12 bytes: 4-byte direction constant + 8-byte big-endian seq. * \[ ] MUST use empty AAD for ChaCha20-Poly1305 encryption. * \[ ] MUST start sequence numbers at 0 and increment per message per direction. * \[ ] MUST reject messages outside the replay window; MUST use bitmap window >= 64 and SHOULD use >= 128. * \[ ] MUST handle large sequence jumps in O(1) by resetting the bitmap rather than iterating. * \[ ] MUST NOT allow sequence wrap with the same directional traffic key; on sequence exhaustion, MUST terminate the session and re-handshake before sending more encrypted frames. * \[ ] MUST preserve sequence state on resume without handshake; if lost, MUST force full handshake. ## Client (UI) * \[ ] MUST pin daemon identity public key on first successful handshake (TOFU); do not pin before signature verification succeeds. * \[ ] MUST persist pins across page reloads (for browser clients); SHOULD survive application restarts. * \[ ] MUST abort on identity mismatch with `identity_key_changed` and require explicit user approval to accept new key. * \[ ] MUST present both stored and new fingerprints on `identity_key_changed`. * \[ ] MUST verify daemon signature using pinned identity key, not relay-provided key. * \[ ] MUST obtain a new session token (new `sid`) on client reconnect; no session resumption. * \[ ] MUST perform a full handshake on client reconnect (new ephemeral keys). * \[ ] MUST handle `Control(session_paused)` by suspending encrypted sends. * \[ ] MUST handle `Control(session_pending)` to indicate daemon reconnected, awaiting readiness. * \[ ] MUST handle `Control(session_resumed)` by resuming with retained session state. * \[ ] MUST handle `Control(session_expired)` by initiating full reconnect with new handshake. * \[ ] SHOULD complete handshake within 30 seconds of WebSocket open; MUST abort with `handshake_timeout` on timeout. * \[ ] SHOULD warn when storage is unavailable or ephemeral (private/incognito). * \[ ] MUST NOT parse Control message text for behavioral decisions; use only the code. ## Daemon (Agent) * \[ ] MUST generate and persist a long-lived Ed25519 identity keypair; register public key with control plane. * \[ ] MUST include identity public key and signed ephemeral X25519 public key in `HandshakeAccept`. * \[ ] MUST sign with the identity key currently registered as active at the control plane. * \[ ] MUST maintain per-client session state (keys, seq, replay window) independently. * \[ ] MUST handle `Control(session_ended)` by cleaning up per-session state (keys, seq counters, replay window). * \[ ] SHOULD complete handshake within 30 seconds of receiving `HandshakeInit`; MUST abort with `handshake_timeout` on timeout. * \[ ] SHOULD store identity keys with restrictive permissions (e.g., 0600). * \[ ] SHOULD best-effort zeroize ephemeral key material after use. ### Session Resumption (resumable daemons only) Daemons without `"session:resume"` in `scp` skip this section—the relay handles session cleanup automatically on reconnect. * \[ ] MUST resume with the same keys and sequence state if relay session is resumed. * \[ ] MUST send `Signal(ready)` for sessions with retained state after reconnect. * \[ ] MUST verify session state integrity before sending `Signal(ready)`; if any component is missing, malformed, or inconsistent, MUST send `Signal(close, reason=state_lost)` instead. * \[ ] MUST send `Signal(close)` for all sessions after process restart or loss of volatile memory. ### Signal Frame Requirements * \[ ] MUST use 2-byte payload: signal code (1B) + reason code (1B). * \[ ] MUST use signal code `0x00` for ready, `0x01` for close. * \[ ] SHOULD use reason code `0x00` (`none`) for ready signal. * \[ ] MUST use appropriate reason code for close: `0x00` none, `0x01` state\_lost, `0x02` shutdown, `0x03` policy, `0x04` error. ## Relay * \[ ] MUST validate relay tokens issued by the control plane (not authenticate directly). * \[ ] MUST enforce token claims: role, daemonId, expiration; sessionId required for client tokens only. * \[ ] MUST enforce user → daemon ownership via token claims. * \[ ] MUST forward endpoint frames (`0x01`, `0x02`, `0x03`) without modification or inspection. * \[ ] MUST handle `Signal(ready)` from daemon: send `Control(session_resumed)` to client, resume routing. * \[ ] MUST handle `Signal(close)` from daemon: send `Control(session_expired)` to client, close session pairing. * \[ ] MUST reject `Signal` (`0x04`) received from clients as `disallowed_sender` if header is parseable. * \[ ] MUST reject `Control` (`0x20`) received from peers as `disallowed_sender` if header is parseable. * \[ ] MUST close WebSocket with `Control(malformed_frame, SessionID=0)` if frame header is malformed or truncated. * \[ ] MUST rate-limit connections and message throughput; when throttling, MUST use `Control(rate_limited)` with SID=0 (connection-level, non-terminal). * \[ ] MUST close slow consumers with `Control(backpressure)` with SID=0 (terminal) when send buffer exceeds threshold; MUST NOT silently drop forwarded frames. * \[ ] MUST send `Control(session_paused)` to client when daemon disconnects. * \[ ] MUST send `Control(session_pending)` to client when resumable daemon reconnects within grace period. * \[ ] MUST NOT send `Control(session_resumed)` until receiving `Signal(ready)` from daemon (resumable daemons only). * \[ ] MUST send `Control(session_expired)` to client and close WebSocket when grace window expires. * \[ ] MUST send `Control(session_ended)` to daemon when client disconnects, if daemon is connected; silently tear down pairing if daemon is offline. * \[ ] MUST validate JWT tokens per [authentication.md](./authentication.md): signature/JWKS, issuer, audience (`sideband-relay`), expiration, and required role/daemon/client claims. * \[ ] MUST verify `sid` in token (base64url-decoded to uint64) matches frame header SessionID for client connections. * \[ ] MUST NOT generate Control frames that leak information derived from encrypted payloads. * \[ ] MUST NOT include identifiers (daemonId, clientId, sessionId, tokens) in Control message text. * \[ ] MUST send `Control(session_expired)` to all paired clients immediately when non-resumable daemon (no `"session:resume"` in `scp`) reconnects. * \[ ] SHOULD send `Control(internal_error, SessionID=0)` and close WebSocket on unrecoverable internal failures. ### Frame Validation Order * \[ ] MUST validate frames in order per [wire-crypto.md](./wire-crypto.md): header parse → payload size → frame type → SessionID validity → frame direction. * \[ ] MUST use `invalid_session_id` (SID=0) if session-bound frame has SessionID=0 or Ping/Pong has non-zero SessionID. * \[ ] MUST use `disallowed_sender` with header's SessionID only after SessionID validity passes. * \[ ] MUST use `rate_limited` with SID=0 (connection-level). ### Keepalive (Ping/Pong) * \[ ] MUST handle Ping (`0x10`) locally: respond with Pong (`0x11`) copying payload. * \[ ] MUST NOT forward Ping or Pong frames. * \[ ] Ping/Pong frames MUST have SessionID = 0 (connection-scoped). * \[ ] Ping payload MUST be 0-8 bytes; relay MUST reject larger payloads. * \[ ] MAY send Ping to endpoints to detect dead connections. ## Control Code Compliance ### Wire Codes (Relay → Endpoint) * \[ ] MUST use code ranges per [control-codes.md](./control-codes.md): 0x01xx auth, 0x02xx routing, 0x03xx session, 0x04xx wire, 0x06xx internal, 0x09xx throttling, 0x10xx state. * \[ ] MUST use correct SessionID scope per [control-codes.md](./control-codes.md) SID column: 0 for connection-level errors, non-zero for session-specific events. * \[ ] Terminal codes (T in [control-codes.md](./control-codes.md)) MUST close WebSocket after sending. * \[ ] Non-terminal codes (N in [control-codes.md](./control-codes.md)) MUST NOT close WebSocket. ### Endpoint Codes (SDK Only) * \[ ] MUST use 0xExxx range for SDK-only codes (never transmitted on wire). * \[ ] `identity_key_changed` (0xE001) MUST trigger abort and user confirmation flow. * \[ ] `handshake_failed` (0xE002) MUST trigger connection close. * \[ ] `handshake_timeout` (0xE003) MUST trigger connection close after 30s. * \[ ] `decrypt_failed` (0xE004) MUST trigger connection close. * \[ ] `sequence_error` (0xE005) MUST reject message; MAY trigger connection close. --- --- url: /sdk.md --- # SDK Design > **Authority**: Navigation (non-normative)\ > **Purpose**: Index and layer boundary for public SDK contracts and design documents. The `sdk/` layer covers the public API surface of `@sideband/peer` and related consumer-facing packages. It sits above the runtime layer and does not define wire encoding or session cryptography. SDK docs may reference `runtime/` for behavioral contracts and `protocols/` for wire invariants. They MUST NOT restate rules defined in those layers. ## Documents | Document | Scope | Status | | ------------------------------- | ----------------------------------------------------------------------- | ------ | | [peer.md](./peer.md) | Peer SDK API design, lifecycle, pub/sub, error handling | Active | | [`@sideband/cloud`](./cloud.md) | Cloud relay SDK: `connect()`, `listen()` → `CloudServer`, Quick Connect | Active | ## Layer Boundary | Concern | Authority | | ------------------------------------- | --------------------------------------------- | | Public API surface (`@sideband/peer`) | This layer | | Session lifecycle and negotiators | [`runtime/session.md`](../runtime/session.md) | | Message routing and dispatch | [`runtime/router.md`](../runtime/router.md) | | Frame encoding | [`protocols/sbp/`](../protocols/sbp/) | | E2EE handshake | [`protocols/sbrp/`](../protocols/sbrp/) | | RPC envelope structure | [`protocols/rpc/`](../protocols/rpc/) | --- --- url: /protocols/sbrp/security-checklist.md --- # Security Checklist > **Authority**: Supporting (Non-normative)\ > **Purpose**: Auditor-facing verification checklist. Requirements are defined in [wire-crypto.md](./wire-crypto.md) and [state-machine.md](./state-machine.md). This checklist summarizes security requirements from normative documents. For authoritative definitions, see the referenced specifications. ## Daemon Security * \[ ] Generate Ed25519 identity key on first run * \[ ] Store identity private key with 0600 permissions * \[ ] Include daemonId in signature payload (context binding) * \[ ] Sign every ephemeral key with identity key * \[ ] Use transcript hash as HKDF salt * \[ ] Generate fresh X25519 ephemeral per session * \[ ] Use directional keys (prevent reflection) * \[ ] Implement bitmap-based sliding window (≥64 messages) * \[ ] Send `Signal(ready)` for sessions with retained state after reconnect * \[ ] Verify session state integrity before sending `Signal(ready)` after reconnect * \[ ] Send `Signal(close, reason=state_lost)` for sessions with lost state after reconnect * \[ ] Best-effort zero ephemeral keys and shared secrets after derivation * \[ ] Best-effort clear all key material on session close ## Client Security * \[ ] Pin identity key on first connection (TOFU) * \[ ] Reject connections if identity key changes * \[ ] Verify signature using pinned key, not freshly-fetched key * \[ ] Include daemonId in signature verification payload * \[ ] Use same transcript hash derivation as daemon * \[ ] Handle `Control(session_paused/resumed/pending)` state transitions * \[ ] Handle `Control(session_expired)` by initiating full reconnect ## Relay Security * \[ ] Implement rate limiting * \[ ] Validate daemon ownership before routing * \[ ] Wait for `Signal(ready)` from daemon before sending `Control(session_resumed)` to client * \[ ] Send `Control(session_pending)` to client when daemon reconnects * \[ ] Never log or inspect encrypted message payloads * \[ ] Never include identifiers in Control message text * \[ ] Respond to Ping with Pong, copying payload; never forward Ping/Pong ## Quick Connect (Optional) * \[ ] Disabled by default; require explicit operator opt-in * \[ ] Enforce code expiry server-side (5 min TTL) * \[ ] Rate-limit code generation * \[ ] Use high-entropy codes (≥128 bits) --- --- url: /guide/self-hosting.md --- # Self-Hosting the Relay This guide describes what is already decided for relay architecture and what remains implementation work. It is intentionally operationally focused, not a protocol spec. *** ## Current Status * Relay architecture is defined in [ADR-016](../adr/016-relay-server-design.md). * Protocol security/trust boundaries are defined in `docs/protocols/sbrp/threat-model.md`. * Public deployment packaging and step-by-step runbooks are not finalized yet. *** ## Relay Responsibilities A compliant relay must: * Accept authenticated WebSocket connections for daemon/client roles. * Route connections deterministically by `daemonId`. * Forward encrypted payloads opaquely (no decryption, no SBP payload inspection). * Emit SBRP control-plane notifications for session lifecycle (`session_paused`, `session_pending`, `session_resumed`, `session_ended`). *** ## Deployment Requirements * Public TLS endpoint reachable by browser and daemon. * Long-lived WebSocket support. * Stateful connection coordination per `daemonId` (for pairing and control signaling). * Authentication service for daemon credentials and short-lived client tokens. The reference hosted direction is Cloudflare Workers + Durable Objects. *** ## Security Baseline * Authenticate before WebSocket upgrade. * Keep relay stateless with respect to plaintext message content. * Treat `daemonId` as routable metadata, not proof of authorization. * Enforce bounded resource policies (connection limits, payload limits, rate limits). SBRP end-to-end encryption protects confidentiality/integrity, but does not replace relay authn/z. *** ## What This Guide Will Add Next 1. Cloudflare deployment runbook (`wrangler.toml`, DO bindings, env vars). 2. Token issuance and validation model for self-hosted control planes. 3. Operational checklist (metrics, alerting, incident playbooks, upgrade strategy). *** ## See Also * [E2EE Guide](e2ee.md) * [ADR-016](../adr/016-relay-server-design.md) * `docs/protocols/sbrp/threat-model.md` * `docs/protocols/sbrp/control-codes.md` --- --- url: /guide/server.md --- # Server `listen()` starts a server that accepts incoming peer connections. It returns a `PeerServer` that manages the connection pool. Import from `@sideband/peer/server` — this subpath is Node/Bun only and intentionally excluded from browser bundles. *** ## Basic Setup ```typescript import { listen } from "@sideband/peer/server"; const server = await listen({ endpoint: "ws://0.0.0.0:8080", onConnection(peer) { // peer is an ConnectedPeer peer.rpc.handle("greet", ({ name }) => `Hello, ${name}`); }, }); // Shutdown await server.close(); ``` *** ## ConnectedPeer vs Peer `ConnectedPeer` is the server-side counterpart to `Peer`. Key differences: | | `Peer` (client) | `ConnectedPeer` (server) | | -------------- | --------------------------------------------------------------------------------- | ------------------------------------ | | `connect()` | Yes — initiates connection | No — already connected | | `reconnecting` | Yes — managed by Peer | No | | Valid states | `idle \| connecting \| negotiating \| active \| paused \| reconnecting \| closed` | `active \| paused \| closed` | | Initial state | `idle` | `active` | | `disconnect()` | Hard close, idempotent | Hard close, idempotent | | `whenReady()` | Waits for `active` | Returns immediately (already active) | `disconnect()` on an `ConnectedPeer` closes that one connection. `server.close()` closes all. When a client reconnects, the server receives a fresh `ConnectedPeer` in `onConnection` — any handlers or state attached to the previous instance are not carried over. Design server-side per-connection setup to be re-initializable in `onConnection`. *** ## Connection Lifecycle `onConnection` is called for each accepted peer, already in `active` state. Register handlers before returning — the peer may receive messages immediately. ```typescript const server = await listen({ endpoint: "ws://0.0.0.0:8080", onConnection(peer) { console.log(`Connected: ${peer.peerId}`); peer.on("disconnected", () => { console.log(`Disconnected: ${peer.peerId}`); }); peer.rpc.handle("echo", (params) => params); }, }); ``` *** ## Server Connection Pool `server.connections` is a `ReadonlyMap` of all active connections: ```typescript // Broadcast an event to all connected peers for (const peer of server.connections.values()) { peer.events.emit("server.broadcast", { message: "hello everyone" }); } ``` `PeerId` is the identity from the handshake. For plain SBP connections, it is the `peerId` declared in the client's `PeerOptions`. When an `ConnectedPeer` transitions to `closed`, it is automatically removed from `server.connections`. *** ## Bidirectional RPC The server can call RPC on connected clients — `ConnectedPeer` has the full `rpc` interface including `rpc.call()`. ```typescript const server = await listen({ endpoint: "ws://0.0.0.0:8080", async onConnection(peer) { // Server calls client const info = await peer.rpc.call<{ version: string }>("client.info"); console.log("Client version:", info.version); // Client calls server (registered handler) peer.rpc.handle("server.ping", () => ({ ok: true })); }, }); ``` *** ## Shutdown `server.close()` transitions all `ConnectedPeer` connections to `"closed"` and stops accepting new connections. It is idempotent. ```typescript process.on("SIGTERM", () => server.close()); ``` Handlers executing at the time of `close()` run to JavaScript completion. Graceful drain (waiting for all in-flight handlers) is a v2 concern — see `docs/sdk/peer.md` Non-Goals. *** ## E2EE Server (SBRP Daemon) For E2EE sessions, pass `relayDaemonNegotiator` in `ListenOptions`. See the [E2EE guide](e2ee.md). *** ## See Also * [Concepts — Peers](concepts.md#peers) * [E2EE Relay (SBRP)](e2ee.md) * ADR-013: `/server` export split rationale * `docs/sdk/peer.md` §6.1, §6.9 — `listen()` RFC --- --- url: /runtime/session.md --- # Session Lifecycle > **Authority**: Primary (Behavioral)\ > **Purpose**: Normative behavioral contract for session lifecycle, negotiators, retry policy, and termination ordering.\ > **Status: Stub** — Normative content is captured in [ADR-009](../adr/009-runtime-peer-lifecycle.md) pending promotion to this document. ## Layer Boundary Session management sits between the transport (raw bytes) and the router (frame dispatch). It is responsible for negotiation, encryption wrapping, and lifecycle state — never for message routing or handler registration. | Layer | Responsibility | Does NOT handle | | --------- | --------------------------------------- | -------------------------- | | Transport | Connection, byte streams | Framing, security, routing | | Session | Negotiation, encryption, session state | Message dispatch, handlers | | Router | Handler registry, dispatch, correlation | Connection management | ## State Machine ``` Idle → Connecting → Negotiating → Active ⇄ RetryWait └──────────────────→ Idle (fatal / terminate()) ``` | State | Meaning | | ------------- | ----------------------------------------------------- | | `Idle` | No session. Initial and terminal state. | | `Connecting` | Transport `connect()` in progress. | | `Negotiating` | Transport open; negotiator establishing channel. | | `Active` | Ready for message exchange. | | `RetryWait` | Waiting before retry attempt (retry must be enabled). | ## Invariants * At most one `Session` may be `Active` per `Peer` in v1. * `terminate()` MUST be idempotent and cancel pending retries. * On retry, the existing session is replaced; retries never create parallel sessions. * When a `SessionChannel` distinct from the transport is present, the runtime MUST close the channel before closing the transport. * Negotiators MUST classify `ProtocolViolation` and `UnsupportedVersion` as fatal per SBP spec. * Session `state` is observable; application code SHOULD NOT branch on internal states — use events instead. ## Full Specification See [ADR-009](../adr/009-runtime-peer-lifecycle.md) for the complete normative spec including the `Negotiator` interface, `RetryPolicy`, `SessionEvents`, and termination ordering. --- --- url: /protocols/sbdp.md --- # Sideband Direct Protocol (SBDP) > **Authority**: Navigation (Non-normative)\ > **Purpose**: Overview and future roadmap for direct P2P sessions.\ > **Status: Stub** — This protocol is not yet specified. SBDP is the planned direct (peer-to-peer) session protocol for Sideband. It will provide secure SBP transport without a relay, using mutual-authenticated session negotiation (ADR-015) over direct transports such as WebRTC DataChannel, QUIC, or TCP. ## Status SBDP is intentionally not specified yet to avoid premature abstraction. The current architecture ensures SBDP can be added without changes to SBP or higher layers. ## Relationship to Other Protocols | Protocol | Role | | -------- | ------------------------------------- | | SBP | Application framing (used unchanged) | | SBRP | Relay-based secure session | | SBDP | Direct secure session (this protocol) | ## Delegation This protocol delegates: * **Wire format**: Inherits SBP frame structure (see [sbp/wire-format.md](../sbp/wire-format.md)) * **Error codes**: Reuses SBP error semantics (see [sbp/errors.md](../sbp/errors.md)) * **Ordering**: Inherits SBP ordering guarantees (see [sbp/behavior.md](../sbp/behavior.md)) This protocol will define: * **P2P session establishment**: Direct connection setup * **Noise XX session negotiation**: Mutual authentication and key derivation * **NAT traversal**: ICE/STUN/TURN or similar ## Design Principles (Preview) * No relay dependency * Secure session establishment (Noise XX per ADR-015) * Reuse SBP framing unchanged * Pluggable transport (WebRTC, raw UDP, etc.) ## Document Authority | Concern | Primary | Supporting | | ------- | ------------------- | ---------- | | All | *Not yet specified* | — | ## Related Documents * [Protocol Architecture](../stack.md): Layer stack, wrapping rules * [SBP Specification](../sbp/): Application framing (will be used unchanged) * [SBRP Specification](../sbrp/): Reference implementation for session layer *No normative specification exists at this time.* --- --- url: /protocols/sbp.md --- # Sideband Protocol (SBP) > **Authority**: Navigation (Non-normative)\ > **Purpose**: Overview and navigation for SBP application framing layer. SBP defines application-level framing for Sideband. It is topology-agnostic and can be carried over relay (SBRP) or direct (SBDP) session layers. ## Scope * Frame types and wire encoding * Handshake, message, ack, error semantics * Ordering and delivery guarantees ## Not Included * Transport (see @sideband/transport) * Encryption (see SBRP or SBDP) * Authentication ## Documents | Document | Status | | ------------------------------- | ---------- | | [Wire Format](./wire-format.md) | Normative | | [Behavior](./behavior.md) | Normative | | [Errors](./errors.md) | Normative | | [Conformance](./conformance.md) | Supporting | ## Related Documents * [Protocol Architecture](../stack.md): Layer stack, wrapping rules, dependency invariants * ADR-001: Protocol versioning * ADR-002: Naming matrix * ADR-003: Control frame invariants * ADR-004: Binary frame ID * ADR-007: Immutable frame types --- --- url: /protocols/sbrp.md --- # Sideband Relay Protocol (SBRP) **Version:** 0.1.0\ **Status:** Draft > **Authority**: Navigation (Non-normative)\ > **Purpose**: Overview, intent, roles, and navigation to normative documents. ## Overview SBRP enables secure communication between local daemons and browser-based UIs via a relay server. The protocol uses a hybrid trust model: * **Relay is trusted** for token validation, access enforcement, and routing * **Relay cannot decrypt** application payloads (end-to-end encrypted) * **Relay cannot perform undetectable MITM** on established TOFU pins This provides persistent multi-device access while ensuring message confidentiality. ```text ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Browser │◄──TLS──►│ Relay │◄──TLS──►│ Daemon │ │ │ auth │ Server │ auth │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ [auth, routing, presence] │ │ │ │ └────────── E2EE encrypted ─────────────┘ [signed handshake, encrypted payloads] ``` ### Design Goals * Persistent access from any device * Multi-device support (mobile, desktop, multiple browsers) * End-to-end encryption for application data * Cryptographic MITM protection (not just trust-based) * Standard OAuth/session authentication * Zero local TLS/certificate configuration ### Non-Goals * Protecting against compromised relay (auth/routing layer) * Transport-level anonymity * Hiding metadata (timing, message size, which daemon you're connecting to) ## Terminology **Client**: Session initiator authenticated via control plane. Generates ephemeral X25519 keys per session. No persistent cryptographic identity. Verifies daemon via TOFU. May be a browser, CLI, native app, or any non-daemon participant. **Daemon**: Long-lived agent with Ed25519 identity keypair. Registers with control plane via API key; connects to relay using presence tokens. Reachable only through relay. **Relay**: Routing authority and token validator. Not an encryption endpoint. Does not authenticate directly; validates tokens issued by control plane. ## Trust Model SBRP uses a layered trust model where the relay handles authentication and routing while being cryptographically excluded from message content. | What Relay Can Do | What Relay Cannot Do | | ---------------------------------- | ------------------------------------------ | | Validate tokens from control plane | Decrypt message content (E2EE) | | Route connections between parties | Perform undetectable MITM after TOFU trust | | See metadata (timing, size) | Forge daemon identity (Ed25519 signatures) | | Drop or delay messages (DoS) | — | **TOFU Identity Pinning**: Clients persist daemon identity public keys locally. First connection trusts the control-plane-provided key; subsequent connections verify against the pinned key. Key changes abort the connection and require user approval. See [threat-model.md](./threat-model.md) for detailed threat analysis and attack resistance. ## Protocol Flow ### Daemon Registration (One-Time) Daemon generates Ed25519 identity keypair on first run and registers with control plane. ```text Daemon Control Plane Relay │ │ │ │── register(identityPublicKey) ──►│ │ │◄─── { daemonId, presenceToken } │ │ │ │ │ │── WSS relay?token= ─────────┼─────────────────────────────►│ ``` ### Client Connection Client lists available daemons, obtains session token, and connects. ```text Client Control Plane Relay │ │ │ │── list daemons ─────────────────►│ │ │◄─── [{ id, identityPublicKey, status }] │ │ │ │ │── request session(daemonId) ────►│ │ │◄─── { relayUrl, token } ─────────│ │ │ │ │ │── WSS relayUrl?token= ──────┼─────────────────────────────►│ ``` ### E2EE Handshake After WebSocket connection, client and daemon perform authenticated key exchange. ```text Client Relay Daemon │ │ │ │── HandshakeInit (0x01) ►│── forward ─────────────►│ │ [32B X25519 key] │ │ │ │ │ Generate ephemeral │ │ │ Sign with Ed25519 │◄───────────────────────────── HandshakeAccept (0x02) │ │ [32B id + 32B key + 64B sig] │ Verify signature │ │ │ Derive session keys │ │ │◄══════════ Encrypted frames (0x03) ══════════════►│ ``` See [wire-crypto.md](./wire-crypto.md) for signature construction, key derivation, and wire format details. ## End-to-End Flow ### Happy Path Walkthrough 1. Daemon registers with control plane, stores Ed25519 identity keypair 2. Client authenticates, obtains session token targeting daemon 3. Client connects to relay, sends HandshakeInit with ephemeral X25519 key 4. Daemon signs its ephemeral key with identity key, sends HandshakeAccept 5. Client verifies signature against pinned identity (TOFU) 6. Both derive session keys via HKDF with transcript hash 7. Encrypted messages flow through relay as opaque bytes ```mermaid sequenceDiagram participant C as Client participant R as Relay participant D as Daemon C->>R: Connect + Session Token R->>D: Forward HandshakeInit D->>R: HandshakeAccept (signed ephemeral) R->>C: Forward Note over C,D: E2EE Session Established C->>R: Data (encrypted) R->>D: Forward (opaque bytes) D->>R: Data (encrypted) R->>C: Forward (opaque bytes) ``` ### Failure Path: Session Interruption 1. Daemon disconnects (network issue, process restart) 2. Relay sends `Control(session_paused)` to client 3. Client suspends sends, shows UI indicator 4. If daemon reconnects within grace period: * Relay sends `Control(session_pending)` to client * Daemon sends `Signal(ready)` for sessions with retained state * Relay sends `Control(session_resumed)` to client 5. If state is lost or grace expires: * Relay sends `Control(session_expired)` to client * Client initiates full reconnect with new handshake See [state-machine.md](./state-machine.md) for complete state transition rules. ## Message Categories | Category | Frame Type | Direction | Purpose | | --------- | ---------- | ---------------- | ----------------------------- | | Endpoint | 0x01–0x03 | E2EE | Handshake and encrypted data | | Signal | 0x04 | Daemon → Relay | Session lifecycle commands | | Control | 0x20 | Relay → Endpoint | Notifications and errors | | Keepalive | 0x10–0x11 | Either | Connection liveness detection | **Endpoint frames** participate in E2EE and are forwarded by relay without inspection. **Signal frames** are daemon-to-relay commands (ready, close). **Control frames** are relay-generated notifications (session\_paused, session\_pending, rate\_limited, backpressure). **Keepalive frames** are connection-scoped and never forwarded. See [wire-crypto.md](./wire-crypto.md) for wire format details. ## Relay Server Responsibilities The relay operates as a frame router with these core responsibilities: | Responsibility | Description | | ------------------ | --------------------------------------------------------------------------------- | | Token validation | Validate JWTs from control plane; enforce claims | | Routing | Route session-bound frames between paired connections | | Presence tracking | Track daemon online/offline; notify clients of changes | | Throttling | Rate-limit connections/throughput (non-terminal); close slow consumers (terminal) | | Keepalive handling | Respond to Ping with Pong; detect dead connections | The relay MUST NOT interpret encrypted payloads or derive information from their content. See [authentication.md](./authentication.md) for token validation details. ## Reconnection ### Daemon Reconnection Daemons may reconnect within a grace period and resume sessions if state is retained: * **State retained**: Daemon sends `Signal(ready)` → Client receives `session_resumed` * **State lost**: Daemon sends `Signal(close)` → Client receives `session_expired` * **Non-resumable daemons**: Relay auto-expires all sessions on reconnect ### Client Reconnection Clients always perform a full reconnect: 1. Obtain new session token from control plane 2. Connect to relay with fresh token 3. Full E2EE handshake (new ephemeral keys) See [state-machine.md](./state-machine.md) for state transition details. ## Multi-Device Support * Each client has an independent E2EE session with the daemon * Daemon maintains separate key state per client (keyed by SessionID) * Messages are not broadcast; each client gets its own responses * Identity pinning is per client/device storage (not synced) ## Security Properties | Property | Mechanism | | ----------------------- | --------------------------------- | | Message confidentiality | ChaCha20-Poly1305 E2EE | | Message integrity | Poly1305 authentication tag | | Daemon authenticity | Ed25519 identity signatures | | Forward secrecy | Ephemeral X25519 per session | | Replay protection | Sequence numbers + sliding window | | MITM detection | TOFU identity pinning | See [threat-model.md](./threat-model.md) for attack resistance analysis. ## Delegation **This protocol delegates:** * **Wire format**: Defines own binary framing (does not use SBP frames directly) * **Error codes**: Defines own Control code taxonomy * **Application protocol**: Encrypted payloads contain SBP frames **This protocol defines:** * **E2EE session**: Handshake, key derivation, encrypted transport * **Control codes**: Relay-to-endpoint notifications * **Signal codes**: Daemon-to-relay lifecycle commands * **Authentication**: Token-based relay access ## Document Authority | Concern | Primary | Supporting | | ---------------------- | ---------------------------------------- | ------------------------------------------------ | | Crypto + wire format | [wire-crypto.md](./wire-crypto.md) | — | | State transitions | [state-machine.md](./state-machine.md) | — | | Control code semantics | [state-machine.md](./state-machine.md) | [control-codes.md](./control-codes.md) | | Control code values | [control-codes.md](./control-codes.md) | — | | Authentication | [authentication.md](./authentication.md) | — | | Threat model | [threat-model.md](./threat-model.md) | — | | Test specification | [conformance.md](./conformance.md) | — | | Security verification | wire-crypto.md, state-machine.md | [security-checklist.md](./security-checklist.md) | ## Local Terminology | Term | Definition | See also | | -------------- | ---------------------------------------- | --------------------------------------------- | | Session Key | Derived HKDF key for this connection | [glossary](../glossary.md#session-key) | | TOFU | Trust-on-first-use identity pinning | [glossary](../glossary.md#tofu) | | Control Frame | Relay-generated notification (0x20) | [glossary](../glossary.md#control-frame-sbrp) | | Signal Frame | Daemon-to-relay lifecycle command (0x04) | — | | Endpoint Frame | Session-bound E2EE frame (0x01–0x03) | — | ## Recommended Reading Order 1. **index.md** (this document) — overview and navigation 2. **[wire-crypto.md](./wire-crypto.md)** — crypto invariants + wire format 3. **[state-machine.md](./state-machine.md)** — lifecycle and control semantics 4. **[control-codes.md](./control-codes.md)** — code catalog 5. **[authentication.md](./authentication.md)** — token validation 6. **[conformance.md](./conformance.md)** — what must pass ## Related Documents * [Protocol Architecture](../stack.md): Layer stack, wrapping rules * [Glossary](../glossary.md): Shared terminology * ADR-002: Naming matrix (SBRP abbreviation) --- --- url: /protocols/sbrp/state-machine.md --- # Sideband Relay Protocol State Machine > **Authority**: Primary (Normative)\ > **Purpose**: State transitions, lifecycle semantics, and control code behavioral definitions. Concise state models for Client, Daemon, and Relay sessions. Intended to complement the [main SBRP spec](./index.md) with implementer-facing control flow. ## Notation * Event names are in `code` format. * Transitions list the next state and key requirements. * Error transitions terminate the session unless otherwise noted. * Control codes reference §14 of the main spec. ## Control Code Quick Reference > **Note**: This table is a non-authoritative snapshot for quick reference. For canonical code values, see [control-codes.md](./control-codes.md). | Code | Name | T/N | SID | Meaning | | -----: | ------------------ | --- | --- | ---------------------- | | 0x0101 | unauthorized | T | 0 | Invalid/expired token | | 0x0102 | forbidden | T | 0 | Access denied | | 0x0201 | daemon\_not\_found | T | S | Unknown daemon ID | | 0x0202 | daemon\_offline | T | S | Daemon not connected | | 0x0301 | session\_not\_found | T | S | Unknown session ID | | 0x0302 | session\_expired | T | S | Session terminated | | 0x0401 | malformed\_frame | T | 0 | Invalid header | | 0x0402 | payload\_too\_large | T | 0 | Exceeds 64KB | | 0x0403 | invalid\_frame\_type | T | 0 | Unknown type byte | | 0x0404 | invalid\_session\_id | T | 0 | SessionID invalid | | 0x0405 | disallowed\_sender | T | S | Wrong direction | | 0x0601 | internal\_error | T | 0 | Relay internal failure | | 0x0901 | rate\_limited | N | 0 | Too many requests | | 0x0902 | backpressure | T | 0 | Send buffer full | | 0x1001 | session\_paused | N | S | Daemon disconnected | | 0x1002 | session\_resumed | N | S | Daemon ready | | 0x1003 | session\_ended | N | S | Client disconnected | | 0x1004 | session\_pending | N | S | Awaiting daemon ready | ## Client (UI) States focus on a single daemon connection. | State | Event | Next | Notes | | -------------- | -------------------------- | -------------- | ------------------------------------------------------------------------ | | `Idle` | `connect` | `Connecting` | Open WebSocket to relay with session token. | | `Connecting` | `ws_open` | `Handshaking` | Send `HandshakeInit` with client ephemeral key. | | `Connecting` | `ws_error` | `Closed` | Surface transport error. | | `Handshaking` | `HandshakeAccept` | `Active` | Verify Ed25519 signature using pinned identity key; derive session keys. | | `Handshaking` | `Control(daemon_offline)` | `Reconnecting` | Relay rejected before handshake; daemon was offline at connection time. | | `Handshaking` | `identity_key_changed` | `Closed` | MUST abort; require explicit user confirmation to accept new key. | | `Handshaking` | `handshake_failed` | `Closed` | Abort; discard ephemeral key material. | | `Active` | `Control(session_paused)` | `Paused` | Keep WebSocket open; suspend encrypted sends. | | `Active` | `Control(session_expired)` | `Reconnecting` | Session ended (state lost or grace expired); trigger full handshake. | | `Active` | `ws_close` | `Reconnecting` | Client reconnect always requires full handshake. | | `Paused` | `Control(session_pending)` | `Pending` | Daemon reconnected; await Signal(ready) or Signal(close). | | `Paused` | `Control(session_expired)` | `Reconnecting` | Trigger full handshake (new ephemeral keys). | | `Pending` | `Control(session_resumed)` | `Active` | Resume with same session keys and sequence state. | | `Pending` | `Control(session_expired)` | `Reconnecting` | Daemon sent Signal(close); trigger full handshake. | | `Reconnecting` | `ws_open` | `Handshaking` | New session token required (new `sid`); generate new ephemeral keys. | | `Reconnecting` | `ws_error` | `Closed` | Surface transport error. | | `Active` | `close` | `Closed` | User-initiated disconnect. | ## Daemon (Agent) States focus on a single client session; daemons may have many in parallel. | State | Event | Next | Notes | | -------------- | ------------------------ | -------------- | -------------------------------------------------------------------------------------------- | | `Idle` | `register` | `Registered` | Create identity keypair; register via control plane. | | `Registered` | `connect` | `Connected` | Open persistent WebSocket to relay with presence token. | | `Connected` | `HandshakeInit` | `Handshaking` | Generate ephemeral X25519 and sign with identity key. | | `Handshaking` | `HandshakeAccept_sent` | `Active` | Derive session keys; init sequence state. | | `Active` | `Control(session_ended)` | `Idle` | Per-session cleanup only; relay signals client disconnect. | | `Active` | `ws_close` | `Reconnecting` | Attempt reconnect using presence token. | | `Reconnecting` | `ws_open_within_grace` | `Connected` | Check state for each session; send `Signal(ready)` or `Signal(close)` per session. | | `Reconnecting` | `ws_open_after_grace` | `Connected` | Start new sessions; old session state is dropped. | | `Connected` | `state_retained` | `Active` | Per-session: send `Signal(ready)`; resume with retained keys, seq counters, replay window. | | `Connected` | `state_lost` | `Connected` | Per-session: send `Signal(close, reason=state_lost)`; await new `HandshakeInit` from client. | | `Connected` | `close` | `Closed` | Tear down relay connection. | ## Relay (Per-Session Pair) A relay tracks the pairing between one client connection and one daemon connection. | State | Event | Next | Notes | | ------------ | -------------------------------------- | ------------ | -------------------------------------------------------------------------------------- | | `None` | `client_ws_open` (daemon on) | `Paired` | Validate session token; bind client to daemon; forward handshake. | | `None` | `client_ws_open` (daemon off) | `Closed` | Send `Control(daemon_offline)` (T); close client. No relay pairing created. | | `None` | `daemon_ws_open` | `DaemonOnly` | Validate presence token; mark daemon online. | | `DaemonOnly` | `client_ws_open` | `Paired` | Bind client to daemon; forward handshake messages. | | `Paired` | `daemon_ws_close` | `Paused` | Send `Control(session_paused)` to client; pause routing. | | `Paired` | `Signal(close)` | `Closed` | Daemon lost state; send `Control(session_expired)` to client; close pairing. | | `Paused` | `daemon_ws_open` (no `session:resume`) | `Closed` | Non-resumable daemon; send `Control(session_expired)` to client immediately. | | `Paused` | `daemon_ws_open_within_grace` | `Pending` | Resumable daemon reconnected; send `Control(session_pending)` to client; await Signal. | | `Paused` | `client_ws_close` | `Closed` | Client disconnected while paused; tear down pairing (no notification). | | `Paused` | `grace_expired` | `Closed` | Send `Control(session_expired)` to client; close WebSocket. | | `Pending` | `Signal(ready)` | `Paired` | Send `Control(session_resumed)` to client; resume routing. | | `Pending` | `Signal(close)` | `Closed` | Daemon lost state; send `Control(session_expired)` to client; close pairing. | | `Pending` | `client_ws_close` | `Closed` | Client disconnected; send `Control(session_ended)` to daemon. | | `Pending` | `grace_expired` | `Closed` | Timeout waiting for daemon signal; send `Control(session_expired)`. | | `Paired` | `client_ws_close` | `Closed` | Send `Control(session_ended)` to daemon; tear down pairing. | ## Required Invariants * No encrypted traffic before `Active` is reached. * Client MUST obtain a new session token (new `sid`) on reconnect; no client-side session resumption. * Handshake SHOULD complete within 30 seconds; timeout triggers `handshake_failed`. * Ping/Pong frames are connection-scoped (SessionID = 0) and never forwarded. * Relay MUST respond to Ping with Pong, copying payload. ### Resumable Daemons (`"session:resume"` in `scp`) * A resumed session MUST reuse the same session keys and sequence state. * After reconnect, daemon MUST send `Signal(ready)` for sessions with retained state, `Signal(close, reason=state_lost)` for sessions with lost state. * Relay MUST send `Control(session_pending)` to client when daemon reconnects. * Relay MUST NOT send `Control(session_resumed)` until daemon sends `Signal(ready)`. * If sequence state is lost (even partially), daemon MUST send `Signal(close, reason=state_lost)` for that session before processing frames. * If the daemon process restarts or loses volatile memory, it MUST send `Signal(close, reason=state_lost)` for all sessions. ### State Integrity Verification Before sending `Signal(ready)` for a session, the daemon MUST verify that all required per-session state is present, well-formed, and internally consistent. If verification fails, daemon MUST send `Signal(close, reason=state_lost)` for that session. **Required components** (per [wire-crypto.md](./wire-crypto.md)): * Session ID * Directional traffic keys (`clientToDaemon`, `daemonToClient`) * Send sequence number * Receive replay state (`maxSeen` and bitmap) **Verification requirements:** 1. All components MUST be present 2. Keys MUST be correct length (32 bytes each) 3. Sequence numbers MUST be valid (non-negative, not exhausted) 4. Replay window MUST be consistent with `maxSeen` If any check fails → `Signal(close, reason=state_lost)` for that session. If all checks pass → `Signal(ready)` for that session. ::: tip Implementation Note For in-memory state (the common case), verification requires checking that: 1. The session object exists with all fields populated 2. Key lengths are correct (32 bytes each) 3. Sequence numbers are valid and not exhausted 4. Replay window is consistent with `maxSeen` Merely confirming field presence is necessary but not sufficient. Daemons that do not persist session state across process restarts will fail these checks on restart and MUST close all sessions. For daemons that persist state to durable storage, implementations SHOULD add an integrity check (e.g., HMAC or authenticated encryption) to detect corruption or tampering during storage. ::: ### Non-Resumable Daemons (`"session:resume"` absent from `scp`) * Relay MUST send `Control(session_expired)` to all paired clients immediately upon daemon reconnect. * Daemon need not track session IDs or send Signal frames. ### Relay Routing Rules (Frame Authority) | Frame Type | Client Can Send | Daemon Can Send | Relay Action | | --------------- | --------------- | --------------- | ------------------------- | | HandshakeInit | ✓ | ✗ | Forward to daemon | | HandshakeAccept | ✗ | ✓ | Forward to client | | Data | ✓ | ✓ | Forward to peer | | Signal | ✗ | ✓ | Process (ready/close) | | Ping | ✓ | ✓ | Respond with Pong locally | | Pong | ✓ | ✓ | Process locally | | Control | ✗ | ✗ | Relay generates only | Relay MUST validate frames in order per §13.3 (header parse → payload size → frame type → SessionID validity → direction) and return the first matching error. --- --- url: /guide/testing.md --- # Testing Sideband is straightforward to test because transport is injectable. For most integration tests, use `LoopbackTransport` from `@sideband/transport` to run client and server in one process. *** ## Minimal Loopback Pair ```typescript import { createPeer } from "@sideband/peer"; import { listen } from "@sideband/peer/server"; import { LoopbackTransport } from "@sideband/transport"; const transport = new LoopbackTransport(); const endpoint = "loopback://test"; const server = await listen({ endpoint, transport, onConnection(peer) { peer.rpc.handle<{ a: number; b: number }, number>( "add", ({ a, b }) => a + b, ); }, }); const client = createPeer({ endpoint, transport, retryPolicy: { mode: "never" }, }); await client.connect(); const result = await client.rpc.call("add", { a: 1, b: 2 }); // result === 3 await client.disconnect(); await server.close(); ``` *** ## Testing RPC + Events Together ```typescript import { test, expect } from "bun:test"; import { createPeer } from "@sideband/peer"; import { listen } from "@sideband/peer/server"; import { LoopbackTransport } from "@sideband/transport"; test("rpc triggers event", async () => { const transport = new LoopbackTransport(); const endpoint = "loopback://rpc-events"; const received: unknown[] = []; const server = await listen({ endpoint, transport, onConnection(peer) { peer.rpc.handle("task.run", ({ taskId }: { taskId: string }) => { peer.events.emit("task.completed", { taskId }); return { ok: true }; }); }, }); const client = createPeer({ endpoint, transport, retryPolicy: { mode: "never" }, }); client.events.on("task.completed", (data) => received.push(data)); await client.connect(); await client.rpc.call("task.run", { taskId: "abc" }); expect(received).toHaveLength(1); await client.disconnect(); await server.close(); }); ``` *** ## Simulating Disconnect/Reconnect `LoopbackTransport` does not expose `drop()`/`restore()` helpers. Simulate outages by closing the server and starting it again on the same endpoint. ```typescript import { createPeer } from "@sideband/peer"; import { listen } from "@sideband/peer/server"; import { LoopbackTransport } from "@sideband/transport"; async function waitFor( predicate: () => boolean, timeoutMs = 1_000, ): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start >= timeoutMs) throw new Error("waitFor timeout"); await new Promise((resolve) => setTimeout(resolve, 5)); } } const transport = new LoopbackTransport(); const endpoint = "loopback://reconnect"; let server = await listen({ endpoint, transport, onConnection() {} }); const client = createPeer({ endpoint, transport, connectionPolicy: { onDisconnect: "pause" }, retryPolicy: { mode: "on-error", initialDelayMs: 10, maxDelayMs: 50, maxAttempts: 10, jitter: 0, }, }); await client.connect(); await server.close(); // force disconnect await waitFor(() => client.state === "reconnecting"); server = await listen({ endpoint, transport, onConnection() {} }); // allow reconnect await waitFor(() => client.state === "active"); await client.disconnect(); await server.close(); ``` *** ## SBRP Coverage For encrypted session coverage, reuse the same loopback pattern but pass `relayClientNegotiator(...)` and `relayDaemonNegotiator(...)`. Current reference tests live in: * `packages/peer/src/negotiator/*.test.ts` * `packages/peer/src/peer.session-signals.test.ts` *** ## See Also * [Concepts — Sessions and Negotiators](concepts.md#sessions-and-negotiators) * `packages/peer/src/peer.test.ts` — lifecycle and RPC integration tests * `packages/transport/src/loopback.ts` — transport behavior --- --- url: /protocols/sbrp/threat-model.md --- # Threat Model > **Authority**: Primary (Normative)\ > **Purpose**: Defines attacker model, trust boundaries, and security assumptions for SBRP. ## Attacker Model SBRP considers three attacker types with progressively stronger capabilities: 1. **Network attacker**: Can observe, modify, delay, or drop traffic between any endpoints 2. **Compromised relay**: Has full control of the relay server, including routing and metadata 3. **Compromised control plane**: Can issue arbitrary tokens, forge user authorization ## Trust Boundaries | Component | Trust Level | Compromise Impact | | --------------- | ------------------ | ---------------------------------------------- | | Relay auth | Trusted | Can deny service, can't decrypt | | Relay transport | Honest-but-curious | Sees encrypted blobs only | | Client runtime | Trusted | Full compromise (inherent to web for browsers) | | Daemon | Trusted | Full compromise | ## Threat Mitigations | Threat | Attacker | Mitigation | Result | | ---------------------- | -------- | --------------------------------- | ------------------------------------- | | Read messages | Relay | ChaCha20-Poly1305 | Cannot decrypt | | MITM handshake | Relay | Ed25519 signatures + TOFU pinning | Cannot forge (after first connection) | | Key substitution | Relay | TOFU identity pinning | Detected on subsequent connections | | Replay messages | Network | Bitmap sequence window | Rejected | | Impersonate daemon | Relay | No private key access | Cannot sign | | Cross-daemon confusion | Relay | daemonId in signature payload | Rejected | | Deny service | Relay | N/A | Accepted risk | ## Attack Resistance | Attack | Mitigation | | -------------------- | ----------------------------------------------------------------------------- | | Relay reads messages | E2EE encryption | | Relay MITM | Ed25519 signatures on ephemeral keys | | Replay attack | Sequence numbers, sliding window | | Reflection attack | Directional keys | | Key substitution | Signature binds ephemeral to identity | | Session hijacking | Token-based auth; relay acts on tokens, cannot verify user intent (see below) | ## Known Limitations | Attack | Status | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | Compromised relay auth | Can deny service, can't decrypt or MITM | | Compromised client runtime | Full compromise (inherent to web apps for browsers) | | Traffic analysis | Relay sees timing, size, frequency; may infer high-level usage patterns | | Daemon identity key compromise | Must rotate key, re-register | | Relay user impersonation | Relay cannot verify user actually requested the session; client is not cryptographically authenticated to daemon | ## Assumptions * TLS between all WebSocket endpoints (browser-relay, daemon-relay) * Control plane is trusted for initial key distribution (TOFU limitation) * Daemon private keys are stored securely (file permissions `0600`) * Clients implement durable TOFU key pinning (IndexedDB or equivalent) * Replay window is implemented correctly (bitmap-based, at least 64 messages) --- --- url: /protocols/transport/abi.md --- # Transport ABI > **Authority**: Primary (Normative)\ > **Purpose**: Extended semantics for the `TransportConnection` interface\ > **See also**: ADR-005, transport/errors.md, transport/websocket.md This document extends ADR-005 with normative behavioral semantics for transport implementations. ADR-005 defines the interface shape; this document specifies runtime behavior. ## 1. Connection Lifecycle States ### 1.1 State Type ```typescript type ConnectionState = "connecting" | "open" | "closing" | "closed"; ``` ### 1.2 Interface Extensions ```typescript interface TransportConnection { /** Current connection state. */ readonly state: ConnectionState; /** * Promise that resolves when the connection closes. * MUST resolve (not reject) regardless of close reason. */ readonly closed: Promise; // ... existing members from ADR-005 } interface CloseInfo { /** True if closed via normal close handshake. */ graceful: boolean; /** Transport-specific close code, if applicable. */ closeCode?: number; /** Human-readable close reason. */ reason?: string; /** Optional; present when the close was abnormal or carries error context. */ error?: TransportError; } ``` ### 1.3 State Transitions ```text connect() called | v +------------+ | connecting | <-- Optional; MAY skip if connect() blocks until open +-----+------+ | connection established v +--------+ | open | +---+----+ | close() called OR remote close OR error v +---------+ | closing | +----+----+ | close handshake complete OR timeout v +---------+ | closed | +---------+ ``` ### 1.4 Normative Rules 1. **Initial state**: A connection returned from `connect()` MUST have `state === "connecting"` or `state === "open"`. 2. **Connecting state**: Implementations MAY expose `"connecting"` state during connection establishment. If exposed, `connect()` resolves before the connection is fully open. If not exposed, `connect()` MUST NOT resolve until state is `"open"`. 3. **Send guard**: `send()` MUST reject if `state !== "open"`. 4. **Close transition**: When `close()` is called: * `state` MUST transition to `"closing"` synchronously * `send()` MUST reject immediately after this transition 5. **Closed transition**: After the close handshake completes (or times out): * `state` MUST transition to `"closed"` * `closed` promise MUST resolve with `CloseInfo` 6. **Error transition**: On transport error: * `state` MUST transition to `"closing"` then `"closed"` * `closed` promise MUST resolve with `CloseInfo` where `graceful === false` 7. **No backwards transitions**: State transitions MUST be monotonic: `connecting -> open -> closing -> closed`. Implementations MUST NOT transition backwards. 8. **Promise resolution**: `closed` MUST resolve (not reject) for all close scenarios. Error information is conveyed via `CloseInfo.graceful` and `CloseInfo.error`. ## 2. Inbound Iterator Semantics The `inbound` property exposes received messages as an async iterable. ```typescript interface TransportConnection { readonly inbound: AsyncIterable; } ``` ### 2.1 Normative Rules 1. **Message-oriented**: Each iteration MUST yield exactly one complete message as a `Uint8Array`. Partial messages MUST NOT be yielded. 2. **Completion on graceful close**: When the connection closes cleanly: * The iterator MUST yield any buffered messages first * The iterator MUST then complete (`done: true`, `value: undefined`) 3. **Error on abnormal close**: When the connection closes abnormally: * The iterator SHOULD yield any buffered messages first (if recoverable) * The iterator MUST then throw a `TransportError` 4. **Single-consumer requirement**: At most one active iterator is allowed per connection: * Calling `[Symbol.asyncIterator]()` while another iterator is active MUST throw `TransportError` with `kind: "transport_failure"` * An iterator becomes inactive when it completes, throws, or the consumer breaks out of the loop 5. **Early break behavior**: Breaking out of a `for await` loop MUST NOT close the connection: * The connection remains open * A subsequent `for await` SHOULD resume iteration * Buffered messages received during the break SHOULD be available 6. **Post-close message delivery**: Messages buffered before close MUST be delivered before iterator completion. Implementations MUST NOT discard buffered messages on close. 7. **Backpressure**: The iterator MAY apply backpressure by delaying `next()` resolution if the consumer is slow. Implementation-defined behavior. 8. **Iterator protocol compliance**: Implementations MUST implement `return()` on the iterator object returned by `[Symbol.asyncIterator]()`. The `return()` method MUST mark the iterator inactive (releasing the single-consumer lock) and resolve any pending `next()` promise with `{ value: undefined, done: true }`. Without this, an early exit from `for await...of` (via `break`, `return`, or a thrown exception in the consumer) does not call `return()`, leaving the lock held and causing the next `[Symbol.asyncIterator]()` call to throw. ### 2.2 Example ```typescript const conn = await transport.connect(endpoint); // Normal consumption for await (const message of conn.inbound) { if (shouldStop(message)) break; // Does NOT close connection } // Can resume iteration for await (const message of conn.inbound) { process(message); } // Iterator completes when connection closes ``` ## 3. Send Concurrency and Ordering ### 3.1 Normative Rules 1. **Concurrent sends supported**: Implementations MUST support multiple concurrent `send()` calls without throwing or corrupting data. 2. **Internal serialization**: Implementations MUST serialize concurrent sends internally. Callers need not coordinate. 3. **Order preservation**: Messages MUST be delivered to the peer in `send()` call order, not promise resolution order: ```typescript // msg1 is delivered before msg2, regardless of which promise resolves first const p1 = conn.send(msg1); const p2 = conn.send(msg2); await Promise.all([p1, p2]); ``` 4. **Non-blocking semantics**: `send()` MAY resolve before the message is fully transmitted to the network. Resolution indicates the message has been accepted for sending. 5. **Error semantics**: `send()` MUST reject with `TransportError` if: * `state !== "open"` * The message exceeds `maxMessageSize` * The send buffer is full and the implementation chooses to reject (see section 4) ## 4. Backpressure Semantics ### 4.1 Normative Rules 1. **Buffer pressure tolerance**: `send()` MUST NOT reject solely due to transient buffer pressure. Implementations MUST buffer messages when the network is slow. 2. **Send buffer limit**: Implementations SHOULD enforce a maximum send buffer size: * Default: 16 MiB (configurable via `limits.maxSendBufferBytes`) * **Recommended behavior**: When exceeded, reject `send()` with `TransportError(kind: "buffer_overflow")` * Alternative: MAY close the connection with `buffer_overflow` * Using `buffer_overflow` (instead of `transport_failure`) enables distinct handling in retry logic 3. **Inbound buffer limit**: Implementations SHOULD enforce a maximum inbound buffer size: * Default: 16 MiB (configurable via `limits.maxInboundBufferBytes`) * When exceeded: MUST close connection with close code 1011 (WebSocket) and `TransportError(kind: "buffer_overflow")` * This prevents OOM when consumers are slow 4. **Buffered amount exposure**: Implementations SHOULD expose `pendingSendBytes` when the underlying transport provides it: ```typescript interface TransportConnection { /** Bytes queued for sending. Undefined if transport doesn't expose this. */ readonly pendingSendBytes?: number; } ``` ### 4.2 Clarification: Buffer Size vs Message Size | Limit | Scope | Default | Error kind | Behavior | | ----------------------- | --------------------- | ------- | ------------------- | ------------------------------------ | | `maxMessageSize` | Single message | 1 MiB | `message_too_large` | Reject immediately (close with 1009) | | `maxSendBufferBytes` | Total queued outbound | 16 MiB | `buffer_overflow` | Reject send (or close) | | `maxInboundBufferBytes` | Total queued inbound | 16 MiB | `buffer_overflow` | Close connection (1011) | These limits are independent. A valid message may be rejected if it would exceed the send buffer limit. ## 5. Connect Options All transports MUST accept a common set of connection options. ### 5.1 Interface ```typescript interface ConnectOptions { /** Connection timeout in milliseconds. */ timeoutMs?: number; /** Signal to abort the connection attempt. */ signal?: AbortSignal; } interface Transport { connect( endpoint: TransportEndpoint, options?: ConnectOptions, ): Promise; } ``` ### 5.2 Normative Rules 1. **Timeout**: If `timeoutMs` is specified and the connection is not established within that time, `connect()` MUST reject with `TransportError(kind: "timeout")`. 2. **Abort signal**: If `signal` is aborted: * Before connect starts: reject immediately with `TransportError(kind: "aborted")` * During connect: abort the attempt and reject with `TransportError(kind: "aborted")` * After connected: no effect (use `close()` instead) 3. **Extensibility**: Transport-specific options (headers, TLS, subprotocols) are defined in transport-specific specifications. See `transport/websocket.md` for WebSocket extensions. ## 6. Close Semantics ### 6.1 Interface ```typescript interface CloseOptions { /** * Transport-specific close code. * Transports that don't support close codes MAY ignore this. * Default is transport-defined (e.g., WebSocket uses 1000). */ closeCode?: number; /** Human-readable reason. */ reason?: string; } interface TransportConnection { close(options?: CloseOptions): Promise; } ``` ### 6.2 Normative Rules 1. **Idempotency**: Multiple `close()` calls MUST NOT throw. Subsequent calls after the first MUST resolve immediately (or when the first call completes). 2. **Bounded completion**: `close()` SHOULD complete within a bounded time: * Recommendation: 5 seconds maximum * Implementations MAY force-close if the peer does not respond 3. **Pending sends rejection**: After `close()` is called, subsequent `send()` calls MUST reject immediately with `TransportError`. 4. **In-flight sends**: Sends already accepted (promise returned but not resolved) MAY complete or be cancelled. Behavior is implementation-defined. 5. **Default close code**: If `closeCode` is not specified, transports SHOULD use a transport-appropriate default (e.g., WebSocket uses 1000). See transport-specific specs for details. 6. **State transition**: `close()` MUST transition `state` to `"closing"` synchronously, then to `"closed"` when complete. ## 7. Listener Accept Semantics Server-side transports expose a listener for accepting connections. ```typescript type ConnectionHandler = ( connection: TransportConnection, ) => void | Promise; interface TransportListener { readonly address: TransportEndpoint; close(): Promise; } interface Transport { listen?( endpoint: TransportEndpoint, handler: ConnectionHandler, ): Promise; } ``` ### 7.1 Normative Rules 1. **Async handler support**: `ConnectionHandler` MAY return a promise. The transport MUST NOT block on handler completion before accepting the next connection. 2. **Handler error isolation**: If the handler throws (sync or async): * The transport MUST log the error * The transport MUST close the offending connection * The transport MUST NOT crash or stop accepting connections * Other connections MUST NOT be affected 3. **Concurrent accepts**: Implementations SHOULD accept connections concurrently: * Handler execution for connection A MUST NOT block acceptance of connection B * Multiple handlers MAY execute concurrently 4. **Listener close behavior**: When `listener.close()` is called: * The listener MUST stop accepting new connections * Existing connections MUST remain valid until individually closed * `close()` SHOULD resolve promptly (not wait for existing connections) 5. **Connection independence**: Each accepted connection is independent: * One connection's failure (close, error, slow consumer) MUST NOT affect other connections * Connections share no state at the transport layer ### 7.2 Example ```typescript const listener = await transport.listen(endpoint, async (conn) => { try { for await (const msg of conn.inbound) { await conn.send(echo(msg)); } } catch (err) { console.error("Connection error:", err); } }); // Later: stop accepting, but existing connections continue await listener.close(); ``` ## 8. Summary of Guarantees | Property | Guarantee | | ------------------- | ------------------------------------------------- | | Message framing | One `send()` = one `inbound` yield | | Send ordering | Call order preserved | | Concurrent sends | Safe; internally serialized | | Iterator consumers | Single-consumer only | | Early break | Does not close connection; releases iterator lock | | Close idempotency | Safe to call multiple times | | Handler isolation | One connection's error doesn't affect others | | State observability | `state` property always reflects current state | | Close notification | `closed` promise resolves for all close types | | Outbound overflow | Reject with `buffer_overflow` (or close) | | Inbound overflow | Close with `buffer_overflow` (prevents OOM) | --- --- url: /protocols/transport/conformance.md --- # Transport Conformance > **Authority**: Primary (Normative)\ > **Purpose**: Conformance test matrix and harness specification for transport implementations.\ > **See also**: transport/abi.md, transport/errors.md, transport/websocket.md This document defines the conformance requirements and test infrastructure for transport implementations. All transports (`browser`, `node`, `loopback`) must pass the applicable tests to be considered conformant. ## 1. Test Matrix The matrix uses requirement levels: * **MUST** — Required for conformance; test failure blocks release * **SHOULD** — Strongly recommended; failure requires documented justification * **N/A** — Not applicable to this transport ### 1.1 Lifecycle Tests | Test | browser | node | loopback | Notes | | -------------------------------------------------- | ------- | ---- | -------- | ----------------------------------------------------------------- | | Connect returns valid connection | MUST | MUST | MUST | Connection has valid `id`, `endpoint`, `inbound`, `send`, `close` | | Connect rejects invalid endpoint | MUST | MUST | MUST | Invalid URL format, unreachable host | | Connect respects timeout | MUST | MUST | SHOULD | `timeoutMs` option honored | | Connect rejects with kind `aborted` on AbortSignal | MUST | MUST | SHOULD | `signal` option cancels in-flight connect | | Connect fails on subprotocol mismatch | MUST | MUST | N/A | Server doesn't accept requested subprotocol | | State transitions correctly | MUST | MUST | MUST | `connecting` -> `open` -> `closing` -> `closed` | | Listen returns listener | N/A | MUST | MUST | Browser cannot listen | | Close is idempotent | MUST | MUST | MUST | Multiple `close()` calls succeed | ### 1.2 Data Transfer Tests | Test | browser | node | loopback | Notes | | -------------------------------------- | ------- | ---- | -------- | ------------------------------------------- | | Send delivers bytes | MUST | MUST | MUST | Sent bytes received intact | | Order preserved | MUST | MUST | MUST | Sequential sends arrive in order | | Large messages (64 KB) | MUST | MUST | MUST | 65536-byte message transfers correctly | | Max message size (1 MiB boundary) | MUST | MUST | MUST | 1048576 bytes succeeds; 1048577 fails | | Concurrent sends preserve order | MUST | MUST | MUST | Parallel `send()` calls maintain call order | | Buffered messages delivered post-close | MUST | MUST | MUST | In-flight messages drain before completion | ### 1.3 Error Handling Tests | Test | browser | node | loopback | Notes | | ---------------------------------- | ------- | ---- | -------- | ----------------------------------------------------- | | Send after close rejects | MUST | MUST | MUST | `send()` on closed connection throws `TransportError` | | Inbound completes after close | MUST | MUST | MUST | Iterator yields `done: true` on graceful close | | Text frame triggers error | MUST | MUST | N/A | WebSocket text frames rejected with code 1003 | | Handler throw doesn't crash server | N/A | MUST | MUST | Exception in `ConnectionHandler` logs, closes conn | ### 1.4 Iterator Semantics Tests | Test | browser | node | loopback | Notes | | ------------------------------------- | ------- | ------ | -------- | ----------------------------------------------- | | Single consumer enforced | MUST | MUST | MUST | Second iterator throws `TransportError` | | Early break doesn't close connection | MUST | MUST | MUST | `break` from `for await` leaves connection open | | Resumes and drains buffer after break | SHOULD | SHOULD | MUST | Subsequent iteration yields buffered messages | *** ## 2. Test Harness Specification The `@sideband/testing` package provides a harness abstraction for running conformance tests against any transport implementation. ### 2.1 Core Interfaces ```typescript import type { Transport, TransportConnection, TransportListener, TransportEndpoint, TransportError, ConnectionHandler, } from "@sideband/transport"; /** * Echo server that reflects received messages back to sender. */ export interface EchoServer { /** Endpoint clients should connect to */ readonly endpoint: TransportEndpoint; /** Number of messages echoed */ readonly messageCount: number; /** Stop the echo server */ close(): Promise; } /** * Injection point for simulating transport failures. */ export type ErrorInjectionPoint = "send" | "receive" | "connect"; /** * Test harness for a specific transport implementation. * Each transport provides its own harness implementation. */ export interface TransportTestHarness { /** The transport under test */ readonly transport: Transport; /** * Create an echo server that reflects all received messages. * Useful for round-trip tests. */ createEchoServer(): Promise; /** * Create a server with custom connection handling. * @param handler Called for each accepted connection */ createServer(handler: ConnectionHandler): Promise; /** * Inject an error at the specified point. * Next operation at that point will fail with the given error. * @param point Where to inject the error * @param error The error to throw */ injectError(point: ErrorInjectionPoint, error: TransportError): void; /** * Simulate an abrupt disconnection (network failure). * @param conn The connection to disconnect */ simulateDisconnect(conn: TransportConnection): Promise; /** * Clean up all resources created by this harness. * Must be called after each test or test suite. */ cleanup(): Promise; } ``` ### 2.2 Suite Factory ````typescript /** * Creates the conformance test suite for a transport. * Uses the test framework's native describe/it functions. * * @param harness Transport-specific test harness * @param options Optional configuration * * @example * ```typescript * import { createConformanceSuite } from "@sideband/testing"; * import { createNodeHarness } from "@sideband/testing/node"; * * describe("node:ws transport conformance", () => { * const harness = createNodeHarness(); * createConformanceSuite(harness, { skip: ["subprotocol"] }); * afterAll(() => harness.cleanup()); * }); * ``` */ export function createConformanceSuite( harness: TransportTestHarness, options?: ConformanceSuiteOptions, ): void; export interface ConformanceSuiteOptions { /** * Test categories to skip (e.g., ["subprotocol", "listen"]). * Use for transports where certain tests are N/A. */ skip?: string[]; /** * Timeout for individual tests in milliseconds. * Default: 5000 */ testTimeout?: number; /** * Message sizes to test in large message tests. * Default: [64 * 1024, 1024 * 1024] */ messageSizes?: number[]; } ```` ### 2.3 Harness Factories Each transport package exports a harness factory: ```typescript // @sideband/testing/loopback export function createLoopbackHarness(): TransportTestHarness; // @sideband/testing/node export function createNodeHarness( options?: NodeHarnessOptions, ): TransportTestHarness; export interface NodeHarnessOptions { /** Port range for test servers. Default: 49152-65535 */ portRange?: [number, number]; /** Host to bind test servers. Default: "127.0.0.1" */ host?: string; } // @sideband/testing/browser (for Playwright) export function createBrowserHarness( page: Page, serverEndpoint: TransportEndpoint, ): TransportTestHarness; ``` *** ## 3. Interoperability Tests Browser-Node interoperability requires a test setup where a Node server runs and a Playwright-controlled browser connects to it. ### 3.1 Test Scenarios | Test | Description | | ----------------------------------- | --------------------------------------------------- | | Browser connects to Node server | Establish WebSocket connection from browser to Node | | Bidirectional message exchange | Send messages both directions, verify receipt | | Close propagation: client to server | Browser closes, Node sees clean close | | Close propagation: server to client | Node closes, browser sees close event | | Large message transfer (1 MiB) | Transfer 1 MiB payload in each direction | | Concurrent connections | Multiple browser tabs connect to same server | | Reconnection after disconnect | Browser reconnects after server-initiated close | ### 3.2 Playwright Test Structure ```typescript import { test, expect } from "@playwright/test"; import { createNodeServer } from "@sideband/testing/node"; import type { TransportEndpoint } from "@sideband/transport"; let serverEndpoint: TransportEndpoint; test.beforeAll(async () => { // Start Node server const server = await createNodeServer({ port: 0 }); serverEndpoint = server.endpoint; }); test.afterAll(async () => { // Cleanup }); test("browser connects to node server", async ({ page }) => { await page.goto("/test-page.html"); // Inject test code into browser context const result = await page.evaluate(async (endpoint) => { const { createWebSocketTransport } = await import("@sideband/transport-ws"); const transport = createWebSocketTransport(); const conn = await transport.connect(endpoint); const success = conn.id !== undefined; await conn.close(); return success; }, serverEndpoint); expect(result).toBe(true); }); test("bidirectional message exchange", async ({ page }) => { await page.goto("/test-page.html"); const result = await page.evaluate(async (endpoint) => { const { createWebSocketTransport } = await import("@sideband/transport-ws"); const transport = createWebSocketTransport(); const conn = await transport.connect(endpoint); // Send message const sent = new TextEncoder().encode("hello"); await conn.send(sent); // Receive echo for await (const received of conn.inbound) { await conn.close(); return new TextDecoder().decode(received) === "hello"; } return false; }, serverEndpoint); expect(result).toBe(true); }); test("large message transfer (1 MiB)", async ({ page }) => { await page.goto("/test-page.html"); const result = await page.evaluate(async (endpoint) => { const { createWebSocketTransport } = await import("@sideband/transport-ws"); const transport = createWebSocketTransport(); const conn = await transport.connect(endpoint); // Send 1 MiB message const size = 1024 * 1024; const sent = new Uint8Array(size); crypto.getRandomValues(sent); await conn.send(sent); // Receive echo and verify for await (const received of conn.inbound) { const match = received.length === size && received.every((b, i) => b === sent[i]); await conn.close(); return match; } return false; }, serverEndpoint); expect(result).toBe(true); }); ``` *** ## 4. Implementation Notes ### 4.1 Package Location Conformance tests and harnesses live in `@sideband/testing`: ```text packages/testing/ src/ harness.ts # TransportTestHarness interface suite.ts # createConformanceSuite implementation loopback.ts # Loopback transport harness node.ts # Node transport harness browser.ts # Browser harness (Playwright helper) interop/ # Interoperability test utilities tests/ loopback.test.ts # Loopback conformance tests node.test.ts # Node conformance tests interop.test.ts # Playwright interop tests ``` ### 4.2 Running Tests ```bash # Run all conformance tests bun test packages/testing # Run specific transport bun test packages/testing/tests/loopback.test.ts bun test packages/testing/tests/node.test.ts # Run interop tests (requires Playwright) bun playwright test packages/testing/tests/interop.test.ts ``` ### 4.3 Adding New Transport Conformance To add conformance tests for a new transport: 1. Create harness factory in `packages/testing/src/.ts` 2. Implement `TransportTestHarness` interface 3. Create test file in `packages/testing/tests/.test.ts` 4. Call `createConformanceSuite()` with appropriate skip list 5. Add to CI workflow Example for a hypothetical QUIC transport: ```typescript // packages/testing/src/quic.ts import type { TransportTestHarness } from "./harness.js"; import { QuicTransport } from "@sideband/transport-quic"; export function createQuicHarness(): TransportTestHarness { const transport = new QuicTransport(); const servers: TransportListener[] = []; return { transport, async createEchoServer() { // Implementation }, async createServer(handler) { // Implementation }, injectError(point, error) { // Implementation (may be no-op for real transports) }, async simulateDisconnect(conn) { // Implementation }, async cleanup() { for (const server of servers) { await server.close(); } servers.length = 0; }, }; } ``` ```typescript // packages/testing/tests/quic.test.ts import { describe, afterAll } from "bun:test"; import { createConformanceSuite } from "../src/suite.js"; import { createQuicHarness } from "../src/quic.js"; describe("quic transport conformance", () => { const harness = createQuicHarness(); createConformanceSuite(harness, { // Skip browser-only or N/A tests skip: ["text-frame"], }); afterAll(() => harness.cleanup()); }); ``` --- --- url: /protocols/transport/errors.md --- # Transport Errors > **Authority**: Primary (Normative)\ > **Purpose**: Error taxonomy and classification for transport operations\ > **See also**: [error-codes.md](../error-codes.md) (wire protocol errors), [transport/abi.md](./abi.md) ## Scope Transport errors are **local** errors that describe why a local transport operation failed. They are NOT transmitted on the wire. Protocol-level errors that travel over the wire are defined in [error-codes.md](../error-codes.md). | Concern | Defined where | Carrier | | ------------------ | --------------------- | ------------------------------ | | Transport errors | This document | `TransportError` class (local) | | SBP errors | error-codes.md | `ErrorFrame` (wire) | | RPC errors | error-codes.md | `RpcError` (wire) | | SBRP control codes | sbrp/control-codes.md | Control frame (wire) | ## Error Types ### TransportErrorKind Implementations MUST use this enumeration to classify transport errors. ```typescript export type TransportErrorKind = | "connection_refused" // Server not accepting connections | "dns_failure" // DNS resolution failed | "tls_failure" // TLS/SSL handshake or certificate error | "timeout" // Connection or operation timed out | "network_offline" // Network unavailable | "abnormal_close" // Connection dropped unexpectedly | "message_too_large" // Message exceeds size limit | "buffer_overflow" // Buffer pressure exceeded limit | "policy_violation" // CSP, CORS, or browser security policy | "authentication_failed" // Relay-level auth (headers/tokens); NOT E2EE auth | "aborted" // Explicit AbortSignal cancellation | "subprotocol_mismatch" // Subprotocol negotiation failed | "transport_failure"; // Catch-all for unmapped errors ``` **Kind semantics**: | Kind | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connection_refused` | The server actively rejected the connection attempt. The target may not be listening or may have rejected the connection. | | `dns_failure` | DNS resolution for the hostname failed. The domain may not exist or DNS may be temporarily unavailable. | | `tls_failure` | TLS/SSL handshake failed due to certificate validation error, protocol mismatch, or cryptographic failure. | | `timeout` | The operation exceeded its time limit. May indicate network congestion, slow server, or unreachable host. | | `network_offline` | The local network interface is unavailable. Browser: `navigator.onLine === false`. | | `abnormal_close` | The connection was established but closed unexpectedly without a proper close handshake. | | `message_too_large` | A single message exceeds the configured `maxMessageSize` limit. | | `buffer_overflow` | Accumulated buffer pressure exceeded the limit. Distinct from `message_too_large` (single-frame violation). | | `policy_violation` | Browser security policy (CSP, CORS, mixed content) blocked the connection or operation. | | `authentication_failed` | Transport-level authentication failed (e.g., HTTP headers, tokens). This is NOT E2EE authentication; SBRP handshake failures use endpoint codes (0xE0xx). | | `aborted` | The operation was explicitly cancelled via `AbortSignal`. | | `subprotocol_mismatch` | WebSocket subprotocol negotiation failed; server did not select a requested protocol. | | `transport_failure` | Catch-all for errors that do not map to a specific kind. | ### TransportError Implementations MUST throw `TransportError` (or a subclass) for all transport-level failures. ```typescript export class TransportError extends Error { readonly kind: TransportErrorKind; readonly cause?: unknown; constructor(kind: TransportErrorKind, message: string, cause?: unknown) { super(message); this.name = "TransportError"; this.kind = kind; this.cause = cause; } } ``` **Normative rules**: 1. `kind` MUST be a valid `TransportErrorKind` value 2. `message` SHOULD be human-readable and suitable for logging 3. `message` MUST NOT contain secrets (tokens, keys, credentials) 4. `cause` SHOULD preserve the original platform error when available 5. Implementations MUST NOT throw plain `Error` for transport failures; use `TransportError` ### CloseInfo Describes how a connection was closed. Returned by the `closed` promise on `TransportConnection`. ```typescript export interface CloseInfo { /** True if the connection closed cleanly via close handshake. */ graceful: boolean; /** Transport-specific close code (e.g., WebSocket 1000-4999) if applicable. */ closeCode?: number; /** Human-readable close reason. */ reason?: string; /** Optional; present when the close was abnormal or carries error context. */ error?: TransportError; } ``` **Normative rules**: 1. `graceful` MUST be `true` only if the close handshake completed successfully 2. `closeCode` SHOULD be present when the transport provides close code semantics (e.g., WebSocket) 3. `error` MUST be present when `graceful` is `false` and the close was triggered by an error 4. `reason` SHOULD be truncated to 123 bytes for WebSocket transports (RFC 6455 limit) ## Error Classification Matrix Implementations and runtime layers SHOULD use this matrix to determine retry behavior. | Kind | Retryable | Rationale | | ----------------------- | --------- | ------------------------------------------------------------------- | | `connection_refused` | Yes | Server may come online | | `dns_failure` | Yes | Cap at 2-3 attempts; DNS may recover | | `tls_failure` | **No** | Certificate issues do not self-resolve | | `timeout` | Yes | Transient network congestion | | `network_offline` | Yes | Wait for online event before retry | | `abnormal_close` | Yes | Transient disconnection | | `message_too_large` | **No** | Configuration mismatch; message must be split or limit increased | | `buffer_overflow` | **No** | Consumer too slow or limit too low; requires consumer or config fix | | `policy_violation` | **No** | CSP/CORS policies are permanent for the page | | `authentication_failed` | **No** | Credentials are invalid; requires user action | | `aborted` | **No** | User-initiated cancellation | | `subprotocol_mismatch` | **No** | Server does not support required subprotocol | | `transport_failure` | Yes | Unknown cause; conservative retry | **Retryability rules**: 1. "Retryable" means the same operation MAY succeed on a subsequent attempt 2. Runtime layers SHOULD implement exponential backoff for retryable errors 3. `dns_failure` retries SHOULD be capped at 2-3 attempts to avoid DNS server load 4. `network_offline` retries SHOULD wait for an online event (browser) or network interface change (Node) 5. Non-retryable errors SHOULD surface immediately to the caller without retry ## Platform Error Mapping ### Helper Functions WebSocket transports SHOULD provide these helper functions for error normalization. These are **not** part of `@sideband/transport` core; they belong in `@sideband/transport-ws`. ```typescript // @sideband/transport-ws only (not exported from @sideband/transport) /** * Normalize a platform error to TransportError. * * @param error - The platform-specific error (Error, Event, etc.) * @param closeInfo - Optional WebSocket close info for context * @returns A normalized TransportError */ export function normalizeError( error: unknown, closeInfo?: Partial, ): TransportError; /** * Infer TransportErrorKind from a WebSocket close code. * * @param closeCode - WebSocket close code (1000-4999) * @returns The corresponding TransportErrorKind */ export function errorKindFromWsCloseCode(closeCode: number): TransportErrorKind; ``` ### Browser Mapping (Heuristic) Browser WebSocket errors are opaque by design (security). Implementations MUST use heuristics to classify errors. | Condition | Kind | Rationale | | ------------------------------------------ | ---------------------- | ---------------------------------------- | | `navigator.onLine === false` | `network_offline` | Browser reports no network | | Close code 1006 + no prior frames received | `connection_refused` | Connection failed before data exchange | | Close code 1006 + had successful frames | `abnormal_close` | Connection dropped after establishment | | Close code 1008 | `policy_violation` | Policy violation (e.g., origin rejected) | | Close code 1009 | `message_too_large` | Message too big | | Close code 1002 | `transport_failure` | Protocol error | | Close code 1003 | `transport_failure` | Unsupported data | | `AbortError` or signal aborted | `aborted` | AbortSignal triggered | | No subprotocol selected | `subprotocol_mismatch` | Subprotocol negotiation failed | | Other/unknown | `transport_failure` | Catch-all | **Browser heuristic rules**: 1. Check `navigator.onLine` first; if `false`, classify as `network_offline` 2. For close code 1006, track whether any frames were successfully exchanged 3. Close code 1006 with no prior frames typically indicates connection failure 4. Close code 1006 with prior frames indicates unexpected disconnect 5. When in doubt, use `transport_failure` as the catch-all > **Implementation note**: Rules 2-4 and subprotocol detection require connection-level context (frame exchange history, handshake state) that may not be available to standalone error normalization functions. Implementations MAY classify all 1006 errors as `abnormal_close` and rely on `transport_failure` as the fallback when connection context is unavailable. The browser WebSocket API does not reliably expose subprotocol negotiation failures; only explicit close code 1010 is detectable. ### Node/Bun Mapping Node.js and Bun provide detailed error codes. Implementations SHOULD map these codes. | Error Code | Kind | Description | | -------------- | -------------------- | ---------------------------- | | `ECONNREFUSED` | `connection_refused` | Connection refused by server | | `ENOTFOUND` | `dns_failure` | DNS lookup failed | | `ETIMEDOUT` | `timeout` | Connection timed out | | `ECONNRESET` | `abnormal_close` | Connection reset by peer | | `EPIPE` | `abnormal_close` | Broken pipe | | `EHOSTUNREACH` | `connection_refused` | Host unreachable | | `ENETUNREACH` | `network_offline` | Network unreachable | | `CERT_*` | `tls_failure` | Certificate validation error | | `ERR_TLS_*` | `tls_failure` | TLS protocol error | | `ERR_SSL_*` | `tls_failure` | SSL/TLS error | | `ABORT_ERR` | `aborted` | AbortSignal triggered | | Other | `transport_failure` | Catch-all | **Node mapping rules**: 1. Check `error.code` property for Node.js system error codes 2. Check `error.name` for `AbortError` 3. TLS errors may appear as `CERT_*`, `ERR_TLS_*`, or `ERR_SSL_*` prefixes 4. Preserve the original error in `cause` for debugging ### WebSocket Close Code Mapping Standard WebSocket close codes and their classification. | Code | Name | Kind | Notes | | --------- | ------------------- | ---------------------- | ------------------------------------- | | 1000 | Normal Closure | (clean close) | `graceful: true` | | 1001 | Going Away | `abnormal_close` | Endpoint going away | | 1002 | Protocol Error | `transport_failure` | Protocol violation | | 1003 | Unsupported Data | `transport_failure` | Text frame received | | 1006 | Abnormal Closure | (see heuristic) | No close frame; use heuristic | | 1007 | Invalid Payload | `transport_failure` | Encoding error | | 1008 | Policy Violation | `policy_violation` | Origin/policy rejected | | 1009 | Message Too Big | `message_too_large` | Single frame exceeds limit | | 1010 | Mandatory Extension | `subprotocol_mismatch` | Required extension missing | | 1011 | Internal Error | `buffer_overflow` | Resource exhaustion (buffer overflow) | | 1012 | Service Restart | `abnormal_close` | Server restarting | | 1013 | Try Again Later | `abnormal_close` | Temporary overload | | 1015 | TLS Handshake | `tls_failure` | TLS failure (never sent) | | 4000-4999 | Private Use | `transport_failure` | Application-defined | ## Implementation Notes ### Error Construction Examples ```typescript // Connection refused throw new TransportError( "connection_refused", `Connection refused: ${endpoint}`, originalError, ); // Timeout with context throw new TransportError( "timeout", `Connect timeout after ${timeoutMs}ms: ${endpoint}`, originalError, ); // Message too large throw new TransportError( "message_too_large", `Message size ${size} exceeds limit ${maxMessageSize}`, undefined, ); // Buffer overflow (inbound or outbound) throw new TransportError( "buffer_overflow", `Inbound buffer overflow: ${bufferedBytes} exceeds limit ${maxInboundBufferBytes}`, undefined, ); // Aborted by signal throw new TransportError( "aborted", "Connection aborted by signal", signal.reason, ); ``` ### Logging Guidelines 1. Log `kind` as a structured field for filtering and metrics 2. Log `message` as the human-readable description 3. Log `cause` at debug level for troubleshooting 4. Do NOT log `cause` if it may contain sensitive data (headers, tokens) ## Security Considerations 1. **Credential leakage**: Error messages MUST NOT include tokens, passwords, or API keys 2. **Timing attacks**: Be cautious of error messages that reveal timing information 3. **Stack traces**: Production deployments SHOULD redact stack traces from user-facing errors 4. **Rate limiting**: Applications SHOULD rate-limit logging of repeated transport errors to prevent log flooding --- --- url: /protocols/transport/websocket.md --- # WebSocket Transport > **Authority**: Primary (Normative)\ > **Purpose**: WebSocket-specific transport rules and constraints.\ > **See also**: [transport/abi.md](./abi.md), [transport/errors.md](./errors.md), [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455) WebSocket-specific behaviors for `@sideband/transport-ws`. Complements the transport ABI with WebSocket protocol constraints. ## Binary-Only Frame Rule WebSocket frames carry binary data exclusively: * Transports MUST send binary frames only (opcode `0x02`) * Transports MUST reject inbound text frames (opcode `0x01`) * On text frame receipt: close with WebSocket code `1003` (Unsupported Data) **Rationale**: SBP frames are binary; text frames indicate misconfigured proxies or incompatible peers. ## Message Framing Transport MUST be message-oriented (v1 decision): * Each `send(Uint8Array)` transmits exactly one WebSocket message * Each `inbound` iteration yields exactly one complete message * No length-prefixing required (WebSocket provides message framing) **Note**: Stream-oriented transports (QUIC, TCP) can be supported via a `FramedTransportAdapter` that adds length-prefix framing without changing the core ABI. ## Message Size Limits | Transport | Default Max | | --------- | ----------- | | Node/Bun | 1 MiB | | Browser | 1 MiB | Configuration via `ConnectOptions.maxMessageSize`: ```typescript maxMessageSize?: number; // Default: 1048576 (1 MiB) ``` **Enforcement**: * **Node/Bun**: Configure underlying `maxPayload` option * **Browser**: Check `MessageEvent.data.byteLength` after receive **On oversized message**: 1. Close with WebSocket code `1009` (Message Too Big) 2. Surface `TransportError(kind: "message_too_large")` ## Connection Options WebSocket transports extend the base `ConnectOptions` (defined in `transport/abi.md`) with WebSocket-specific options: ```typescript import type { ConnectOptions as BaseConnectOptions } from "@sideband/transport"; interface SubprotocolOptions { /** * Subprotocols to offer during handshake. * Default: undefined (no subprotocol requested) * * For Sideband connections, set explicitly: * @example { offer: ["sideband.v1"], requireSelection: true } */ offer?: string[]; /** * Whether to fail if server doesn't select a requested subprotocol. * Default: false (transport is generic; Sideband policy is opt-in) * * Note: This is a CLIENT-SIDE option. Server uses `select` callback. */ requireSelection?: boolean; /** * Server-side: custom subprotocol selection logic. * Called with the client's offered subprotocols. * Return the selected subprotocol, or undefined to accept without subprotocol. * * Default: select first client offer that appears in server's `offer` list. * * @example * // Accept any version, prefer latest * select: (offered) => offered.includes("sideband.v2") ? "sideband.v2" : "sideband.v1" */ select?: (clientOffers: string[]) => string | undefined; } export interface ConnectOptions extends BaseConnectOptions { // Inherited from base: timeoutMs?, signal? /** * Subprotocol negotiation options. */ subprotocols?: SubprotocolOptions; /** * Connection limits. */ limits?: { /** Max single message size. Default: 1 MiB */ maxMessageSize?: number; /** Max bytes queued for sending. Default: 16 MiB */ maxSendBufferBytes?: number; /** Max bytes buffered for inbound. Default: 16 MiB */ maxInboundBufferBytes?: number; }; /** * Authentication token. Mutually exclusive with advanced.headers.Authorization. * * - Node/Bun: sent via Authorization header (default) * - Browser: sent via query param (browsers cannot set WS headers) */ auth?: { token: string; /** * How to send the token. * - "header": Authorization header (Node/Bun only, throws in browser) * - "query": URL query parameter * Default: "header" in Node/Bun, throws in browser (forces explicit choice) */ mode?: "header" | "query"; /** Header name when mode="header". Default: "Authorization" */ headerName?: string; /** Query param name when mode="query". Default: "token" */ queryParam?: string; }; /** * Advanced/escape-hatch options. Use intent-based options above when possible. * Conflicts with higher-level options cause validation errors. */ advanced?: { /** Custom headers (Node/Bun only, ignored in browser) */ headers?: Record; /** Query params to append to URL */ query?: Record; /** TLS options passthrough (Node only) */ tls?: TlsOptions; }; } // Passthrough to Node's tls.connect options // Requires tsconfig moduleResolution: "node16" | "nodenext" | "bundler" type TlsOptions = Partial; ``` **Browser header limitation**: Browser WebSocket API does not support custom headers. Use `auth: { mode: "query" }` to pass authentication tokens via URL query parameters. In browser, specifying `auth` without `mode: "query"` throws a configuration error. **Validation rules**: * If `auth` is set and `advanced.headers.Authorization` is set: throw configuration error * If `auth.mode === "header"` in browser: throw with message "Browsers cannot set WebSocket headers. Use `auth: { mode: 'query' }` explicitly." ## Subprotocol Negotiation WebSocket subprotocol negotiation rules: **Client-side**: * Default `subprotocols.offer`: `undefined` (no subprotocol requested) * Default `subprotocols.requireSelection`: `false` (transport is generic) * If `requireSelection: true` and server doesn't select a requested subprotocol: fail with `TransportError(kind: "subprotocol_mismatch")` **Server-side**: * If `subprotocols.select` callback provided: use it for custom selection logic * Otherwise, if `subprotocols.offer` provided: select first client offer that appears in server's offer list * If no match and no custom logic: accept without subprotocol (RFC 6455 allows this) **Sideband-recommended configuration** (explicit opt-in to protocol enforcement): ```typescript const conn = await transport.connect(endpoint, { subprotocols: { offer: ["sideband.v1"], requireSelection: true, // Fail if server doesn't support Sideband }, }); ``` Implementations MUST expose `readonly subprotocol?: string` on `TransportConnection` to indicate the negotiated subprotocol (undefined if none selected). ```typescript export interface TransportConnection { // ... existing members readonly subprotocol?: string; // Negotiated WebSocket subprotocol } ``` ## Keepalive Responsibility Three keepalive mechanisms exist at different layers: | Layer | Mechanism | Scope | Required? | Access | | --------- | ------------------ | ---------------- | --------- | --------- | | Transport | WebSocket ping | Connection | Optional | Node only | | SBRP | SBRP Ping (`0x10`) | Relay connection | Required | Both | | SBP | `ControlOp.Ping` | E2EE session | Optional | Both | **Orthogonality**: * **WebSocket ping**: Transparent to higher layers; TCP-level keepalive. Browser WebSocket API does not expose ping/pong; Node/Bun implementations MAY configure automatic pings. * **SBRP ping**: Relay-terminated; handles browser clients (no native ping access). Required for relay mode. * **SBP ping**: End-to-end encrypted; for application-level liveness detection. These mechanisms do not conflict; each serves a distinct purpose. ## Close Code Mapping Standard WebSocket close codes and their semantics: | Code | RFC 6455 Name | Scenario | Reason (example) | | ---- | -------------------- | ---------------------------- | ----------------------------- | | 1000 | Normal Closure | Normal shutdown | `"Session ended"` | | 1001 | Going Away | Page unload, server shutdown | `"Going away"` | | 1002 | Protocol Error | Protocol violation | `"Invalid frame"` | | 1003 | Unsupported Data | Text frame received | `"Text frames not supported"` | | 1009 | Message Too Big | Message exceeds max size | `"Frame exceeds max"` | | 1011 | Unexpected Condition | Unexpected server condition | `"Internal error"` | | 1012 | Service Restart | Service restarting | `"Service restarting"` | | 1013 | Try Again Later | Temporary overload | `"Try again later"` | **Mapping to `TransportError.kind`**: | Close Code | `TransportErrorKind` | Notes | | ---------- | ---------------------------------------- | ------------------------------------- | | 1000 | (clean close) | `CloseInfo.graceful = true` | | 1001 | `abnormal_close` | Peer going away | | 1002 | `transport_failure` | Protocol error | | 1003 | `transport_failure` | Text frame (we sent binary) | | 1006 | `abnormal_close` or `connection_refused` | Abnormal closure (see note) | | 1009 | `message_too_large` | Single message exceeds size limit | | 1011 | `buffer_overflow` | Resource exhaustion (buffer overflow) | | 1012 | `abnormal_close` | Service restart | | 1013 | `abnormal_close` | Try again later | **Note on code 1006**: This code indicates abnormal closure (no close frame received). Heuristic: * If no frames were successfully exchanged: `connection_refused` * If connection had successful frames: `abnormal_close` ## Abort/Cancellation `AbortSignal` support via `ConnectOptions.signal`: ```typescript const controller = new AbortController(); const conn = await transport.connect(url, { signal: controller.signal }); ``` **Behavior by phase**: | Phase | Abort Effect | | --------------- | -------------------------------------------------------------- | | Before connect | Reject immediately with `TransportError(kind: "aborted")` | | During connect | Close WebSocket, reject with `TransportError(kind: "aborted")` | | After connected | No effect; use `close()` instead | **Rationale**: Post-connection abort has no effect to prevent accidental disconnection. Explicit `close()` is required for intentional shutdown. ## Origin Validation (Node/Bun Servers) Server-side origin validation for DNS rebinding protection: ```typescript export interface ListenOptions { // ... existing members /** * Origin validation policy for DNS rebinding protection. * * Default behavior: * - Localhost listeners: "localhost" (allow localhost origins only) * - Non-localhost listeners: "any" (allow any origin) * * IMPORTANT: Connections without Origin header (non-browser clients) are * always allowed. Origin is a browser-only header and is not suitable * for authentication. Use proper authentication mechanisms. */ originPolicy?: | "any" // Allow any origin (including absent) | "localhost" // Allow localhost origins (absent OK) | { allow: string[] } // Allow specific origins (absent OK) | ((origin: string | undefined, request: unknown) => boolean); // Custom subprotocols?: SubprotocolOptions; limits?: { maxMessageSize?: number; maxSendBufferBytes?: number; maxInboundBufferBytes?: number; }; } ``` **Normative rules**: * Connections without `Origin` header (non-browser clients like CLI tools, other servers) MUST be allowed. Origin is a browser-only header and is not suitable for authentication. * If `originPolicy` is specified, MUST validate `Origin` header according to policy * If `originPolicy` callback throws, MUST reject connection with `TransportError(kind: "transport_failure")` * SHOULD log rejected connections for debugging **Default behavior**: | Endpoint Type | Default `originPolicy` | | ------------------------------------------- | ---------------------- | | Localhost (`127.0.0.1`, `::1`, `localhost`) | `"localhost"` | | Non-localhost | `"any"` | **Policy semantics**: | Policy value | Behavior | | -------------------------- | ---------------------------------------------------- | | `"any"` | Accept any origin, including absent | | `"localhost"` | Accept localhost origins (see list below), absent OK | | `{ allow: [...] }` | Accept listed origins, absent OK | | `(origin, req) => boolean` | Custom validation; return `true` to accept | **Localhost origins** (when `originPolicy: "localhost"`): ```typescript [ "http://localhost", "https://localhost", "http://127.0.0.1", "https://127.0.0.1", "http://[::1]", "https://[::1]", ]; ``` **Security rationale**: DNS rebinding attacks allow malicious websites to connect to localhost services by manipulating DNS responses. The `"localhost"` default for localhost listeners protects daemon use-cases where a local WebSocket server accepts connections from browser clients. **Important**: Origin validation protects against DNS rebinding, not against malicious clients. Use proper authentication for security. ## Implementation Notes ### Browser Environment * Uses native `WebSocket` API * Cannot set custom headers; use `query` option for auth tokens * Cannot access WebSocket ping/pong; rely on SBRP ping for keepalive * Check `navigator.onLine` for `network_offline` error classification * Message size check must occur after receive (`MessageEvent.data.byteLength`) ### Node/Bun Environment * Use `ws` package or Bun native WebSocket * Configure `maxPayload` for message size limits * Support `advanced.headers` and `advanced.tls` options * MAY configure automatic WebSocket ping interval * Implement `originPolicy` validation on server accept * Allow connections without `Origin` header (non-browser clients) ### Error Normalization Implementations MUST normalize platform-specific errors to `TransportError`: **Browser heuristics**: | Condition | `TransportErrorKind` | | --------------------------------------- | -------------------- | | `navigator.onLine === false` | `network_offline` | | Close code 1006 + no prior frames | `connection_refused` | | Close code 1006 + had successful frames | `abnormal_close` | | Close code 1008 | `policy_violation` | | Close code 1009 | `message_too_large` | **Node/Bun error codes**: | Error Code | `TransportErrorKind` | | --------------------- | -------------------- | | `ECONNREFUSED` | `connection_refused` | | `ENOTFOUND` | `dns_failure` | | `ETIMEDOUT` | `timeout` | | `CERT_*`, `ERR_TLS_*` | `tls_failure` | --- --- url: /why-sideband.md --- # Why Sideband? Building browser ↔ local daemon communication is harder than it looks. What starts as "just WebSockets" becomes a months-long detour through reconnection state machines, NAT traversal, and TLS certificate management. Sideband handles the hard parts so you can focus on your app. **Sideband in one sentence:** > Browser ↔ local daemon communication — without WebSocket code. ## The Problem You have a local daemon (AI agent, dev tool, background service) and need a browser UI. Sounds simple: ``` Browser → WebSocket → localhost:3000 → Daemon ``` Then users arrive. * First, reconnect bugs you cannot reproduce * Then NATs and corporate firewalls * Then HTTPS + localhost certificate hacks * Then security reviews flag DNS rebinding * Then support tickets say "it just hangs" Each issue is solvable. Together, they turn networking into your product. ## Why This Keeps Happening Browser ↔ daemon networking combines every hard thing at once: TCP state across sleep/wake, user networks you do not control, and security constraints around localhost. By the time it works for real users, you have rebuilt a small transport stack. ## Common Alternatives (and Their Limits) ### DIY WebSockets * Reconnect logic is a state machine, not a retry loop * Mixed-content and CORS lead to self-signed cert management * Every release risks regression in edge cases **Why Sideband wins:** Delete your WebSocket code. Reconnects, multiplexing, and correlation are handled. ### ngrok / Cloudflare Quick Tunnels * ngrok injects an interstitial page that breaks API clients * Cloudflare Quick Tunnels have no SSE support and aggressive phishing detection * TLS terminates at their edge, so they can inspect traffic **Why Sideband wins:** End-to-end encryption by default. No interstitials, no PKI ceremony. ### Tailscale / Mesh VPNs * Requires VPN client install for every user * Funnel needs account setup, ACL config, and port restrictions (443/8443/10000) * Bundling `tsnet` bloats binary size and memory **Why Sideband wins:** Browser-to-daemon access without VPN clients or admin console configuration. ### WebRTC DataChannels * You still need a signaling channel (usually WebSockets) * Hole-punching fails on symmetric NATs; TURN becomes mandatory * Mobile carriers aggressively timeout UDP mappings **Why Sideband wins:** Predictable relay-based sessions without signaling servers or TURN. ### Firebase / Supabase Realtime * Broadcast is at-most-once (lossy); Postgres Changes hammers WAL * Fan-out billing: 1 message to 100 subscribers = 101 billable messages * Not designed for local daemon patterns **Why Sideband wins:** Local-first RPC without cloud gravity or write amplification. ## What Sideband Does Sideband replaces your browser ↔ daemon networking layer. You do not manage: * WebSocket lifecycles and reconnection state * TLS certificates for localhost * NAT traversal or port forwarding * Multi-client coordination * Request/response correlation You get a single abstraction that works across networks and devices. | Feature | Benefit | | ---------------------- | ------------------------------------------------------ | | E2EE by default | Relay cannot decrypt your payloads | | NAT traversal | Works behind firewalls without port forwarding | | Automatic reconnection | Handles network changes, sleep/wake, flaky connections | | Multiplexed streams | Multiple concurrent requests over one connection | | Typed RPC | Request/response correlation with TypeScript types | ## How It Works Sideband is a layered stack ([protocols](/protocols/)) with two session modes: * **SBRP (relay-based)** — available today; secure sessions with end-to-end encryption * **SBDP (direct/P2P)** — specified and planned for when peers can connect directly ``` ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Browser │◄─TLS───►│ Relay │◄─TLS───►│ Daemon │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └────────── E2EE encrypted ─────────────┘ ``` The relay validates tokens, routes connections, and sees metadata (timing/size), but cannot decrypt payloads. Endpoints authenticate daemons via Ed25519 signatures and establish session keys with X25519 + ChaCha20-Poly1305 (TOFU pinning on first connection). ## When Teams Switch to Sideband * Localhost-only is blocking growth or demos * Users want remote access from another device * Reconnect bugs keep resurfacing * Security reviews flag local endpoints * You are tired of debugging networking instead of your product If any of these sound familiar, you are past the point where DIY is cheap. ## When Sideband Is Not a Fit * Cloud-native SaaS (you already have backend infra) * Apps without a browser component * Compliance-first environments (early-stage) ## Get Started See the [Getting Started](/guide/) guide to add Sideband to your project.