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>
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.connectrejects. Caller (orFallbackBridgeTransport) moves on. - SSE returns non-200.
SseBridge.connectthrows aBridgeErrorwithhttpStatus. - Long-poll returns non-200. Same —
BridgeErrorwithhttpStatus. - Mid-stream error after connect. WS/SSE auto-reconnect; long-poll
swallows transient errors and continues looping. Errors flow to the
caller's
onErrorhandler.
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
fetchto 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
connectrejections so the fallback chain advances.