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:
115
CHANGELOG.md
115
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
|
||||
|
||||
Reference in New Issue
Block a user