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