/** * Test helpers — boot a local prekey server, mint Shade instances, and * pair them with an in-process delivery transport that calls * `shade.receive` on the recipient. Mirrors the pattern other Shade * test suites use, kept private to this package so we don't pull in * `@shade/server` at runtime. */ import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents } from '@shade/server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createShade, type Shade } from '@shade/sdk'; import type { ShadeEnvelope } from '@shade/core'; import type { RecoveryDeliver } from '../src/setup.js'; export interface TestEnv { prekeyUrl: string; stop: () => void; } export async function startTestPrekeyServer(): Promise { const crypto = new SubtleCryptoProvider(); const prekey = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events: new PrekeyServerEvents(), }); const server = Bun.serve({ port: 0, fetch: prekey.fetch }); const port = (server as unknown as { port: number }).port; return { prekeyUrl: `http://localhost:${port}`, stop: () => server.stop(), }; } export async function spawnShade(prekeyUrl: string, address: string): Promise { return createShade({ prekeyServer: prekeyUrl, address }); } /** * In-process transport that delivers `(to, envelope)` to the named * Shade by calling `shade.receive(from, envelope)` on it. Matches the * `RecoveryDeliver` shape so it can be plugged into setup/guardian/ * request flows. * * Construction order matters: register every party before delivering * so the lookup never fails. Use `addr` to keep the from-address * symmetric with what `Shade.send` uses. */ export class MemoryRecoveryTransport { private readonly directory = new Map(); /** Per-pair pending-deliveries chain to preserve ordering. */ private readonly chains = new Map>(); /** Counters of how many envelopes flowed in each direction (for tests). */ public readonly delivered: Array<{ from: string; to: string }> = []; /** * Optional drop-after-N policy used to simulate guardians that go * unreachable. Keyed on `to` address. Set with `dropAfter(addr, n)`. */ private readonly dropPolicies = new Map(); add(shade: Shade): void { this.directory.set(shade.myAddress, shade); } /** Drop envelopes addressed to `to` after the first `n` have flowed. */ dropAfter(to: string, n: number): void { this.dropPolicies.set(to, n); } /** * Build a `RecoveryDeliver` callback bound to `from`. Call this once * per Shade so its outbound sends route through the transport. */ bind(from: Shade): RecoveryDeliver { return async (to: string, envelope: ShadeEnvelope) => { const recipient = this.directory.get(to); if (recipient === undefined) { throw new Error(`MemoryRecoveryTransport: unknown recipient "${to}"`); } // Apply drop policy. const policy = this.dropPolicies.get(to); if (policy !== undefined) { if (policy <= 0) throw new Error(`MemoryRecoveryTransport: dropping for "${to}"`); this.dropPolicies.set(to, policy - 1); } this.delivered.push({ from: from.myAddress, to }); // Serialize per (from→to) pair to preserve ordering. const key = `${from.myAddress}→${to}`; const prev = this.chains.get(key) ?? Promise.resolve(); const next = prev.then(async () => { await recipient.receive(from.myAddress, envelope); }); this.chains.set( key, next.catch(() => { // chain even on failures so subsequent sends don't deadlock return undefined; }), ); await next; }; } }