From 8746571d2a0dfc9025f5326ae02c6d9550f6fa42 Mon Sep 17 00:00:00 2001 From: Sterister Date: Thu, 7 May 2026 23:00:58 +0200 Subject: [PATCH] release(v4.6.1): bind globalThis.fetch in browser-receiver-sensitive call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers' Window.fetch is a WebIDL bound operation; storing it as this.fetchImpl / this.fetchFn and calling via the instance receiver threw "Illegal invocation" on the first request. Bind once at construction in InboxClient, LongPollBridge, and SseBridge. Reported by Prism (multi-device E2EE terminal), blocking every browser consumer of the v4.6 transport stack on inbox.start() / bridge.connect(). WsBridge unaffected (uses WebSocket). Node/Bun fetch tolerates a free receiver, so the bug never surfaced server-side — added regression tests that install a strict-receiver globalThis.fetch to catch the issue without an actual browser harness. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 56 ++++++++++ packages/shade-cli/package.json | 2 +- packages/shade-core/package.json | 2 +- packages/shade-crypto-web/package.json | 2 +- packages/shade-dashboard/package.json | 2 +- packages/shade-files/package.json | 2 +- packages/shade-inbox-server/package.json | 2 +- packages/shade-inbox/package.json | 2 +- packages/shade-inbox/src/client.ts | 9 +- packages/shade-inbox/tests/client.test.ts | 45 ++++++++ packages/shade-key-transparency/package.json | 2 +- packages/shade-keychain/package.json | 2 +- packages/shade-observability/package.json | 2 +- packages/shade-observer/package.json | 2 +- packages/shade-proto/package.json | 2 +- packages/shade-recovery/package.json | 2 +- packages/shade-sdk/package.json | 2 +- packages/shade-server/package.json | 2 +- packages/shade-storage-encrypted/package.json | 2 +- packages/shade-storage-indexeddb/package.json | 2 +- packages/shade-storage-postgres/package.json | 2 +- packages/shade-storage-sqlite/package.json | 2 +- packages/shade-streams/package.json | 2 +- packages/shade-transfer/package.json | 2 +- packages/shade-transport-bridge/package.json | 2 +- .../src/long-poll-bridge.ts | 5 +- .../shade-transport-bridge/src/sse-bridge.ts | 5 +- .../tests/bridge.test.ts | 101 ++++++++++++++++++ packages/shade-transport-webrtc/package.json | 2 +- packages/shade-transport/package.json | 2 +- packages/shade-widgets/package.json | 2 +- 31 files changed, 243 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067e952..7a2f3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ 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.6.1] — 2026-05-07 — Browser `fetch` receiver lost in `Inbox` and HTTP bridges + +Every browser consumer of the v4.6.0 transport stack crashed on the +*first* network call with: + +``` +Failed to execute 'fetch' on 'Window': Illegal invocation +``` + +`@shade/inbox`, `@shade/transport-bridge` (`SseBridge`, `LongPollBridge`) +each cached the default `globalThis.fetch` reference as a class property +and later invoked it as `this.fetchImpl(url, …)` / `this.fetchFn(url, …)`. +The browser's `fetch` is a WebIDL bound operation: calling it as a +method on any object other than the `Window` rejects with the error +above. Node/Bun `fetch` tolerates a free receiver, so the bug only +manifested in actual browsers and slipped through the SDK test suite. + +Reported by Prism (multi-device E2EE terminal) — `inbox.start()` → +`register()` → `client.register()` → `this.fetchImpl(url, …)` threw on +the first `/v1/inbox/register` POST, so `transport.start()` never sent +the pair handshake and the web side timed out after 30s with "PC did +not reply". + +### Fixed + +#### `@shade/inbox` — `InboxClient` constructor +`fetchImpl` is now `(options.fetch ?? globalThis.fetch).bind(globalThis)`. +A consumer-supplied `options.fetch` is bound too — a custom fetch with +its own receiver requirements must bind itself; binding to `globalThis` +is otherwise a no-op for free functions. + +#### `@shade/transport-bridge` — `LongPollBridge` and `SseBridge` constructors +Same binding fix in both. `WsBridge` was unaffected (uses `WebSocket`). + +### Tests +- `packages/shade-inbox/tests/client.test.ts` — installs a strict-receiver + `globalThis.fetch` that mimics the WebIDL "Illegal invocation" check, + constructs `InboxClient` with no `fetch` override, runs `register()`, + and asserts the strict fetch saw `globalThis` as `this`. Pre-fix this + throws; post-fix it passes. +- `packages/shade-transport-bridge/tests/bridge.test.ts` — same regression + for both `LongPollBridge.connect()` (probe call) and `SseBridge.connect()` + (open-once call). + +### Migration + +None. Existing `options.fetch` overrides keep working unchanged. Apps +shipping a workaround like + +```ts +new Inbox({ ..., fetch: globalThis.fetch.bind(globalThis) }); +``` + +can drop the `.bind(globalThis)` and the redundant `fetch:` option once +they're on `4.6.1`. + ## [4.6.0] — 2026-05-07 — Broadcast channels (Signal sender-keys for one-to-many fan-out) Prism's PC desktop is the *sender* in a one-to-many fan-out — one PTY diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 1b9c9ee..8e189fb 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 5d4cf79..c0fd55a 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.6.0", + "version": "4.6.1", "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 5bde671..b8af4ba 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.6.0", + "version": "4.6.1", "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 2416f1e..8193fe4 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 1ce602a..8a66d89 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.6.0", + "version": "4.6.1", "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 51f0395..b39d2d5 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.6.0", + "version": "4.6.1", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index c470f26..0682d2a 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/src/client.ts b/packages/shade-inbox/src/client.ts index 289a96c..aef5f1e 100644 --- a/packages/shade-inbox/src/client.ts +++ b/packages/shade-inbox/src/client.ts @@ -52,7 +52,14 @@ export class InboxClient { private readonly fetchImpl: typeof fetch; constructor(private readonly options: InboxClientOptions) { - this.fetchImpl = options.fetch ?? globalThis.fetch; + // Bind once. The browser's `globalThis.fetch` is a WebIDL bound + // operation that throws "Illegal invocation" when called as a method + // on another object (which is what `this.fetchImpl(...)` does). + // Node/Bun fetch tolerates a free receiver, but binding is harmless. + // A consumer-supplied `options.fetch` is bound to the global too — + // a fetch that requires a specific receiver must bind itself. + const f = options.fetch ?? globalThis.fetch; + this.fetchImpl = f.bind(globalThis); } /** diff --git a/packages/shade-inbox/tests/client.test.ts b/packages/shade-inbox/tests/client.test.ts index d0d983c..e071cbf 100644 --- a/packages/shade-inbox/tests/client.test.ts +++ b/packages/shade-inbox/tests/client.test.ts @@ -281,3 +281,48 @@ describe('tamper detection', () => { expect(result.received).toBe(0); }); }); + +describe('InboxClient — default fetch is bound to globalThis', () => { + // Regression: browsers' `fetch` is a WebIDL bound operation that throws + // "Illegal invocation" when called as a method on another object. The + // class stores `fetchImpl` and calls `this.fetchImpl(...)`, which strips + // the Window receiver. Constructor must `bind(globalThis)`. + test('default path passes globalThis as `this` (no Illegal invocation)', async () => { + const realFetch = globalThis.fetch; + let observedReceiver: unknown = 'unset'; + function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise { + observedReceiver = this; + if (this !== globalThis) { + throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"); + } + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: strictFetch, + }); + try { + const id = await makeIdentity(); + const client = new InboxClient({ + baseUrl: 'http://example.invalid', + crypto, + signingPrivateKey: id.signingPrivateKey, + // No `fetch` override on purpose — this exercises the default path. + }); + await client.register({ address: 'whoever', signingKey: id.signingPublicKey }); + expect(observedReceiver).toBe(globalThis); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: realFetch, + }); + } + }); +}); diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index fdf2bef..2697d35 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.6.0", + "version": "4.6.1", "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 b158033..b2306e0 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.6.0", + "version": "4.6.1", "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 eb7bba2..0ec3a83 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.6.0", + "version": "4.6.1", "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 ed725d5..60faf82 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.6.0", + "version": "4.6.1", "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 c43a911..8c39550 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.6.0", + "version": "4.6.1", "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 e302d91..0b45387 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.6.0", + "version": "4.6.1", "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 948bb49..8e488b5 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.6.0", + "version": "4.6.1", "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 ae7e1b5..f3be14b 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index e5863d0..07c149f 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.6.0", + "version": "4.6.1", "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 eb0d7ed..2081369 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.6.0", + "version": "4.6.1", "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 48bf42c..46bc2ba 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.6.0", + "version": "4.6.1", "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 7461350..ccd538b 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.6.0", + "version": "4.6.1", "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 f8fa80a..3fe6507 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.6.0", + "version": "4.6.1", "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 ee603e4..40cc869 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.6.0", + "version": "4.6.1", "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 fad48c0..4888962 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.6.0", + "version": "4.6.1", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/src/long-poll-bridge.ts b/packages/shade-transport-bridge/src/long-poll-bridge.ts index 356b904..455e149 100644 --- a/packages/shade-transport-bridge/src/long-poll-bridge.ts +++ b/packages/shade-transport-bridge/src/long-poll-bridge.ts @@ -51,7 +51,10 @@ export class LongPollBridge implements BridgeTransport { private loopPromise: Promise | null = null; constructor(private readonly options: LongPollBridgeOptions) { - this.fetchFn = options.fetch ?? globalThis.fetch; + // Bind to globalThis: browser `fetch` is a WebIDL bound operation + // and throws "Illegal invocation" when called as `this.fetchFn(...)`. + const f = options.fetch ?? globalThis.fetch; + this.fetchFn = f.bind(globalThis); this.cursor = options.startCursor ?? 0; } diff --git a/packages/shade-transport-bridge/src/sse-bridge.ts b/packages/shade-transport-bridge/src/sse-bridge.ts index 8a54646..fc3facf 100644 --- a/packages/shade-transport-bridge/src/sse-bridge.ts +++ b/packages/shade-transport-bridge/src/sse-bridge.ts @@ -58,7 +58,10 @@ export class SseBridge implements BridgeTransport { console.warn('[shade-bridge:sse]', err.message); constructor(private readonly options: SseBridgeOptions) { - this.fetchFn = options.fetch ?? globalThis.fetch; + // Bind to globalThis: browser `fetch` is a WebIDL bound operation + // and throws "Illegal invocation" when called as `this.fetchFn(...)`. + const f = options.fetch ?? globalThis.fetch; + this.fetchFn = f.bind(globalThis); this.cursor = options.startCursor ?? 0; } diff --git a/packages/shade-transport-bridge/tests/bridge.test.ts b/packages/shade-transport-bridge/tests/bridge.test.ts index e6eb81c..feb57e2 100644 --- a/packages/shade-transport-bridge/tests/bridge.test.ts +++ b/packages/shade-transport-bridge/tests/bridge.test.ts @@ -510,3 +510,104 @@ describe('Bridge cursor resume', () => { await sseB.disconnect(); }); }); + +describe('Bridges — default fetch is bound to globalThis', () => { + // Regression: browsers' `fetch` is a WebIDL bound operation that throws + // "Illegal invocation" when called via `this.fetchFn(...)`. Constructors + // for LongPollBridge / SseBridge must `bind(globalThis)`. + function installStrictFetch(): { restore: () => void; getReceiver: () => unknown } { + const realFetch = globalThis.fetch; + let observedReceiver: unknown = 'unset'; + function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise { + observedReceiver = this; + if (this !== globalThis) { + throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"); + } + return Promise.resolve( + new Response('{"blobs":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: strictFetch, + }); + return { + restore: () => + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: realFetch, + }), + getReceiver: () => observedReceiver, + }; + } + + test('LongPollBridge default path passes globalThis as `this`', async () => { + const { restore, getReceiver } = installStrictFetch(); + try { + const id = await generateIdentityKeyPair(crypto); + const bridge = new LongPollBridge({ + baseUrl: 'http://example.invalid', + auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' }, + pollTimeoutMs: 100, + requestTimeoutMs: 200, + errorBackoffMs: 50, + disableLoop: true, + }); + await bridge.connect({ onMessage: () => {} }); + expect(getReceiver()).toBe(globalThis); + await bridge.disconnect(); + } finally { + restore(); + } + }); + + test('SseBridge default path passes globalThis as `this`', async () => { + // For SSE we only need to know the very first fetch was bound; the + // 200-with-empty-stream response will let openOnce return cleanly, + // and disableAutoReconnect prevents an infinite reconnect loop. + const realFetch = globalThis.fetch; + let observedReceiver: unknown = 'unset'; + function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise { + observedReceiver = this; + if (this !== globalThis) { + throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"); + } + // Empty SSE-shaped body: stream closes immediately. + return Promise.resolve( + new Response('', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }), + ); + } + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: strictFetch, + }); + try { + const id = await generateIdentityKeyPair(crypto); + const bridge = new SseBridge({ + baseUrl: 'http://example.invalid', + auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' }, + initialBackoffMs: 50, + maxBackoffMs: 100, + disableAutoReconnect: true, + }); + await bridge.connect({ onMessage: () => {} }); + expect(observedReceiver).toBe(globalThis); + await bridge.disconnect(); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: realFetch, + }); + } + }); +}); diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index 6939469..da40074 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.6.0", + "version": "4.6.1", "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 c34452c..7e9f3fd 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.6.0", + "version": "4.6.1", "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 573d89b..7acf710 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.6.0", + "version": "4.6.1", "type": "module", "main": "src/index.ts", "types": "src/index.ts",