release(v4.8.4): server-side cross-channel dedup via BridgeDeliveryLog
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:
2026-05-08 16:31:42 +02:00
parent d47774ef1c
commit a98ea8a1bd
32 changed files with 467 additions and 41 deletions

View File

@@ -0,0 +1,140 @@
/**
* BridgeDeliveryLog — V4.8.4 cross-channel dedup.
*
* Records per-`(address, msgId)` "delivered via bridge push" timestamps so
* the inbox-fetch route can filter out blobs the bridge has already pushed
* to the recipient. The relay's contract becomes:
*
* one `Inbox.send` ⇒ one observable delivery on the recipient
*
* even when the recipient runs both a bridge subscription (WS / SSE) AND
* the regular inbox-poll. Without it, bridge-push and inbox-poll are
* independent paths against the same store and the recipient gets the
* same envelope twice — bridge-first, then ~30 s later via the next poll
* — tripping on already-consumed prekeys (`one-time prekey not found`)
* or surfacing as duplicate `shade.receive` work.
*
* The log is in-memory per process and intentionally bounded: each entry
* lives for `graceMs` (default 60 s, well past a typical `pollIntervalMs`
* of 30 s). After grace, the entry is forgotten and inbox-poll falls back
* to delivering the blob — that's the legitimate "bridge dropped the
* frame, poll picked up" recovery path. If the recipient explicitly
* acks the blob (HTTP `DELETE /v1/inbox/:addr/:msgId`), the blob is gone
* from storage and the log entry is moot.
*
* Multi-bridge per address (e.g. WS + SSE redundancy on the same client,
* or two devices sharing one signing key) is preserved: every bridge
* connection still fetches + pushes the blob — each push records its own
* timestamp — so each connected bridge gets the frame. Only the *poll*
* fetch is filtered, not the bridge fetches themselves.
*
* @see Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
*/
const DEFAULT_GRACE_MS = 60_000;
export interface BridgeDeliveryLogOptions {
/**
* How long a `(address, msgId)` mark suppresses inbox-poll delivery.
* Defaults to 60_000ms — twice the default `pollIntervalMs` of the
* `@shade/inbox` orchestrator, so a poll cycle that races a bridge
* push always sees the mark, but a stuck recipient still gets the
* blob via poll within ~minutes.
*/
graceMs?: number;
/**
* Maximum entries per address. Bounds memory under a busy address.
* Oldest entries (by recorded timestamp) are evicted first. Default
* 8192 — comfortably above any realistic backlog.
*/
maxPerAddress?: number;
}
export class BridgeDeliveryLog {
private readonly log = new Map<string, Map<string, number>>();
private readonly graceMs: number;
private readonly maxPerAddress: number;
constructor(options: BridgeDeliveryLogOptions = {}) {
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
this.maxPerAddress = options.maxPerAddress ?? 8192;
}
/** Mark `(address, msgId)` as bridge-delivered at `now`. */
recordDelivered(address: string, msgId: string, now: number): void {
let inner = this.log.get(address);
if (!inner) {
inner = new Map();
this.log.set(address, inner);
}
inner.set(msgId, now);
// Lazy cleanup: drop entries past 2× grace so the map stays bounded
// without a separate timer. Bound by `maxPerAddress` as a fallback
// for pathological burst scenarios.
if (inner.size > this.maxPerAddress) {
const cutoff = now - this.graceMs * 2;
for (const [id, ts] of inner) {
if (ts < cutoff) inner.delete(id);
}
// Still over cap? Drop the oldest.
if (inner.size > this.maxPerAddress) {
const sorted = Array.from(inner.entries()).sort((a, b) => a[1] - b[1]);
const toDrop = sorted.slice(0, inner.size - this.maxPerAddress);
for (const [id] of toDrop) inner.delete(id);
}
}
}
/**
* Returns true if `(address, msgId)` was bridge-delivered within the
* grace window.
*/
isRecentlyDelivered(address: string, msgId: string, now: number): boolean {
const inner = this.log.get(address);
if (!inner) return false;
const ts = inner.get(msgId);
if (ts === undefined) return false;
if (now - ts > this.graceMs) {
inner.delete(msgId); // tombstone the stale entry
return false;
}
return true;
}
/**
* Filter `blobs` down to those not currently in the bridge-delivered
* grace window. Used by the inbox-fetch route to suppress duplicates.
*/
filterRecent<T extends { msgId: string }>(
address: string,
blobs: T[],
now: number,
): T[] {
const inner = this.log.get(address);
if (!inner || inner.size === 0) return blobs;
return blobs.filter((b) => {
const ts = inner.get(b.msgId);
if (ts === undefined) return true;
if (now - ts > this.graceMs) {
inner.delete(b.msgId);
return true;
}
return false;
});
}
/** Drop the entry for `(address, msgId)`. Called from blob-delete paths. */
forget(address: string, msgId: string): void {
this.log.get(address)?.delete(msgId);
}
/** Drop every entry for `address`. Called from address-delete paths. */
forgetAddress(address: string): void {
this.log.delete(address);
}
/** Test-only inspection. */
size(address: string): number {
return this.log.get(address)?.size ?? 0;
}
}

View File

@@ -39,6 +39,7 @@ import { verifyPayload, validateAddress } from '@shade/server';
import type { InboxStore } from './store.js';
import type { InboxServerEvents } from './events.js';
import { PresenceTracker, type TrackedBridgeKind } from './presence.js';
import { BridgeDeliveryLog } from './bridge-delivery-log.js';
export type BridgeKind = 'stream' | 'poll' | 'ws';
/**
@@ -74,6 +75,16 @@ export interface BridgeRoutesOptions {
* the bridge auto-creates an internal tracker bound to `events`.
*/
presenceTracker?: PresenceTracker;
/**
* V4.8.4 — shared bridge delivery log. After every successful WS /
* SSE push we record `(address, msgId, now)` here so the inbox-fetch
* route can suppress the same blob from a subsequent inbox-poll
* within the log's grace window. Pass the same instance to
* `createInboxRoutes` (or use the auto-created one returned in
* `bridgeRoutes.bridgeDeliveryLog`). When omitted, the bridge
* auto-creates its own log.
*/
bridgeDeliveryLog?: BridgeDeliveryLog;
}
interface VerifiedBridgeRequest {
@@ -103,6 +114,13 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
websocket: unknown;
/** Live presence tracker. Tests + observers can read it; routes update it. */
presence: PresenceTracker;
/**
* V4.8.4 — the shared bridge-delivery log this router writes to on
* every successful push. Wire the same instance into
* `createInboxRoutes({ bridgeDeliveryLog })` so the inbox-fetch route
* can suppress recently-pushed blobs.
*/
bridgeDeliveryLog: BridgeDeliveryLog;
} {
const app = new Hono();
const pageLimit = opts.pageLimit ?? 50;
@@ -111,6 +129,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null);
const bridgeDeliveryLog = opts.bridgeDeliveryLog ?? new BridgeDeliveryLog();
app.onError((err, c) => {
if (err instanceof ShadeError) {
@@ -138,6 +157,10 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
const writer = makeBlobWriter(opts.store, pageLimit);
const delivered = new DeliveredIdLru();
const recordPush = (msgId: string): void => {
bridgeDeliveryLog.recordDelivered(address, msgId, Date.now());
};
// Initial backlog drain.
const flushed = await flushTo(
writer,
@@ -149,6 +172,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
event: 'envelope',
data: JSON.stringify(serializeBlob(blob)),
});
recordPush(blob.msgId);
},
delivered,
);
@@ -181,6 +205,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
event: 'envelope',
data: JSON.stringify(serializeBlob(blob)),
});
recordPush(blob.msgId);
},
delivered,
);
@@ -296,6 +321,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
limit: pageLimit,
});
if (blobs.length > 0) {
const now = Date.now();
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
return c.json(buildPollResponse(blobs, verified.since));
}
@@ -310,6 +337,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
fallbackPollIntervalMs,
abortSignal: c.req.raw.signal,
});
const now = Date.now();
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
return c.json(buildPollResponse(blobs, verified.since));
});
@@ -380,6 +409,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
cursor,
async (blob) => {
ws.send(JSON.stringify(serializeBlob(blob)));
bridgeDeliveryLog.recordDelivered(address, blob.msgId, Date.now());
},
delivered,
);
@@ -414,7 +444,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
}),
);
return { app, websocket, presence };
return { app, websocket, presence, bridgeDeliveryLog };
}
// ─── helpers ──────────────────────────────────────────────────

View File

@@ -34,6 +34,8 @@ export { createBridgeRoutes } from './bridge.js';
export type { BridgeRoutesOptions, BridgeKind } from './bridge.js';
export { PresenceTracker } from './presence.js';
export type { TrackedBridgeKind } from './presence.js';
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
/**
* Create a standalone Shade Inbox Server.
@@ -51,12 +53,13 @@ export function createInboxServer(options: {
store?: InboxStore;
disableRateLimit?: boolean;
events?: InboxServerEvents;
} & Pick<InboxRoutesOptions, 'observability' | 'quota'>): Hono {
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>): Hono {
const store = options.store ?? new MemoryInboxStore();
const routesOptions: InboxRoutesOptions = {};
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
if (options.events !== undefined) routesOptions.events = options.events;
if (options.observability !== undefined) routesOptions.observability = options.observability;
if (options.quota !== undefined) routesOptions.quota = options.quota;
if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
return createInboxRoutes(store, options.crypto, routesOptions);
}

View File

@@ -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 });
});