diff options
Diffstat (limited to 'packages/meshbay-hub')
15 files changed, 195 insertions, 9 deletions
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( |