summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 12:54:29 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 12:54:29 +0200
commit06062d2e8352a205fa634970d260ab0f5de97f04 (patch)
tree0b10ee28d0de9d9af5fea0febfb404658177e1d5
parent7c46b4e7dc2893974a37d6a701123a95803fcb95 (diff)
downloadmeshbay-06062d2e8352a205fa634970d260ab0f5de97f04.tar.gz
fix(client): three defects a real desktop found in ten minutes
All three came from the operator running the application on Ubuntu GNOME. None would have been found by anything already in the suite. **A second copy of the hub address.** `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub served, false of one loaded from a package, where the origin is `app://meshbay` and `/v1/users/register` resolves against the application's own protocol handler. **Sign-up and sign-in, the first two things anybody does, failed with "Not found."** The seam was changed in `app.js` and in the signalling call and this was missed: the same shape as the duplicate `MNP_VERSION` in `protocol.py`, a second copy of a constant that is harmless until the context changes. `test_hub_address_seam.py` refuses any file that decides where the hub is, and any `fetch('/v1/…')` relative to the page origin. **A window handler reading a variable another path reassigns.** Changing the hub closes one window and opens another; `closed` arrives *after* the replacement is assigned, so the outgoing window nulled the reference to the incoming one and its `ready-to-show` crashed on it — a modal "A JavaScript error occurred in the main process". Every handler now belongs to the window it was created with. The CDP test wrote `config.json` in advance, so it never took the one path that creates a second window; it does now, starting from an empty user-data dir. **A first run that could not be undone.** The hub address was accepted on anything URL-shaped and there was no way to change it afterwards — the prompt only appears when none is set, so a typo meant editing JSON by hand. `https` typed at a hub speaking `http` produced `TypeError: fetch failed`, which names nothing. Now: the address is probed before being written, failures say which URL and why ("does not speak https. If this hub is on your own machine, it is probably http"), Settings can change it, and Electron's "Error invoking remote method" wrapper is stripped from what a person reads. Verified on the operator's desktop: **safeStorage really uses the GNOME keyring** — Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's `v11` prefix, the marker for keyring-backed encryption (the fixed-key fallback writes `v10`). Headless, the same code reports `unavailable` and refuses to store rather than downgrading in silence, which is now explained in Settings instead of shown as a bare word. Unrelated but found while testing: `test_locales.py` assigned to `globalThis.navigator`, which is read-only from Node 22. The client's build already requires Node 22+, so the first CI machine configured for it would have failed these tests for no visible reason. 809 tests pass on Node 18 and Node 24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md20
-rw-r--r--docs/desktop-client-v1.md4
-rw-r--r--packages/meshbay-client/src/main.js115
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js19
-rw-r--r--packages/meshbay-hub/tests/test_hub_address_seam.py79
-rw-r--r--packages/meshbay-hub/tests/test_locales.py6
18 files changed, 312 insertions, 31 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 61d8e96..77d13d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -382,6 +382,26 @@ anything that assumes one key per person.
MNP 0.2 and 405 on the Stage-C endpoints while the tree had 0.3 — so testing
against it proves what is deployed, not what is written
+- **A second copy of the hub address is what breaks the app, not the protocol.**
+ `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub
+ served, false of one loaded from a package, where the origin is `app://meshbay`
+ and `/v1/users/register` hits the application's own protocol handler. **Sign-up
+ and sign-in, the first two things anybody does, failed with "Not found."** Found
+ by a person clicking Register. `test_hub_address_seam.py` now refuses any file
+ that decides where the hub is, or fetches `/v1/…` relative to the page origin
+
+- **safeStorage is real on a desktop and honest without one** (checked
+ 2026-08-18, Ubuntu GNOME). `secrets.bin` carries Chromium's `v11` prefix,
+ which means keyring-backed; the fixed-key fallback writes `v10`. Headless, the
+ same code reports `unavailable` and refuses to store rather than downgrading
+ silently
+
+- **`globalThis.navigator` is read-only from Node 22.** `test_locales.py`
+ assigned to it and broke the moment the suite ran under a newer Node — which
+ the desktop client's build already requires, so the first CI machine set up
+ for it would have failed these tests for no visible reason. Use
+ `Object.defineProperty`; the suite is now green on 18 and 24
+
## Two lessons that cost four rounds of live testing
- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
index fedfc8d..cde8453 100644
--- a/docs/desktop-client-v1.md
+++ b/docs/desktop-client-v1.md
@@ -1057,7 +1057,7 @@ build existing.
|---|---|---|
| D1 | ✅ **DONE 2026-08-18** — `static/platform.js`; `HUB` is `platform.hubBase()` and the transport is built with the same base. Browser behaviour identical, which was the acceptance criterion | 1 |
| D2 | ✅ **RUNS** (2026-08-18, Electron 42 / Chromium 148 under xvfb). The packaged interface mounts over `app://`, secure context, `crypto.subtle` present, Argon2 WASM loaded, zero console errors. Three things were learned by running it — see §3.1 |
-| D3 | ◐ **PARTIAL** — the bridge (`secrets.get/set/clear/backend`) and the honest report of what the OS is actually doing: `unprotected_fallback` when safeStorage finds no keyring, surfaced in Settings rather than swallowed. The native key *lifecycle* belongs with D4 and needs a running application to mean anything | 1 |
+| D3 | ✅ **DONE, verified on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18). Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's **`v11`** prefix — the marker for keyring-backed encryption; the fixed-key fallback writes `v10`. The key name does not appear in clear. On a headless session the same code reports `unavailable` and **refuses to store**, which is the honest outcome and is now explained in Settings rather than left as a bare word |
| D4 | ✅ **DONE 2026-08-18, verified against a hub running this code** — first-run hub prompt (no default, on purpose), passphrase sign-in registers this device, later launches sign in with the device key and no passphrase. **The renderer never holds that key**: it is generated, stored and used entirely in the main process, which signs on request — the same rule as the save dialog, because the renderer is the part that parses hostile input. Measured: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → a stranger's key 401. **Not verified:** safeStorage actually persisting the key, which needs a desktop with a keyring — this session has none, and the application correctly *refuses* rather than storing unprotected | 1 |
| D5 | Node management panel over the Stage-B ops, root selection included | 2 |
| D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 |
@@ -1088,7 +1088,7 @@ Deletions enabled once native is the recommended client are unchanged from
| O2 | LAN enrolment door | One endpoint, bounded window, one-time code, closes permanently on success. Small but it executes before any authentication |
| O3 | `device_policy {allow_bundle: false}` | The mechanism that actually closes C4 (§5.1). Needs to be signed by a pinned key, never settable by the hub |
| O4 | Node-admin panel isolation | Node-supplied strings (filenames, hub-originated usernames) rendered in a process holding the user's keys. H2 was exactly this. Separate window or partition at minimum |
-| O5 | `MESHBAY_UNLOCK_KEY` in `node.env` | Plaintext in the user's home. The client could move it to the OS keychain for the desktop persona |
+| O5 | `MESHBAY_UNLOCK_KEY` in `node.env` | Still open for the **node**. For the **client**, the OS keychain path is proven: `safeStorage` on a real GNOME desktop uses the keyring (`v11`), and refuses rather than downgrading where there is none |
| O6 | Electron version floor | X25519 and Ed25519 in WebCrypto must be verified on the pinned version, not assumed |
| ~~O7~~ | Several directories in one group | **Decided 2026-08-17** — named roots, unique names, union root. See §6.7 |
| O8 | Minimum client version in `GET /v1/hub/version` | Needed before the first public package (§2.6). Trivial now, awkward once clients are in the wild |
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 82dfec9..4bc6392 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -256,7 +256,7 @@ function createWindow() {
width: 1200, height: 800,
};
- mainWindow = new BrowserWindow({
+ const win = new BrowserWindow({
...bounds,
minWidth: 320,
show: false,
@@ -282,7 +282,7 @@ function createWindow() {
},
});
- mainWindow.once('ready-to-show', () => mainWindow.show());
+ win.once('ready-to-show', () => win.show());
// The hub must never become the document origin. Anything that would navigate
// away from the packaged interface is refused, and an external link opens in
@@ -290,26 +290,35 @@ function createWindow() {
const isOurs = (target) => {
try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; }
};
- mainWindow.webContents.on('will-navigate', (event, target) => {
+ win.webContents.on('will-navigate', (event, target) => {
if (!isOurs(target)) event.preventDefault();
});
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url);
return { action: 'deny' };
});
// Nothing in this application needs a camera, a microphone or a location.
- mainWindow.webContents.session.setPermissionRequestHandler(
+ win.webContents.session.setPermissionRequestHandler(
(_wc, _permission, callback) => callback(false));
- mainWindow.on('close', () => {
- if (!mainWindow.isMinimized() && !mainWindow.isFullScreen()) {
- config = { ...config, window: mainWindow.getBounds() };
+ win.on('close', () => {
+ if (!win.isMinimized() && !win.isFullScreen()) {
+ config = { ...config, window: win.getBounds() };
writeConfig(config);
}
});
- mainWindow.on('closed', () => { mainWindow = null; });
- mainWindow.loadURL(`${SCHEME}://meshbay/index.html`);
+ // `win`, not `mainWindow`. Changing the hub closes one window and opens
+ // another, and `closed` arrives *after* the replacement has been assigned —
+ // so a handler reading the module variable nulls out the new window, and the
+ // new window's `ready-to-show` then crashes on it. Every handler here belongs
+ // to the window it was created with, and only the current one may clear the
+ // reference.
+ win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
+
+ mainWindow = win;
+ win.loadURL(`${SCHEME}://meshbay/index.html`);
+ return win;
}
// ── Bridge ──────────────────────────────────────────────────────────────────
@@ -320,7 +329,7 @@ function createWindow() {
// input, so it is treated as hostile even though it is our own code.
function registerBridge() {
- ipcMain.handle('hub:set', (_e, base) => {
+ ipcMain.handle('hub:set', async (_e, base) => {
const url = String(base || '').trim().replace(/\/+$/, '');
if (url && !/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.)/.test(url)) {
// http is allowed only to a loopback address, for someone running a hub
@@ -328,15 +337,35 @@ function registerBridge() {
// the wire in clear.
throw new Error('The hub address must be https');
}
+ // Ask the hub whether it is one, before writing the address down.
+ //
+ // Without this the first-run screen accepts anything shaped like a URL and
+ // the application is then broken with no way back — there was no way to
+ // change the hub once it was set, so a typo meant editing a JSON file by
+ // hand. `https` typed at an `http` hub is the obvious case and it fails
+ // with a TLS error that says nothing to anyone.
+ let version;
+ try {
+ const probe = await fetch(`${url}/v1/hub/version`,
+ { signal: AbortSignal.timeout(10000) });
+ if (!probe.ok) throw new Error(`answered ${probe.status}`);
+ version = await probe.json();
+ if (!version || !version.hub) throw new Error('did not answer as a hub');
+ } catch (e) {
+ throw new Error(describeUnreachable(url, e));
+ }
+
config = { ...config, hubBase: url };
writeConfig(config);
// The renderer reads the address from a process argument, so the window has
// to be rebuilt for a change to take. Reloading in place would leave the
// interface talking to the old hub with no sign of it.
- if (mainWindow) {
- mainWindow.close();
- createWindow();
- }
+ // Build the replacement first, then close the old one: the new window is
+ // what `mainWindow` points at, so the outgoing window's `closed` handler
+ // finds a reference that is no longer its own and leaves it alone.
+ const outgoing = mainWindow;
+ createWindow();
+ if (outgoing && !outgoing.isDestroyed()) outgoing.close();
return url;
});
@@ -360,11 +389,18 @@ function registerBridge() {
if (!base || target.origin !== base.origin) {
throw new Error('Refused: not this hub');
}
- const response = await fetch(target, {
- method: (init && init.method) || 'GET',
- headers: (init && init.headers) || {},
- body: (init && init.body) || undefined,
- });
+ let response;
+ try {
+ response = await fetch(target, {
+ method: (init && init.method) || 'GET',
+ headers: (init && init.headers) || {},
+ body: (init && init.body) || undefined,
+ });
+ } catch (e) {
+ // Node's fetch says "fetch failed" for everything from a refused
+ // connection to a TLS mismatch, which tells a person nothing at all.
+ throw new Error(describeUnreachable(target.origin, e));
+ }
return {
status: response.status,
ok: response.ok,
@@ -373,6 +409,16 @@ function registerBridge() {
};
});
+ ipcMain.handle('hub:probe', async (_e, url) => {
+ const target = String(url || config.hubBase || '').replace(/\/+$/, '');
+ if (!target) return null;
+ try {
+ const r = await fetch(`${target}/v1/hub/version`,
+ { signal: AbortSignal.timeout(10000) });
+ return r.ok ? await r.json() : null;
+ } catch { return null; }
+ });
+
ipcMain.handle('device:ensure', () => ensureDeviceKey());
ipcMain.handle('device:public', () => {
const key = deviceKey();
@@ -446,6 +492,35 @@ function registerBridge() {
});
}
+/**
+ * Why the hub could not be reached, in words somebody can act on.
+ *
+ * `TypeError: fetch failed` is what Node says for a refused connection, a DNS
+ * failure and a TLS mismatch alike. The most common mistake by far is `https`
+ * typed at a hub speaking plain `http`, so that one is named outright.
+ */
+function describeUnreachable(url, error) {
+ const cause = (error && error.cause) || {};
+ const code = cause.code || '';
+ const detail = cause.message || error.message || String(error);
+
+ if (/^https:/.test(url) &&
+ (code === 'ECONNRESET' || /wrong version|SSL|TLS|EPROTO/i.test(detail))) {
+ return `${url} does not speak https. If this hub is on your own machine, ` +
+ `it is probably http — try http:// instead.`;
+ }
+ if (code === 'ECONNREFUSED') {
+ return `Nothing is listening at ${url}. Is the hub running?`;
+ }
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
+ return `${url} could not be found. Check the address.`;
+ }
+ if (error && error.name === 'TimeoutError') {
+ return `${url} did not answer in time.`;
+ }
+ return `Could not reach ${url}: ${detail}`;
+}
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
// One instance. Two would fight over the config file and the secrets blob, and
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 34f146a..4091ae7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -675,7 +675,7 @@ function FirstRunPage({ onSet }) {
await window.meshbay.setHubBase(url.trim());
onSet();
} catch (err) {
- setError(err.message || String(err));
+ setError(platform.bridgeMessage(err));
setBusy(false);
}
};
@@ -3999,6 +3999,11 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
// headless session, a minimal desktop — and does it silently. Somebody who
// believes the OS is protecting their keys deserves to be told when it is not.
const [keyBackend, setKeyBackend] = useState('');
+ // Changing the hub after the first run. Without this a typo on the first
+ // screen was permanent: the prompt only appears when no hub is set, so a
+ // wrong one left editing a JSON file by hand as the only way out.
+ const [hubInput, setHubInput] = useState('');
+ const [hubError, setHubError] = useState('');
useEffect(() => {
if (!platform.secrets.available) return;
platform.secrets.backend().then(setKeyBackend).catch(() => {});
@@ -4105,6 +4110,29 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
`}
+ ${platform.isNative && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.hub_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.hub_current')}</span>
+ <span class="settings-value">${platform.hubBase() || '—'}</span>
+ </div>
+ <p class="settings-hint">${t('settings.hub_hint')}</p>
+ <form onSubmit=${async (e) => {
+ e.preventDefault();
+ setHubError('');
+ try {
+ await window.meshbay.setHubBase(hubInput.trim());
+ } catch (err) { setHubError(platform.bridgeMessage(err)); }
+ }} style="display:flex;gap:8px">
+ <input type="text" placeholder=${platform.hubBase()}
+ value=${hubInput} onInput=${e => setHubInput(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('settings.hub_change')}</button>
+ </form>
+ ${hubError && html`<p class="error-msg">${hubError}</p>`}
+ </div>
+ `}
+
${keyBackend && html`
<div class="settings-section">
<h3 class="settings-heading">${t('settings.keys_heading')}</h3>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index a27522d..ce38d35 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -21,7 +21,31 @@
*/
const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512
-const HUB = ''; // same origin
+/**
+ * Where the hub is, and how to reach it — resolved when a call is made, not
+ * when this file loads.
+ *
+ * This used to be `const HUB = ''`, "same origin", which is true of a page the
+ * hub served and false of one loaded from a package: there the origin is
+ * `app://meshbay`, so `/v1/users/register` resolved against it and the
+ * application's own protocol handler answered 404. Registration and sign-in —
+ * the first two things anybody does — failed with "Not found".
+ *
+ * This is a classic script, loaded before the module graph, so it cannot import
+ * the adapter. It reads the global the adapter publishes, at call time: by then
+ * `platform.js` has run, and in a browser both of these are exactly what they
+ * were before.
+ */
+function hubBase() {
+ const p = typeof window !== 'undefined' && window.MeshBayPlatform;
+ return p ? p.hubBase() : '';
+}
+
+function hubCall(path, init) {
+ const p = typeof window !== 'undefined' && window.MeshBayPlatform;
+ return p && p.apiFetch ? p.apiFetch(hubBase() + path, init)
+ : fetch(hubBase() + path, init);
+}
// ── Auth key derivation (password split) ──────────────────────────────────────
@@ -198,7 +222,7 @@ async function registerUser(username, email, password) {
// It also means the hub stores no user key to publish, which is what H3 read.
const authKey = await deriveAuthKey(password, username);
- const resp = await fetch(`${HUB}/v1/users/register`, {
+ const resp = await hubCall('/v1/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, auth_key: authKey }),
@@ -258,7 +282,7 @@ async function decryptBundleWithKey(bundleB64, aesKeyOrPair) {
async function loginAndRecover(username, password) {
const authKey = await deriveAuthKey(password, username);
- const resp = await fetch(`${HUB}/v1/users/login`, {
+ const resp = await hubCall('/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, auth_key: authKey }),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 8b652b5..ff7bd8a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -204,6 +204,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 9018384..738a862 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -196,6 +196,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 32e5f8a..fb5cb0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -200,6 +200,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 6517a7c..a849730 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -204,6 +204,10 @@ export default {
'firstrun.note': 'Il n’y a volontairement pas de valeur par défaut : cette application ne fait confiance à un hub que pour son API, jamais pour l’interface, qui est livrée avec l’application.',
'device.this_device': 'Cet appareil',
'settings.keys_heading': 'Clés sur cet appareil',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Actuellement',
+ 'settings.hub_hint': 'Changer cette adresse vous déconnecte et redémarre la fenêtre. Un hub sur votre propre machine est généralement en http, pas en https.',
+ 'settings.hub_change': 'Changer',
'settings.keys_where': 'Protégées par',
'settings.keys_unprotected': 'Aucun trousseau système ne fonctionne : vos clés sont chiffrées avec une clé qui n’est pas secrète. Quiconque peut lire les fichiers de cette machine peut les lire. Démarrez un trousseau, ou considérez cet appareil comme non fiable.',
'settings.keys_unavailable': 'Ce système n’offre aucun stockage de clés, donc cet appareil ne peut pas être mémorisé — votre phrase secrète vous sera redemandée à chaque fois. C’est le comportement sûr : rien n’a été stocké sans protection.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 470801d..17ad3e0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -203,6 +203,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 33bdc46..47221a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -198,6 +198,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 56a2c9f..617cdcd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -204,6 +204,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 31e5c13..ddbd1a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -210,6 +210,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index d6563ab..18c0921 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -202,6 +202,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index a7bbb0e..d868335 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -189,6 +189,10 @@ export default {
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
+ 'settings.hub_heading': 'Hub',
+ 'settings.hub_current': 'Currently',
+ 'settings.hub_hint': 'Changing this signs you out and restarts the window. A hub on your own machine is usually http, not https.',
+ 'settings.hub_change': 'Change',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index ae04e53..994f558 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -103,6 +103,20 @@ export const secrets = {
* keypair bundle on each node is what a second browser recovers — which is
* finding C4, and the reason the application exists.
*/
+/**
+ * The sentence out of a bridge error.
+ *
+ * Electron wraps anything thrown in the main process as
+ * "Error invoking remote method 'hub:set': Error: …", which puts plumbing in
+ * front of a message written for a person. The message is the part that was
+ * written for them.
+ */
+export function bridgeMessage(err) {
+ const raw = (err && err.message) || String(err);
+ const m = raw.match(/^Error invoking remote method '[^']*':\s*(?:\w*Error:\s*)?(.*)$/s);
+ return m ? m[1] : raw;
+}
+
export const device = {
available: Boolean(bridge && bridge.device),
async ensure() {
@@ -168,7 +182,7 @@ export async function nativeSave(suggestedName, size) {
}
export default { isNative, hubBase, capabilities, secrets, nativeSave,
- apiFetch, device };
+ apiFetch, device, bridgeMessage };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -176,5 +190,6 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave,
// hub end up disagreeing about how to reach it.
if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
- nativeSave, apiFetch, device };
+ nativeSave, apiFetch, device,
+ bridgeMessage };
}
diff --git a/packages/meshbay-hub/tests/test_hub_address_seam.py b/packages/meshbay-hub/tests/test_hub_address_seam.py
new file mode 100644
index 0000000..392a21c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_hub_address_seam.py
@@ -0,0 +1,79 @@
+"""
+One address for the hub, resolved in one place.
+
+`keyderive.js` carried its own `const HUB = '' // same origin`. True of a page
+the hub served; false of one loaded from a package, where the origin is
+`app://meshbay` — so `/v1/users/register` resolved against that and the
+application's own protocol handler answered 404. **Registration and sign-in, the
+first two things anybody does, failed with "Not found".**
+
+It was found by a person clicking Register, not by anything here, and it is the
+same shape as the duplicate `MNP_VERSION` in `protocol.py`: a second copy of a
+constant, harmless until something changes underneath it.
+
+So: no file that talks to the hub may decide for itself where the hub is.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+# Files that issue hub requests. `app.js` gets its base from `platform.hubBase()`
+# and the rest reach the adapter through the global it publishes.
+CALLERS = ["app.js", "keyderive.js", "transport.js", "crypto.js"]
+
+
+def _source(name: str) -> str:
+ return (STATIC / name).read_text(encoding="utf-8")
+
+
+@pytest.mark.parametrize("name", CALLERS)
+def test_no_file_decides_where_the_hub_is(name):
+ """
+ A literal empty base means "same origin", which is an assumption about how
+ the page was loaded — and it is wrong in the application.
+ """
+ for line in _source(name).splitlines():
+ stripped = line.strip()
+ if stripped.startswith("*") or stripped.startswith("//"):
+ continue # prose about the fix, not the fix
+ assert not re.match(r"const HUB\s*=\s*['\"]{2}\s*;", stripped), (
+ f"{name} hard-codes the hub as the current origin")
+
+
+@pytest.mark.parametrize("name", CALLERS)
+def test_hub_paths_are_never_fetched_against_the_page_origin(name):
+ """
+ `fetch('/v1/...')` resolves against whatever served the page. In a browser
+ that is the hub; in the application it is the package, and the request never
+ leaves the machine.
+ """
+ source = _source(name)
+ bad = re.findall(r"""\bfetch\(\s*['"`]/v1/""", source)
+ assert not bad, (
+ f"{name} fetches a hub path relative to the page origin — "
+ f"{len(bad)} site(s)")
+
+
+def test_the_adapter_is_reachable_from_a_classic_script():
+ """
+ `keyderive.js` and `transport.js` load before the module graph and cannot
+ import. The adapter therefore publishes a global, and they read it when a
+ call is made rather than when they load — by which time it exists.
+ """
+ platform = _source("platform.js")
+ assert "window.MeshBayPlatform" in platform
+
+ for name in ("keyderive.js", "transport.js"):
+ source = _source(name)
+ assert "MeshBayPlatform" in source, (
+ f"{name} does not reach the adapter, so it has an answer of its own")
+
+
+def test_the_adapter_is_the_only_thing_that_answers_where():
+ """One implementation, so a second cannot drift from it."""
+ platform = _source("platform.js")
+ assert platform.count("export function hubBase()") == 1
diff --git a/packages/meshbay-hub/tests/test_locales.py b/packages/meshbay-hub/tests/test_locales.py
index 4035f1f..59ea2bf 100644
--- a/packages/meshbay-hub/tests/test_locales.py
+++ b/packages/meshbay-hub/tests/test_locales.py
@@ -158,7 +158,7 @@ def test_locale_resolution_is_region_aware(tmp_path):
const out = {};
for (const tags of [['pt-BR'], ['pt'], ['zh-CN'], ['zh'], ['fr-CA'],
['de-AT'], ['ru', 'it'], ['ko']]) {
- globalThis.navigator = { languages: tags, language: tags[0] };
+ Object.defineProperty(globalThis, 'navigator', { value: { languages: tags, language: tags[0] }, configurable: true });
delete store.mb_lang;
out[tags.join(',')] = await i18n.initLocale();
}
@@ -186,7 +186,7 @@ def test_counted_string_picks_the_right_polish_form(tmp_path):
setItem: (k, v) => { store[k] = v; },
};
globalThis.document = { documentElement: {} };
- globalThis.navigator = { languages: ['pl'], language: 'pl' };
+ Object.defineProperty(globalThis, 'navigator', { value: { languages: ['pl'], language: 'pl' }, configurable: true });
const i18n = await import('./i18n.js');
await i18n.initLocale();
console.log(JSON.stringify(
@@ -205,7 +205,7 @@ def test_interpolated_value_is_not_read_as_a_replacement_pattern(tmp_path):
setItem: (k, v) => { store[k] = v; },
};
globalThis.document = { documentElement: {} };
- globalThis.navigator = { languages: ['en'], language: 'en' };
+ Object.defineProperty(globalThis, 'navigator', { value: { languages: ['en'], language: 'en' }, configurable: true });
const i18n = await import('./i18n.js');
await i18n.initLocale();
console.log(JSON.stringify(