release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user