diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2f3c7..4a674fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,121 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.7.0] — 2026-05-07 — Peer-presence events for instant `BroadcastChannel` revoke + +`BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a +paired peer's sender-key membership when, say, a tab closes or a laptop +locks — but until now there was no signal saying "this peer's bridge just +went away". Apps had to fall back to client-side heartbeats: +`apps/web/src/shade/heartbeat.ts`-style 20s pings + a 10s GC sweep, with a +~45s worst-case revoke window. For a terminal-mirroring product whose +threat model includes *"someone takes the unattended laptop"*, 45s of +legitimate broadcast access for the attacker is too long. + +This release surfaces the bridge-connection-lifecycle signal that +`createBridgeRoutes` already had internally. The inbox event bus now emits +`inbox.peer_connected` / `inbox.peer_disconnected` on the 0↔1 boundary +across WS + SSE bridges, and a new `/v1/bridge/presence` SSE endpoint plus +the `PresenceBridge` client class let any authenticated SDK subscribe to +presence transitions for a watcher-declared address list. The SDK glue +collapses to ~5 lines: + +```ts +const sub = await new PresenceBridge({ baseUrl, crypto, signingPrivateKey, address }).subscribe({ + watch: paired_peers, + onPresenceChange: (e) => { + if (e.status === 'offline') void channel.removeMember(e.address); + }, +}); +``` + +Reported by Prism — collapses Prism's wave-3 heartbeat-based revoke from +~45s to ~50ms (one network round-trip) for the overwhelmingly common case +of a clean WS close. + +### Added + +#### `@shade/inbox-server` +- `InboxServerEventMap` gains two new event names: + - `inbox.peer_connected` — `{ address, bridgeKind: 'ws' | 'sse' }` — + fires when an address transitions from zero to ≥1 active push-bridge + connections. + - `inbox.peer_disconnected` — `{ address, bridgeKind, reason: 'closed' | 'error' }` + — fires when the last push-bridge connection for the address closes. +- New `PresenceTracker` class (`packages/shade-inbox-server/src/presence.ts`) + — per-address connection-count map; emits transitions into a wired + `InboxServerEvents`. Two parallel bridges (WS + SSE during a fallback + handover) collapse into one `peer_connected` / `peer_disconnected` + pair so consumers don't see flicker. +- `createBridgeRoutes` now returns `{ app, websocket, presence }` so + operators / tests can read the live presence map. A `presenceTracker` + option lets multiple route mounts share state. +- New `GET /v1/bridge/presence` SSE endpoint: + - Auth: signed query `{ address, kind: 'presence', watched: string[], + signedAt, signature }` against the watcher's registered owner key. + `kind: 'presence'` is bound into the canonical signed payload to + prevent cross-endpoint replay against `/v1/bridge/{stream,poll,ws}`. + - On open: emits one `event: presence` SSE frame per watched address + with the current online/offline snapshot. + - On change: streams `{ address, status, at, via: 'ws'|'sse' }` frames + filtered server-side to the watcher's address list. + - Subscribing does NOT itself count as a peer-bridge connection — a + PresenceBridge open will not make the watcher appear online to + other watchers. + - `MAX_WATCHED_ADDRESSES = 64` per subscription. + +#### `@shade/transport-bridge` +- New `PresenceBridge` class with `subscribe({ watch, onPresenceChange, + onError? })` returning `{ addPeer, removePeer, watching, unsubscribe }`. +- `addPeer` / `removePeer` mutate the watched set by aborting the + current SSE connection so the run loop reopens with a fresh signed + query. Mutations are expected to be rare (only on pair / unpair) so + the brief reconnect gap is acceptable. +- Auto-reconnect with exponential backoff (250ms → 10s, same defaults + as `SseBridge`); `disableAutoReconnect: true` for tests. +- `signPresenceQuery` helper exported from `@shade/transport-bridge/auth` + for non-PresenceBridge consumers (manual EventSource, observability + scrapers, etc.). + +### Why long-poll is NOT tracked + +A long-poll client toggles in/out of `/v1/bridge/poll` every few seconds, +and treating each request boundary as a presence transition would +dominate the event stream with flapping. Push transports are also the +only ones where a ~50ms revoke window matters — long-poll users are +already on a slow path. Apps that need presence over long-poll continue +to use client-side heartbeats. + +### Tests +- `packages/shade-transport-bridge/tests/bridge.test.ts` — four blocks + covering all acceptance criteria from the request: + - **(1)** `WsBridge.connect()` then `disconnect()` → operator's + `events.on(...)` sees `inbox.peer_connected` then + `inbox.peer_disconnected` with `address: 'alice'`, `bridgeKind: 'ws'`. + - **(2A)** Bob subscribes presence on `[alice]`; alice opens a + WsBridge → bob's `onPresenceChange` fires `online` within 2s. + - **(3)** Bob's `[alice]` subscription must NOT receive frames for + an unrelated `carol` address opening her own bridge. + - **(4)** Alice's bridge reopens after a drop → bob sees `online` + again on the same subscription. + - Plus an `addPeer` / `removePeer` regression that verifies the + reconnect-on-mutation path delivers a fresh snapshot for the new + address and stops delivering for the removed one. + +### Migration + +None. Strict additive — existing `InboxServerEvents` consumers keep +working unchanged. `createBridgeRoutes`'s return type added a +`presence` field; destructuring code that names only `app, websocket` +keeps compiling. + +For Prism specifically: drop the wave-3 heartbeat module +(`apps/web/src/shade/heartbeat.ts`) on the PC sidecar and replace with +a `PresenceBridge` subscription on the paired-peer set. Keep the +heartbeat as a network-partition fallback if you want a belt-and- +braces revoke story; with presence-events the worst-case revoke window +drops from ~45s to one server→PC round-trip. + ## [4.6.1] — 2026-05-07 — Browser `fetch` receiver lost in `Inbox` and HTTP bridges Every browser consumer of the v4.6.0 transport stack crashed on the diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 8e189fb..0616aea 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index c0fd55a..cf7cb20 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index b8af4ba..f680924 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index 8193fe4..fa4fb76 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 8a66d89..0effdc4 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index b39d2d5..574f580 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/src/bridge.ts b/packages/shade-inbox-server/src/bridge.ts index 41fcce8..7a583a4 100644 --- a/packages/shade-inbox-server/src/bridge.ts +++ b/packages/shade-inbox-server/src/bridge.ts @@ -38,8 +38,15 @@ import { import { verifyPayload, validateAddress } from '@shade/server'; import type { InboxStore } from './store.js'; import type { InboxServerEvents } from './events.js'; +import { PresenceTracker, type TrackedBridgeKind } from './presence.js'; export type BridgeKind = 'stream' | 'poll' | 'ws'; +/** + * Wire-protocol kind tag for `/v1/bridge/presence`. Distinct from + * `BridgeKind` because the canonical signed payload is shaped + * differently (`watched: string[]` instead of `since: number`). + */ +export type PresenceKind = 'presence'; export interface BridgeRoutesOptions { store: InboxStore; @@ -60,6 +67,13 @@ export interface BridgeRoutesOptions { * Default 1_000. */ fallbackPollIntervalMs?: number; + /** + * Inject an existing presence tracker. Useful when multiple + * `createBridgeRoutes` calls need to share state (e.g. mounting the + * routes under several hostnames in a single process). When omitted, + * the bridge auto-creates an internal tracker bound to `events`. + */ + presenceTracker?: PresenceTracker; } interface VerifiedBridgeRequest { @@ -68,6 +82,13 @@ interface VerifiedBridgeRequest { since: number; } +interface VerifiedPresenceRequest { + /** The watcher's address (signer of the request). */ + address: string; + /** Addresses whose presence the watcher is asking to track. */ + watched: string[]; +} + /** * Build the bridge Hono router and a paired Bun-WebSocket handler. * @@ -80,6 +101,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { app: Hono; /** Pass to `Bun.serve({ websocket })`. Undefined if Bun adapter is missing. */ websocket: unknown; + /** Live presence tracker. Tests + observers can read it; routes update it. */ + presence: PresenceTracker; } { const app = new Hono(); const pageLimit = opts.pageLimit ?? 50; @@ -87,6 +110,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { const longPollDefault = opts.longPollTimeoutMs ?? 25_000; const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000; const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000; + const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null); app.onError((err, c) => { if (err instanceof ShadeError) { @@ -102,6 +126,14 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { const verified = await verifyBridgeAuth(c, opts, 'stream'); return streamSSE(c, async (stream) => { const address = verified.address; + const connId = presence.newConnectionId(); + presence.markConnected(address, 'sse', connId); + let presenceClosed = false; + const closePresence = (reason: 'closed' | 'error'): void => { + if (presenceClosed) return; + presenceClosed = true; + presence.markDisconnected(address, 'sse', connId, reason); + }; let cursor = verified.since; const writer = makeBlobWriter(opts.store, pageLimit); @@ -163,6 +195,68 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { clearInterval(fallbackTimer); clearInterval(heartbeat); await pendingFlushPromise.catch(() => {}); + closePresence('closed'); + }); + }); + + // ─── Presence (V4.7) ────────────────────────────────────────── + // SSE feed of `peer_connected` / `peer_disconnected` events filtered + // by a watcher-supplied address list. Subscribing does NOT count as + // a peer-bridge connection (it doesn't call `markConnected`) so + // monitoring presence doesn't make you appear online to others. + app.get('/v1/bridge/presence', async (c) => { + const verified = await verifyPresenceAuth(c, opts); + return streamSSE(c, async (stream) => { + const watched = new Set(verified.watched); + + // Initial snapshot — one frame per watched address with current + // status. Lets subscribers render UI immediately rather than + // waiting for the next state change. + const now = Date.now(); + for (const addr of verified.watched) { + await stream.writeSSE({ + event: 'presence', + data: JSON.stringify({ + address: addr, + status: presence.isOnline(addr) ? 'online' : 'offline', + at: now, + }), + }); + } + + let unsubscribe: (() => void) | null = null; + if (opts.events) { + unsubscribe = opts.events.on((e) => { + if (e.name !== 'inbox.peer_connected' && e.name !== 'inbox.peer_disconnected') return; + const data = e.data as { address: string; bridgeKind: TrackedBridgeKind }; + if (!watched.has(data.address)) return; + const status = e.name === 'inbox.peer_connected' ? 'online' : 'offline'; + // Fire-and-forget: drop the frame if the stream has gone away. + void stream + .writeSSE({ + event: 'presence', + data: JSON.stringify({ + address: data.address, + status, + at: e.timestamp, + via: data.bridgeKind, + }), + }) + .catch(() => {}); + }); + } + const heartbeat = setInterval(() => { + stream.write(`: ping ${Date.now()}\n\n`).catch(() => {}); + }, heartbeatIntervalMs); + + await new Promise((resolve) => { + const sig = c.req.raw.signal; + if (sig.aborted) return resolve(); + sig.addEventListener('abort', () => resolve(), { once: true }); + }); + + unsubscribe?.(); + clearInterval(heartbeat); }); }); @@ -230,6 +324,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { } const address = verified.address; + const connId = presence.newConnectionId(); let cursor = verified.since; const writer = makeBlobWriter(opts.store, pageLimit); let unsubscribe: (() => void) | null = null; @@ -237,12 +332,19 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { let pendingFlushPromise: Promise = Promise.resolve(); let signalled = false; let connected = true; + let presenceClosed = false; + const closePresence = (reason: 'closed' | 'error'): void => { + if (presenceClosed) return; + presenceClosed = true; + presence.markDisconnected(address, 'ws', connId, reason); + }; return { onOpen(_evt: unknown, ws: { send: (data: string) => void; close: (code?: number, reason?: string) => void; }) { + presence.markConnected(address, 'ws', connId); const triggerFlush = (): void => { signalled = true; pendingFlushPromise = pendingFlushPromise.then(async () => { @@ -269,12 +371,19 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): { connected = false; unsubscribe?.(); if (fallbackTimer) clearInterval(fallbackTimer); + closePresence('closed'); + }, + onError() { + connected = false; + unsubscribe?.(); + if (fallbackTimer) clearInterval(fallbackTimer); + closePresence('error'); }, }; }), ); - return { app, websocket }; + return { app, websocket, presence }; } // ─── helpers ────────────────────────────────────────────────── @@ -321,6 +430,68 @@ async function verifyBridgeAuth( return { address, kind: kind as BridgeKind, since }; } +const MAX_WATCHED_ADDRESSES = 64; + +/** + * Verify a `/v1/bridge/presence` request. + * + * Signed canonical payload: `{address, kind: 'presence', watched: string[], + * signedAt}`. The watcher's address must be a registered inbox; the + * signature is verified against the registered owner key for that + * address. The `watched` list bounds what the subscription will + * receive — server-side filtering is enforced inside the handler. + */ +async function verifyPresenceAuth( + c: Context, + opts: BridgeRoutesOptions, +): Promise { + const url = new URL(c.req.url); + const qs = url.searchParams; + const address = validateAddress(qs.get('address')); + const kind = qs.get('kind'); + if (kind !== 'presence') { + throw new ValidationError(`bridge kind mismatch: expected presence`, 'kind'); + } + const watchedRaw = qs.get('watched'); + if (watchedRaw === null) throw new ValidationError('missing watched', 'watched'); + // Empty subscription is allowed (subscribe to nothing — useful for a + // client that intends to call addPeer right after open). A null/ + // missing param is still rejected so the canonicalization is + // unambiguous. + const watched = + watchedRaw === '' + ? [] + : watchedRaw.split(',').map((a) => validateAddress(a)); + if (watched.length > MAX_WATCHED_ADDRESSES) { + throw new ValidationError( + `watched list too large: ${watched.length} > ${MAX_WATCHED_ADDRESSES}`, + 'watched', + ); + } + const signedAtStr = qs.get('signedAt'); + const signature = qs.get('signature'); + if (signedAtStr === null) throw new ValidationError('missing signedAt', 'signedAt'); + if (!signature) throw new ValidationError('missing signature', 'signature'); + const signedAt = Number(signedAtStr); + if (!Number.isFinite(signedAt)) { + throw new ValidationError('signedAt must be a number', 'signedAt'); + } + + const owner = await opts.store.getAddressOwner(address); + if (!owner) { + throw new UnauthorizedError(`address ${address} is not registered`); + } + + await verifyPayload(opts.crypto, owner, { + address, + kind, + watched, + signedAt, + signature, + }); + return { address, watched }; +} + interface BlobRow { msgId: string; ciphertext: Uint8Array; diff --git a/packages/shade-inbox-server/src/events.ts b/packages/shade-inbox-server/src/events.ts index cd588c9..6fae395 100644 --- a/packages/shade-inbox-server/src/events.ts +++ b/packages/shade-inbox-server/src/events.ts @@ -21,6 +21,19 @@ export interface InboxServerEventMap { 'inbox.expired_purged': { count: number }; 'inbox.rate_limited': { route: string; key: string }; 'inbox.quota_rejected': { address: string; reason: 'address-quota' | 'sender-quota' | 'body-too-large' }; + // V4.7 — bridge presence transitions. Emitted on the 0↔1 boundary + // across tracked transports for a given address. Long-poll is + // intentionally NOT tracked: an LP client toggles in/out of a request + // every few seconds, and the resulting flapping would dominate the + // event stream. Push transports (WS, SSE) are also the only ones + // where the ~50ms revoke window for `BroadcastChannel.removeMember` + // matters — long-poll users are already on a slow path. + 'inbox.peer_connected': { address: string; bridgeKind: 'ws' | 'sse' }; + 'inbox.peer_disconnected': { + address: string; + bridgeKind: 'ws' | 'sse'; + reason: 'closed' | 'error'; + }; } export type InboxServerEventName = keyof InboxServerEventMap; diff --git a/packages/shade-inbox-server/src/index.ts b/packages/shade-inbox-server/src/index.ts index 146d4cf..085b42f 100644 --- a/packages/shade-inbox-server/src/index.ts +++ b/packages/shade-inbox-server/src/index.ts @@ -32,6 +32,8 @@ export { export type { InboxQuotaConfig } from './quota.js'; export { createBridgeRoutes } from './bridge.js'; export type { BridgeRoutesOptions, BridgeKind } from './bridge.js'; +export { PresenceTracker } from './presence.js'; +export type { TrackedBridgeKind } from './presence.js'; /** * Create a standalone Shade Inbox Server. diff --git a/packages/shade-inbox-server/src/presence.ts b/packages/shade-inbox-server/src/presence.ts new file mode 100644 index 0000000..c427ebe --- /dev/null +++ b/packages/shade-inbox-server/src/presence.ts @@ -0,0 +1,75 @@ +/** + * V4.7 — bridge-connection presence tracking. + * + * The bridge handlers (`/v1/bridge/stream` and `/v1/bridge/ws`) call + * `markConnected` on open and `markDisconnected` on close. The tracker + * keeps a per-address set of connection ids; the `inbox.peer_connected` + * / `inbox.peer_disconnected` events fire only on the 0↔1 boundary so + * that two simultaneous bridges (e.g. SSE + WS during a transport- + * fallback handover) collapse into a single connected/disconnected + * pair from the consumer's point of view. + * + * Long-poll (`/v1/bridge/poll`) is intentionally NOT tracked — see the + * note on `InboxServerEventMap` in `events.ts`. + */ + +import type { InboxServerEvents } from './events.js'; + +export type TrackedBridgeKind = 'ws' | 'sse'; + +export class PresenceTracker { + private readonly connections = new Map>(); + private nextConnId = 1; + + constructor(private readonly events: InboxServerEvents | null) {} + + /** Allocate a fresh connection id for `markConnected` / `markDisconnected`. */ + newConnectionId(): string { + return `c${this.nextConnId++}`; + } + + /** + * Snapshot: is `address` currently connected over any tracked transport? + * Used by `/v1/bridge/presence` to push the initial state to a new + * subscriber. + */ + isOnline(address: string): boolean { + const set = this.connections.get(address); + return set !== undefined && set.size > 0; + } + + markConnected(address: string, bridgeKind: TrackedBridgeKind, connectionId: string): void { + let set = this.connections.get(address); + const wasOnline = set !== undefined && set.size > 0; + if (!set) { + set = new Set(); + this.connections.set(address, set); + } + set.add(connectionId); + if (!wasOnline) { + this.events?.emit('inbox.peer_connected', { address, bridgeKind }); + } + } + + markDisconnected( + address: string, + bridgeKind: TrackedBridgeKind, + connectionId: string, + reason: 'closed' | 'error', + ): void { + const set = this.connections.get(address); + if (!set) return; + if (!set.delete(connectionId)) return; + if (set.size === 0) { + this.connections.delete(address); + this.events?.emit('inbox.peer_disconnected', { address, bridgeKind, reason }); + } + } + + /** Inspect the underlying map. Test/observability use only. */ + snapshot(): Map> { + return new Map( + Array.from(this.connections.entries(), ([k, v]) => [k, v as ReadonlySet]), + ); + } +} diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 0682d2a..bd92d75 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index 2697d35..6c7a994 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index b2306e0..4fc805b 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 0ec3a83..1b8622b 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 60faf82..325dacd 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 8c39550..9afe2b1 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index 0b45387..e8e1604 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index 8e488b5..a00b72f 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index f3be14b..30ce969 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index 07c149f..f0903f4 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 2081369..a3472d3 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 46bc2ba..aa0fec0 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index ccd538b..3380e01 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index 3fe6507..cb284bc 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 40cc869..85f1940 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index 4888962..bc74d90 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/src/auth.ts b/packages/shade-transport-bridge/src/auth.ts index de564d7..daa1ac9 100644 --- a/packages/shade-transport-bridge/src/auth.ts +++ b/packages/shade-transport-bridge/src/auth.ts @@ -62,3 +62,39 @@ export function bridgeQueryToCanonical(qs: URLSearchParams): { if (!Number.isFinite(signedAt)) return null; return { address, kind, since, signedAt, signature }; } + +// ─── V4.7 — presence subscription auth ──────────────────────────── + +export interface PresenceAuthInput { + crypto: CryptoProvider; + signingPrivateKey: Uint8Array; + /** The watcher's own address (signer of the request). */ + address: string; + /** Addresses to subscribe presence updates for. May be empty. */ + watched: readonly string[]; +} + +/** + * Build the signed query string for `GET /v1/bridge/presence`. The + * `kind: 'presence'` field is bound into the canonical payload to + * prevent cross-endpoint replay against `/v1/bridge/{stream,poll,ws}`. + * + * The `watched` array is sorted to give the signed bytes a canonical + * order; the wire form encodes it as a single comma-separated + * `watched=` parameter (address syntax disallows `,`). + */ +export async function signPresenceQuery(input: PresenceAuthInput): Promise { + const watched = [...input.watched].sort(); + const signed = await signPayload(input.crypto, input.signingPrivateKey, { + address: input.address, + kind: 'presence', + watched, + }); + const qs = new URLSearchParams(); + qs.set('address', input.address); + qs.set('kind', 'presence'); + qs.set('watched', watched.join(',')); + qs.set('signedAt', String(signed.signedAt)); + qs.set('signature', signed.signature); + return qs; +} diff --git a/packages/shade-transport-bridge/src/index.ts b/packages/shade-transport-bridge/src/index.ts index aaa560b..3428836 100644 --- a/packages/shade-transport-bridge/src/index.ts +++ b/packages/shade-transport-bridge/src/index.ts @@ -23,8 +23,17 @@ export { decodeWireMessage } from './types.js'; export { BridgeError } from './errors.js'; -export { signBridgeQuery, bridgeQueryToCanonical } from './auth.js'; -export type { BridgeKind, BridgeAuthInput } from './auth.js'; +export { signBridgeQuery, bridgeQueryToCanonical, signPresenceQuery } from './auth.js'; +export type { BridgeKind, BridgeAuthInput, PresenceAuthInput } from './auth.js'; + +export { PresenceBridge } from './presence-bridge.js'; +export type { + PresenceBridgeOptions, + PresenceSubscribeOptions, + PresenceSubscription, + PresenceChange, + PresenceVia, +} from './presence-bridge.js'; export { SseBridge } from './sse-bridge.js'; export type { SseBridgeOptions } from './sse-bridge.js'; diff --git a/packages/shade-transport-bridge/src/presence-bridge.ts b/packages/shade-transport-bridge/src/presence-bridge.ts new file mode 100644 index 0000000..4e174dd --- /dev/null +++ b/packages/shade-transport-bridge/src/presence-bridge.ts @@ -0,0 +1,337 @@ +/** + * V4.7 — presence subscription client. + * + * Consumes the SSE feed at `/v1/bridge/presence?…` and fires + * `onPresenceChange` whenever a watched address transitions + * online/offline. Tracking is server-side: the inbox-server emits + * presence events on the 0↔1 boundary across WS + SSE bridge + * connections, and this client filters by the watcher's declared + * address list. + * + * Threat model context: the typical consumer (Prism, password + * managers, anything sender-key-broadcasting) wires this to + * `BroadcastChannel.removeMember` so a clean WS/SSE close on a + * paired-peer device revokes its sender-key membership within + * ~50ms. Long-poll bridges are deliberately NOT tracked on the + * server (see `inbox-server` `events.ts`); presence here is + * push-transport only. + * + * Watched-list mutations (`addPeer` / `removePeer`) trigger a + * reconnect with a fresh signed query so the server-side filter + * reflects the new set. Mutations are expected to be rare (only on + * pair / unpair, not on every message), so the brief reconnect gap + * is acceptable. + */ + +import type { CryptoProvider } from '@shade/core'; +import { signPresenceQuery } from './auth.js'; +import { BridgeError } from './errors.js'; + +export type PresenceVia = 'ws' | 'sse'; + +export interface PresenceChange { + address: string; + status: 'online' | 'offline'; + /** Server's wall-clock time (ms since epoch) when the change happened. */ + at: number; + /** Which transport carried the connection. Absent on the initial snapshot. */ + via?: PresenceVia; +} + +export interface PresenceBridgeOptions { + /** Bridge base URL — same as `LongPollBridge` / `SseBridge`. */ + baseUrl: string; + crypto: CryptoProvider; + /** Watcher's Ed25519 signing key (the address must be a registered inbox). */ + signingPrivateKey: Uint8Array; + /** Watcher's address (the registered inbox owner). */ + address: string; + /** Override `fetch` (tests). */ + fetch?: typeof fetch; + /** Initial reconnect backoff (ms). Default 250. */ + initialBackoffMs?: number; + /** Max reconnect backoff (ms). Default 10_000. */ + maxBackoffMs?: number; + /** Disable automatic reconnect. Default false. */ + disableAutoReconnect?: boolean; +} + +export interface PresenceSubscribeOptions { + /** Initial set of addresses to watch. May be empty. */ + watch: readonly string[]; + /** Fired whenever a watched address transitions, plus once per address on initial open. */ + onPresenceChange: (change: PresenceChange) => void | Promise; + /** Optional reconnect / parse error reporter. */ + onError?: (err: Error) => void; +} + +export interface PresenceSubscription { + /** Add an address to the watched set. Triggers a reconnect. */ + addPeer(address: string): Promise; + /** Remove an address from the watched set. Triggers a reconnect. */ + removePeer(address: string): Promise; + /** Snapshot of the currently-watched addresses. */ + watching(): readonly string[]; + /** Tear down. Idempotent. */ + unsubscribe(): Promise; +} + +const DEFAULT_INITIAL_BACKOFF = 250; +const DEFAULT_MAX_BACKOFF = 10_000; + +export class PresenceBridge { + private readonly fetchFn: typeof fetch; + + constructor(private readonly options: PresenceBridgeOptions) { + const f = options.fetch ?? globalThis.fetch; + this.fetchFn = f.bind(globalThis); + } + + async subscribe(opts: PresenceSubscribeOptions): Promise { + const session = new PresenceSession(this.options, this.fetchFn, opts); + await session.start(); + return session; + } +} + +class PresenceSession implements PresenceSubscription { + private watched: string[]; + private abortController: AbortController | null = null; + private currentReader: ReadableStreamDefaultReader | null = null; + private disposed = false; + private readLoopPromise: Promise | null = null; + private readonly onPresenceChange: PresenceSubscribeOptions['onPresenceChange']; + private readonly onError: NonNullable; + private firstOpenResolve: (() => void) | null = null; + private firstOpenReject: ((err: Error) => void) | null = null; + private firstOpenSettled = false; + + constructor( + private readonly options: PresenceBridgeOptions, + private readonly fetchFn: typeof fetch, + opts: PresenceSubscribeOptions, + ) { + this.watched = [...opts.watch]; + this.onPresenceChange = opts.onPresenceChange; + this.onError = + opts.onError ?? ((err) => console.warn('[shade-bridge:presence]', err.message)); + } + + watching(): readonly string[] { + return [...this.watched]; + } + + async start(): Promise { + return this.openAndPump(); + } + + /** + * Open one SSE connection and resolve once the first response has + * been received (so that callers can `await subscribe()` and know + * the connection is established before the first state change). + * The read loop continues in the background. + */ + private openAndPump(): Promise { + return new Promise((resolve, reject) => { + this.firstOpenSettled = false; + this.firstOpenResolve = () => { + if (this.firstOpenSettled) return; + this.firstOpenSettled = true; + resolve(); + }; + this.firstOpenReject = (err: Error) => { + if (this.firstOpenSettled) return; + this.firstOpenSettled = true; + reject(err); + }; + this.readLoopPromise = this.runLoop(); + }); + } + + private async runLoop(): Promise { + let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF; + const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF; + let firstAttempt = true; + while (!this.disposed) { + try { + await this.openOnce(); + if (firstAttempt) { + firstAttempt = false; + this.firstOpenResolve?.(); + } + // Reset backoff on a successful open. + backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF; + await this.consume(); + } catch (err) { + if (this.disposed) return; + if (firstAttempt) { + // Failed before we ever got a 200 — surface to the caller of subscribe(). + this.firstOpenReject?.(err as Error); + return; + } + this.onError(err as Error); + } + this.currentReader = null; + if (this.disposed || this.options.disableAutoReconnect) return; + await sleep(backoff); + backoff = Math.min(backoff * 2, maxBackoff); + } + } + + private async openOnce(): Promise { + const qs = await signPresenceQuery({ + crypto: this.options.crypto, + signingPrivateKey: this.options.signingPrivateKey, + address: this.options.address, + watched: this.watched, + }); + const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/presence?${qs.toString()}`; + this.abortController = new AbortController(); + let res: Response; + try { + res = await this.fetchFn(url, { + method: 'GET', + headers: { accept: 'text/event-stream', 'cache-control': 'no-cache' }, + signal: this.abortController.signal, + }); + } catch (err) { + throw new BridgeError(`presence connect failed: ${(err as Error).message}`); + } + if (!res.ok) { + throw new BridgeError(`presence connect failed: HTTP ${res.status}`, res.status); + } + if (!res.body) { + throw new BridgeError('presence response has no body'); + } + this.currentReader = res.body.getReader() as ReadableStreamDefaultReader; + } + + private async consume(): Promise { + const reader = this.currentReader; + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ''; + let dataLines: string[] = []; + let eventName: string | null = null; + while (true) { + let chunk: Awaited>; + try { + chunk = await reader.read(); + } catch (err) { + // Reader cancelled (mutation / unsubscribe) — exit cleanly. + if (this.disposed || (err as Error).name === 'AbortError') return; + throw err; + } + if (chunk.done) return; + buf += decoder.decode(chunk.value, { stream: true }); + let idx; + while ((idx = buf.indexOf('\n')) !== -1) { + const rawLine = buf.slice(0, idx); + buf = buf.slice(idx + 1); + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (line === '') { + if (dataLines.length > 0) { + await this.dispatch(eventName, dataLines.join('\n')); + } + dataLines = []; + eventName = null; + continue; + } + if (line.startsWith(':')) continue; + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + let val = colon === -1 ? '' : line.slice(colon + 1); + if (val.startsWith(' ')) val = val.slice(1); + if (field === 'data') dataLines.push(val); + else if (field === 'event') eventName = val; + } + } + } + + private async dispatch(name: string | null, data: string): Promise { + if (name !== null && name !== '' && name !== 'presence') return; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch (err) { + this.onError(new BridgeError(`malformed presence data: ${(err as Error).message}`)); + return; + } + const change = parsed as PresenceChange; + if ( + typeof change.address !== 'string' || + (change.status !== 'online' && change.status !== 'offline') || + typeof change.at !== 'number' + ) { + this.onError(new BridgeError('presence frame missing required fields')); + return; + } + try { + await this.onPresenceChange(change); + } catch (err) { + this.onError(err as Error); + } + } + + async addPeer(address: string): Promise { + if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed'); + if (this.watched.includes(address)) return; + this.watched = [...this.watched, address]; + await this.reconnect(); + } + + async removePeer(address: string): Promise { + if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed'); + if (!this.watched.includes(address)) return; + this.watched = this.watched.filter((a) => a !== address); + await this.reconnect(); + } + + /** + * Tear down the current SSE connection so the run loop reopens with + * the new watched list. Cancels via abort + reader.cancel — both are + * tolerated by the consume() catch path. + */ + private async reconnect(): Promise { + const reader = this.currentReader; + this.currentReader = null; + this.abortController?.abort(); + if (reader) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + } + } + + async unsubscribe(): Promise { + if (this.disposed) return; + this.disposed = true; + const reader = this.currentReader; + this.currentReader = null; + this.abortController?.abort(); + if (reader) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + } + if (this.readLoopPromise) { + try { + await this.readLoopPromise; + } catch { + /* ignore */ + } + } + } +} + +function stripTrailingSlash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/packages/shade-transport-bridge/tests/bridge.test.ts b/packages/shade-transport-bridge/tests/bridge.test.ts index feb57e2..b7419e9 100644 --- a/packages/shade-transport-bridge/tests/bridge.test.ts +++ b/packages/shade-transport-bridge/tests/bridge.test.ts @@ -28,7 +28,9 @@ import { WsBridge, FallbackBridgeTransport, signBridgeQuery, + PresenceBridge, type IncomingMessage, + type PresenceChange, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); @@ -611,3 +613,257 @@ describe('Bridges — default fetch is bound to globalThis', () => { } }); }); + +// ─── V4.7 — presence events ──────────────────────────────────────── + +async function registerAddress( + baseUrl: string, + address: string, + identity: Awaited>, +): Promise { + const body = await signPayload(crypto, identity.signingPrivateKey, { + address, + signingKey: toBase64(identity.signingPublicKey), + }); + const res = await fetch(`${baseUrl}/v1/inbox/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); +} + +describe('Presence — server emits peer_connected / peer_disconnected', () => { + let h: Harness; + beforeAll(async () => { + h = await bootstrap(); + // bootstrap registers bob; alice needs registration too so her bridge + // can authenticate against /v1/bridge/ws. + await registerAddress(h.baseUrl, 'alice', h.alice); + }); + afterAll(() => { + h.server.stop(true); + }); + + test('open + close a WsBridge → events fire on the inbox event bus (acceptance 1)', async () => { + const seen: Array<{ name: string; address: string; bridgeKind: string }> = []; + const off = h.events.on((e) => { + if (e.name === 'inbox.peer_connected' || e.name === 'inbox.peer_disconnected') { + seen.push({ + name: e.name, + address: e.data.address, + bridgeKind: e.data.bridgeKind, + }); + } + }); + try { + const ws = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await ws.connect({ onMessage: () => {} }); + await waitFor(() => seen.some((s) => s.name === 'inbox.peer_connected'), 2_000); + await ws.disconnect(); + await waitFor(() => seen.some((s) => s.name === 'inbox.peer_disconnected'), 2_000); + expect(seen[0]).toEqual({ name: 'inbox.peer_connected', address: 'alice', bridgeKind: 'ws' }); + expect(seen[seen.length - 1]).toEqual({ + name: 'inbox.peer_disconnected', + address: 'alice', + bridgeKind: 'ws', + }); + } finally { + off(); + } + }); +}); + +describe('PresenceBridge — subscribe to remote presence changes', () => { + let h: Harness; + beforeAll(async () => { + h = await bootstrap(); + await registerAddress(h.baseUrl, 'alice', h.alice); + }); + afterAll(() => { + h.server.stop(true); + }); + + test('online → offline → online over a single subscription (acceptance 2A + 4)', async () => { + const presence = new PresenceBridge({ + baseUrl: h.baseUrl, + crypto, + signingPrivateKey: h.bob.signingPrivateKey, + address: 'bob', + initialBackoffMs: 50, + maxBackoffMs: 200, + }); + + const changes: PresenceChange[] = []; + const sub = await presence.subscribe({ + watch: ['alice'], + onPresenceChange: (e) => { + changes.push(e); + }, + }); + + try { + // Initial snapshot — alice not yet connected. + await waitFor(() => changes.length >= 1, 2_000); + expect(changes[0]!.address).toBe('alice'); + expect(changes[0]!.status).toBe('offline'); + expect(changes[0]!.via).toBeUndefined(); + + // Alice opens a WsBridge → bob must see online (acceptance 2A). + const aliceWs = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await aliceWs.connect({ onMessage: () => {} }); + await waitFor( + () => changes.some((c) => c.status === 'online' && c.address === 'alice'), + 2_000, + ); + const onlineFrame = changes.find((c) => c.status === 'online' && c.address === 'alice')!; + expect(onlineFrame.via).toBe('ws'); + + // Alice's bridge drops → bob must see offline. + await aliceWs.disconnect(); + await waitFor( + () => + changes.filter((c) => c.address === 'alice' && c.status === 'offline').length >= 2, + 2_000, + ); + + // Reconnect: alice reopens → bob sees online again (acceptance 4). + const aliceWs2 = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await aliceWs2.connect({ onMessage: () => {} }); + await waitFor( + () => changes.filter((c) => c.address === 'alice' && c.status === 'online').length >= 2, + 2_000, + ); + await aliceWs2.disconnect(); + } finally { + await sub.unsubscribe(); + } + }); + + test('subscription on [alice] does not leak carol (acceptance 3)', async () => { + const carol = await generateIdentityKeyPair(crypto); + await registerAddress(h.baseUrl, 'carol', carol); + + const presence = new PresenceBridge({ + baseUrl: h.baseUrl, + crypto, + signingPrivateKey: h.bob.signingPrivateKey, + address: 'bob', + initialBackoffMs: 50, + maxBackoffMs: 200, + }); + + const changes: PresenceChange[] = []; + const sub = await presence.subscribe({ + watch: ['alice'], + onPresenceChange: (e) => { + changes.push(e); + }, + }); + try { + // Drain the initial snapshot for alice. + await waitFor(() => changes.length >= 1, 2_000); + const baseline = changes.length; + + // Carol opens a bridge — bob's alice-only subscription must NOT see her. + const carolWs = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await carolWs.connect({ onMessage: () => {} }); + // Give the server time to emit + filter. + await new Promise((r) => setTimeout(r, 250)); + await carolWs.disconnect(); + await new Promise((r) => setTimeout(r, 100)); + + const newFrames = changes.slice(baseline); + for (const f of newFrames) { + expect(f.address).toBe('alice'); + expect(f.address).not.toBe('carol'); + } + } finally { + await sub.unsubscribe(); + } + }); + + test('addPeer / removePeer mutate the watched set via reconnect', async () => { + const carol = await generateIdentityKeyPair(crypto); + await registerAddress(h.baseUrl, 'carol-2', carol); + + const presence = new PresenceBridge({ + baseUrl: h.baseUrl, + crypto, + signingPrivateKey: h.bob.signingPrivateKey, + address: 'bob', + initialBackoffMs: 50, + maxBackoffMs: 200, + }); + + const changes: PresenceChange[] = []; + const sub = await presence.subscribe({ + watch: ['alice'], + onPresenceChange: (e) => { + changes.push(e); + }, + }); + try { + await waitFor(() => changes.length >= 1, 2_000); + expect(sub.watching()).toEqual(['alice']); + + // Add carol-2 — reconnect should deliver a fresh snapshot that + // includes the new address. + await sub.addPeer('carol-2'); + await waitFor(() => changes.some((c) => c.address === 'carol-2'), 2_000); + expect(sub.watching().sort()).toEqual(['alice', 'carol-2']); + + const carolWs = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol-2' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await carolWs.connect({ onMessage: () => {} }); + await waitFor( + () => changes.some((c) => c.address === 'carol-2' && c.status === 'online'), + 2_000, + ); + await carolWs.disconnect(); + + // removePeer → carol-2 events must stop arriving. + await sub.removePeer('carol-2'); + const baseline = changes.filter((c) => c.address === 'carol-2').length; + const carolWs2 = new WsBridge({ + baseUrl: h.baseUrl, + auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol-2' }, + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await carolWs2.connect({ onMessage: () => {} }); + await new Promise((r) => setTimeout(r, 250)); + await carolWs2.disconnect(); + await new Promise((r) => setTimeout(r, 100)); + const after = changes.filter((c) => c.address === 'carol-2').length; + expect(after).toBe(baseline); + expect(sub.watching()).toEqual(['alice']); + } finally { + await sub.unsubscribe(); + } + }); +}); diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index da40074..a2b8861 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 7e9f3fd..1303eba 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index 7acf710..3760b34 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.6.1", + "version": "4.7.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",