release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed.

1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU.
   The relay durably stores blobs and pushes them to every active
   delivery channel; without a cross-channel ack the bridge frame
   ran first and the next inbox-poll re-dispatched the same blob
   ~30 s later, tripping on consumed prekeys. Bridge consumers now
   plumb pushed frames through `acceptBridgeFrame`, which shares
   the dedup gate + ack path with `pollOnce`. Whichever channel
   delivers first wins; the other acks-and-skips. Inbox records
   the msgId before the ack so a parallel poll can't observe an
   in-flight ack window.

2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces
   the receiver to label the new session by the relay's sender
   fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext
   typically announces the peer's real address. Aliasing moves
   session, trusted identity, peer-verification, and identity-
   version under the canonical label. Holds the per-peer mutex on
   both labels (lexicographic order) so concurrent crypto ops can't
   observe a half-moved state. Refuses to overwrite an existing
   session at the new label.

Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's
expiry so receivers can pass bridge frames straight to
`acceptBridgeFrame` without inventing a TTL.

Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy
path, refuse-to-overwrite, and same-label no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:49:36 +02:00
parent 8c606ad498
commit d47774ef1c
33 changed files with 519 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/inbox",
"version": "4.8.2",
"version": "4.8.3",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -63,6 +63,14 @@ export interface InboxOptions {
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 10;
/**
* Cap for the cross-channel msgId dedup LRU. Each entry is a 64-char hex
* string; 4096 entries ≈ 256 KiB of overhead, plenty of headroom for
* bursty traffic (the LRU only needs to span the window between a bridge
* push and the next inbox-poll catching up — typically 30 s × the
* recipient's throughput).
*/
const DEFAULT_DEDUP_LRU_CAP = 4096;
/**
* High-level inbox orchestrator.
@@ -105,6 +113,23 @@ export class Inbox {
private started = false;
private registered = false;
/**
* Bounded msgId dedup window. Used by both the inbox-poll path
* (`pollOnce` → `handleBlob`) and the bridge-push path
* (`acceptBridgeFrame`). The relay stores blobs durably and pushes
* them to every active delivery channel; without a shared dedup gate
* here the recipient processes the same envelope twice — once from
* the bridge, again from the next inbox-poll. The duplicate receive
* trips on consumed one-time prekeys ("OPK not found") and pollutes
* logs even when the canonical first delivery succeeded. See V4.8.3
* Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
*
* Insertion order is FIFO; the oldest msgId is evicted once the LRU
* exceeds `DEFAULT_DEDUP_LRU_CAP`.
*/
private readonly deliveredIds = new Set<string>();
private readonly deliveredOrder: string[] = [];
constructor(private readonly options: InboxOptions) {
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
baseUrl: options.baseUrl,
@@ -380,9 +405,52 @@ export class Inbox {
return total;
}
/**
* Feed a blob delivered by a bridge transport (WS / SSE / long-poll
* push) into the same dispatch + ack pipeline that `pollOnce` uses.
*
* Wire-up pattern:
* ```ts
* const bridge = new FallbackBridgeTransport([...]);
* await bridge.connect({
* onMessage: async (msg) => {
* await inbox.acceptBridgeFrame({
* msgId: msg.msgId!, // present on v4.8+ relays
* ciphertext: msg.bytes,
* receivedAt: msg.receivedAt,
* expiresAt: msg.expiresAt ?? Date.now() + 7 * 24 * 3600 * 1000,
* ...(msg.from !== undefined ? { from: msg.from } : {}),
* });
* },
* });
* ```
*
* The Inbox's bounded msgId LRU is shared between this path and
* `pollOnce`, so whichever channel delivers first wins; the
* other channel acks-and-skips when the same msgId comes back
* around. Both paths also DELETE the blob from the relay on success
* so subsequent polls don't see it either.
*
* Returns `true` if the blob was newly dispatched, `false` if it
* was a duplicate or rejected by the handler (handler still gets a
* chance to retry on the next poll if it threw).
*/
async acceptBridgeFrame(blob: FetchedBlob): Promise<boolean> {
return this.handleBlob(blob);
}
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
if (!this.incomingHandler) return false;
// Cross-channel msgId dedup. If the bridge already delivered this
// blob, the inbox-poll copy must not re-dispatch (would re-trigger
// X3DH / consume an OPK we no longer have). We still ack so the
// relay drops the now-redundant copy.
if (this.deliveredIds.has(blob.msgId)) {
await this.ackQuietly(blob.msgId);
return false;
}
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
// server bug or malicious operator can't sneak a different blob past
// the client's hash check.
@@ -422,17 +490,34 @@ export class Inbox {
return false;
}
try {
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
} catch (err) {
// Decryption succeeded; ack just failed. Will be retried later, and
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
}
// Mark before the ack so a slow-network ack doesn't leave a window
// where a parallel pollOnce sees the same msgId and re-dispatches.
this.recordDelivered(blob.msgId);
await this.ackQuietly(blob.msgId);
this.events.emit('inbox.message_received', {
senderHint,
msgId: blob.msgId,
});
return true;
}
private async ackQuietly(msgId: string): Promise<void> {
try {
await this.client.ack({ address: this.options.ownAddress, msgId });
} catch (err) {
// Dispatch (or skip) succeeded; the ack just failed. Next poll
// will see the blob again and the dedup gate above will skip it.
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
}
}
private recordDelivered(msgId: string): void {
if (this.deliveredIds.has(msgId)) return;
this.deliveredIds.add(msgId);
this.deliveredOrder.push(msgId);
if (this.deliveredOrder.length > DEFAULT_DEDUP_LRU_CAP) {
const evicted = this.deliveredOrder.shift()!;
this.deliveredIds.delete(evicted);
}
}
}

View File

@@ -170,6 +170,120 @@ describe('Inbox orchestrator', () => {
expect(seen[1]!.to).toBe('carol');
});
test('cross-channel dedup: acceptBridgeFrame + pollOnce never re-dispatch the same msgId (V4.8.3)', async () => {
// Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`:
// a single relay PUT was being delivered twice — once via WS bridge
// push, again ~30 s later via inbox-poll catching up. Both copies
// would dispatch `shade.receive`, the second one tripping on
// already-consumed prekeys. The cross-channel msgId LRU inside
// Inbox is the dedup gate; this test exercises it directly via
// `acceptBridgeFrame` followed by `pollOnce`.
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
await bobInbox.register();
// Alice PUTs a blob via the relay HTTP API.
const ct = randBytes(64);
const msgId = await computeMsgId(ct);
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
const putResult = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
expect(putResult.idempotent).toBe(false);
const dispatched: string[] = [];
bobInbox.onIncoming(async (raw) => {
dispatched.push(raw.msgId);
return null;
});
// Simulate the bridge push arriving first.
await bobInbox.acceptBridgeFrame({
msgId,
ciphertext: ct,
receivedAt: putResult.receivedAt,
expiresAt: Date.now() + 60_000,
});
expect(dispatched).toEqual([msgId]);
// The inbox-poll path catches up next — without dedup it would
// re-dispatch. With the LRU it acks-and-skips.
const polled = await bobInbox.tick();
expect(polled.received).toBe(0);
expect(dispatched).toEqual([msgId]); // still one entry
});
test('cross-channel dedup also covers poll-first then bridge-second order', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
await bobInbox.register();
const ct = randBytes(48);
const msgId = await computeMsgId(ct);
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
const putRes = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
const dispatched: string[] = [];
bobInbox.onIncoming(async (raw) => {
dispatched.push(raw.msgId);
return null;
});
// Poll first.
const polled = await bobInbox.tick();
expect(polled.received).toBe(1);
// Bridge frame for the same msgId arrives after the poll already
// dispatched + ack'd it — must be a no-op.
const handled = await bobInbox.acceptBridgeFrame({
msgId,
ciphertext: ct,
receivedAt: putRes.receivedAt,
expiresAt: Date.now() + 60_000,
});
expect(handled).toBe(false);
expect(dispatched).toEqual([msgId]);
});
test('flush retries on transient server failure', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });