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",