Files
Shade/docs/transport.md
Sterister e6fdf31b49
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
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00

9.5 KiB

Shade Transport — Bridge Layer (V3.7)

Looking for V3.11 (peer-to-peer chunk transport via RTCDataChannel)? See docs/webrtc.md. This page covers the V3.7 bridge layer that ships ciphertext envelopes (control plane) over WS / SSE / long-poll. The two are orthogonal: the bridge handles store-and-forward control envelopes; WebRTC handles direct chunk data.

The bridge layer is the answer to: "my client is a browser extension / strict-corp-proxy / edge-runtime / iOS app — I cannot keep a WebSocket open. How do I receive ciphertext envelopes?"

It is built on top of the V3.6 inbox: every transport delivers the same inbox blobs, with the same authentication semantics. Application code sees a single IncomingMessage shape and never branches on transport.

┌─────────────────────────────────────────────────────────────────┐
│                       application code                          │
│                                                                 │
│   bridge.connect({ onMessage: (m) => decrypt(m.bytes) })        │
└────────────────────────────────┬────────────────────────────────┘
                                 │
       ┌─────────────────────────┴──────────────────────────┐
       │              FallbackBridgeTransport               │
       │             (sticky-after-first-success)           │
       └──┬──────────────────┬─────────────────────────┬────┘
          │                  │                         │
   ┌──────▼─────┐     ┌──────▼─────┐            ┌──────▼─────┐
   │  WsBridge  │     │  SseBridge │            │ LongPoll   │
   │  /v1/      │     │  /v1/      │            │ Bridge     │
   │  bridge/ws │     │  bridge/   │            │ /v1/bridge │
   │            │     │  stream    │            │ /poll      │
   └──────┬─────┘     └──────┬─────┘            └──────┬─────┘
          │                  │                         │
          └──────────────────┼─────────────────────────┘
                             │
                       ┌─────▼──────┐
                       │  inbox     │  ← the same V3.6 store
                       │  blobs     │     and events
                       └────────────┘

When to reach for which

Transport Latency Proxy resilience Browser Server cost
WebSocket ms breaks under strict CONNECT-blocking proxies one socket per client
SSE ms passes most HTTP proxies (text/event-stream) one streamed response per client
long-poll ≤ 25 s passes anything that allows GET one held request per client

The recommended composition:

import {
  FallbackBridgeTransport,
  WsBridge,
  SseBridge,
  LongPollBridge,
} from '@shade/transport-bridge';

const auth = {
  crypto,                   // CryptoProvider
  signingPrivateKey,        // recipient's Ed25519 private key
  address: 'bob',
};

const bridge = new FallbackBridgeTransport([
  new WsBridge({       baseUrl: 'https://relay.example.com', auth }),
  new SseBridge({      baseUrl: 'https://relay.example.com', auth }),
  new LongPollBridge({ baseUrl: 'https://relay.example.com', auth }),
]);

await bridge.connect({
  onMessage: async (msg) => {
    // msg.bytes is a Uint8Array — pass it to your decrypt path.
    // msg.from is the relay-known sender hint (may be empty); the
    // authoritative sender comes from the decrypted envelope.
    // msg.msgId is the relay's deterministic message id (sha256(ciphertext)).
    const envelope = decodeEnvelope(msg.bytes);
    await shade.receive(senderAddress, envelope);
  },
});

// Read which transport the fallback chain settled on:
console.log(bridge.activeKind); // "ws" | "sse" | "long-poll"

The IncomingMessage shape

interface IncomingMessage {
  from: string;          // relay-side sender hint (may be "")
  bytes: Uint8Array;     // the ciphertext envelope, exactly as PUT
  receivedAt: number;    // relay-monotonic cursor — NOT wall-clock arrival
  msgId?: string;        // sha256(bytes) — useful for ack/dedup
}

from is intentionally a hint — sender provenance lives inside the encrypted envelope and is recovered post-decrypt. The bridge layer is plaintext-blind by design.

Auth — signed query parameters

Every bridge request signs the canonical {address, kind, since, signedAt} payload with the recipient's Ed25519 signing private key. The server looks up the address-owner key registered via /v1/inbox/register and verifies the signature.

kind is bound into the canonical payload so a signature for /poll cannot be replayed against /stream or /ws.

The browser EventSource API does not let callers attach custom headers; query parameters are the only portable carrier and so the bridge protocol uses them uniformly across all three transports.

Server-side — createBridgeRoutes

import { createBridgeRoutes } from '@shade/inbox-server';
import { Hono } from 'hono';

const inbox = new MemoryInboxStore();
const events = new InboxServerEvents();

const bridge = createBridgeRoutes({
  store: inbox,
  crypto,
  events,
  longPollTimeoutMs: 25_000,        // default — under typical proxy idle limits
  heartbeatIntervalMs: 15_000,      // SSE keepalive comments
  fallbackPollIntervalMs: 1_000,    // when no `events` emitter is wired
});

const app = new Hono();
app.route('/', bridge.app);

Bun.serve({
  port: 3900,
  fetch: (req, srv) => app.fetch(req, srv),
  websocket: bridge.websocket as any,
});

The bridge subscribes to InboxServerEvents (inbox.blob_stored) for push-style delivery — when an event fires for a connected address, the server fetches new blobs and forwards them. If no events emitter is wired, the server falls back to a small in-process polling timer at fallbackPollIntervalMs cadence.

Cursor & resume

Every IncomingMessage.receivedAt is the relay's monotonic cursor for the address. Bridges expose getCursor() so applications can persist the high-water mark and pass it as startCursor on the next connect():

const sse = new SseBridge({
  baseUrl,
  auth,
  startCursor: await persistedCursor.load(),
});

await sse.connect({
  onMessage: async (msg) => {
    await persistedCursor.save(msg.receivedAt);
    // …
  },
});

For SSE specifically, the server emits an id: field per event; the bridge sends it back as Last-Event-ID plus the since= query parameter on reconnect, so a flapping connection picks up exactly where it left off without duplicates.

Reconnect & backoff

Bridge Auto-reconnect Backoff
WS yes (default) 250 ms → 10 s exponential
SSE yes (default) 250 ms → 10 s exponential
long-poll always on (the loop is the reconnect) 2 s on hard error

Pass disableAutoReconnect: true (WS / SSE) for tests where you want a single attempt and immediate surfaced error.

Long-poll concurrency

The LongPollBridge issues exactly one request at a time. The next request fires after the previous one resolves. This guarantees a client never holds more than one TCP connection on the server, which matches the V3.7 acceptance criterion and keeps capacity planning simple: max in-flight long-poll requests = number of connected clients.

Failure modes

  • WS handshake rejected (4xxx code). WsBridge.connect rejects. Caller (or FallbackBridgeTransport) moves on.
  • SSE returns non-200. SseBridge.connect throws a BridgeError with httpStatus.
  • Long-poll returns non-200. Same — BridgeError with httpStatus.
  • Mid-stream error after connect. WS/SSE auto-reconnect; long-poll swallows transient errors and continues looping. Errors flow to the caller's onError handler.

Acceptance test coverage (V3.7)

packages/shade-transport-bridge/tests/bridge.test.ts covers:

  • "Send 100 small messages" — one test per transport, all pass.
  • "WS blocked by proxy → SSE → long-poll" — fallback test boots a server where the WS endpoint is unreachable and the SSE endpoint returns 502, verifies the chain falls all the way through to long-poll without message loss.
  • "Long-poll uses ≤ 1 outstanding request" — wraps fetch to count in-flight requests over 1.5 s of steady-state operation.
  • Cursor resume — tears down an SSE connection mid-stream, pushes more blobs, reconnects with the persisted cursor, asserts exactly the new blobs are delivered (no overlap with the pre-disconnect set).
  • Auth rejection — wrong signing key and unregistered address both produce hard connect rejections so the fallback chain advances.