release(v4.8.4): server-side cross-channel dedup via BridgeDeliveryLog
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
V4.8.3 shipped client-side cross-channel dedup hook (`Inbox.acceptBridgeFrame`), but recipients that didn't migrate to the new wiring still observed the same envelope twice — once via WS bridge push, again ~30 s later via inbox-poll. Prism re-verified the FR after 4.8.3 and asked for a relay-side enforcement so app code doesn't have to ack-via-DELETE on every bridge frame. V4.8.4 adds an in-memory `BridgeDeliveryLog` (default 60 s grace, 8192-per-address cap) that records every successful WS / SSE / long-poll push of `(address, msgId)`. The `/v1/inbox/:addr/fetch` route filters out blobs in the log's grace window so a recipient running both a bridge and the 30 s poll cadence sees exactly one delivery. Cursor advances over the full fetched window so a poll that straddles a suppressed blob doesn't stall. The standalone server auto-wires the log between `createBridgeRoutes` and `createInboxRoutes`. Custom mounts thread the same instance through `bridgeDeliveryLog` on both factories. Tests cover WS-then-poll, SSE-then-poll, and a negative control (non-bridge-pushed blob still comes through inbox-fetch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import type { InboxStore } from './store.js';
|
||||
import { InboxServerEvents, shortHash } from './events.js';
|
||||
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
|
||||
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
|
||||
import type { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||
|
||||
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
|
||||
const MAX_META_BODY_SIZE = 64 * 1024;
|
||||
@@ -54,6 +55,16 @@ export interface InboxRoutesOptions {
|
||||
observability?: ObservabilityHook;
|
||||
/** Override quota policy. */
|
||||
quota?: Partial<InboxQuotaConfig>;
|
||||
/**
|
||||
* V4.8.4 — shared bridge delivery log. When provided (and the same
|
||||
* instance is wired into `createBridgeRoutes`), the inbox-fetch route
|
||||
* filters out blobs already pushed via bridge within the log's grace
|
||||
* window. Without this, a recipient that runs both a bridge
|
||||
* subscription and inbox-poll receives the same envelope twice.
|
||||
* Optional — leaving it unset preserves the pre-V4.8.4 behavior of
|
||||
* always returning every blob the cursor matches.
|
||||
*/
|
||||
bridgeDeliveryLog?: BridgeDeliveryLog;
|
||||
}
|
||||
|
||||
export function createInboxRoutes(
|
||||
@@ -171,6 +182,7 @@ export function createInboxRoutes(
|
||||
await verifyPayload(crypto, owner, { ...body, address });
|
||||
|
||||
await store.deleteAddress(address);
|
||||
options.bridgeDeliveryLog?.forgetAddress(address);
|
||||
events?.emit('inbox.address_deleted', { address });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -317,13 +329,25 @@ export function createInboxRoutes(
|
||||
await verifyPayload(crypto, owner, { ...body, address });
|
||||
|
||||
const now = Date.now();
|
||||
const rows = await store.fetchBlobs({
|
||||
const rawRows = await store.fetchBlobs({
|
||||
address,
|
||||
sinceCursor,
|
||||
now,
|
||||
limit: quota.fetchPageLimit,
|
||||
});
|
||||
|
||||
// V4.8.4 — drop blobs the bridge has already pushed to this address
|
||||
// within the grace window. This is the cross-channel dedup gate that
|
||||
// makes "one inbox.send ⇒ one observable delivery" hold even when
|
||||
// the recipient runs both a bridge subscription and inbox-poll. The
|
||||
// cursor still advances over the whole `rawRows` window so the
|
||||
// client doesn't get stuck behind suppressed blobs — pollOnce uses
|
||||
// `nextCursor` (max receivedAt seen by the server, suppressed or
|
||||
// not) for the next fetch.
|
||||
const rows = options.bridgeDeliveryLog
|
||||
? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
|
||||
: rawRows;
|
||||
|
||||
let bytes = 0;
|
||||
const blobs = rows.map((r) => {
|
||||
bytes += r.ciphertext.length;
|
||||
@@ -344,14 +368,24 @@ export function createInboxRoutes(
|
||||
if (r.senderFp) out.from = r.senderFp;
|
||||
return out;
|
||||
});
|
||||
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor;
|
||||
// Advance the cursor past the FULL rawRows window — including blobs
|
||||
// we suppressed because the bridge already pushed them. If we
|
||||
// anchored the cursor on `rows` only, suppressed blobs in the
|
||||
// middle of the window would block all subsequent fetches forever
|
||||
// (re-fetched on every poll, re-suppressed, no progress). The
|
||||
// bridge-delivery contract is "the bridge frame is the canonical
|
||||
// delivery"; if the recipient missed processing it, they fall back
|
||||
// to ack-via-DELETE or the blob ages out at TTL — same as a
|
||||
// recipient that crashes mid-handler in the no-bridge case.
|
||||
const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
|
||||
const nextCursor = cursorAnchor;
|
||||
|
||||
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
|
||||
|
||||
return c.json({
|
||||
blobs,
|
||||
cursor: nextCursor,
|
||||
hasMore: rows.length === quota.fetchPageLimit,
|
||||
hasMore: rawRows.length === quota.fetchPageLimit,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,6 +411,10 @@ export function createInboxRoutes(
|
||||
if (removed) {
|
||||
events?.emit('inbox.blob_acked', { address, msgId });
|
||||
}
|
||||
// Drop any bridge-delivery mark — keeps the log bounded under
|
||||
// sustained traffic (otherwise long-lived addresses accumulate
|
||||
// entries even after the underlying blob is gone).
|
||||
options.bridgeDeliveryLog?.forget(address, msgId);
|
||||
return c.json({ ok: removed });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user