release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed. 1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU. The relay durably stores blobs and pushes them to every active delivery channel; without a cross-channel ack the bridge frame ran first and the next inbox-poll re-dispatched the same blob ~30 s later, tripping on consumed prekeys. Bridge consumers now plumb pushed frames through `acceptBridgeFrame`, which shares the dedup gate + ack path with `pollOnce`. Whichever channel delivers first wins; the other acks-and-skips. Inbox records the msgId before the ack so a parallel poll can't observe an in-flight ack window. 2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces the receiver to label the new session by the relay's sender fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext typically announces the peer's real address. Aliasing moves session, trusted identity, peer-verification, and identity- version under the canonical label. Holds the per-peer mutex on both labels (lexicographic order) so concurrent crypto ops can't observe a half-moved state. Refuses to overwrite an existing session at the new label. Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's expiry so receivers can pass bridge frames straight to `acceptBridgeFrame` without inventing a TTL. Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy path, refuse-to-overwrite, and same-label no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
CHANGELOG.md
106
CHANGELOG.md
@@ -5,6 +5,112 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
against. V4.8.2 closed the same-channel duplicate (8× via WS bridge
|
||||||
|
became 1×); V4.8.3 closes the cross-channel duplicate (bridge push +
|
||||||
|
inbox-poll catching up still delivered the same `msgId` twice) and
|
||||||
|
adds the missing `Shade.aliasSession` primitive that lets receivers
|
||||||
|
canonicalize their first-contact `fp:<senderfp>` label to the peer's
|
||||||
|
real address once the plaintext announces it.
|
||||||
|
|
||||||
|
**(1) `Inbox.acceptBridgeFrame(blob)` + shared msgId LRU.** The
|
||||||
|
relay's `Inbox.send` durably stores the blob and pushes it to every
|
||||||
|
active delivery channel. Without a client-side cross-channel ack, a
|
||||||
|
recipient running both a bridge and an inbox-poll cycle processed the
|
||||||
|
same blob twice — the bridge frame ran first, the
|
||||||
|
30 s-cadence inbox-poll fetched it again, and the duplicate dispatch
|
||||||
|
tripped on already-consumed one-time prekeys
|
||||||
|
(`one-time prekey not found: <id>`) or surfaced as duplicate
|
||||||
|
`shade.receive` work even when the canonical first delivery had
|
||||||
|
succeeded. The new `Inbox.acceptBridgeFrame(blob)` plumbs bridge
|
||||||
|
deliveries through the same dispatch + ack pipeline that `pollOnce`
|
||||||
|
uses; both paths share a 4096-entry msgId LRU so whichever channel
|
||||||
|
delivers first wins, and the other channel acks-and-skips when the
|
||||||
|
same `msgId` comes back around. The relay drops the blob on either
|
||||||
|
ack so subsequent polls don't see it.
|
||||||
|
|
||||||
|
**(2) `Shade.aliasSession(oldLabel, newLabel)`.** First-contact
|
||||||
|
forces the receiver to label the new session by the relay's
|
||||||
|
sender-fingerprint hint (`fp:<senderfp>` — the only sender label
|
||||||
|
visible at receive-time per the V4.8 sender-attribution feature),
|
||||||
|
because the receiver doesn't yet know the sender's prekey-server
|
||||||
|
address. The post-decrypt plaintext typically *announces* the
|
||||||
|
sender's address; without an SDK primitive to canonicalize, every
|
||||||
|
subsequent `send`/`receive` would either fail
|
||||||
|
(`Failed to decrypt message — wrong key or tampered data`) or
|
||||||
|
require app-level fp ↔ address translation around every call.
|
||||||
|
`aliasSession` moves the per-peer storage rows (session, trusted
|
||||||
|
identity, peer-verification record, identity-version counter) under
|
||||||
|
the new label, holding the per-peer mutex on **both** labels for the
|
||||||
|
duration so concurrent encrypt/decrypt can't observe a half-moved
|
||||||
|
state. The send/receive `encryptChains` + `decryptChains` queues for
|
||||||
|
the old label are also dropped so future operations start fresh.
|
||||||
|
Refuses to overwrite an existing session under the new label
|
||||||
|
(call `resetSession(newLabel)` first if that's intentional).
|
||||||
|
|
||||||
|
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
|
||||||
|
handshake's web-side `paired` reply now decrypts cleanly,
|
||||||
|
`BroadcastChannel.addMember` accepts the sender-address-vs-bilateral
|
||||||
|
cross-check, and steady-state heartbeats / terminal traffic don't
|
||||||
|
log a duplicate "OPK not found" per envelope.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### `@shade/inbox`
|
||||||
|
- `Inbox.acceptBridgeFrame(blob: FetchedBlob): Promise<boolean>` —
|
||||||
|
feed a bridge-pushed envelope through the same dispatch + ack +
|
||||||
|
dedup pipeline as `pollOnce`. Returns `true` when newly dispatched,
|
||||||
|
`false` for a duplicate or a handler-rejected blob. Wire-up
|
||||||
|
pattern documented inline.
|
||||||
|
- A 4096-entry FIFO msgId LRU (`deliveredIds` + `deliveredOrder`) is
|
||||||
|
shared between `acceptBridgeFrame` and `pollOnce` so cross-channel
|
||||||
|
duplicates are skipped (and acked) without re-running
|
||||||
|
`incomingHandler`.
|
||||||
|
- `Inbox.handleBlob` now records every successfully-dispatched
|
||||||
|
msgId before issuing the ack, eliminating the
|
||||||
|
ack-in-flight window where a parallel `pollOnce` could see the
|
||||||
|
blob and re-dispatch.
|
||||||
|
|
||||||
|
#### `@shade/transport-bridge`
|
||||||
|
- `IncomingMessage.expiresAt?: number` — relay-assigned absolute
|
||||||
|
expiry, surfaced from the wire envelope so receivers can pass it
|
||||||
|
straight to `Inbox.acceptBridgeFrame` without inventing a TTL.
|
||||||
|
`decodeWireMessage` populates it when the wire message includes
|
||||||
|
one (V4.8.3 relay onward).
|
||||||
|
|
||||||
|
#### `@shade/sdk`
|
||||||
|
- `Shade.aliasSession(oldLabel: string, newLabel: string): Promise<void>` —
|
||||||
|
rename a session and its companion per-peer rows. Throws on
|
||||||
|
no-such-session-for-oldLabel and refuses-to-overwrite-newLabel.
|
||||||
|
|
||||||
|
#### `@shade/core`
|
||||||
|
- `ShadeSessionManager.aliasSession(oldLabel, newLabel)` — the
|
||||||
|
primitive backing `Shade.aliasSession`. Holds the per-peer mutex
|
||||||
|
on both labels (acquired in lexicographic order) so the rename is
|
||||||
|
atomic w.r.t. concurrent crypto ops.
|
||||||
|
- `ShadeEventMap['session.aliased']: { oldLabel, newLabel }` — emitted
|
||||||
|
on a successful rename. Surfaced for observability dashboards.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `packages/shade-inbox/tests/client.test.ts` — two new cases:
|
||||||
|
bridge-then-poll and poll-then-bridge, both asserting exactly one
|
||||||
|
`incomingHandler` dispatch per `inbox.send`.
|
||||||
|
- `packages/shade-sdk/tests/sdk.test.ts` — new `aliasSession`
|
||||||
|
cases: happy-path canonicalization (Bob initiates as `alice`, Alice
|
||||||
|
receives under `fp:bobfp`, aliases to `bob`, subsequent ratchet
|
||||||
|
exchange in both directions decrypts cleanly), refuses-to-overwrite,
|
||||||
|
same-label no-op.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
None. `acceptBridgeFrame` and `aliasSession` are additive. Existing
|
||||||
|
bridge consumers that don't call `acceptBridgeFrame` keep working as
|
||||||
|
before — they just don't get cross-channel dedup, and the same
|
||||||
|
duplicate-on-poll behavior persists. `aliasSession` callers are
|
||||||
|
opt-in.
|
||||||
|
|
||||||
## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup
|
## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup
|
||||||
|
|
||||||
Two interlocking robustness fixes for the first-contact / duplicate-fan-out
|
Two interlocking robustness fixes for the first-contact / duplicate-fan-out
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface ShadeEventMap {
|
|||||||
'identity.rotated': { newFingerprint: string };
|
'identity.rotated': { newFingerprint: string };
|
||||||
'session.created': { address: string; remoteIdentityKeyHash: string };
|
'session.created': { address: string; remoteIdentityKeyHash: string };
|
||||||
'session.removed': { address: string };
|
'session.removed': { address: string };
|
||||||
|
/** V4.8.3 — emitted when `aliasSession` moves a peer's per-peer state. */
|
||||||
|
'session.aliased': { oldLabel: string; newLabel: string };
|
||||||
'message.encrypted': { address: string; counter: number; ciphertextSize: number };
|
'message.encrypted': { address: string; counter: number; ciphertextSize: number };
|
||||||
'message.decrypted': { address: string; counter: number; plaintextSize: number };
|
'message.decrypted': { address: string; counter: number; plaintextSize: number };
|
||||||
'ratchet.dh_step': { address: string };
|
'ratchet.dh_step': { address: string };
|
||||||
|
|||||||
@@ -265,6 +265,73 @@ export class ShadeSessionManager {
|
|||||||
// Note: we keep the trusted identity; new session will verify against it.
|
// Note: we keep the trusted identity; new session will verify against it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move every per-peer storage row for `oldLabel` (session, trusted
|
||||||
|
* identity, peer-verification, identity-version counter) to
|
||||||
|
* `newLabel`. Used to canonicalize sessions when first-contact
|
||||||
|
* forces the receiver to label by sender-fingerprint hint
|
||||||
|
* (`fp:<hex>`) and a later in-band announcement reveals the peer's
|
||||||
|
* canonical address.
|
||||||
|
*
|
||||||
|
* Holds the per-peer mutex on **both** labels for the whole
|
||||||
|
* migration so concurrent encrypt/decrypt for either label can't
|
||||||
|
* observe a half-moved state. Locks are taken in lexicographic
|
||||||
|
* order to avoid deadlocks if two callers alias in opposite
|
||||||
|
* directions.
|
||||||
|
*
|
||||||
|
* Throws if no session exists for `oldLabel`. Throws (refuses to
|
||||||
|
* overwrite) if a session already exists for `newLabel`. No-ops
|
||||||
|
* when `oldLabel === newLabel`.
|
||||||
|
*
|
||||||
|
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
|
||||||
|
*/
|
||||||
|
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
|
||||||
|
if (oldLabel === newLabel) return;
|
||||||
|
const [first, second] = oldLabel < newLabel ? [oldLabel, newLabel] : [newLabel, oldLabel];
|
||||||
|
await this.runUnderPeerLock(first, () =>
|
||||||
|
this.runUnderPeerLock(second, () => this.aliasUnderLock(oldLabel, newLabel)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async aliasUnderLock(oldLabel: string, newLabel: string): Promise<void> {
|
||||||
|
const session = await this.storage.getSession(oldLabel);
|
||||||
|
if (!session) throw new NoSessionError(oldLabel);
|
||||||
|
const collision = await this.storage.getSession(newLabel);
|
||||||
|
if (collision) {
|
||||||
|
throw new Error(
|
||||||
|
`aliasSession: refusing to overwrite an existing session for "${newLabel}". ` +
|
||||||
|
`If you want to replace it, call resetSession("${newLabel}") first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Move the session.
|
||||||
|
await this.storage.saveSession(newLabel, session);
|
||||||
|
// Re-pin trust under the new label using the session's stored DH
|
||||||
|
// identity key — `saveTrustedIdentity` is the same primitive that
|
||||||
|
// the X3DH initiator/responder uses, and the DH key in `session`
|
||||||
|
// is the value that was pinned at session-establish time. The old
|
||||||
|
// pin under `oldLabel` is harmless leftover (the storage interface
|
||||||
|
// has no remove for trust pins) and would only be re-checked if a
|
||||||
|
// fresh X3DH against `oldLabel` somehow happened later.
|
||||||
|
await this.storage.saveTrustedIdentity(newLabel, session.remoteIdentityKey);
|
||||||
|
// Migrate the peer-verification record if present.
|
||||||
|
const verification = await this.storage.getPeerVerification(oldLabel);
|
||||||
|
if (verification) {
|
||||||
|
await this.storage.savePeerVerification({
|
||||||
|
...verification,
|
||||||
|
peerAddress: newLabel,
|
||||||
|
});
|
||||||
|
await this.storage.removePeerVerification(oldLabel);
|
||||||
|
}
|
||||||
|
// Carry the identity-version counter forward so peer rotation
|
||||||
|
// history is preserved.
|
||||||
|
const oldVersion = await this.storage.getPeerIdentityVersion(oldLabel);
|
||||||
|
for (let i = 1; i < oldVersion; i++) {
|
||||||
|
await this.storage.bumpPeerIdentityVersion(newLabel);
|
||||||
|
}
|
||||||
|
await this.storage.removeSession(oldLabel);
|
||||||
|
this.events?.emit('session.aliased', { oldLabel, newLabel });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a changed remote identity. This should only be called after
|
* Accept a changed remote identity. This should only be called after
|
||||||
* verifying the new identity out-of-band (e.g., comparing fingerprints).
|
* verifying the new identity out-of-band (e.g., comparing fingerprints).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export interface InboxOptions {
|
|||||||
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||||
const DEFAULT_MAX_ATTEMPTS = 10;
|
const DEFAULT_MAX_ATTEMPTS = 10;
|
||||||
|
/**
|
||||||
|
* Cap for the cross-channel msgId dedup LRU. Each entry is a 64-char hex
|
||||||
|
* string; 4096 entries ≈ 256 KiB of overhead, plenty of headroom for
|
||||||
|
* bursty traffic (the LRU only needs to span the window between a bridge
|
||||||
|
* push and the next inbox-poll catching up — typically 30 s × the
|
||||||
|
* recipient's throughput).
|
||||||
|
*/
|
||||||
|
const DEFAULT_DEDUP_LRU_CAP = 4096;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level inbox orchestrator.
|
* High-level inbox orchestrator.
|
||||||
@@ -105,6 +113,23 @@ export class Inbox {
|
|||||||
private started = false;
|
private started = false;
|
||||||
private registered = false;
|
private registered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded msgId dedup window. Used by both the inbox-poll path
|
||||||
|
* (`pollOnce` → `handleBlob`) and the bridge-push path
|
||||||
|
* (`acceptBridgeFrame`). The relay stores blobs durably and pushes
|
||||||
|
* them to every active delivery channel; without a shared dedup gate
|
||||||
|
* here the recipient processes the same envelope twice — once from
|
||||||
|
* the bridge, again from the next inbox-poll. The duplicate receive
|
||||||
|
* trips on consumed one-time prekeys ("OPK not found") and pollutes
|
||||||
|
* logs even when the canonical first delivery succeeded. See V4.8.3
|
||||||
|
* Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
|
||||||
|
*
|
||||||
|
* Insertion order is FIFO; the oldest msgId is evicted once the LRU
|
||||||
|
* exceeds `DEFAULT_DEDUP_LRU_CAP`.
|
||||||
|
*/
|
||||||
|
private readonly deliveredIds = new Set<string>();
|
||||||
|
private readonly deliveredOrder: string[] = [];
|
||||||
|
|
||||||
constructor(private readonly options: InboxOptions) {
|
constructor(private readonly options: InboxOptions) {
|
||||||
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
|
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
|
||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
@@ -380,9 +405,52 @@ export class Inbox {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed a blob delivered by a bridge transport (WS / SSE / long-poll
|
||||||
|
* push) into the same dispatch + ack pipeline that `pollOnce` uses.
|
||||||
|
*
|
||||||
|
* Wire-up pattern:
|
||||||
|
* ```ts
|
||||||
|
* const bridge = new FallbackBridgeTransport([...]);
|
||||||
|
* await bridge.connect({
|
||||||
|
* onMessage: async (msg) => {
|
||||||
|
* await inbox.acceptBridgeFrame({
|
||||||
|
* msgId: msg.msgId!, // present on v4.8+ relays
|
||||||
|
* ciphertext: msg.bytes,
|
||||||
|
* receivedAt: msg.receivedAt,
|
||||||
|
* expiresAt: msg.expiresAt ?? Date.now() + 7 * 24 * 3600 * 1000,
|
||||||
|
* ...(msg.from !== undefined ? { from: msg.from } : {}),
|
||||||
|
* });
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The Inbox's bounded msgId LRU is shared between this path and
|
||||||
|
* `pollOnce`, so whichever channel delivers first wins; the
|
||||||
|
* other channel acks-and-skips when the same msgId comes back
|
||||||
|
* around. Both paths also DELETE the blob from the relay on success
|
||||||
|
* so subsequent polls don't see it either.
|
||||||
|
*
|
||||||
|
* Returns `true` if the blob was newly dispatched, `false` if it
|
||||||
|
* was a duplicate or rejected by the handler (handler still gets a
|
||||||
|
* chance to retry on the next poll if it threw).
|
||||||
|
*/
|
||||||
|
async acceptBridgeFrame(blob: FetchedBlob): Promise<boolean> {
|
||||||
|
return this.handleBlob(blob);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
|
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
|
||||||
if (!this.incomingHandler) return false;
|
if (!this.incomingHandler) return false;
|
||||||
|
|
||||||
|
// Cross-channel msgId dedup. If the bridge already delivered this
|
||||||
|
// blob, the inbox-poll copy must not re-dispatch (would re-trigger
|
||||||
|
// X3DH / consume an OPK we no longer have). We still ack so the
|
||||||
|
// relay drops the now-redundant copy.
|
||||||
|
if (this.deliveredIds.has(blob.msgId)) {
|
||||||
|
await this.ackQuietly(blob.msgId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
|
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
|
||||||
// server bug or malicious operator can't sneak a different blob past
|
// server bug or malicious operator can't sneak a different blob past
|
||||||
// the client's hash check.
|
// the client's hash check.
|
||||||
@@ -422,17 +490,34 @@ export class Inbox {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Mark before the ack so a slow-network ack doesn't leave a window
|
||||||
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
|
// where a parallel pollOnce sees the same msgId and re-dispatches.
|
||||||
} catch (err) {
|
this.recordDelivered(blob.msgId);
|
||||||
// Decryption succeeded; ack just failed. Will be retried later, and
|
await this.ackQuietly(blob.msgId);
|
||||||
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
|
|
||||||
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
|
|
||||||
}
|
|
||||||
this.events.emit('inbox.message_received', {
|
this.events.emit('inbox.message_received', {
|
||||||
senderHint,
|
senderHint,
|
||||||
msgId: blob.msgId,
|
msgId: blob.msgId,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ackQuietly(msgId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.ack({ address: this.options.ownAddress, msgId });
|
||||||
|
} catch (err) {
|
||||||
|
// Dispatch (or skip) succeeded; the ack just failed. Next poll
|
||||||
|
// will see the blob again and the dedup gate above will skip it.
|
||||||
|
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordDelivered(msgId: string): void {
|
||||||
|
if (this.deliveredIds.has(msgId)) return;
|
||||||
|
this.deliveredIds.add(msgId);
|
||||||
|
this.deliveredOrder.push(msgId);
|
||||||
|
if (this.deliveredOrder.length > DEFAULT_DEDUP_LRU_CAP) {
|
||||||
|
const evicted = this.deliveredOrder.shift()!;
|
||||||
|
this.deliveredIds.delete(evicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,120 @@ describe('Inbox orchestrator', () => {
|
|||||||
expect(seen[1]!.to).toBe('carol');
|
expect(seen[1]!.to).toBe('carol');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cross-channel dedup: acceptBridgeFrame + pollOnce never re-dispatch the same msgId (V4.8.3)', async () => {
|
||||||
|
// Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`:
|
||||||
|
// a single relay PUT was being delivered twice — once via WS bridge
|
||||||
|
// push, again ~30 s later via inbox-poll catching up. Both copies
|
||||||
|
// would dispatch `shade.receive`, the second one tripping on
|
||||||
|
// already-consumed prekeys. The cross-channel msgId LRU inside
|
||||||
|
// Inbox is the dedup gate; this test exercises it directly via
|
||||||
|
// `acceptBridgeFrame` followed by `pollOnce`.
|
||||||
|
const store = new MemoryInboxStore();
|
||||||
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
const bob = await makeIdentity();
|
||||||
|
const alice = await makeIdentity();
|
||||||
|
|
||||||
|
const bobInbox = new Inbox({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
ownAddress: 'bob',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: bob.signingPrivateKey,
|
||||||
|
signingPublicKey: bob.signingPublicKey,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
await bobInbox.register();
|
||||||
|
|
||||||
|
// Alice PUTs a blob via the relay HTTP API.
|
||||||
|
const ct = randBytes(64);
|
||||||
|
const msgId = await computeMsgId(ct);
|
||||||
|
const aliceClient = new InboxClient({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: alice.signingPrivateKey,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
const putResult = await aliceClient.put({
|
||||||
|
recipientAddress: 'bob',
|
||||||
|
senderSigningKey: alice.signingPublicKey,
|
||||||
|
envelope: ct,
|
||||||
|
});
|
||||||
|
expect(putResult.idempotent).toBe(false);
|
||||||
|
|
||||||
|
const dispatched: string[] = [];
|
||||||
|
bobInbox.onIncoming(async (raw) => {
|
||||||
|
dispatched.push(raw.msgId);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the bridge push arriving first.
|
||||||
|
await bobInbox.acceptBridgeFrame({
|
||||||
|
msgId,
|
||||||
|
ciphertext: ct,
|
||||||
|
receivedAt: putResult.receivedAt,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
});
|
||||||
|
expect(dispatched).toEqual([msgId]);
|
||||||
|
|
||||||
|
// The inbox-poll path catches up next — without dedup it would
|
||||||
|
// re-dispatch. With the LRU it acks-and-skips.
|
||||||
|
const polled = await bobInbox.tick();
|
||||||
|
expect(polled.received).toBe(0);
|
||||||
|
expect(dispatched).toEqual([msgId]); // still one entry
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-channel dedup also covers poll-first then bridge-second order', async () => {
|
||||||
|
const store = new MemoryInboxStore();
|
||||||
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
const bob = await makeIdentity();
|
||||||
|
const alice = await makeIdentity();
|
||||||
|
|
||||||
|
const bobInbox = new Inbox({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
ownAddress: 'bob',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: bob.signingPrivateKey,
|
||||||
|
signingPublicKey: bob.signingPublicKey,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
await bobInbox.register();
|
||||||
|
const ct = randBytes(48);
|
||||||
|
const msgId = await computeMsgId(ct);
|
||||||
|
const aliceClient = new InboxClient({
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
crypto,
|
||||||
|
signingPrivateKey: alice.signingPrivateKey,
|
||||||
|
fetch: honoFetch(app),
|
||||||
|
});
|
||||||
|
const putRes = await aliceClient.put({
|
||||||
|
recipientAddress: 'bob',
|
||||||
|
senderSigningKey: alice.signingPublicKey,
|
||||||
|
envelope: ct,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatched: string[] = [];
|
||||||
|
bobInbox.onIncoming(async (raw) => {
|
||||||
|
dispatched.push(raw.msgId);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll first.
|
||||||
|
const polled = await bobInbox.tick();
|
||||||
|
expect(polled.received).toBe(1);
|
||||||
|
|
||||||
|
// Bridge frame for the same msgId arrives after the poll already
|
||||||
|
// dispatched + ack'd it — must be a no-op.
|
||||||
|
const handled = await bobInbox.acceptBridgeFrame({
|
||||||
|
msgId,
|
||||||
|
ciphertext: ct,
|
||||||
|
receivedAt: putRes.receivedAt,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
});
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(dispatched).toEqual([msgId]);
|
||||||
|
});
|
||||||
|
|
||||||
test('flush retries on transient server failure', async () => {
|
test('flush retries on transient server failure', async () => {
|
||||||
const store = new MemoryInboxStore();
|
const store = new MemoryInboxStore();
|
||||||
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
const app = createInboxServer({ crypto, store, disableRateLimit: true });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -655,6 +655,41 @@ export class Shade {
|
|||||||
await this.gates.revoke(address);
|
await this.gates.revoke(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move every per-peer storage row for `oldLabel` (session, trusted
|
||||||
|
* identity, peer-verification, identity-version counter) to
|
||||||
|
* `newLabel`. Use this when first-contact forced you to label a
|
||||||
|
* session by the relay's sender-fingerprint hint
|
||||||
|
* (`fp:<hex>` — see `IncomingMessage.from` / `FetchedBlob.from`) and
|
||||||
|
* the just-decrypted plaintext announces the peer's canonical
|
||||||
|
* address: alias once and every subsequent
|
||||||
|
* `send`/`receive`/broadcast cross-check operates under the
|
||||||
|
* announced label, no app-side fp ↔ address mapping needed for the
|
||||||
|
* receive path.
|
||||||
|
*
|
||||||
|
* The rename is atomic from a per-peer-mutex perspective — both
|
||||||
|
* labels are locked for the duration so concurrent encrypt/decrypt
|
||||||
|
* can't observe a half-moved state. Throws if `oldLabel` has no
|
||||||
|
* session, or if `newLabel` already does (refuses to overwrite —
|
||||||
|
* call `resetSession` first if that's intentional).
|
||||||
|
*
|
||||||
|
* After alias, the SDK's internal serialization queues
|
||||||
|
* (`encryptChains`, `decryptChains`) for `oldLabel` are dropped so
|
||||||
|
* future operations don't queue behind a stale chain.
|
||||||
|
*
|
||||||
|
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
|
||||||
|
*/
|
||||||
|
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
await this.manager.aliasSession(oldLabel, newLabel);
|
||||||
|
// The SDK's per-`from` chains are keyed by label; drop the old
|
||||||
|
// entries so future `send`/`receive` to either label start with a
|
||||||
|
// fresh queue rather than chaining off whatever was last in flight
|
||||||
|
// for `oldLabel`.
|
||||||
|
this.encryptChains.delete(oldLabel);
|
||||||
|
this.decryptChains.delete(oldLabel);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a peer's rotated identity. Bumps the per-peer identity-version
|
* Accept a peer's rotated identity. Bumps the per-peer identity-version
|
||||||
* counter so any earlier verification automatically goes stale, then
|
* counter so any earlier verification automatically goes stale, then
|
||||||
|
|||||||
@@ -131,6 +131,75 @@ describe('createShade — happy path', () => {
|
|||||||
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
|
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aliasSession migrates a session from fp:<hex> to a canonical address label (V4.8.3)', async () => {
|
||||||
|
// Reproduces the Prism FR `session-label-asymmetry-v4.8.2`. Bob
|
||||||
|
// initiates X3DH against Alice using Alice's prekey-server
|
||||||
|
// address. Alice receives the prekey envelope under the relay's
|
||||||
|
// sender-fingerprint hint (`fp:<bobfp>`), because that's the only
|
||||||
|
// sender label the bridge surfaces at first contact. The
|
||||||
|
// post-decrypt plaintext announces Bob's real address; Alice then
|
||||||
|
// canonicalizes the session by aliasing `fp:<bobfp>` → `bob` and
|
||||||
|
// every subsequent send/receive operates symmetrically.
|
||||||
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
||||||
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
||||||
|
|
||||||
|
// First contact — Bob sends, Alice receives under the fp-label.
|
||||||
|
const env1 = await bob.send('alice', 'hello, my address is bob');
|
||||||
|
const fpLabel = 'fp:bobfingerprint16';
|
||||||
|
expect(await alice.receive(fpLabel, env1)).toBe('hello, my address is bob');
|
||||||
|
|
||||||
|
// Alice canonicalizes: move the session from the fp-label to bob's
|
||||||
|
// real address.
|
||||||
|
await alice.aliasSession(fpLabel, 'bob');
|
||||||
|
|
||||||
|
// Subsequent ratchet messages flow under the canonical label both
|
||||||
|
// directions. Bob's session for Alice is keyed under `alice`
|
||||||
|
// (Bob's send target); Alice's session for Bob is now keyed under
|
||||||
|
// `bob` (post-alias). Symmetry restored.
|
||||||
|
const env2 = await bob.send('alice', 'reply 1');
|
||||||
|
expect(await alice.receive('bob', env2)).toBe('reply 1');
|
||||||
|
|
||||||
|
const env3 = await alice.send('bob', 'reply 2');
|
||||||
|
expect(await bob.receive('alice', env3)).toBe('reply 2');
|
||||||
|
|
||||||
|
// The old fp-label has no session — receive under it would now
|
||||||
|
// fail. (We don't assert the error shape, only that the label is
|
||||||
|
// gone.)
|
||||||
|
await expect(alice.receive(fpLabel, env3)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliasSession refuses to overwrite an existing session', async () => {
|
||||||
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
||||||
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
||||||
|
const carol = await createShade({ prekeyServer: server.url, address: 'carol' });
|
||||||
|
try {
|
||||||
|
// Two distinct first-contact prekey envelopes — one from Bob,
|
||||||
|
// one from Carol — let Alice end up with two real sessions in
|
||||||
|
// storage at two different labels.
|
||||||
|
const env1 = await bob.send('alice', 'one');
|
||||||
|
await alice.receive('fp:bobfp', env1);
|
||||||
|
const env2 = await carol.send('alice', 'two');
|
||||||
|
await alice.receive('fp:carolfp', env2);
|
||||||
|
|
||||||
|
await expect(alice.aliasSession('fp:carolfp', 'fp:bobfp')).rejects.toThrow(
|
||||||
|
/refusing to overwrite/i,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await carol.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliasSession is a no-op when oldLabel === newLabel', async () => {
|
||||||
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
||||||
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
||||||
|
const env = await bob.send('alice', 'hi');
|
||||||
|
await alice.receive('fp:bobfp', env);
|
||||||
|
// Same-label alias is a no-op; session must still decrypt the next message.
|
||||||
|
await alice.aliasSession('fp:bobfp', 'fp:bobfp');
|
||||||
|
const env2 = await bob.send('alice', 'hi again');
|
||||||
|
expect(await alice.receive('fp:bobfp', env2)).toBe('hi again');
|
||||||
|
});
|
||||||
|
|
||||||
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
|
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
|
||||||
// Reproduces the Prism FR scenario: a single PUT is fanned out
|
// Reproduces the Prism FR scenario: a single PUT is fanned out
|
||||||
// multiple times by the relay (or any duplicating transport), the
|
// multiple times by the relay (or any duplicating transport), the
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export interface IncomingMessage {
|
|||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
|
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
|
||||||
msgId?: string;
|
msgId?: string;
|
||||||
|
/**
|
||||||
|
* Absolute expiry (ms since epoch) reported by the relay. Surfaced for
|
||||||
|
* symmetry with `Inbox.handleBlob` so that
|
||||||
|
* `Inbox.acceptBridgeFrame(msg)` can be wired directly without the
|
||||||
|
* caller having to invent a TTL. Optional — pre-V4.8.3 relays / non-
|
||||||
|
* inbox bridges don't populate it.
|
||||||
|
*/
|
||||||
|
expiresAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscriber callback. Bridges MAY invoke it concurrently. */
|
/** Subscriber callback. Bridges MAY invoke it concurrently. */
|
||||||
@@ -83,6 +91,7 @@ export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage {
|
|||||||
receivedAt: wire.receivedAt,
|
receivedAt: wire.receivedAt,
|
||||||
};
|
};
|
||||||
if (wire.msgId !== undefined) msg.msgId = wire.msgId;
|
if (wire.msgId !== undefined) msg.msgId = wire.msgId;
|
||||||
|
if (wire.expiresAt !== undefined) msg.expiresAt = wire.expiresAt;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.8.2",
|
"version": "4.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user