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,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));
}