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:
512
packages/shade-transport-bridge/tests/bridge.test.ts
Normal file
512
packages/shade-transport-bridge/tests/bridge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user