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/),
|
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).
|
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
|
## [4.7.0] — 2026-05-07 — Peer-presence events for instant `BroadcastChannel` revoke
|
||||||
|
|
||||||
`BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a
|
`BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -497,6 +497,8 @@ interface BlobRow {
|
|||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** V4.8 — relay-captured sender fingerprint. Optional for legacy rows. */
|
||||||
|
senderFp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlobWriter {
|
interface BlobWriter {
|
||||||
@@ -542,13 +544,26 @@ function serializeBlob(blob: BlobRow): {
|
|||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
from?: string;
|
||||||
} {
|
} {
|
||||||
return {
|
const out: {
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: string;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
from?: string;
|
||||||
|
} = {
|
||||||
msgId: blob.msgId,
|
msgId: blob.msgId,
|
||||||
ciphertext: toBase64(blob.ciphertext),
|
ciphertext: toBase64(blob.ciphertext),
|
||||||
receivedAt: blob.receivedAt,
|
receivedAt: blob.receivedAt,
|
||||||
expiresAt: blob.expiresAt,
|
expiresAt: blob.expiresAt,
|
||||||
};
|
};
|
||||||
|
// V4.8 — relay-captured sender fingerprint. The transport-bridge
|
||||||
|
// wire format already accepts `from`; populating it lets receivers
|
||||||
|
// bootstrap unknown-sender first-contact via `shade.receive('fp:<hex>',
|
||||||
|
// env)` without requiring an out-of-band sender hint.
|
||||||
|
if (blob.senderFp) out.from = blob.senderFp;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPollResponse(blobs: BlobRow[], sinceFallback: number): {
|
function buildPollResponse(blobs: BlobRow[], sinceFallback: number): {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface BlobRow {
|
|||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,7 @@ export class MemoryInboxStore implements InboxStore {
|
|||||||
msgId: string;
|
msgId: string;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
}): Promise<{ created: boolean; receivedAt: number }> {
|
}): Promise<{ created: boolean; receivedAt: number }> {
|
||||||
const list = this.blobs.get(args.address) ?? [];
|
const list = this.blobs.get(args.address) ?? [];
|
||||||
const existing = list.find((r) => r.msgId === args.msgId);
|
const existing = list.find((r) => r.msgId === args.msgId);
|
||||||
@@ -41,12 +43,14 @@ export class MemoryInboxStore implements InboxStore {
|
|||||||
// multiple blobs land in the same millisecond.
|
// multiple blobs land in the same millisecond.
|
||||||
const receivedAt = Math.max(this.nextReceivedAt + 1, Date.now());
|
const receivedAt = Math.max(this.nextReceivedAt + 1, Date.now());
|
||||||
this.nextReceivedAt = receivedAt;
|
this.nextReceivedAt = receivedAt;
|
||||||
list.push({
|
const row: BlobRow = {
|
||||||
msgId: args.msgId,
|
msgId: args.msgId,
|
||||||
ciphertext: new Uint8Array(args.ciphertext),
|
ciphertext: new Uint8Array(args.ciphertext),
|
||||||
receivedAt,
|
receivedAt,
|
||||||
expiresAt: args.expiresAt,
|
expiresAt: args.expiresAt,
|
||||||
});
|
};
|
||||||
|
if (args.senderFp !== undefined) row.senderFp = args.senderFp;
|
||||||
|
list.push(row);
|
||||||
this.blobs.set(args.address, list);
|
this.blobs.set(args.address, list);
|
||||||
return { created: true, receivedAt };
|
return { created: true, receivedAt };
|
||||||
}
|
}
|
||||||
@@ -56,18 +60,36 @@ export class MemoryInboxStore implements InboxStore {
|
|||||||
sinceCursor: number;
|
sinceCursor: number;
|
||||||
now: number;
|
now: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>> {
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
const list = this.blobs.get(args.address) ?? [];
|
const list = this.blobs.get(args.address) ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) => r.receivedAt > args.sinceCursor && r.expiresAt > args.now)
|
.filter((r) => r.receivedAt > args.sinceCursor && r.expiresAt > args.now)
|
||||||
.sort((a, b) => a.receivedAt - b.receivedAt)
|
.sort((a, b) => a.receivedAt - b.receivedAt)
|
||||||
.slice(0, args.limit)
|
.slice(0, args.limit)
|
||||||
.map((r) => ({
|
.map((r) => {
|
||||||
msgId: r.msgId,
|
const out: {
|
||||||
ciphertext: new Uint8Array(r.ciphertext),
|
msgId: string;
|
||||||
receivedAt: r.receivedAt,
|
ciphertext: Uint8Array;
|
||||||
expiresAt: r.expiresAt,
|
receivedAt: number;
|
||||||
}));
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
} = {
|
||||||
|
msgId: r.msgId,
|
||||||
|
ciphertext: new Uint8Array(r.ciphertext),
|
||||||
|
receivedAt: r.receivedAt,
|
||||||
|
expiresAt: r.expiresAt,
|
||||||
|
};
|
||||||
|
if (r.senderFp !== undefined) out.senderFp = r.senderFp;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -255,11 +255,19 @@ export function createInboxRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V4.8: capture sender fingerprint at PUT time. The sender's
|
||||||
|
// signing key was just verified for this request, so the fingerprint
|
||||||
|
// is bound to the same authentication path that authorized the
|
||||||
|
// store. Surfaced on bridge push + inbox-fetch responses to
|
||||||
|
// bootstrap unknown-sender first-contact (X3DH pair handshake).
|
||||||
|
const senderFp = await shortHash(senderKey);
|
||||||
|
|
||||||
const result = await store.putBlob({
|
const result = await store.putBlob({
|
||||||
address,
|
address,
|
||||||
msgId,
|
msgId,
|
||||||
ciphertext: ctBytes,
|
ciphertext: ctBytes,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
senderFp,
|
||||||
});
|
});
|
||||||
if (result.created) {
|
if (result.created) {
|
||||||
events?.emit('inbox.blob_stored', {
|
events?.emit('inbox.blob_stored', {
|
||||||
@@ -319,12 +327,22 @@ export function createInboxRoutes(
|
|||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
const blobs = rows.map((r) => {
|
const blobs = rows.map((r) => {
|
||||||
bytes += r.ciphertext.length;
|
bytes += r.ciphertext.length;
|
||||||
return {
|
const out: {
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: string;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
from?: string;
|
||||||
|
} = {
|
||||||
msgId: r.msgId,
|
msgId: r.msgId,
|
||||||
ciphertext: toBase64(r.ciphertext),
|
ciphertext: toBase64(r.ciphertext),
|
||||||
receivedAt: r.receivedAt,
|
receivedAt: r.receivedAt,
|
||||||
expiresAt: r.expiresAt,
|
expiresAt: r.expiresAt,
|
||||||
};
|
};
|
||||||
|
// V4.8: surface sender fingerprint when present. Empty for blobs
|
||||||
|
// persisted by a pre-4.8 relay that didn't track sender provenance.
|
||||||
|
if (r.senderFp) out.from = r.senderFp;
|
||||||
|
return out;
|
||||||
});
|
});
|
||||||
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor;
|
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor;
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,20 @@ export interface InboxStore {
|
|||||||
* **Idempotent**: if a row already exists for `(address, msgId)` the
|
* **Idempotent**: if a row already exists for `(address, msgId)` the
|
||||||
* implementation MUST return `{ created: false }` and leave the existing
|
* implementation MUST return `{ created: false }` and leave the existing
|
||||||
* row untouched. A fresh insert returns `{ created: true, receivedAt }`.
|
* row untouched. A fresh insert returns `{ created: true, receivedAt }`.
|
||||||
|
*
|
||||||
|
* `senderFp` (V4.8+) is the 8-byte hex of SHA-256(senderSigningKey)
|
||||||
|
* — captured at PUT time when the relay verified the sender's
|
||||||
|
* signature. Optional so legacy 4.7 callers compile, but populated by
|
||||||
|
* `createInboxRoutes` from 4.8 onward and surfaced on bridge push +
|
||||||
|
* inbox-fetch responses to bootstrap unknown-sender first-contact
|
||||||
|
* (X3DH pair handshake).
|
||||||
*/
|
*/
|
||||||
putBlob(args: {
|
putBlob(args: {
|
||||||
address: string;
|
address: string;
|
||||||
msgId: string;
|
msgId: string;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
}): Promise<{ created: boolean; receivedAt: number }>;
|
}): Promise<{ created: boolean; receivedAt: number }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +65,20 @@ export interface InboxStore {
|
|||||||
sinceCursor: number;
|
sinceCursor: number;
|
||||||
now: number;
|
now: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>>;
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
/**
|
||||||
|
* Sender fingerprint — 8-byte hex of SHA-256(senderSigningKey)
|
||||||
|
* captured at PUT time. Empty/undefined for blobs persisted by a
|
||||||
|
* pre-4.8 relay that didn't track sender provenance.
|
||||||
|
*/
|
||||||
|
senderFp?: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a single blob by `(address, msgId)`. Returns true if a row was
|
* Delete a single blob by `(address, msgId)`. Returns true if a row was
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export interface FetchedBlob {
|
|||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
/** Absolute expiry time (ms since epoch) reported by the server. */
|
/** Absolute expiry time (ms since epoch) reported by the server. */
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/**
|
||||||
|
* Sender fingerprint — 8-byte hex of SHA-256(senderSigningKey),
|
||||||
|
* captured by the relay at PUT time when the sender's signature was
|
||||||
|
* verified. Empty/undefined when the relay is pre-4.8 or the blob
|
||||||
|
* predates sender-fingerprint tracking. Use as an unknown-sender
|
||||||
|
* bootstrap label (`fp:<hex>`) for X3DH first-contact; the
|
||||||
|
* authoritative sender identity is recovered post-decrypt from the
|
||||||
|
* envelope itself, so `from` is a hint, not a trust anchor.
|
||||||
|
*/
|
||||||
|
from?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchResult {
|
export interface FetchResult {
|
||||||
@@ -151,12 +161,18 @@ export class InboxClient {
|
|||||||
);
|
);
|
||||||
const blobs = Array.isArray(json.blobs) ? json.blobs : [];
|
const blobs = Array.isArray(json.blobs) ? json.blobs : [];
|
||||||
return {
|
return {
|
||||||
blobs: blobs.map((b: any) => ({
|
blobs: blobs.map((b: any): FetchedBlob => {
|
||||||
msgId: String(b.msgId),
|
const out: FetchedBlob = {
|
||||||
ciphertext: fromBase64(String(b.ciphertext)),
|
msgId: String(b.msgId),
|
||||||
receivedAt: Number(b.receivedAt),
|
ciphertext: fromBase64(String(b.ciphertext)),
|
||||||
expiresAt: Number(b.expiresAt),
|
receivedAt: Number(b.receivedAt),
|
||||||
})),
|
expiresAt: Number(b.expiresAt),
|
||||||
|
};
|
||||||
|
// V4.8 — relay-supplied sender fingerprint hint. Optional; absent
|
||||||
|
// on pre-4.8 relays or for blobs persisted before tracking landed.
|
||||||
|
if (typeof b.from === 'string' && b.from.length > 0) out.from = b.from;
|
||||||
|
return out;
|
||||||
|
}),
|
||||||
cursor: Number(json.cursor ?? sinceCursor),
|
cursor: Number(json.cursor ?? sinceCursor),
|
||||||
hasMore: Boolean(json.hasMore),
|
hasMore: Boolean(json.hasMore),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,9 +16,20 @@ import { InboxClientEvents, type InboxClientListener } from './events.js';
|
|||||||
* decrypt path it owns) and either return a sender-hint for telemetry
|
* decrypt path it owns) and either return a sender-hint for telemetry
|
||||||
* (the address the SDK extracted, or `null`) or throw to keep the blob
|
* (the address the SDK extracted, or `null`) or throw to keep the blob
|
||||||
* on the server for a later retry.
|
* on the server for a later retry.
|
||||||
|
*
|
||||||
|
* V4.8: `raw.from` is the relay-captured sender fingerprint (8-byte hex
|
||||||
|
* of SHA-256 over the sender's signing key). Empty when the relay is
|
||||||
|
* pre-4.8 or didn't track the sender. Use it as the `fp:<hex>` bootstrap
|
||||||
|
* label for X3DH first-contact handshakes.
|
||||||
*/
|
*/
|
||||||
export type DecryptHandler = (
|
export type DecryptHandler = (
|
||||||
raw: { msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number },
|
raw: {
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
from?: string;
|
||||||
|
},
|
||||||
) => Promise<string | null | undefined> | string | null | undefined;
|
) => Promise<string | null | undefined> | string | null | undefined;
|
||||||
|
|
||||||
export interface InboxOptions {
|
export interface InboxOptions {
|
||||||
@@ -149,6 +160,14 @@ export class Inbox {
|
|||||||
signingKey: this.options.signingPublicKey,
|
signingKey: this.options.signingPublicKey,
|
||||||
});
|
});
|
||||||
this.registered = true;
|
this.registered = true;
|
||||||
|
// V4.8: gate the first poll on register success. `start()` calls
|
||||||
|
// `register()` fire-and-forget; without this kick, the very first
|
||||||
|
// `pollOnce()` (scheduled synchronously alongside register) would
|
||||||
|
// race the register HTTP RTT and return SHADE_NOT_FOUND. The
|
||||||
|
// pollOnce() guard skips polls until `registered === true`; this
|
||||||
|
// immediate schedule ensures we don't wait the full pollIntervalMs
|
||||||
|
// for the next attempt once register lands.
|
||||||
|
if (this.started) this.schedulePoll(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop the address from the server. Local queue/cursor are preserved. */
|
/** Drop the address from the server. Local queue/cursor are preserved. */
|
||||||
@@ -217,7 +236,11 @@ export class Inbox {
|
|||||||
this.scheduleRegisterRetry();
|
this.scheduleRegisterRetry();
|
||||||
});
|
});
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
this.schedulePoll(0);
|
// V4.8: do NOT schedule the first poll synchronously. `register()`
|
||||||
|
// success kicks `schedulePoll(0)` so the first poll fires after the
|
||||||
|
// server has acknowledged the address. Pre-fix this raced register
|
||||||
|
// and burned a 404 on every fresh-address `start()`.
|
||||||
|
if (this.registered) this.schedulePoll(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop background timers. Pending entries remain in the queue. */
|
/** Stop background timers. Pending entries remain in the queue. */
|
||||||
@@ -375,12 +398,20 @@ export class Inbox {
|
|||||||
|
|
||||||
let senderHint: string | null = null;
|
let senderHint: string | null = null;
|
||||||
try {
|
try {
|
||||||
const result = await this.incomingHandler({
|
const raw: {
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
from?: string;
|
||||||
|
} = {
|
||||||
msgId: blob.msgId,
|
msgId: blob.msgId,
|
||||||
ciphertext: blob.ciphertext,
|
ciphertext: blob.ciphertext,
|
||||||
receivedAt: blob.receivedAt,
|
receivedAt: blob.receivedAt,
|
||||||
expiresAt: blob.expiresAt,
|
expiresAt: blob.expiresAt,
|
||||||
});
|
};
|
||||||
|
if (blob.from !== undefined) raw.from = blob.from;
|
||||||
|
const result = await this.incomingHandler(raw);
|
||||||
senderHint = result ?? null;
|
senderHint = result ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.events.emit('inbox.message_decrypt_failed', {
|
this.events.emit('inbox.message_decrypt_failed', {
|
||||||
|
|||||||
@@ -326,3 +326,150 @@ describe('InboxClient — default fetch is bound to globalThis', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Inbox.start() — fresh-address register/poll race (V4.8)', () => {
|
||||||
|
// Regression: pre-4.8 `start()` called `register()` fire-and-forget AND
|
||||||
|
// `schedulePoll(0)` synchronously, so the first poll often beat the
|
||||||
|
// register HTTP RTT and got SHADE_NOT_FOUND on a fresh address. Fix:
|
||||||
|
// start() defers the first poll; register() success kicks it.
|
||||||
|
test('fresh address: no fetch fires before register completes', async () => {
|
||||||
|
const store = new MemoryInboxStore();
|
||||||
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
const alice = await makeIdentity();
|
||||||
|
|
||||||
|
// Order observed by the server: must be register-then-fetch, never
|
||||||
|
// fetch-then-register.
|
||||||
|
const calls: Array<'register' | 'fetch' | 'put'> = [];
|
||||||
|
let registerArrived = false;
|
||||||
|
const recordingFetch: typeof fetch = (async (input, init) => {
|
||||||
|
const u =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: (input as Request).url;
|
||||||
|
if (u.includes('/v1/inbox/register')) {
|
||||||
|
calls.push('register');
|
||||||
|
// Hold register for a tick to widen the race window.
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
registerArrived = true;
|
||||||
|
} else if (u.endsWith('/fetch')) {
|
||||||
|
// Any fetch arriving before register is the race we're guarding
|
||||||
|
// against.
|
||||||
|
if (!registerArrived) {
|
||||||
|
throw new Error('fetch fired before register completed (race not fixed)');
|
||||||
|
}
|
||||||
|
calls.push('fetch');
|
||||||
|
} else if (u.includes('/v1/inbox/')) {
|
||||||
|
calls.push('put');
|
||||||
|
}
|
||||||
|
return honoFetch(app)(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const inbox = new Inbox({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
ownAddress: 'alice',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: alice.signingPrivateKey,
|
||||||
|
signingPublicKey: alice.signingPublicKey,
|
||||||
|
pollIntervalMs: 30_000, // Long enough that only register's kick triggers.
|
||||||
|
fetch: recordingFetch,
|
||||||
|
});
|
||||||
|
inbox.onIncoming(() => null);
|
||||||
|
inbox.start();
|
||||||
|
|
||||||
|
// Wait until register has completed and the success-kick poll lands.
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
inbox.stop();
|
||||||
|
|
||||||
|
expect(calls[0]).toBe('register');
|
||||||
|
// First fetch (if any) must be after register.
|
||||||
|
const firstFetchIdx = calls.indexOf('fetch');
|
||||||
|
if (firstFetchIdx !== -1) {
|
||||||
|
expect(firstFetchIdx).toBeGreaterThan(calls.indexOf('register'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FetchedBlob.from — relay-supplied sender fingerprint (V4.8)', () => {
|
||||||
|
test('inbox-fetch response carries from = 8-byte hex of SHA-256(senderSigningKey)', async () => {
|
||||||
|
const store = new MemoryInboxStore();
|
||||||
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
const bob = await makeIdentity();
|
||||||
|
const alice = await makeIdentity();
|
||||||
|
|
||||||
|
const bobClient = new InboxClient({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: bob.signingPrivateKey,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
const aliceClient = new InboxClient({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: alice.signingPrivateKey,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
|
||||||
|
await aliceClient.put({
|
||||||
|
recipientAddress: 'bob',
|
||||||
|
senderSigningKey: alice.signingPublicKey,
|
||||||
|
envelope: randBytes(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await bobClient.fetch({ address: 'bob' });
|
||||||
|
expect(fetched.blobs.length).toBe(1);
|
||||||
|
const fp = fetched.blobs[0]!.from;
|
||||||
|
expect(fp).toBeDefined();
|
||||||
|
expect(fp).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
|
||||||
|
// Must be reproducible: SHA-256(alice.signingPublicKey) → first 8 bytes hex.
|
||||||
|
const digest = await globalThis.crypto.subtle.digest(
|
||||||
|
'SHA-256',
|
||||||
|
alice.signingPublicKey as unknown as ArrayBuffer,
|
||||||
|
);
|
||||||
|
const expected = Array.from(new Uint8Array(digest).slice(0, 8), (b) =>
|
||||||
|
b.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
expect(fp).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DecryptHandler raw arg propagates from to the app', async () => {
|
||||||
|
const store = new MemoryInboxStore();
|
||||||
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
const bob = await makeIdentity();
|
||||||
|
const alice = await makeIdentity();
|
||||||
|
|
||||||
|
const aliceClient = new InboxClient({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: alice.signingPrivateKey,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
const bobInbox = new Inbox({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
ownAddress: 'bob',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: bob.signingPrivateKey,
|
||||||
|
signingPublicKey: bob.signingPublicKey,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bobInbox.register();
|
||||||
|
await aliceClient.put({
|
||||||
|
recipientAddress: 'bob',
|
||||||
|
senderSigningKey: alice.signingPublicKey,
|
||||||
|
envelope: randBytes(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
let observed: string | undefined = undefined;
|
||||||
|
bobInbox.onIncoming((raw) => {
|
||||||
|
observed = raw.from;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await bobInbox.tick();
|
||||||
|
expect(observed).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -223,9 +223,16 @@ export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
|||||||
ciphertext TEXT NOT NULL,
|
ciphertext TEXT NOT NULL,
|
||||||
received_at BIGINT NOT NULL,
|
received_at BIGINT NOT NULL,
|
||||||
expires_at BIGINT NOT NULL,
|
expires_at BIGINT NOT NULL,
|
||||||
|
sender_fp TEXT,
|
||||||
PRIMARY KEY (address, msg_id)
|
PRIMARY KEY (address, msg_id)
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
// V4.8 — sender fingerprint column. Idempotent ADD COLUMN for live
|
||||||
|
// databases that came up under a 4.7-or-earlier schema.
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE shade_inbox_blobs
|
||||||
|
ADD COLUMN IF NOT EXISTS sender_fp TEXT
|
||||||
|
`;
|
||||||
await sql`
|
await sql`
|
||||||
CREATE INDEX IF NOT EXISTS shade_inbox_addr_expires_idx
|
CREATE INDEX IF NOT EXISTS shade_inbox_addr_expires_idx
|
||||||
ON shade_inbox_blobs(address, expires_at)
|
ON shade_inbox_blobs(address, expires_at)
|
||||||
|
|||||||
@@ -56,18 +56,20 @@ export class PostgresInboxStore implements InboxStore {
|
|||||||
msgId: string;
|
msgId: string;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
}): Promise<{ created: boolean; receivedAt: number }> {
|
}): Promise<{ created: boolean; receivedAt: number }> {
|
||||||
// ON CONFLICT DO NOTHING + RETURNING keeps it idempotent and atomic.
|
// ON CONFLICT DO NOTHING + RETURNING keeps it idempotent and atomic.
|
||||||
// When a row already exists, we look up its received_at in a follow-up
|
// When a row already exists, we look up its received_at in a follow-up
|
||||||
// SELECT.
|
// SELECT.
|
||||||
const inserted = await this.sql<Array<{ received_at: string }>>`
|
const inserted = await this.sql<Array<{ received_at: string }>>`
|
||||||
INSERT INTO shade_inbox_blobs (address, msg_id, ciphertext, received_at, expires_at)
|
INSERT INTO shade_inbox_blobs (address, msg_id, ciphertext, received_at, expires_at, sender_fp)
|
||||||
VALUES (
|
VALUES (
|
||||||
${args.address},
|
${args.address},
|
||||||
${args.msgId},
|
${args.msgId},
|
||||||
${toBase64(args.ciphertext)},
|
${toBase64(args.ciphertext)},
|
||||||
nextval('shade_inbox_seq'),
|
nextval('shade_inbox_seq'),
|
||||||
${args.expiresAt}
|
${args.expiresAt},
|
||||||
|
${args.senderFp ?? null}
|
||||||
)
|
)
|
||||||
ON CONFLICT (address, msg_id) DO NOTHING
|
ON CONFLICT (address, msg_id) DO NOTHING
|
||||||
RETURNING received_at::text
|
RETURNING received_at::text
|
||||||
@@ -90,14 +92,23 @@ export class PostgresInboxStore implements InboxStore {
|
|||||||
sinceCursor: number;
|
sinceCursor: number;
|
||||||
now: number;
|
now: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>> {
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
const rows = await this.sql<Array<{
|
const rows = await this.sql<Array<{
|
||||||
msg_id: string;
|
msg_id: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
sender_fp: string | null;
|
||||||
}>>`
|
}>>`
|
||||||
SELECT msg_id, ciphertext, received_at::text, expires_at::text
|
SELECT msg_id, ciphertext, received_at::text, expires_at::text, sender_fp
|
||||||
FROM shade_inbox_blobs
|
FROM shade_inbox_blobs
|
||||||
WHERE address = ${args.address}
|
WHERE address = ${args.address}
|
||||||
AND received_at > ${args.sinceCursor}
|
AND received_at > ${args.sinceCursor}
|
||||||
@@ -105,12 +116,22 @@ export class PostgresInboxStore implements InboxStore {
|
|||||||
ORDER BY received_at ASC
|
ORDER BY received_at ASC
|
||||||
LIMIT ${args.limit}
|
LIMIT ${args.limit}
|
||||||
`;
|
`;
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => {
|
||||||
msgId: r.msg_id,
|
const out: {
|
||||||
ciphertext: fromBase64(r.ciphertext),
|
msgId: string;
|
||||||
receivedAt: parseInt(r.received_at, 10),
|
ciphertext: Uint8Array;
|
||||||
expiresAt: parseInt(r.expires_at, 10),
|
receivedAt: number;
|
||||||
}));
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
} = {
|
||||||
|
msgId: r.msg_id,
|
||||||
|
ciphertext: fromBase64(r.ciphertext),
|
||||||
|
receivedAt: parseInt(r.received_at, 10),
|
||||||
|
expiresAt: parseInt(r.expires_at, 10),
|
||||||
|
};
|
||||||
|
if (r.sender_fp) out.senderFp = r.sender_fp;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
ciphertext TEXT NOT NULL,
|
ciphertext TEXT NOT NULL,
|
||||||
received_at INTEGER NOT NULL,
|
received_at INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
|
sender_fp TEXT,
|
||||||
PRIMARY KEY (address, msg_id)
|
PRIMARY KEY (address, msg_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_inbox_addr_expires
|
CREATE INDEX IF NOT EXISTS idx_inbox_addr_expires
|
||||||
@@ -62,6 +63,14 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_inbox_expires
|
CREATE INDEX IF NOT EXISTS idx_inbox_expires
|
||||||
ON inbox_blobs(expires_at);
|
ON inbox_blobs(expires_at);
|
||||||
`);
|
`);
|
||||||
|
// V4.8 — sender fingerprint column. Idempotent ALTER for live
|
||||||
|
// databases that came up under a 4.7-or-earlier schema.
|
||||||
|
const cols = this.db
|
||||||
|
.prepare(`PRAGMA table_info(inbox_blobs)`)
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
if (!cols.some((c) => c.name === 'sender_fp')) {
|
||||||
|
this.db.exec(`ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareStatements() {
|
private prepareStatements() {
|
||||||
@@ -73,13 +82,13 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
deleteOwner: this.db.prepare('DELETE FROM inbox_owners WHERE address = ?'),
|
deleteOwner: this.db.prepare('DELETE FROM inbox_owners WHERE address = ?'),
|
||||||
deleteOwnerBlobs: this.db.prepare('DELETE FROM inbox_blobs WHERE address = ?'),
|
deleteOwnerBlobs: this.db.prepare('DELETE FROM inbox_blobs WHERE address = ?'),
|
||||||
insertBlob: this.db.prepare(
|
insertBlob: this.db.prepare(
|
||||||
'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at, sender_fp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
),
|
),
|
||||||
findBlob: this.db.prepare(
|
findBlob: this.db.prepare(
|
||||||
'SELECT received_at FROM inbox_blobs WHERE address = ? AND msg_id = ?',
|
'SELECT received_at FROM inbox_blobs WHERE address = ? AND msg_id = ?',
|
||||||
),
|
),
|
||||||
fetchSince: this.db.prepare(
|
fetchSince: this.db.prepare(
|
||||||
`SELECT msg_id, ciphertext, received_at, expires_at
|
`SELECT msg_id, ciphertext, received_at, expires_at, sender_fp
|
||||||
FROM inbox_blobs
|
FROM inbox_blobs
|
||||||
WHERE address = ? AND received_at > ? AND expires_at > ?
|
WHERE address = ? AND received_at > ? AND expires_at > ?
|
||||||
ORDER BY received_at ASC
|
ORDER BY received_at ASC
|
||||||
@@ -124,6 +133,7 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
msgId: string;
|
msgId: string;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
}): Promise<{ created: boolean; receivedAt: number }> {
|
}): Promise<{ created: boolean; receivedAt: number }> {
|
||||||
const existing = this.stmts.findBlob.get(args.address, args.msgId) as
|
const existing = this.stmts.findBlob.get(args.address, args.msgId) as
|
||||||
| { received_at: number }
|
| { received_at: number }
|
||||||
@@ -139,6 +149,7 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
toBase64(args.ciphertext),
|
toBase64(args.ciphertext),
|
||||||
receivedAt,
|
receivedAt,
|
||||||
args.expiresAt,
|
args.expiresAt,
|
||||||
|
args.senderFp ?? null,
|
||||||
);
|
);
|
||||||
return { created: true, receivedAt };
|
return { created: true, receivedAt };
|
||||||
}
|
}
|
||||||
@@ -148,19 +159,43 @@ export class SqliteInboxStore implements InboxStore {
|
|||||||
sinceCursor: number;
|
sinceCursor: number;
|
||||||
now: number;
|
now: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<Array<{ msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }>> {
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
const rows = this.stmts.fetchSince.all(
|
const rows = this.stmts.fetchSince.all(
|
||||||
args.address,
|
args.address,
|
||||||
args.sinceCursor,
|
args.sinceCursor,
|
||||||
args.now,
|
args.now,
|
||||||
args.limit,
|
args.limit,
|
||||||
) as Array<{ msg_id: string; ciphertext: string; received_at: number; expires_at: number }>;
|
) as Array<{
|
||||||
return rows.map((r) => ({
|
msg_id: string;
|
||||||
msgId: r.msg_id,
|
ciphertext: string;
|
||||||
ciphertext: fromBase64(r.ciphertext),
|
received_at: number;
|
||||||
receivedAt: r.received_at,
|
expires_at: number;
|
||||||
expiresAt: r.expires_at,
|
sender_fp: string | null;
|
||||||
}));
|
}>;
|
||||||
|
return rows.map((r) => {
|
||||||
|
const out: {
|
||||||
|
msgId: string;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
receivedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
senderFp?: string;
|
||||||
|
} = {
|
||||||
|
msgId: r.msg_id,
|
||||||
|
ciphertext: fromBase64(r.ciphertext),
|
||||||
|
receivedAt: r.received_at,
|
||||||
|
expiresAt: r.expires_at,
|
||||||
|
};
|
||||||
|
if (r.sender_fp) out.senderFp = r.sender_fp;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
async deleteBlob(address: string, msgId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -178,6 +178,104 @@ describe('SqliteInboxStore', () => {
|
|||||||
expect(blobs.length).toBe(0);
|
expect(blobs.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('senderFp round-trips through put + fetch (V4.8)', async () => {
|
||||||
|
const ct = randBytes(40);
|
||||||
|
const fp = '0123456789abcdef';
|
||||||
|
await store.putBlob({
|
||||||
|
address: 'bob',
|
||||||
|
msgId: 'a'.repeat(64),
|
||||||
|
ciphertext: ct,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
senderFp: fp,
|
||||||
|
});
|
||||||
|
const blobs = await store.fetchBlobs({
|
||||||
|
address: 'bob',
|
||||||
|
sinceCursor: 0,
|
||||||
|
now: Date.now(),
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(blobs.length).toBe(1);
|
||||||
|
expect(blobs[0]!.senderFp).toBe(fp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => {
|
||||||
|
const ct = randBytes(40);
|
||||||
|
await store.putBlob({
|
||||||
|
address: 'bob',
|
||||||
|
msgId: 'b'.repeat(64),
|
||||||
|
ciphertext: ct,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
});
|
||||||
|
const blobs = await store.fetchBlobs({
|
||||||
|
address: 'bob',
|
||||||
|
sinceCursor: 0,
|
||||||
|
now: Date.now(),
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(blobs.length).toBe(1);
|
||||||
|
expect(blobs[0]!.senderFp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => {
|
||||||
|
// Reproduce a pre-4.8 schema in a fresh DB, then reopen via
|
||||||
|
// SqliteInboxStore which should run the idempotent ALTER without
|
||||||
|
// dropping the existing rows.
|
||||||
|
store.close();
|
||||||
|
try {
|
||||||
|
unlinkSync(dbPath);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
unlinkSync(dbPath + '-wal');
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
unlinkSync(dbPath + '-shm');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const { Database } = await import('bun:sqlite');
|
||||||
|
const legacy = new Database(dbPath, { create: true });
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE inbox_blobs (
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
msg_id TEXT NOT NULL,
|
||||||
|
ciphertext TEXT NOT NULL,
|
||||||
|
received_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (address, msg_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
legacy.prepare(
|
||||||
|
'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000);
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
store = new SqliteInboxStore(dbPath);
|
||||||
|
const blobs = await store.fetchBlobs({
|
||||||
|
address: 'bob',
|
||||||
|
sinceCursor: 0,
|
||||||
|
now: Date.now(),
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(blobs.length).toBe(1);
|
||||||
|
expect(blobs[0]!.senderFp).toBeUndefined();
|
||||||
|
|
||||||
|
// New writes after migration carry senderFp.
|
||||||
|
await store.putBlob({
|
||||||
|
address: 'bob',
|
||||||
|
msgId: 'd'.repeat(64),
|
||||||
|
ciphertext: randBytes(8),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
senderFp: 'feedfacedeadbeef',
|
||||||
|
});
|
||||||
|
const after = await store.fetchBlobs({
|
||||||
|
address: 'bob',
|
||||||
|
sinceCursor: 0,
|
||||||
|
now: Date.now(),
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
const newer = after.find((b) => b.msgId === 'd'.repeat(64));
|
||||||
|
expect(newer?.senderFp).toBe('feedfacedeadbeef');
|
||||||
|
});
|
||||||
|
|
||||||
test('countBlobs ignores expired entries', async () => {
|
test('countBlobs ignores expired entries', async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await store.putBlob({
|
await store.putBlob({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -867,3 +867,87 @@ describe('PresenceBridge — subscribe to remote presence changes', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── V4.8 — sender-fingerprint propagation on bridge push ──────────
|
||||||
|
|
||||||
|
describe('Sender attribution — bridge push surfaces IncomingMessage.from', () => {
|
||||||
|
test('SSE push carries from = 8-byte hex of SHA-256(senderSigningKey)', async () => {
|
||||||
|
const h = await bootstrap();
|
||||||
|
try {
|
||||||
|
const received: IncomingMessage[] = [];
|
||||||
|
const bridge = new SseBridge({
|
||||||
|
baseUrl: h.baseUrl,
|
||||||
|
auth: bobAuth(h),
|
||||||
|
initialBackoffMs: 50,
|
||||||
|
maxBackoffMs: 200,
|
||||||
|
disableAutoReconnect: true,
|
||||||
|
});
|
||||||
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
||||||
|
try {
|
||||||
|
await putBlob(h, rand(48));
|
||||||
|
await waitFor(() => received.length === 1, 5_000);
|
||||||
|
const fp = received[0]!.from;
|
||||||
|
expect(fp).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
const digest = await globalThis.crypto.subtle.digest(
|
||||||
|
'SHA-256',
|
||||||
|
h.alice.signingPublicKey as unknown as ArrayBuffer,
|
||||||
|
);
|
||||||
|
const expected = Array.from(new Uint8Array(digest).slice(0, 8), (b) =>
|
||||||
|
b.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
expect(fp).toBe(expected);
|
||||||
|
} finally {
|
||||||
|
await bridge.disconnect();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
h.server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WS push carries from likewise', async () => {
|
||||||
|
const h = await bootstrap();
|
||||||
|
try {
|
||||||
|
const received: IncomingMessage[] = [];
|
||||||
|
const bridge = new WsBridge({
|
||||||
|
baseUrl: h.baseUrl,
|
||||||
|
auth: bobAuth(h),
|
||||||
|
connectTimeoutMs: 2_000,
|
||||||
|
disableAutoReconnect: true,
|
||||||
|
});
|
||||||
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
||||||
|
try {
|
||||||
|
await putBlob(h, rand(48));
|
||||||
|
await waitFor(() => received.length === 1, 5_000);
|
||||||
|
expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
} finally {
|
||||||
|
await bridge.disconnect();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
h.server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('long-poll push carries from likewise', async () => {
|
||||||
|
const h = await bootstrap();
|
||||||
|
try {
|
||||||
|
const received: IncomingMessage[] = [];
|
||||||
|
const bridge = new LongPollBridge({
|
||||||
|
baseUrl: h.baseUrl,
|
||||||
|
auth: bobAuth(h),
|
||||||
|
pollTimeoutMs: 500,
|
||||||
|
requestTimeoutMs: 1_500,
|
||||||
|
errorBackoffMs: 50,
|
||||||
|
});
|
||||||
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
||||||
|
try {
|
||||||
|
await putBlob(h, rand(48));
|
||||||
|
await waitFor(() => received.length === 1, 5_000);
|
||||||
|
expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
} finally {
|
||||||
|
await bridge.disconnect();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
h.server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user