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
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:
224
docs/transport.md
Normal file
224
docs/transport.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Shade Transport — Bridge Layer (V3.7)
|
||||
|
||||
> **Looking for V3.11 (peer-to-peer chunk transport via `RTCDataChannel`)?**
|
||||
> See [docs/webrtc.md](./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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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`
|
||||
|
||||
```ts
|
||||
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()`:
|
||||
|
||||
```ts
|
||||
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.
|
||||
Reference in New Issue
Block a user