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

@@ -53,6 +53,7 @@ export class SqliteInboxStore implements InboxStore {
ciphertext TEXT NOT NULL,
received_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
sender_fp TEXT,
PRIMARY KEY (address, msg_id)
);
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
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() {
@@ -73,13 +82,13 @@ export class SqliteInboxStore implements InboxStore {
deleteOwner: this.db.prepare('DELETE FROM inbox_owners WHERE address = ?'),
deleteOwnerBlobs: this.db.prepare('DELETE FROM inbox_blobs WHERE address = ?'),
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(
'SELECT received_at FROM inbox_blobs WHERE address = ? AND msg_id = ?',
),
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
WHERE address = ? AND received_at > ? AND expires_at > ?
ORDER BY received_at ASC
@@ -124,6 +133,7 @@ export class SqliteInboxStore implements InboxStore {
msgId: string;
ciphertext: Uint8Array;
expiresAt: number;
senderFp?: string;
}): Promise<{ created: boolean; receivedAt: number }> {
const existing = this.stmts.findBlob.get(args.address, args.msgId) as
| { received_at: number }
@@ -139,6 +149,7 @@ export class SqliteInboxStore implements InboxStore {
toBase64(args.ciphertext),
receivedAt,
args.expiresAt,
args.senderFp ?? null,
);
return { created: true, receivedAt };
}
@@ -148,19 +159,43 @@ export class SqliteInboxStore implements InboxStore {
sinceCursor: number;
now: 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(
args.address,
args.sinceCursor,
args.now,
args.limit,
) as Array<{ msg_id: string; ciphertext: string; received_at: number; expires_at: number }>;
return rows.map((r) => ({
msgId: r.msg_id,
ciphertext: fromBase64(r.ciphertext),
receivedAt: r.received_at,
expiresAt: r.expires_at,
}));
) as Array<{
msg_id: string;
ciphertext: string;
received_at: number;
expires_at: number;
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> {