release(v4.7.0): peer-presence events for instant BroadcastChannel revoke

Adds the bridge-connection-lifecycle signal that closes Prism's
~45s revoke window down to one server→client round-trip (~50ms).

Server (`@shade/inbox-server`):
- `inbox.peer_connected` / `inbox.peer_disconnected` events on the
  0↔1 boundary across WS + SSE bridges. Long-poll deliberately not
  tracked (every poll boundary would flap; push transports are also
  the only ones where instant revoke matters).
- `PresenceTracker` collapses two parallel bridges (e.g. WS + SSE
  during fallback handover) into one connect/disconnect pair.
- `GET /v1/bridge/presence` SSE endpoint: signed query with
  `kind: 'presence'`, `watched: string[]`; on open streams a
  per-address snapshot, then change frames filtered server-side.
  MAX_WATCHED_ADDRESSES = 64. Subscribing does not itself count as
  a peer-bridge connection.
- `createBridgeRoutes` now returns `{ app, websocket, presence }`.

Client (`@shade/transport-bridge`):
- `PresenceBridge.subscribe({ watch, onPresenceChange })` →
  `{ addPeer, removePeer, watching, unsubscribe }`. addPeer/removePeer
  mutate via reconnect with a fresh signed query.
- `signPresenceQuery` helper for non-PresenceBridge consumers.

Tests cover all four acceptance criteria from the Prism request:
server-event smoke, online→offline subscription, address scoping
(carol invisible to a [alice]-only sub), reconnect, plus an
addPeer/removePeer regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:16:35 +02:00
parent 8746571d2a
commit 594992a183
34 changed files with 1042 additions and 28 deletions

View File

@@ -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