release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,45 @@
# @shade/transport-bridge
Transport-agnostic delivery for Shade: **WS → SSE → long-poll**, in priority
order, behind a single `IncomingMessage` interface.
```ts
import {
FallbackBridgeTransport,
WsBridge,
SseBridge,
LongPollBridge,
} from '@shade/transport-bridge';
const auth = { crypto, signingPrivateKey, address: 'bob' };
const bridge = new FallbackBridgeTransport([
new WsBridge({ baseUrl, auth }),
new SseBridge({ baseUrl, auth }),
new LongPollBridge({ baseUrl, auth }),
]);
await bridge.connect({
onMessage: (msg) => {
// msg: { from: string; bytes: Uint8Array; receivedAt: number; msgId?: string }
},
});
console.log(bridge.activeKind); // "ws" | "sse" | "long-poll"
```
Pair with `createBridgeRoutes` in `@shade/inbox-server` to expose the
matching `/v1/bridge/{stream,poll,ws}` endpoints. Full design + threat
model in [`docs/transport.md`](../../docs/transport.md).
## What it solves
Browser extensions, strict corporate proxies, and edge runtimes routinely
block long-lived WebSockets. Apps that already use the Shade inbox shouldn't
have to write three custom delivery paths to handle the realistic mix of
hostile networks they ship into. This package is the canonical answer.
## Status
V3.7. Stable wire format, additive change to `@shade/inbox-server`. See
[CHANGELOG](../../CHANGELOG.md).

View File

@@ -0,0 +1,27 @@
{
"name": "@shade/transport-bridge",
"version": "4.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@shade/core": "workspace:*",
"@shade/server": "workspace:*"
},
"optionalDependencies": {
"@shade/inbox-server": "workspace:*"
},
"peerDependencies": {
"hono": "^4"
},
"peerDependenciesMeta": {
"hono": {
"optional": true
}
},
"devDependencies": {
"@shade/crypto-web": "workspace:*",
"@shade/inbox-server": "workspace:*",
"hono": "^4.12.12"
}
}

View File

@@ -0,0 +1,64 @@
/**
* Bridge auth primitive.
*
* SSE/EventSource cannot carry custom headers in browsers, so the bridge
* protocol uses signed query parameters for every endpoint kind. The
* signature is over the canonical `{address, kind, since, signedAt}` payload
* using the recipient's Ed25519 signing key. The server looks up the owner
* key for `address` (registered via `/v1/inbox/register`) and verifies the
* signature with the same `verifyPayload` path used by the inbox.
*/
import type { CryptoProvider } from '@shade/core';
import { signPayload } from '@shade/server';
export type BridgeKind = 'stream' | 'poll' | 'ws';
export interface BridgeAuthInput {
crypto: CryptoProvider;
signingPrivateKey: Uint8Array;
address: string;
kind: BridgeKind;
since: number;
}
/**
* Returns the URLSearchParams that must be appended to the bridge URL
* (`/v1/bridge/stream`, `/v1/bridge/poll`, `/v1/bridge/ws`). The same shape
* is consumed by `verifyBridgeAuth` on the server side.
*/
export async function signBridgeQuery(input: BridgeAuthInput): Promise<URLSearchParams> {
const signed = await signPayload(input.crypto, input.signingPrivateKey, {
address: input.address,
kind: input.kind,
since: input.since,
});
const qs = new URLSearchParams();
qs.set('address', input.address);
qs.set('kind', input.kind);
qs.set('since', String(input.since));
qs.set('signedAt', String(signed.signedAt));
qs.set('signature', signed.signature);
return qs;
}
export function bridgeQueryToCanonical(qs: URLSearchParams): {
address: string;
kind: BridgeKind;
since: number;
signedAt: number;
signature: string;
} | null {
const address = qs.get('address');
const kind = qs.get('kind') as BridgeKind | null;
const sinceStr = qs.get('since');
const signedAtStr = qs.get('signedAt');
const signature = qs.get('signature');
if (!address || !kind || sinceStr === null || !signedAtStr || !signature) return null;
if (kind !== 'stream' && kind !== 'poll' && kind !== 'ws') return null;
const since = Number(sinceStr);
const signedAt = Number(signedAtStr);
if (!Number.isFinite(since) || since < 0) return null;
if (!Number.isFinite(signedAt)) return null;
return { address, kind, since, signedAt, signature };
}

View File

@@ -0,0 +1,23 @@
import { ShadeError } from '@shade/core';
/**
* Thrown when a bridge transport fails — connect rejected, malformed wire
* data, abrupt disconnect with no reconnect possible, etc.
*
* Carries `httpStatus` when the failure has a recognizable HTTP root cause
* so a fallback chain can decide whether to skip to the next bridge.
*/
export class BridgeError extends ShadeError {
constructor(message: string, public readonly httpStatus?: number) {
super('SHADE_BRIDGE_ERROR', message);
this.name = 'BridgeError';
}
override toJSON(): { name: string; code: string; message: string; httpStatus?: number } {
const base = super.toJSON();
if (this.httpStatus !== undefined) {
return { ...base, httpStatus: this.httpStatus };
}
return base;
}
}

View File

@@ -0,0 +1,78 @@
/**
* FallbackBridgeTransport — composes a chain of bridges in priority order.
*
* On `connect`, tries each bridge in turn. The first one that completes
* `connect` without throwing wins; later bridges are dropped on the floor.
* If every bridge throws, the composed call re-throws the last error so the
* caller sees a real failure rather than silent degradation.
*
* Fallback is sticky: once a transport is selected, it stays selected for
* the lifetime of the instance. To re-evaluate the chain (e.g. after a
* proxy goes down), instantiate a new `FallbackBridgeTransport`.
*/
import type { BridgeConnectOptions, BridgeTransport } from './types.js';
import { BridgeError } from './errors.js';
export class FallbackBridgeTransport implements BridgeTransport {
readonly kind = 'fallback';
private active: BridgeTransport | null = null;
private connectStarted = false;
private disposed = false;
private attemptedKinds: string[] = [];
constructor(private readonly chain: BridgeTransport[]) {
if (chain.length === 0) {
throw new BridgeError('FallbackBridgeTransport requires a non-empty chain');
}
}
/** Stable identifier of the picked transport, e.g. "ws" / "sse" / "long-poll". */
get activeKind(): string | null {
return this.active?.kind ?? null;
}
/** Read-only list of bridges that were attempted (in order) before success. */
get attempts(): readonly string[] {
return this.attemptedKinds;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('FallbackBridgeTransport.connect already called');
this.connectStarted = true;
let lastErr: Error | null = null;
for (const bridge of this.chain) {
this.attemptedKinds.push(bridge.kind);
try {
await bridge.connect(opts);
if (this.disposed) {
// disconnect was called concurrently — clean up the just-connected
// bridge before returning.
await bridge.disconnect().catch(() => {});
return;
}
this.active = bridge;
return;
} catch (err) {
lastErr = err as Error;
// Best-effort cleanup: the bridge may have started reconnect timers
// even though `connect` rejected.
try {
await bridge.disconnect();
} catch {
/* ignore */
}
}
}
throw lastErr ?? new BridgeError('all bridges failed to connect');
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.active) {
const cur = this.active;
this.active = null;
await cur.disconnect();
}
}
}

View File

@@ -0,0 +1,38 @@
/**
* @shade/transport-bridge — V3.7
*
* Transport-agnostic delivery for environments that cannot or will not run
* a WebSocket: SSE primary fallback, long-poll secondary, plus a thin WS
* adapter for the happy path. All three implementations surface the same
* {@link IncomingMessage} shape so application code stays portable.
*
* Server-side routes live in `@shade/inbox-server`'s `createBridgeRoutes`.
* Both ends share the same auth scheme: signed query parameters using the
* recipient's Ed25519 signing key, verified against the address-owner key
* registered via `/v1/inbox/register`.
*/
export type {
IncomingMessage,
IncomingMessageHandler,
BridgeConnectOptions,
BridgeTransport,
BridgeWireMessage,
} from './types.js';
export { decodeWireMessage } from './types.js';
export { BridgeError } from './errors.js';
export { signBridgeQuery, bridgeQueryToCanonical } from './auth.js';
export type { BridgeKind, BridgeAuthInput } from './auth.js';
export { SseBridge } from './sse-bridge.js';
export type { SseBridgeOptions } from './sse-bridge.js';
export { LongPollBridge } from './long-poll-bridge.js';
export type { LongPollBridgeOptions } from './long-poll-bridge.js';
export { WsBridge } from './ws-bridge.js';
export type { WsBridgeOptions } from './ws-bridge.js';
export { FallbackBridgeTransport } from './fallback.js';

View File

@@ -0,0 +1,161 @@
/**
* Long-poll bridge.
*
* Repeatedly issues `GET /v1/bridge/poll?since=<cursor>&…` with a 25-second
* default timeout (under typical proxy idle cutoffs). Each request blocks on
* the server until either a new envelope is available for the address or the
* timeout fires. The contract is simple by design — at any time exactly one
* outstanding request per client.
*
* The signed query string is regenerated for every request because the
* inbox auth layer rejects signatures older than 5 minutes — long-poll
* connections may live indefinitely, but each individual request lives at
* most `pollTimeoutMs` (server) + a small buffer.
*/
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface LongPollBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
fetch?: typeof fetch;
/** Server-side hold timeout, in ms. Default 25_000. */
pollTimeoutMs?: number;
/** Client-side request budget, must exceed pollTimeoutMs. Default +5_000. */
requestTimeoutMs?: number;
/** Initial cursor — start of stream by default. */
startCursor?: number;
/** Backoff after an unrecoverable network error. Default 2_000. */
errorBackoffMs?: number;
/** Disable auto-loop (single request only — for tests). */
disableLoop?: boolean;
}
const DEFAULT_POLL_TIMEOUT_MS = 25_000;
const DEFAULT_ERROR_BACKOFF_MS = 2_000;
export class LongPollBridge implements BridgeTransport {
readonly kind = 'long-poll';
private readonly fetchFn: typeof fetch;
private cursor: number;
private disposed = false;
private connectStarted = false;
private inflight: AbortController | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:long-poll]', err.message);
private loopPromise: Promise<void> | null = null;
constructor(private readonly options: LongPollBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('LongPollBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
// Single probe request — establishes that the endpoint is reachable and
// authenticates correctly. We surface auth/4xx errors as connect-time
// failures so a fallback chain can switch to the next transport.
await this.pollOnce({ probe: true });
if (this.options.disableLoop) return;
this.loopPromise = this.runLoop();
}
async disconnect(): Promise<void> {
this.disposed = true;
this.inflight?.abort();
if (this.loopPromise) {
try {
await this.loopPromise;
} catch {
/* swallow loop teardown errors */
}
}
}
getCursor(): number {
return this.cursor;
}
private async runLoop(): Promise<void> {
while (!this.disposed) {
try {
await this.pollOnce({ probe: false });
} catch (err) {
if (this.disposed) return;
this.onError(err as Error);
await sleep(this.options.errorBackoffMs ?? DEFAULT_ERROR_BACKOFF_MS);
}
}
}
private async pollOnce(args: { probe: boolean }): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'poll',
since: this.cursor,
});
const pollTimeoutMs = this.options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
qs.set('timeoutMs', String(pollTimeoutMs));
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/poll?${qs.toString()}`;
this.inflight = new AbortController();
const requestBudget = this.options.requestTimeoutMs ?? pollTimeoutMs + 5_000;
const watchdog = setTimeout(() => this.inflight?.abort(), requestBudget);
try {
const res = await this.fetchFn(url, {
method: 'GET',
headers: { accept: 'application/json' },
signal: this.inflight.signal,
});
if (!res.ok) {
// For a probe call, surface the error so the caller can fall back.
// For a steady-state call, the loop's catch will rate-limit retries.
throw new BridgeError(`long-poll failed: HTTP ${res.status}`, res.status);
}
const body = (await res.json()) as { blobs: BridgeWireMessage[]; cursor?: number };
if (!Array.isArray(body.blobs)) {
throw new BridgeError('long-poll body missing `blobs` array');
}
for (const wire of body.blobs) {
if (typeof wire.receivedAt === 'number' && wire.receivedAt > this.cursor) {
this.cursor = wire.receivedAt;
}
const msg = decodeWireMessage(wire);
if (this.onMessage) {
try {
await this.onMessage(msg);
} catch (err) {
this.onError(err as Error);
}
}
}
if (typeof body.cursor === 'number' && body.cursor > this.cursor) {
this.cursor = body.cursor;
}
} catch (err) {
if (this.disposed) return;
// Probe call surfaces the error; loop call passes it back to the loop.
if (args.probe) throw err;
throw err;
} finally {
clearTimeout(watchdog);
}
}
}
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,246 @@
/**
* SSE (Server-Sent Events) bridge.
*
* Connects to `<base>/v1/bridge/stream?…` with a signed query string and
* parses an SSE feed manually using `fetch` + `ReadableStream`. We intentionally
* do NOT use the platform `EventSource` because:
*
* 1. `EventSource` does not let callers attach custom headers — query-param
* auth is the only portable route, but custom retry / reconnect knobs
* are also useful and the platform API hides them.
* 2. `EventSource` is not available in Node by default; rolling our own
* reader keeps the package zero-dep across runtimes.
*
* The wire format follows the standard SSE spec:
* `id: <receivedAt>\nevent: envelope\ndata: <json>\n\n`
*
* Only the `envelope` event carries payload; comment lines (`: ping`) are
* tolerated as keepalives. The `id` field is fed back to the server as
* `Last-Event-ID` (also encoded as the `since` query param) on reconnect to
* resume from the highest-seen cursor.
*/
import type { BridgeConnectOptions, BridgeTransport, IncomingMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface SseBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
/** Override `fetch` (tests). */
fetch?: typeof fetch;
/** Initial reconnect backoff (ms). Default 250. */
initialBackoffMs?: number;
/** Max backoff (ms). Default 10_000. */
maxBackoffMs?: number;
/** Initial cursor — start of stream by default. */
startCursor?: number;
/** Disable auto-reconnect (tests). Default false. */
disableAutoReconnect?: boolean;
}
const DEFAULT_INITIAL_BACKOFF = 250;
const DEFAULT_MAX_BACKOFF = 10_000;
export class SseBridge implements BridgeTransport {
readonly kind = 'sse';
private readonly fetchFn: typeof fetch;
private cursor: number;
private abortController: AbortController | null = null;
private connected = false;
private disposed = false;
private connectStarted = false;
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:sse]', err.message);
constructor(private readonly options: SseBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('SseBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
// Open the first connection; throw if it fails immediately so callers
// can fall back to a different transport.
await this.openOnce();
// Spawn the read loop; subsequent reconnects happen in the background.
void this.readLoop();
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.currentReader) {
try {
await this.currentReader.cancel();
} catch {
/* ignore */
}
}
this.abortController?.abort();
this.connected = false;
}
/** Public so tests / observability can read the latest cursor. */
getCursor(): number {
return this.cursor;
}
/**
* Opens a single SSE connection and stores the reader on the instance.
* Throws on hard errors (network refused, non-200 status). Caller drives
* the read loop.
*/
private async openOnce(): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'stream',
since: this.cursor,
});
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/stream?${qs.toString()}`;
this.abortController = new AbortController();
let res: Response;
try {
res = await this.fetchFn(url, {
method: 'GET',
headers: {
accept: 'text/event-stream',
'cache-control': 'no-cache',
'last-event-id': String(this.cursor),
},
signal: this.abortController.signal,
});
} catch (err) {
throw new BridgeError(`SSE connect failed: ${(err as Error).message}`);
}
if (!res.ok) {
throw new BridgeError(`SSE connect failed: HTTP ${res.status}`, res.status);
}
if (!res.body) {
throw new BridgeError('SSE response has no body');
}
this.currentReader = res.body.getReader();
this.connected = true;
}
private async readLoop(): Promise<void> {
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
while (!this.disposed) {
try {
if (!this.currentReader) {
await this.openOnce();
}
await this.consume(this.currentReader!);
// Stream closed cleanly — server-side close. Reconnect.
} catch (err) {
if (this.disposed) return;
this.onError(err as Error);
}
this.currentReader = null;
this.connected = false;
if (this.disposed || this.options.disableAutoReconnect) return;
await sleep(backoff);
backoff = Math.min(backoff * 2, maxBackoff);
}
}
private async consume(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
const decoder = new TextDecoder();
let buf = '';
let dataLines: string[] = [];
let eventName: string | null = null;
let eventId: string | null = null;
while (true) {
const { value, done } = await reader.read();
if (done) return;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const rawLine = buf.slice(0, idx);
buf = buf.slice(idx + 1);
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line === '') {
// dispatch
if (dataLines.length > 0) {
const dataStr = dataLines.join('\n');
await this.handleEvent(eventName, eventId, dataStr);
}
dataLines = [];
eventName = null;
eventId = null;
continue;
}
if (line.startsWith(':')) continue; // comment / keepalive
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
switch (field) {
case 'data':
dataLines.push(val);
break;
case 'event':
eventName = val;
break;
case 'id':
eventId = val;
break;
// 'retry' ignored; we drive backoff ourselves.
}
}
}
}
private async handleEvent(name: string | null, id: string | null, data: string): Promise<void> {
if (id !== null) {
const n = Number(id);
if (Number.isFinite(n) && n > this.cursor) this.cursor = n;
}
if (name && name !== 'envelope' && name !== 'message' && name !== '') {
// Ignore non-payload events (e.g. ready, heartbeat).
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch (err) {
this.onError(new BridgeError(`malformed SSE data: ${(err as Error).message}`));
return;
}
const wire = parsed as { msgId: string; ciphertext: string; receivedAt: number; from?: string };
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
this.onError(new BridgeError('SSE event missing required fields'));
return;
}
const msg: IncomingMessage = decodeWireMessage(wire);
if (this.onMessage !== null) {
try {
await this.onMessage(msg);
} catch (err) {
this.onError(err as Error);
}
}
}
/** True between connect()'s first successful open and disconnect/error. */
get isConnected(): boolean {
return this.connected;
}
}
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,94 @@
/**
* Common types for the @shade/transport-bridge package.
*
* The bridge layer abstracts "how do I receive ciphertext envelopes" so that
* an application can stay transport-agnostic. Three implementations ship in
* v0.1.0: WS, SSE, long-poll. A `FallbackBridgeTransport` wraps them in
* priority order so a client blocked from WebSockets by a strict proxy
* automatically slides down to SSE, then long-poll.
*
* Every bridge surfaces the same {@link IncomingMessage} shape — application
* code never branches on transport.
*/
/**
* A single transport-level incoming message.
*
* `from` is the relay-known sender fingerprint (8-byte hex of the sender's
* Ed25519 signing key, computed by the inbox server at PUT time). It is
* empty for legacy blobs that were stored before the inbox started tracking
* sender provenance, and for non-inbox bridges that have no upstream sender
* notion. The authoritative sender identity always lives inside the Double
* Ratchet envelope and is recovered post-decrypt — `from` is a hint, not a
* trust anchor.
*/
export interface IncomingMessage {
from: string;
bytes: Uint8Array;
receivedAt: number;
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
msgId?: string;
}
/** Subscriber callback. Bridges MAY invoke it concurrently. */
export type IncomingMessageHandler = (msg: IncomingMessage) => void | Promise<void>;
export interface BridgeConnectOptions {
onMessage: IncomingMessageHandler;
/** Caller-supplied error handler; defaults to console.warn. */
onError?: (err: Error) => void;
}
/**
* Common contract for every bridge transport.
*
* Implementations are expected to:
* - block in `connect` until they have either established a session OR
* surfaced a hard failure as a thrown error;
* - keep delivering messages until `disconnect` is called;
* - reconnect transparently on transient drops while bumping the cursor
* to avoid duplicate delivery (Last-Event-ID for SSE, `since` cursor for
* long-poll, etc.).
*
* Bridges are *single-use*: call `connect` once, then `disconnect`. Calling
* `connect` twice on the same instance must throw.
*/
export interface BridgeTransport {
connect(options: BridgeConnectOptions): Promise<void>;
disconnect(): Promise<void>;
/** Resolves to a stable identifier ("ws" | "sse" | "long-poll" | …). */
readonly kind: string;
}
/**
* Wire-format for a single message inside an SSE event, long-poll batch
* entry, or WS frame. The bridge protocol is JSON-over-the-wire with
* base64-encoded ciphertext — the same shape regardless of transport.
*/
export interface BridgeWireMessage {
msgId: string;
/** base64 ciphertext */
ciphertext: string;
receivedAt: number;
expiresAt?: number;
/** Sender fingerprint hint (may be empty). */
from?: string;
}
export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage {
const bytes = base64ToBytes(wire.ciphertext);
const msg: IncomingMessage = {
from: wire.from ?? '',
bytes,
receivedAt: wire.receivedAt,
};
if (wire.msgId !== undefined) msg.msgId = wire.msgId;
return msg;
}
function base64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

View File

@@ -0,0 +1,205 @@
/**
* WebSocket bridge.
*
* Connects to `<base>/v1/bridge/ws?…` (where `base` uses the http(s):// URL
* — the bridge swaps to ws(s):// internally) and consumes JSON-encoded
* `BridgeWireMessage` frames pushed by the server.
*
* Auth uses the same signed query-string scheme as SSE/long-poll. The server
* runs the verification at upgrade time and rejects upgrades with a 4xx;
* once the WebSocket is open, frames are unauthenticated at the WS layer
* (the address-owner relationship is bound at upgrade and the connection is
* dedicated to that address).
*/
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';
export interface WsBridgeOptions {
baseUrl: string;
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
/** WebSocket constructor override (browsers vs Node vs Bun). */
WebSocketCtor?: typeof WebSocket;
/** Connect timeout (ms). Default 5_000. */
connectTimeoutMs?: number;
/** Initial cursor. */
startCursor?: number;
/** Disable auto-reconnect on drop. Default false. */
disableAutoReconnect?: boolean;
/** Initial reconnect backoff (ms). Default 250. */
initialBackoffMs?: number;
/** Max reconnect backoff (ms). Default 10_000. */
maxBackoffMs?: number;
}
const DEFAULT_CONNECT_TIMEOUT = 5_000;
const DEFAULT_INITIAL_BACKOFF = 250;
const DEFAULT_MAX_BACKOFF = 10_000;
export class WsBridge implements BridgeTransport {
readonly kind = 'ws';
private readonly Ctor: typeof WebSocket;
private cursor: number;
private connectStarted = false;
private disposed = false;
private current: WebSocket | null = null;
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
console.warn('[shade-bridge:ws]', err.message);
private reconnectPromise: Promise<void> | null = null;
constructor(private readonly options: WsBridgeOptions) {
const ctor =
options.WebSocketCtor ??
((globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket);
if (ctor === undefined) {
throw new BridgeError('WebSocket constructor not available; pass options.WebSocketCtor');
}
this.Ctor = ctor;
this.cursor = options.startCursor ?? 0;
}
async connect(opts: BridgeConnectOptions): Promise<void> {
if (this.connectStarted) throw new BridgeError('WsBridge.connect already called');
this.connectStarted = true;
this.onMessage = opts.onMessage;
if (opts.onError) this.onError = opts.onError;
await this.openOnce();
}
async disconnect(): Promise<void> {
this.disposed = true;
if (this.current) {
try {
this.current.close(1000, 'client disconnect');
} catch {
/* ignore */
}
this.current = null;
}
if (this.reconnectPromise) {
try {
await this.reconnectPromise;
} catch {
/* swallow */
}
}
}
getCursor(): number {
return this.cursor;
}
private async openOnce(): Promise<void> {
const qs = await signBridgeQuery({
crypto: this.options.auth.crypto,
signingPrivateKey: this.options.auth.signingPrivateKey,
address: this.options.auth.address,
kind: 'ws',
since: this.cursor,
});
const url = `${toWsUrl(this.options.baseUrl)}/v1/bridge/ws?${qs.toString()}`;
const ws = new this.Ctor(url);
ws.binaryType = 'arraybuffer';
const connectTimeoutMs = this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
try {
ws.close();
} catch {
/* ignore */
}
reject(new BridgeError(`WS connect timeout to ${url}`));
}, connectTimeoutMs);
ws.addEventListener('open', () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener('error', () => {
clearTimeout(timer);
reject(new BridgeError(`WS connect error to ${url}`));
});
});
this.current = ws;
this.attachListeners(ws);
}
private attachListeners(ws: WebSocket): void {
ws.addEventListener('message', (ev) => {
const data = ev.data;
let text: string;
if (typeof data === 'string') {
text = data;
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder().decode(data);
} else {
this.onError(new BridgeError('WS frame neither text nor ArrayBuffer'));
return;
}
let wire: BridgeWireMessage;
try {
wire = JSON.parse(text);
} catch (err) {
this.onError(new BridgeError(`malformed WS frame: ${(err as Error).message}`));
return;
}
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
this.onError(new BridgeError('WS frame missing required fields'));
return;
}
if (wire.receivedAt > this.cursor) this.cursor = wire.receivedAt;
const msg = decodeWireMessage(wire);
if (this.onMessage) {
Promise.resolve(this.onMessage(msg)).catch((err) => this.onError(err as Error));
}
});
const onCloseOrError = (): void => {
if (this.disposed) return;
this.current = null;
if (this.options.disableAutoReconnect) return;
this.reconnectPromise = this.reconnectLoop();
};
ws.addEventListener('close', onCloseOrError);
ws.addEventListener('error', () => {
// Treat error as a close — the platform fires 'close' too in most
// implementations, but Node's `ws` library does not always.
if (this.current === ws) {
try {
ws.close();
} catch {
/* ignore */
}
}
});
}
private async reconnectLoop(): Promise<void> {
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
while (!this.disposed) {
await sleep(backoff);
if (this.disposed) return;
try {
await this.openOnce();
return;
} catch (err) {
this.onError(err as Error);
backoff = Math.min(backoff * 2, maxBackoff);
}
}
}
}
function toWsUrl(httpUrl: string): string {
const trimmed = httpUrl.endsWith('/') ? httpUrl.slice(0, -1) : httpUrl;
if (trimmed.startsWith('https://')) return 'wss://' + trimmed.slice('https://'.length);
if (trimmed.startsWith('http://')) return 'ws://' + trimmed.slice('http://'.length);
return trimmed; // already ws(s)://
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,512 @@
/**
* Bridge integration tests — exercises real Bun.serve + InboxServer +
* createBridgeRoutes against actual SSE / long-poll / WS clients.
*
* The acceptance criteria from V3.7 we cover here:
* 1. "Send 100 small messages" passes on all three transports.
* 2. WS-blocked client falls through to SSE without message loss.
* 3. Long-poll uses no more than one outstanding request per client.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Hono } from 'hono';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
createInboxRoutes,
createBridgeRoutes,
InboxServerEvents,
MemoryInboxStore,
computeMsgId,
type InboxStore,
} from '@shade/inbox-server';
import { signPayload } from '@shade/server';
import { generateIdentityKeyPair, toBase64 } from '@shade/core';
import {
SseBridge,
LongPollBridge,
WsBridge,
FallbackBridgeTransport,
signBridgeQuery,
type IncomingMessage,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
interface Harness {
server: ReturnType<typeof Bun.serve>;
baseUrl: string;
store: InboxStore;
events: InboxServerEvents;
bob: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
alice: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
}
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
const store = new MemoryInboxStore();
const events = new InboxServerEvents();
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
const bridge = createBridgeRoutes({
store,
crypto,
events,
longPollTimeoutMs: 1_000,
longPollMaxTimeoutMs: 2_000,
heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50,
});
const app = new Hono();
app.route('/', inboxApp);
app.route('/', bridge.app);
const port = 19000 + Math.floor(Math.random() * 1000);
const server = Bun.serve({
port,
fetch: (req, srv) => app.fetch(req, srv),
websocket: opts.mountWs === false ? undefined : (bridge.websocket as any),
});
// Register Bob.
const bob = await generateIdentityKeyPair(crypto);
const alice = await generateIdentityKeyPair(crypto);
const regBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
const regRes = await fetch(`http://localhost:${port}/v1/inbox/register`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(regBody),
});
expect(regRes.status).toBe(200);
return {
server,
baseUrl: `http://localhost:${port}`,
store,
events,
bob,
alice,
};
}
async function putBlob(harness: Harness, ciphertext: Uint8Array): Promise<string> {
const msgId = await computeMsgId(ciphertext);
const body = await signPayload(crypto, harness.alice.signingPrivateKey, {
senderSigningKey: toBase64(harness.alice.signingPublicKey),
msgId,
ciphertext: toBase64(ciphertext),
});
const res = await fetch(`${harness.baseUrl}/v1/inbox/bob`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
return msgId;
}
function rand(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
function bobAuth(harness: Harness) {
return {
crypto,
signingPrivateKey: harness.bob.signingPrivateKey,
address: 'bob',
};
}
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await new Promise((r) => setTimeout(r, 25));
}
throw new Error(`waitFor timeout: predicate still false after ${timeoutMs}ms`);
}
describe('signBridgeQuery', () => {
test('produces deterministic shape', async () => {
const id = await generateIdentityKeyPair(crypto);
const qs = await signBridgeQuery({
crypto,
signingPrivateKey: id.signingPrivateKey,
address: 'foo',
kind: 'stream',
since: 42,
});
expect(qs.get('address')).toBe('foo');
expect(qs.get('kind')).toBe('stream');
expect(qs.get('since')).toBe('42');
expect(qs.get('signedAt')).toMatch(/^\d+$/);
expect(qs.get('signature')).toBeTruthy();
});
});
describe('SseBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over SSE', async () => {
const received: IncomingMessage[] = [];
const bridge = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 500,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(32 + (i % 8));
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 5_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
expect(ids.size).toBe(100);
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
});
describe('LongPollBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over long-poll', async () => {
const received: IncomingMessage[] = [];
const bridge = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 500,
requestTimeoutMs: 1_500,
errorBackoffMs: 50,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(48);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 8_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
test('one outstanding request at a time', async () => {
let inflight = 0;
let peak = 0;
const wrapped: typeof fetch = async (input, init) => {
inflight++;
peak = Math.max(peak, inflight);
try {
return await fetch(input as any, init as any);
} finally {
inflight--;
}
};
const bridge = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 200,
requestTimeoutMs: 500,
errorBackoffMs: 50,
fetch: wrapped,
});
const received: IncomingMessage[] = [];
await bridge.connect({ onMessage: (m) => void received.push(m) });
// Let the loop run a few times; nothing else should be pumping the
// bridge concurrently.
await new Promise((r) => setTimeout(r, 1_500));
expect(peak).toBeLessThanOrEqual(1);
await bridge.disconnect();
});
});
describe('WsBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over WS', async () => {
const received: IncomingMessage[] = [];
const bridge = new WsBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
connectTimeoutMs: 2_000,
disableAutoReconnect: true,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(40);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 5_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
});
describe('FallbackBridgeTransport', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('falls through WS → SSE when WS endpoint is blocked', async () => {
const received: IncomingMessage[] = [];
const ws = new WsBridge({
baseUrl: 'http://127.0.0.1:1', // unreachable on purpose
auth: bobAuth(h),
connectTimeoutMs: 500,
disableAutoReconnect: true,
});
const sse = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 500,
});
const lp = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 500,
requestTimeoutMs: 1_500,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
await fallback.connect({
onMessage: (m) => {
received.push(m);
},
});
expect(fallback.activeKind).toBe('sse');
expect(fallback.attempts).toEqual(['ws', 'sse']);
const sent: string[] = [];
for (let i = 0; i < 10; i++) {
const ct = rand(32);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 10, 5_000);
expect(received.length).toBe(10);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await fallback.disconnect();
});
test('falls through SSE → long-poll when SSE endpoint returns 502', async () => {
// Build a server where SSE explicitly returns 502; long-poll passes
// through. We build a small Hono shim that wraps the bridge app.
const port = h.server.port;
const altPort = port + 1;
const store = h.store; // share data with the main harness
const events = h.events;
const bridge = createBridgeRoutes({
store,
crypto,
events,
longPollTimeoutMs: 500,
longPollMaxTimeoutMs: 1_000,
heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50,
});
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
const wrapped = new Hono();
wrapped.get('/v1/bridge/stream', (c) => c.text('bad gateway', 502));
wrapped.route('/', bridge.app);
wrapped.route('/', inboxApp);
const altServer = Bun.serve({
port: altPort,
fetch: (req, srv) => wrapped.fetch(req, srv),
websocket: bridge.websocket as any,
});
try {
const altUrl = `http://localhost:${altPort}`;
const received: IncomingMessage[] = [];
const sse = new SseBridge({
baseUrl: altUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
const lp = new LongPollBridge({
baseUrl: altUrl,
auth: bobAuth(h),
pollTimeoutMs: 200,
requestTimeoutMs: 600,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([sse, lp]);
await fallback.connect({
onMessage: (m) => {
received.push(m);
},
});
expect(fallback.activeKind).toBe('long-poll');
// Push a message via the original PUT path.
const ct = rand(32);
await putBlob(h, ct);
await waitFor(() => received.length >= 1, 4_000);
expect(received.length).toBeGreaterThanOrEqual(1);
await fallback.disconnect();
} finally {
altServer.stop(true);
}
});
test('throws when every bridge fails', async () => {
const ws = new WsBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
connectTimeoutMs: 200,
disableAutoReconnect: true,
});
const sse = new SseBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
initialBackoffMs: 50,
maxBackoffMs: 100,
disableAutoReconnect: true,
});
const lp = new LongPollBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
pollTimeoutMs: 100,
requestTimeoutMs: 300,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
await expect(
fallback.connect({ onMessage: () => undefined }),
).rejects.toThrow();
await fallback.disconnect();
});
});
describe('Bridge auth — rejects bad signatures', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('SSE rejects when signature is wrong', async () => {
const eve = await generateIdentityKeyPair(crypto);
const sse = new SseBridge({
baseUrl: h.baseUrl,
auth: { crypto, signingPrivateKey: eve.signingPrivateKey, address: 'bob' },
initialBackoffMs: 50,
maxBackoffMs: 100,
disableAutoReconnect: true,
});
await expect(sse.connect({ onMessage: () => undefined })).rejects.toThrow();
await sse.disconnect();
});
test('long-poll rejects when address is unregistered', async () => {
const newId = await generateIdentityKeyPair(crypto);
const lp = new LongPollBridge({
baseUrl: h.baseUrl,
auth: { crypto, signingPrivateKey: newId.signingPrivateKey, address: 'unregistered' },
pollTimeoutMs: 200,
requestTimeoutMs: 500,
errorBackoffMs: 50,
});
await expect(lp.connect({ onMessage: () => undefined })).rejects.toThrow();
await lp.disconnect();
});
});
describe('Bridge cursor resume', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('SSE picks up after disconnect using Last-Event-ID cursor', async () => {
const first: IncomingMessage[] = [];
const sseA = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
await sseA.connect({ onMessage: (m) => first.push(m) });
await putBlob(h, rand(20));
await putBlob(h, rand(24));
await waitFor(() => first.length === 2, 3_000);
const cursor = sseA.getCursor();
await sseA.disconnect();
// Push more while disconnected.
await putBlob(h, rand(28));
await putBlob(h, rand(32));
const second: IncomingMessage[] = [];
const sseB = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
startCursor: cursor,
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
await sseB.connect({ onMessage: (m) => second.push(m) });
await waitFor(() => second.length === 2, 3_000);
expect(second.length).toBe(2);
// No overlap with the first batch.
const firstIds = new Set(first.map((m) => m.msgId));
for (const m of second) expect(firstIds.has(m.msgId!)).toBe(false);
await sseB.disconnect();
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}