diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7440b8..653b8ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,93 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [4.8.4] — 2026-05-08 — Server-side cross-channel dedup via `BridgeDeliveryLog`
+
+V4.8.3 shipped the *client-side* cross-channel dedup hook
+(`Inbox.acceptBridgeFrame`), but a recipient that didn't migrate to
+the new wiring kept observing the same envelope twice — once via the
+WS bridge push, again ~30 s later when the next inbox-poll cycle
+fetched it. Prism re-verified the FR after 4.8.3 and called this out:
+they wanted the relay itself to enforce the
+"one `Inbox.send` ⇒ one observable delivery" contract so app code
+doesn't have to ack-via-DELETE on every bridge frame.
+
+V4.8.4 adds the **server-side** dedup gate. A new in-memory
+`BridgeDeliveryLog` (default 60 s grace, 8192-entry-per-address cap)
+records every successful WS / SSE / long-poll push of
+`(address, msgId)`. The inbox-fetch route reads the log and filters
+out blobs the bridge has already pushed within the grace window. The
+cursor advances over the *full* fetched window so a poll cycle that
+straddles a suppressed blob doesn't get stuck — the bridge frame is
+the canonical delivery; a recipient that crashed before processing it
+falls back to ack-via-DELETE or waits for TTL the same way it would
+in a no-bridge deployment.
+
+The standalone server (`@shade/server`) auto-wires the log between
+`createBridgeRoutes` and `createInboxRoutes`, so self-hosted relays
+get the fix without configuration. Custom mounts thread the same
+instance through `bridgeDeliveryLog` on both factories.
+
+Reported by Prism (multi-device E2EE terminal). Acceptance: a single
+`inbox.send` from sender A produces exactly one observable receive
+on recipient B even when B runs both a bridge subscription and the
+30 s `Inbox.pollOnce` cadence — confirmed by `bun test
+packages/shade-transport-bridge/tests/bridge.test.ts`'s new
+"BridgeDeliveryLog" describe block (WS + SSE coverage).
+
+### Added
+
+#### `@shade/inbox-server`
+- `BridgeDeliveryLog` class — in-memory `Map
>` with grace-window filtering. Bounded per address
+ (default 8192, oldest-first eviction); lazy cleanup on insert.
+ Exported from the package root.
+- `BridgeDeliveryLogOptions` — `{ graceMs?, maxPerAddress? }`.
+- `createBridgeRoutes(...).bridgeDeliveryLog` — the auto-created log
+ the bridge handlers write to. Inject one explicitly via
+ `BridgeRoutesOptions.bridgeDeliveryLog` when you need to share an
+ instance across multiple bridge mounts.
+- `InboxRoutesOptions.bridgeDeliveryLog` — when provided, the
+ `/v1/inbox/:addr/fetch` route filters out blobs in the log's
+ grace window. Cursor advances over the *full* unsuppressed-plus-
+ suppressed page so successive polls don't stall.
+- `createInboxServer({ bridgeDeliveryLog })` — opt-in for the
+ high-level factory.
+
+#### `@shade/server` — `standalone.ts`
+- The shared `BridgeDeliveryLog` is auto-wired between
+ `createBridgeRoutes` and `createInboxRoutes` so self-hosted
+ relays inherit the cross-channel dedup with zero configuration.
+
+### Tests
+- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
+ "BridgeDeliveryLog" describe block:
+ 1. WS push then `/v1/inbox/:addr/fetch` returns 0 blobs but the
+ cursor has advanced.
+ 2. SSE push records into the log identically (transport parity).
+ 3. A blob the bridge never pushed (e.g. bridge wasn't connected)
+ still comes through inbox-fetch — the filter is bridge-
+ delivered-specific, not a blanket suppression.
+- `bootstrap()` in the test file now wires the bridge's
+ auto-created log into `createInboxRoutes`, mirroring the
+ standalone-server wiring.
+
+### Migration
+
+For self-hosted operators of the standalone server: drop-in.
+
+For consumers that mount `createInboxRoutes` + `createBridgeRoutes`
+themselves: pass the same `bridgeDeliveryLog` to both factories. The
+bridge auto-creates one and exposes it as
+`bridgeRoutes.bridgeDeliveryLog`; the inbox routes accept it as
+`InboxRoutesOptions.bridgeDeliveryLog`. Without the wiring, the bridge
+push still works as before but the cross-channel dedup is off.
+
+For client-side consumers, V4.8.3's `Inbox.acceptBridgeFrame` is
+still the recommended path (instant ack, no grace-window wait), but
+clients that only consume bridge pushes via their own dispatcher now
+get the dedup for free as long as the relay is on V4.8.4.
+
## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession`
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json
index 5fa57ad..6b6bc1d 100644
--- a/packages/shade-cli/package.json
+++ b/packages/shade-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/cli",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/cli.ts",
"bin": {
diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json
index 66fd812..f9c6382 100644
--- a/packages/shade-core/package.json
+++ b/packages/shade-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/core",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json
index 72a1e80..3d94a66 100644
--- a/packages/shade-crypto-web/package.json
+++ b/packages/shade-crypto-web/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/crypto-web",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json
index 89410dc..7e37b6d 100644
--- a/packages/shade-dashboard/package.json
+++ b/packages/shade-dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/dashboard",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json
index a906924..5c5cebf 100644
--- a/packages/shade-files/package.json
+++ b/packages/shade-files/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/files",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json
index 2d77b70..703f438 100644
--- a/packages/shade-inbox-server/package.json
+++ b/packages/shade-inbox-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/inbox-server",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-inbox-server/src/bridge-delivery-log.ts b/packages/shade-inbox-server/src/bridge-delivery-log.ts
new file mode 100644
index 0000000..6ff4d59
--- /dev/null
+++ b/packages/shade-inbox-server/src/bridge-delivery-log.ts
@@ -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>();
+ 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(
+ 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;
+ }
+}
diff --git a/packages/shade-inbox-server/src/bridge.ts b/packages/shade-inbox-server/src/bridge.ts
index 52ea6bd..f7ca9eb 100644
--- a/packages/shade-inbox-server/src/bridge.ts
+++ b/packages/shade-inbox-server/src/bridge.ts
@@ -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 ──────────────────────────────────────────────────
diff --git a/packages/shade-inbox-server/src/index.ts b/packages/shade-inbox-server/src/index.ts
index 085b42f..ebdb764 100644
--- a/packages/shade-inbox-server/src/index.ts
+++ b/packages/shade-inbox-server/src/index.ts
@@ -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): Hono {
+} & Pick): 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);
}
diff --git a/packages/shade-inbox-server/src/routes.ts b/packages/shade-inbox-server/src/routes.ts
index 042c0bd..e46dfea 100644
--- a/packages/shade-inbox-server/src/routes.ts
+++ b/packages/shade-inbox-server/src/routes.ts
@@ -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;
+ /**
+ * 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 });
});
diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json
index e3f973a..44c8613 100644
--- a/packages/shade-inbox/package.json
+++ b/packages/shade-inbox/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/inbox",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json
index 82831b8..f4046da 100644
--- a/packages/shade-key-transparency/package.json
+++ b/packages/shade-key-transparency/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/key-transparency",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json
index 373e708..626541b 100644
--- a/packages/shade-keychain/package.json
+++ b/packages/shade-keychain/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/keychain",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json
index 8f6660f..6a7f43d 100644
--- a/packages/shade-observability/package.json
+++ b/packages/shade-observability/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/observability",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json
index 3949d7a..df794f2 100644
--- a/packages/shade-observer/package.json
+++ b/packages/shade-observer/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/observer",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json
index 2cd5b39..6d804fa 100644
--- a/packages/shade-proto/package.json
+++ b/packages/shade-proto/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/proto",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json
index 60115fe..ef8f98b 100644
--- a/packages/shade-recovery/package.json
+++ b/packages/shade-recovery/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/recovery",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json
index 7932a26..29f77d5 100644
--- a/packages/shade-sdk/package.json
+++ b/packages/shade-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/sdk",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json
index 51ec8cf..3e544a4 100644
--- a/packages/shade-server/package.json
+++ b/packages/shade-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/server",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts
index f983ab6..9e63a43 100644
--- a/packages/shade-server/src/standalone.ts
+++ b/packages/shade-server/src/standalone.ts
@@ -182,22 +182,26 @@ app.route(
...(kt ? { keyTransparency: kt } : {}),
}),
);
-app.route(
- '/',
- createInboxRoutes(inboxStore, crypto, {
- events: inboxEvents,
- disableRateLimit,
- }),
-);
-
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
-// Held as a top-level reference so the WebSocket handler can be passed to
-// Bun.serve below.
+// Created BEFORE the inbox routes so the shared bridge-delivery log can
+// be wired into both. The log is the cross-channel dedup gate that lets
+// the inbox-fetch route skip blobs already pushed via bridge — see
+// V4.8.4 changelog and the Prism FR
+// `cross-channel-duplicate-fanout-v4.8.2.md`.
const bridgeRoutes = createBridgeRoutes({
store: inboxStore,
crypto,
events: inboxEvents,
});
+
+app.route(
+ '/',
+ createInboxRoutes(inboxStore, crypto, {
+ events: inboxEvents,
+ disableRateLimit,
+ bridgeDeliveryLog: bridgeRoutes.bridgeDeliveryLog,
+ }),
+);
app.route('/', bridgeRoutes.app);
// ─── Optional: Observer + Dashboard ──────────────────────────
diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json
index f2619d5..ce5b966 100644
--- a/packages/shade-storage-encrypted/package.json
+++ b/packages/shade-storage-encrypted/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/storage-encrypted",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json
index b94d206..5ee33ae 100644
--- a/packages/shade-storage-indexeddb/package.json
+++ b/packages/shade-storage-indexeddb/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/storage-indexeddb",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json
index 7898fb8..d541322 100644
--- a/packages/shade-storage-postgres/package.json
+++ b/packages/shade-storage-postgres/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/storage-postgres",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json
index 766f151..97f9429 100644
--- a/packages/shade-storage-sqlite/package.json
+++ b/packages/shade-storage-sqlite/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/storage-sqlite",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json
index 7e54476..96d8060 100644
--- a/packages/shade-streams/package.json
+++ b/packages/shade-streams/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/streams",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json
index 851ab2e..47e1580 100644
--- a/packages/shade-transfer/package.json
+++ b/packages/shade-transfer/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/transfer",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json
index ed00853..dc96d78 100644
--- a/packages/shade-transport-bridge/package.json
+++ b/packages/shade-transport-bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/transport-bridge",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-transport-bridge/tests/bridge.test.ts b/packages/shade-transport-bridge/tests/bridge.test.ts
index 0010745..a0098d9 100644
--- a/packages/shade-transport-bridge/tests/bridge.test.ts
+++ b/packages/shade-transport-bridge/tests/bridge.test.ts
@@ -47,7 +47,9 @@ interface Harness {
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise {
const store = new MemoryInboxStore();
const events = new InboxServerEvents();
- const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
+ // V4.8.4 — share a BridgeDeliveryLog between bridge + inbox routes so
+ // the inbox-fetch path filters out blobs the bridge already pushed.
+ // Mirrors the wiring in `@shade/server/standalone.ts`.
const bridge = createBridgeRoutes({
store,
crypto,
@@ -57,6 +59,11 @@ async function bootstrap(opts: { mountWs?: boolean } = {}): Promise {
heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50,
});
+ const inboxApp = createInboxRoutes(store, crypto, {
+ events,
+ disableRateLimit: true,
+ bridgeDeliveryLog: bridge.bridgeDeliveryLog,
+ });
const app = new Hono();
app.route('/', inboxApp);
app.route('/', bridge.app);
@@ -952,6 +959,123 @@ describe('Sender attribution — bridge push surfaces IncomingMessage.from', ()
});
});
+// ─── V4.8.4 — cross-channel dedup via shared BridgeDeliveryLog ────────
+
+describe('BridgeDeliveryLog — bridge push suppresses subsequent inbox-poll for the same msgId', () => {
+ test('WS push then /v1/inbox/:addr/fetch: fetch returns 0 blobs but advances cursor', async () => {
+ // Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`
+ // (re-verified on 4.8.3): a single inbox.send was being delivered
+ // both via the WS bridge AND via the next inbox-poll cycle, the
+ // duplicate dispatch tripping on already-consumed prekeys.
+ // V4.8.4's shared BridgeDeliveryLog records every successful
+ // bridge push and the inbox-fetch route filters those msgIds out
+ // for the grace window — so a recipient that runs both a bridge
+ // and a poll cycle observes exactly one delivery.
+ const h = await bootstrap();
+ try {
+ const received: IncomingMessage[] = [];
+ const bridge = new WsBridge({
+ baseUrl: h.baseUrl,
+ auth: bobAuth(h),
+ connectTimeoutMs: 2_000,
+ disableAutoReconnect: true,
+ });
+ await bridge.connect({ onMessage: (m) => received.push(m) });
+ try {
+ const msgId = await putBlob(h, rand(48));
+ await waitFor(() => received.length === 1, 2_000);
+ // Give the bridge handler a tick to record the push in the log
+ // (it happens after the await on ws.send returns).
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Now do a regular inbox-fetch as if the recipient's
+ // `Inbox.pollOnce` cycle fired. With V4.8.4 wiring, the
+ // bridge-pushed msgId is filtered out.
+ const body = await signPayload(crypto, h.bob.signingPrivateKey, {
+ address: 'bob',
+ sinceCursor: 0,
+ });
+ const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const fetchJson = (await res.json()) as { blobs: unknown[]; cursor: number };
+ expect(fetchJson.blobs.length).toBe(0);
+ // Cursor advances past the suppressed blob so the next poll
+ // doesn't re-fetch the same range and stay stuck.
+ expect(fetchJson.cursor).toBeGreaterThan(0);
+ expect(received[0]!.msgId).toBe(msgId);
+ } finally {
+ await bridge.disconnect();
+ }
+ } finally {
+ h.server.stop(true);
+ }
+ });
+
+ test('SSE push also records into the log (parity with WS)', async () => {
+ const h = await bootstrap();
+ try {
+ const received: IncomingMessage[] = [];
+ const bridge = new SseBridge({
+ baseUrl: h.baseUrl,
+ auth: bobAuth(h),
+ initialBackoffMs: 50,
+ maxBackoffMs: 200,
+ disableAutoReconnect: true,
+ });
+ await bridge.connect({ onMessage: (m) => received.push(m) });
+ try {
+ await putBlob(h, rand(48));
+ await waitFor(() => received.length === 1, 2_000);
+ await new Promise((r) => setTimeout(r, 50));
+ const body = await signPayload(crypto, h.bob.signingPrivateKey, {
+ address: 'bob',
+ sinceCursor: 0,
+ });
+ const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const fetchJson = (await res.json()) as { blobs: unknown[] };
+ expect(fetchJson.blobs.length).toBe(0);
+ } finally {
+ await bridge.disconnect();
+ }
+ } finally {
+ h.server.stop(true);
+ }
+ });
+
+ test('a non-bridge-pushed msgId is still returned by inbox-fetch', async () => {
+ // Negative control: blobs that the bridge never pushed (e.g. the
+ // bridge wasn't connected when the put landed) must still come
+ // through the inbox-fetch path. The filter is bridge-delivered-
+ // specific, not a blanket suppression.
+ const h = await bootstrap();
+ try {
+ // No bridge connected.
+ const msgId = await putBlob(h, rand(48));
+ const body = await signPayload(crypto, h.bob.signingPrivateKey, {
+ address: 'bob',
+ sinceCursor: 0,
+ });
+ const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const fetchJson = (await res.json()) as { blobs: Array<{ msgId: string }> };
+ expect(fetchJson.blobs.length).toBe(1);
+ expect(fetchJson.blobs[0]!.msgId).toBe(msgId);
+ } finally {
+ h.server.stop(true);
+ }
+ });
+});
+
// ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─
describe('Bridge dedup — single PUT yields exactly one push per connection', () => {
diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json
index 291ce52..aaabb3f 100644
--- a/packages/shade-transport-webrtc/package.json
+++ b/packages/shade-transport-webrtc/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/transport-webrtc",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json
index 3b14b3c..8b2905d 100644
--- a/packages/shade-transport/package.json
+++ b/packages/shade-transport/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/transport",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json
index e1c3cbe..30db2ba 100644
--- a/packages/shade-widgets/package.json
+++ b/packages/shade-widgets/package.json
@@ -1,6 +1,6 @@
{
"name": "@shade/widgets",
- "version": "4.8.3",
+ "version": "4.8.4",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",