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

@@ -178,6 +178,104 @@ describe('SqliteInboxStore', () => {
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 () => {
const now = Date.now();
await store.putBlob({