release(v4.8.0): sender-fingerprint attribution + Inbox.start race fix

Two unblocking changes for first-contact flows.

Sender attribution: relay captures shortHash(senderSigningKey) at
PUT time (after signature verification, no new trust surface) and
surfaces it on bridge push (IncomingMessage.from) + inbox-fetch
(FetchedBlob.from) + DecryptHandler raw arg. Apps receiving a prekey
envelope from a never-before-seen peer can now bootstrap X3DH via
shade.receive('fp:<hex>', env) — pre-4.8 the wire envelope didn't
authenticate the sender and there was no out-of-band hint to use.
Idempotent ALTER TABLE migrations for SQLite + Postgres add a
sender_fp TEXT column; legacy rows surface as from=undefined
(inter-version compat).

Inbox.start() race: pre-4.8 start() called register() fire-and-forget
AND schedulePoll(0) synchronously, so the first poll on a fresh
address often beat the register HTTP RTT and got SHADE_NOT_FOUND.
start() now defers; register() success kicks schedulePoll(0). Manual
tick() is unaffected (deliberate user action, no gating).

Both reported by Prism. Tests cover all five acceptance criteria
from the sender-attribution request (PUT capture, bridge surface,
fetch surface, inter-version compat, end-to-end pair smoke) plus
the three from the race-fix request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:11:59 +02:00
parent 594992a183
commit 1fb59a7076
38 changed files with 705 additions and 67 deletions

View File

@@ -5,6 +5,129 @@ 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.8.0] — 2026-05-08 — Sender-fingerprint attribution + `Inbox.start()` race fix
Two unblocking changes for first-contact flows. First, the relay now
captures the sender's signing-key fingerprint at PUT time and surfaces
it on every downstream delivery — bridge push (`IncomingMessage.from`)
and inbox-fetch response (`FetchedBlob.from`). Without it, an app
receiving a prekey envelope from a never-before-seen peer cannot
decrypt it: `shade.receive(from, env)` requires a sender address and
the wire envelope itself doesn't authenticate the sender. The
fingerprint is the same 8-byte hex of SHA-256(senderSigningKey) that
`IncomingMessage.from` was already documented as carrying; the field
just wasn't populated.
Second, `Inbox.start()` no longer races register vs the first poll.
Pre-fix, a fresh address calling `start()` saw the very first
`/v1/inbox/{addr}/fetch` POST race the register HTTP RTT and return
`SHADE_NOT_FOUND` — confusing 404 in DevTools, ~30s gap until the next
scheduled poll, and inbox-fetch silently dark for the gap (bridge push
covered for it, which is why this slipped through). `start()` now
defers the first poll; `register()` success kicks `schedulePoll(0)`.
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked: web POSTs pair frame to PC inbox, PC's
`onIncoming` gets `raw.from = "fp:<hex>"`, calls
`shade.receive('fp:<hex>', env)`, parses plaintext, learns real
address, sends paired-reply.
### Added
#### `@shade/inbox-server`
- `InboxStore.putBlob({ ..., senderFp? })` — store interface accepts an
optional 8-byte hex fingerprint. `MemoryInboxStore`,
`SqliteInboxStore` (`@shade/storage-sqlite`), and `PostgresInboxStore`
(`@shade/storage-postgres`) all persist + return it.
- `InboxStore.fetchBlobs(...)` rows expose `senderFp?: string`.
Undefined for legacy rows persisted by a pre-4.8 relay.
- `POST /v1/inbox/:address` route computes `shortHash(senderSigningKey)`
after the sender's signature is verified and forwards it to
`store.putBlob({ ..., senderFp })`. The signature verification path
authorizes the same fingerprint that gets persisted — no new trust
surface.
- `POST /v1/inbox/:address/fetch` response includes `from` per blob
when the row has a fingerprint. Absent on legacy rows.
- Bridge endpoints (`/v1/bridge/{stream,poll,ws}`) now populate
`BridgeWireMessage.from` from the row's `senderFp`. The
`transport-bridge` wire format already accepted `from`; v4.7 just
never filled it.
#### `@shade/inbox`
- `FetchedBlob.from?: string` — relay-supplied sender fingerprint hint,
parsed from the fetch response.
- `DecryptHandler` raw arg gains `from?: string`. Apps that ignore it
keep working unchanged (back-compat: the field is optional).
### Fixed
#### `@shade/inbox` — `Inbox.start()` register/poll race
`start()` no longer schedules the first poll synchronously alongside
the fire-and-forget `register()`. Instead, `register()` success kicks
`schedulePoll(0)`, so the first poll fires after the server has
acknowledged the address. Already-registered instances (where the
local `this.registered` flag is true at `start()` time, e.g. after a
restart that hydrated state) get an immediate poll as before.
### Storage migrations
Idempotent ALTER TABLE for live deployments:
- **SQLite** (`@shade/storage-sqlite`): on open, the store does
`PRAGMA table_info(inbox_blobs)` and runs
`ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT` if the column is
missing. Fresh databases get the column from the `CREATE TABLE IF
NOT EXISTS` directly.
- **Postgres** (`@shade/storage-postgres`): `ensureInboxServerTables`
runs `ALTER TABLE shade_inbox_blobs ADD COLUMN IF NOT EXISTS
sender_fp TEXT`.
Both leave existing rows with `sender_fp = NULL`. The fetch path emits
`from` only when the column is non-empty, so legacy blobs surface as
`from: undefined` (acceptance criterion (4): inter-version compat).
### Tests
- `packages/shade-inbox/tests/client.test.ts`:
- **Race fix**: spy fetch records the order of `register` and `fetch`
requests; first `fetch` (if any) must follow `register`. Pre-fix
the recording fetch threw "fetch fired before register completed
(race not fixed)".
- **Fetch attribution**: `FetchedBlob.from` matches
`SHA-256(senderSigningKey)[:8]` in hex.
- **DecryptHandler propagation**: `raw.from` arrives in the app's
handler.
- `packages/shade-transport-bridge/tests/bridge.test.ts`: same
fingerprint regression for SSE, WS, and long-poll bridges
(`IncomingMessage.from` non-empty + matches the expected digest).
- `packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts`:
- senderFp round-trip through put + fetch.
- senderFp omitted on put → fetched row has `senderFp: undefined`.
- **Pre-4.8 schema migration**: open a DB seeded with a v4.7
`inbox_blobs` schema (no `sender_fp` column), reopen via
`SqliteInboxStore`, verify the legacy row survives + new writes
carry the new field.
### Migration
None required for app code. Existing handlers that ignore
`raw.from` / `IncomingMessage.from` keep working unchanged. Apps that
want sender-attributed first-contact:
```ts
inbox.onIncoming(async (raw) => {
const tentativeAddr = raw.from ? `fp:${raw.from}` : null;
if (!tentativeAddr) return null; // legacy relay; drop
const env = decodeEnvelope(raw.ciphertext);
const plaintext = await shade.receive(tentativeAddr, env);
// pair frame announces real address; reconcile fp:<hex> → real
return null;
});
```
For Prism specifically: drop the `await this.inbox.register()`
workaround in `apps/web/src/shade/transport.ts` and
`packages/shade-sidecar/src/transport.ts`. `inbox.start()` on 4.8+
no longer races and the explicit pre-register is redundant.
## [4.7.0] — 2026-05-07 — Peer-presence events for instant `BroadcastChannel` revoke
`BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a