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:
@@ -223,9 +223,16 @@ export async function ensureInboxServerTables(sql: Sql): Promise<void> {
|
||||
ciphertext TEXT NOT NULL,
|
||||
received_at BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
sender_fp TEXT,
|
||||
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`
|
||||
CREATE INDEX IF NOT EXISTS shade_inbox_addr_expires_idx
|
||||
ON shade_inbox_blobs(address, expires_at)
|
||||
|
||||
@@ -56,18 +56,20 @@ export class PostgresInboxStore implements InboxStore {
|
||||
msgId: string;
|
||||
ciphertext: Uint8Array;
|
||||
expiresAt: number;
|
||||
senderFp?: string;
|
||||
}): Promise<{ created: boolean; receivedAt: number }> {
|
||||
// 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
|
||||
// SELECT.
|
||||
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 (
|
||||
${args.address},
|
||||
${args.msgId},
|
||||
${toBase64(args.ciphertext)},
|
||||
nextval('shade_inbox_seq'),
|
||||
${args.expiresAt}
|
||||
${args.expiresAt},
|
||||
${args.senderFp ?? null}
|
||||
)
|
||||
ON CONFLICT (address, msg_id) DO NOTHING
|
||||
RETURNING received_at::text
|
||||
@@ -90,14 +92,23 @@ export class PostgresInboxStore 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 = await this.sql<Array<{
|
||||
msg_id: string;
|
||||
ciphertext: string;
|
||||
received_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
|
||||
WHERE address = ${args.address}
|
||||
AND received_at > ${args.sinceCursor}
|
||||
@@ -105,12 +116,22 @@ export class PostgresInboxStore implements InboxStore {
|
||||
ORDER BY received_at ASC
|
||||
LIMIT ${args.limit}
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
msgId: r.msg_id,
|
||||
ciphertext: fromBase64(r.ciphertext),
|
||||
receivedAt: parseInt(r.received_at, 10),
|
||||
expiresAt: parseInt(r.expires_at, 10),
|
||||
}));
|
||||
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: 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> {
|
||||
|
||||
Reference in New Issue
Block a user