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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-sqlite",
|
||||
"version": "4.7.0",
|
||||
"version": "4.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user