aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 10:30:59 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 10:30:59 +0200
commit68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (patch)
tree2a9ae0352ec09aca413448c9b2e5e9a638b36471 /packages/meshbay-hub
parent30e855f55f1d920b25da0bdd8e538c249d3c0c26 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js8
-rw-r--r--packages/meshbay-hub/tests/harness/session_harness.mjs6
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py49
5 files changed, 96 insertions, 16 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',
diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs
index f0d0cb2..3be862e 100644
--- a/packages/meshbay-hub/tests/harness/session_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/session_harness.mjs
@@ -110,6 +110,12 @@ const ctx = {
localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S,
loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'),
console,
+ // Hub calls go through the platform adapter since the interface started
+ // shipping in a package: in a browser it is `fetch`, in the application it is
+ // the main process, because an `app://` origin is refused by CORS. The
+ // harness models the browser side, which is the one whose renewal logic this
+ // exercises.
+ platform: { apiFetch: (...args) => fakeFetch(...args) },
};
const fns = new Function(...Object.keys(ctx),
sessionBlock + '\n' + hubFetchFn +
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index 13d194e..c4f5b08 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -85,11 +85,18 @@ def test_no_permission_is_granted_to_the_page():
])
def test_the_scheme_is_privileged(privilege):
"""
- Without `secure` the scheme is not a secure context, the service worker
- silently refuses to register, and streamed downloads break with no error —
- the same failure mode as an uncontrolled page, which this codebase has
- already learned once. `standard` gives a real origin, so IndexedDB survives
- an update instead of being keyed to something that moves.
+ `secure` is what makes it a secure context, and without it **the whole of
+ `crypto.subtle` is undefined** — measured, not assumed: the first probe
+ loaded a `data:` URL and every algorithm failed with TypeError, AES-GCM
+ included. `standard` gives a real origin, so IndexedDB survives an update
+ instead of being keyed to something that moves.
+
+ An earlier version of this docstring said `secure` was what let the service
+ worker register. That is wrong: Chromium refuses to register a worker on a
+ custom scheme whatever its privileges — "The URL protocol of the current
+ origin ('app://meshbay') is not supported". The application therefore has no
+ service worker and does not need one; it saves files through a native
+ dialog, which is better than the path the worker exists to provide.
"""
assert privilege in _main(), f"{privilege} missing from the scheme privileges"
@@ -107,6 +114,18 @@ def test_the_protocol_handler_cannot_be_walked_out_of():
# ── Content Security Policy ─────────────────────────────────────────────────
+def test_the_policy_is_sent_as_a_header():
+ """
+ A <meta> policy cannot carry `frame-ancestors`, and having it there means
+ one directive of the policy is decoration. The handler is also the only
+ thing that serves the interface, so this is one source rather than two.
+ """
+ source = _main()
+ assert "'Content-Security-Policy': CSP" in source
+ assert "Content-Security-Policy" not in INDEX.read_text(encoding="utf-8") \
+ .split("-->")[1], "the packaged page still carries a policy of its own"
+
+
def test_the_policy_keeps_wasm_unsafe_eval():
"""
The bundle key is Argon2id in WebAssembly. A policy that forbids it does not
@@ -116,14 +135,20 @@ def test_the_policy_keeps_wasm_unsafe_eval():
def _policy() -> str:
- """The meta tag's content, not the file — the comment above it names the
- same directives and would satisfy a naive search."""
+ """
+ The policy the protocol handler sends, read out of the CSP constant.
+
+ Not a <meta> tag: `frame-ancestors` is ignored there, and a directive that
+ silently does nothing is worse than one that is absent. Chromium said so in
+ the console the first time the application was launched.
+ """
import re
- page = INDEX.read_text(encoding="utf-8")
- match = re.search(
- r'http-equiv="Content-Security-Policy"\s+content="([^"]*)"', page)
- assert match, "no Content-Security-Policy meta tag"
- return match.group(1)
+ source = _main()
+ match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S)
+ assert match, "no CSP constant in the main process"
+ return "; ".join(
+ line.strip().strip('",').strip('"')
+ for line in match.group(1).splitlines() if line.strip())
def _directive(name: str) -> str: