diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 12:54:29 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 12:54:29 +0200 |
| commit | 06062d2e8352a205fa634970d260ab0f5de97f04 (patch) | |
| tree | 0b10ee28d0de9d9af5fea0febfb404658177e1d5 /packages | |
| parent | 7c46b4e7dc2893974a37d6a701123a95803fcb95 (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages')
16 files changed, 290 insertions, 29 deletions
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( |