diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 10:30:59 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 10:30:59 +0200 |
| commit | 68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (patch) | |
| tree | 2a9ae0352ec09aca413448c9b2e5e9a638b36471 /packages/meshbay-hub/src/meshbay_hub | |
| parent | 30e855f55f1d920b25da0bdd8e538c249d3c0c26 (diff) | |
| download | meshbay-68bfe56a19aeb4c16f8e185fc85d8eee61aef78f.tar.gz | |
fix(client): the desktop client runs, and running it corrected three things
Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts
over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM
loaded, and no console errors. Three statements in the design were wrong, and
only launching it found them.
**A CSP in a `<meta>` tag silently drops `frame-ancestors`.** Chromium says so
in the console. A policy carrying a directive that does nothing is worse than
one without it, so the policy is sent as a header by the protocol handler —
which is also the only thing serving the interface, so one source instead of
two.
**`secure: true` is not what makes the service worker register.** Chromium
refuses a worker on a custom scheme whatever its privileges: "The URL protocol
of the current origin ('app://meshbay') is not supported". The application has
no service worker and needs none — it saves through a native dialog, which is
the better of the two paths. `sw.js` stays in the package because the same files
serve the browser, where it is one of only three ways to write a large file.
What `secure: true` is actually for was measured at the same time: without it
**the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:`
URL and every algorithm failed with TypeError, AES-GCM included — which is why
the probe was rewritten before believing its answer. X25519 and Ed25519 are both
present on Chromium 148, settling the version floor left open as O6.
**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS
refuses it. The hub has *no CORS middleware at all* — its API is reachable from
no web origin whatever — and that is worth keeping. Widening it for
`app://meshbay` would be worse than it looks: that origin is not a credential,
since any Electron application can claim the same scheme and host name.
So every hub call leaves from the main process, exactly as saving a file does,
and it refuses any origin that is not the hub the user signed in to.
`platform.apiFetch()` is `fetch` in a browser and the bridge in the application,
so no caller has to know which it got. `transport.js` reaches it through a
global because it is a classic script, not a module — the alternative was a
second fetch path, which is how two callers of one hub start disagreeing about
how to reach it.
Verified from inside Electron: the main process gets 200 from
/v1/hub/version, the renderer is refused by CORS, and **a script served by the
hub is refused by the policy** — T3's mitigation demonstrated rather than
asserted.
Build note, written into the README because it will bite the next person:
**Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download
script `require()`s an ESM module, which Node gained in 22. Node 24 LTS,
checksum-verified against nodejs.org, is what this was built with.
package-lock.json is committed; builds use `npm ci`, not `npm install`.
799 tests pass, e2e.py still passes end to end. The session harness needed a
platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the
adapter is now part of the environment it models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/platform.js | 45 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 8 |
3 files changed, 53 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index abb3374..24ef43f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -237,7 +237,7 @@ async function refreshAccessToken() { if (_refreshing) return _refreshing; _refreshing = (async () => { try { - const r = await fetch(HUB + '/v1/users/token/refresh', { + const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: _auth.refreshToken }), @@ -299,7 +299,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { if (bearer) headers['Authorization'] = `Bearer ${bearer}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); - const r = await fetch(HUB + path, opts); + const r = await platform.apiFetch(HUB + path, opts); if (r.status === 401 && bearer && !_retried) { // The one case worth a second attempt: the access token aged out while // nothing was talking to the hub. Renew once and replay. If the renewal diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 0663a42..fcc866e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -88,6 +88,40 @@ export const secrets = { }; /** + * Call the hub. + * + * In a browser this is `fetch`, unchanged — the page came from the hub, so the + * request is same-origin and nothing is in the way. + * + * In the application the page's origin is `app://meshbay`, and a browser fetch + * from it is refused by CORS. The hub has **no CORS middleware at all**, and + * that is worth keeping: its API is reachable from no web origin whatever. + * Widening it for `app://meshbay` would be worse than it appears, because that + * origin is not a credential — any Electron application can claim the same + * scheme and host name. + * + * So the main process makes the call. It returns a small object rather than a + * Response, and this shapes it back into something with `.ok`, `.status` and + * `.json()`, so callers do not have to know which one they got. + */ +export async function apiFetch(url, init) { + if (!bridge || !bridge.fetch) return fetch(url, init); + const raw = await bridge.fetch(String(url), init && { + method: init.method, + headers: init.headers, + body: init.body, + }); + return { + ok: raw.ok, + status: raw.status, + statusText: String(raw.status), + headers: new Headers(raw.headers || {}), + text: async () => raw.body, + json: async () => JSON.parse(raw.body), + }; +} + +/** * Save a decrypted file to disk. * * Returns null when there is no native path, so the caller keeps today's @@ -100,4 +134,13 @@ export async function nativeSave(suggestedName, size) { return bridge.saveFile(suggestedName, size); } -export default { isNative, hubBase, capabilities, secrets, nativeSave }; +export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch }; + +// Also a global, because `transport.js` is loaded as a classic script — it +// predates the module graph and exposes `MeshBayTransport` the same way. The +// alternative was a second fetch path there, which is how two callers of one +// hub end up disagreeing about how to reach it. +if (typeof window !== 'undefined') { + window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, + nativeSave, apiFetch }; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 769114e..1a63e23 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -162,7 +162,13 @@ class MeshBayTransport { }; }); - const resp = await fetch(`${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { + // Signaling is a hub call like any other, so it goes the same way — in the + // application that means through the main process, because the renderer's + // app:// origin is refused by CORS. + const call = (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) + || fetch; + const resp = await call( + `${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json', |