aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 12:57:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 12:57:58 +0200
commit2c6921aa2c35ffd41b6c453e6700574ef631ba2c (patch)
tree01c766e13607e4f957900bfd36b4f722e8c8b3c5 /packages/meshbay-hub
parent8a4651e9d223de856ff085b329801998f95db138 (diff)
downloadmeshbay-2c6921aa2c35ffd41b6c453e6700574ef631ba2c.tar.gz
fix(client): the page names node operations, and the app confirms what widens the node
node:call is replaced by named operations with checked arguments; hosting a group, sharing an unpicked folder, key rotation, denylist clearing and a change of node account are confirmed by a native dialog. Every channel checks its sender, secrets:get/set/clear are gone, node:start writes the app's own hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js57
-rw-r--r--packages/meshbay-hub/tests/test_connect_never_hangs.py4
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py81
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py4
19 files changed, 231 insertions, 99 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 3a85bf7..a12fea4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1397,6 +1397,9 @@ const mount = () => {
// browser and on macOS. A language change reloads the page, which comes back
// through here, so nothing else has to watch for it.
platform.setTrayLabels(trayLabels()).catch(() => {});
+ // Same moment, same reason: the app's own confirmation dialogs are worded
+ // from the catalogue of the language this page settled on.
+ platform.setUiLocale(getLocale()).catch(() => {});
};
initLocale().then(mount, (err) => {
// Nothing in initLocale() is supposed to reject. If something does, an
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index 7556010..d4c2ab8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -253,11 +253,11 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl
const mainRoot = roots[0];
const attachBody = {
name: name.trim(),
- shared_dir: mainRoot.path,
+ path: mainRoot.path,
writable: mainRoot.writable !== false,
};
- await platform.node.call('POST', '/api/groups/attach', attachBody);
- await platform.node.call('POST', '/api/reload');
+ await platform.node.op('attachGroup', attachBody);
+ await platform.node.op('reload');
update('done');
advance();
@@ -272,8 +272,9 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl
update('running');
for (let i = 1; i < roots.length; i++) {
const r = roots[i];
- await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
- path: r.path, name: r.name, writable: !!r.writable, removable: !!r.removable,
+ await withRetry(() => platform.node.op('addRoot', {
+ groupId: gid, path: r.path, name: r.name,
+ writable: !!r.writable, removable: !!r.removable,
}));
}
await platform.waitForRootsIndexed(gid, setIndexProgress);
@@ -283,20 +284,20 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl
// 5. GEK init
update('running');
- await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
+ await withRetry(() => platform.node.op('initGek', { groupId: gid }));
update('done');
advance();
// 6. Generate pairing code
update('running');
- const pairResult = await platform.node.call('POST', '/api/operator/pair');
+ const pairResult = await platform.node.op('pairOperator');
if (pairResult && pairResult.code) {
await platform.node.setPairingCode(pairResult.code);
session.pendingJoinCode = pairResult.code;
}
update('done');
- try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
+ try { await platform.node.op('reload'); } catch { /* best effort */ }
setStep(3);
if (onCreated) onCreated();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 048f831..f16bdf8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -122,9 +122,6 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
const overLoopback = !isLocal && !overMnp && nodeAvail;
const canEdit = isLocal || overMnp || overLoopback;
- const rootUrl = (name, suffix = '') =>
- '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix;
-
// Deliberately no index refresh after a root change.
//
// Adding a root makes the node reload, which rescans — minutes on a real
@@ -162,7 +159,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
}));
const ok = await run(async () => {
if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn);
- else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates);
+ else if (overLoopback) await platform.node.op('updateRoot', { groupId, rootName, updates });
else throw new Error(t('node.root_no_route'));
});
// Only a failure clears the patch here; a success waits for the node's
@@ -177,13 +174,13 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
const doEjectRoot = useCallback((rootName) => run(async () => {
if (overMnp) await transport.ejectRoot(groupId, rootName, signFn);
- else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject'));
+ else if (overLoopback) await platform.node.op('ejectRoot', { groupId, rootName });
else throw new Error(t('node.root_no_route'));
}), [overMnp, overLoopback, transport, groupId, signFn, run]);
const doPlugRoot = useCallback((rootName) => run(async () => {
if (overMnp) await transport.plugRoot(groupId, rootName, signFn);
- else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug'));
+ else if (overLoopback) await platform.node.op('plugRoot', { groupId, rootName });
else throw new Error(t('node.root_no_route'));
}), [overMnp, overLoopback, transport, groupId, signFn, run]);
@@ -198,8 +195,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
const ok = await run(async () => {
if (overMnp) await transport.removeRoot(groupId, rootName, signFn);
else if (overLoopback) {
- await platform.node.call('DELETE', rootUrl(rootName));
- await platform.node.call('POST', '/api/reload');
+ await platform.node.op('removeRoot', { groupId, rootName });
+ await platform.node.op('reload');
} else throw new Error(t('node.root_no_route'));
});
if (ok) say(t('node.root_removed'));
@@ -228,9 +225,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
if (overMnp) {
await transport.addRoot(groupId, path, { name }, signFn);
} else if (overLoopback) {
- await platform.node.call('POST', '/api/groups/' + groupId + '/roots',
- { path, name });
- await platform.node.call('POST', '/api/reload');
+ await platform.node.op('addRoot', { groupId, path, name });
+ await platform.node.op('reload');
await platform.watchIndexProgress(groupId, setIndexProgress);
} else throw new Error(t('node.root_no_route'));
});
@@ -468,7 +464,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const detect = await platform.node.detect();
if (!detect.detected) { setNodeDetected(false); return; }
setNodeDetected(true);
- const data = await platform.node.call('GET', '/api/groups');
+ const data = await platform.node.op('groups');
const groups = data.groups || [];
const ng = groups.find(g => g.id === groupId);
if (ng) {
@@ -496,7 +492,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const waitForRootCount = useCallback(async (expectedCount) => {
for (let i = 0; i < 10; i++) {
try {
- const data = await platform.node.call('GET', '/api/groups');
+ const data = await platform.node.op('groups');
const ng = (data.groups || []).find(g => g.id === groupId);
const roots = (ng && ng.roots) || [];
if (roots.length === expectedCount) {
@@ -780,8 +776,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
try {
if (platform.node.available) {
try {
- await platform.node.call('POST',
- `/api/members/${member.user_id}/revoke?group_id=${groupId}`);
+ await platform.node.op('revokeMember', { userId: member.user_id, groupId });
} catch { /* best effort — node may not host this group */ }
} else if (transport && transport.connected && operatorPaired) {
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
@@ -1311,8 +1306,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Node detach first (reversible), then hub delete (irreversible)
if (nodeDetected && nodeGroupName) {
try {
- await platform.node.call('POST', '/api/groups/detach',
- { name: nodeGroupName });
+ await platform.node.op('detachGroup', { name: nodeGroupName });
} catch (detachErr) {
if (!await ask(t('settings_node.detach_failed_continue'))) return;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
index b136578..6730e60 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
@@ -54,7 +54,7 @@ function useLoopbackActivity() {
const poll = async () => {
let delay = IDLE_POLL_MS;
try {
- const data = await platform.node.call('GET', '/api/index-status');
+ const data = await platform.node.op('indexStatus');
const next = {};
for (const g of (data && data.groups) || []) next[g.group_id] = fromLoopback(g);
if (Object.values(next).some((j) => j.scanning || j.queued.length)) {
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 3c0f1ec..e2363eb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1240,4 +1240,10 @@ export default {
'hosts.approve': "Genehmigen",
'hosts.refuse': "Ablehnen",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?',
+ 'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.',
+ 'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.',
+ 'native.declined': 'Abgebrochen — nichts wurde geändert.',
};
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 947c61d..2fdda50 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1221,4 +1221,10 @@ export default {
'hosts.approve': "Approve",
'hosts.refuse': "Refuse",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?',
+ 'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.',
+ 'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.',
+ 'native.declined': 'Cancelled — nothing was changed.',
};
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 a0220d8..498cba3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1234,4 +1234,10 @@ export default {
'hosts.approve': "Aprobar",
'hosts.refuse': "Rechazar",
'hosts.online': "en línea",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?',
+ 'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.',
+ 'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.',
+ 'native.declined': 'Cancelado: no se ha cambiado nada.',
};
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 c4bc37e..c62ed98 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1249,4 +1249,10 @@ export default {
'hosts.approve': "Approuver",
'hosts.refuse': "Refuser",
'hosts.online': "en ligne",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?',
+ 'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.',
+ 'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.',
+ 'native.declined': 'Annulé — rien n\'a été modifié.',
};
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 59505b8..9f1d9f6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1248,4 +1248,10 @@ export default {
'hosts.approve': "Approva",
'hosts.refuse': "Rifiuta",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?',
+ 'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.',
+ 'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.',
+ 'native.declined': 'Annullato: non è stato modificato nulla.',
};
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 bba9564..a4bb5ca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1232,4 +1232,10 @@ export default {
'hosts.approve': "承認",
'hosts.refuse': "拒否",
'hosts.online': "オンライン",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?',
+ 'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。',
+ 'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。',
+ 'native.declined': 'キャンセルしました。何も変更されていません。',
};
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 87e5b87..2fdf236 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1250,4 +1250,10 @@ export default {
'hosts.approve': "Goedkeuren",
'hosts.refuse': "Weigeren",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?',
+ 'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.',
+ 'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.',
+ 'native.declined': 'Geannuleerd — er is niets gewijzigd.',
};
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 6d81b25..0530b79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1276,4 +1276,10 @@ export default {
'hosts.approve': "Akceptuj",
'hosts.refuse': "Odrzuć",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?',
+ 'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.',
+ 'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.',
+ 'native.declined': 'Anulowano — nic nie zostało zmienione.',
};
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 7b12ea5..d2be432 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
@@ -1235,4 +1235,10 @@ export default {
'hosts.approve': "Aprovar",
'hosts.refuse': "Recusar",
'hosts.online': "online",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?',
+ 'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.',
+ 'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.',
+ 'native.declined': 'Cancelado — nada foi alterado.',
};
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 9f3c902..c994780 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
@@ -1221,4 +1221,10 @@ export default {
'hosts.approve': "批准",
'hosts.refuse': "拒绝",
'hosts.online': "在线",
+
+ // Worded by the desktop main process for its own dialogs (main.js).
+ 'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?',
+ 'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。',
+ 'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。',
+ 'native.declined': '已取消,未做任何更改。',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 7fd7ab1..f940934 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -10,7 +10,7 @@ import { HUB } from './hub-client.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
// Electron-only: talks to the local node daemon via its loopback HTTP API
-// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
+// (platform.node.op), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
// true / false from a node that has read its roster, null from one that has
@@ -163,8 +163,8 @@ function NodeServicePanel({ onChanged, token, username }) {
</div>`;
}
-async function nodeCall(method, path, body) {
- return platform.node.call(method, path, body);
+async function nodeOp(name, args) {
+ return platform.node.op(name, args);
}
// Hand a generated file to the user: native Save As on the desktop, a blob
@@ -237,11 +237,11 @@ export function NodePage({ groups, token, username }) {
setStatus('error');
return;
}
- const result = await nodeCall('GET', '/api/groups');
+ const result = await nodeOp('groups');
setNodeGroups(result.groups || []);
setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
- try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
+ try { setNodeInfo(await nodeOp('status')); } catch {}
setStatus('connected');
} catch (err) {
setError(platform.bridgeMessage(err));
@@ -259,11 +259,11 @@ export function NodePage({ groups, token, username }) {
const refresh = useCallback(async () => {
try {
- const result = await nodeCall('GET', '/api/groups');
+ const result = await nodeOp('groups');
setNodeGroups(result.groups || []);
setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
- try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
+ try { setNodeInfo(await nodeOp('status')); } catch {}
} catch {}
}, []);
@@ -283,8 +283,8 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path });
- await nodeCall('POST', '/api/reload');
+ await nodeOp('addRoot', { groupId, path: chosen.path });
+ await nodeOp('reload');
await refresh();
setActionMsg(t('node.root_added'));
} catch (err) {
@@ -299,8 +299,8 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`);
- await nodeCall('POST', '/api/reload');
+ await nodeOp('removeRoot', { groupId, rootName });
+ await nodeOp('reload');
await refresh();
setActionMsg(t('node.root_removed'));
} catch (err) {
@@ -313,7 +313,7 @@ export function NodePage({ groups, token, username }) {
const loadRoster = useCallback(async (groupId) => {
setBusy(true);
try {
- const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`);
+ const result = await nodeOp('roster', { groupId });
setRoster(result);
setRosterGroup(groupId);
} catch (err) {
@@ -328,7 +328,7 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', `/api/members/${userId}/unpin`);
+ await nodeOp('unpinMember', { userId });
setActionMsg(t('node.unpin_done'));
if (rosterGroup) await loadRoster(rosterGroup);
} catch (err) {
@@ -338,12 +338,13 @@ export function NodePage({ groups, token, username }) {
}
}, [rosterGroup, loadRoster]);
+ // No confirmation drawn here: replacing the key is asked natively by the
+ // app itself (node:op), which a script in this page cannot answer.
const rotateGek = useCallback(async (groupId) => {
- if (!await ask(t('node.gek_rotate_confirm'))) return;
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`);
+ await nodeOp('initGek', { groupId, rotate: true });
setActionMsg(t('node.gek_rotated'));
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
@@ -355,7 +356,7 @@ export function NodePage({ groups, token, username }) {
const loadDenylist = useCallback(async () => {
setBusy(true);
try {
- const result = await nodeCall('GET', '/api/denylist');
+ const result = await nodeOp('denylist');
setDenylist(result);
setShowDenylist(true);
} catch (err) {
@@ -366,12 +367,11 @@ export function NodePage({ groups, token, username }) {
}, []);
const clearDenylist = useCallback(async (subject) => {
- const label = subject || t('node.denylist_clear_all');
- if (!await ask(t('node.denylist_clear_confirm', { subject: label }))) return;
+ // Asked natively by the app (node:op): it re-admits whoever it kept out.
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`);
+ await nodeOp('clearDenylist', { subject });
setActionMsg(t('node.denylist_cleared'));
await loadDenylist();
} catch (err) {
@@ -386,8 +386,8 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', '/api/groups/detach', { name });
- await nodeCall('POST', '/api/reload');
+ await nodeOp('detachGroup', { name });
+ await nodeOp('reload');
setActionMsg(t('node.detached'));
await refresh();
} catch (err) {
@@ -401,7 +401,7 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', '/api/reload');
+ await nodeOp('reload');
setActionMsg(t('node.reloaded'));
await refresh();
} catch (err) {
@@ -416,7 +416,7 @@ export function NodePage({ groups, token, username }) {
setSavingSettings(true);
setActionMsg('');
try {
- await nodeCall('PUT', '/api/node-settings', editSettings);
+ await nodeOp('setNodeSettings', { settings: editSettings });
setNodeSettings({ ...editSettings });
setActionMsg(t('node.settings_saved'));
} catch (err) {
@@ -431,7 +431,7 @@ export function NodePage({ groups, token, username }) {
setSavingStun(true);
setActionMsg('');
try {
- await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun });
+ await nodeOp('setNodeSettings', { settings: { stun_servers: editStun } });
setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s);
setActionMsg(t('node.stun_saved'));
} catch (err) {
@@ -486,7 +486,7 @@ export function NodePage({ groups, token, username }) {
setSavingIce(true);
setActionMsg('');
try {
- await nodeCall('PUT', '/api/node-settings', { ice_interfaces: editIce });
+ await nodeOp('setNodeSettings', { settings: { ice_interfaces: editIce } });
setNodeSettings(s => s ? { ...s, ice_interfaces: [...editIce] } : s);
setActionMsg(t('node.ice_saved'));
} catch (err) {
@@ -521,7 +521,7 @@ export function NodePage({ groups, token, username }) {
setPairBusy(true);
setPairStatus('');
try {
- const result = await nodeCall('POST', '/api/operator/pair');
+ const result = await nodeOp('pairOperator');
if (result && result.code) {
await platform.node.setPairingCode(result.code);
}
@@ -538,7 +538,7 @@ export function NodePage({ groups, token, username }) {
const loadPeers = useCallback(async () => {
setBusy(true);
try {
- const r = await nodeCall('GET', '/api/peers');
+ const r = await nodeOp('peers');
setPeers(r.peers || []);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
@@ -551,9 +551,8 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
try {
const offset = auditPage * auditPageSize;
- let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`;
- if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`;
- const r = await nodeCall('GET', path);
+ const r = await nodeOp('audit', { limit: auditPageSize, offset,
+ event: auditEvent || undefined });
setAudit(r.entries || []);
setAuditHasMore(!!r.has_more);
} catch (err) {
@@ -572,12 +571,11 @@ export function NodePage({ groups, token, username }) {
// mid-export (which shifts rows to a higher offset) cannot duplicate one.
const PAGE = 1000;
const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever
- const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : '';
const seen = new Set();
const rows = [];
for (let page = 0; page < MAX_PAGES; page++) {
- const r = await nodeCall(
- 'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`);
+ const r = await nodeOp('audit', { limit: PAGE, offset: page * PAGE,
+ event: auditEvent || undefined });
const batch = r.entries || [];
for (const e of batch) {
if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); }
@@ -613,7 +611,7 @@ export function NodePage({ groups, token, username }) {
const loadCache = useCallback(async () => {
setBusy(true);
try {
- const r = await nodeCall('GET', '/api/index-cache');
+ const r = await nodeOp('indexCache');
setCacheCount(r.count ?? 0);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
@@ -626,7 +624,7 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- const r = await nodeCall('POST', '/api/index-cache/prune');
+ const r = await nodeOp('pruneIndexCache');
setCacheCount(r.kept ?? null);
setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 }));
} catch (err) {
@@ -639,7 +637,7 @@ export function NodePage({ groups, token, username }) {
const loadNodeRoster = useCallback(async () => {
setBusy(true);
try {
- const r = await nodeCall('GET', '/api/roster');
+ const r = await nodeOp('roster');
setNodeRoster(r);
} catch (err) {
setActionMsg(platform.bridgeMessage(err));
@@ -653,7 +651,7 @@ export function NodePage({ groups, token, username }) {
setBusy(true);
setActionMsg('');
try {
- await nodeCall('POST', `/api/members/${userId}/unpin`);
+ await nodeOp('unpinMember', { userId });
setActionMsg(t('node.unpin_done'));
await loadNodeRoster();
} catch (err) {
@@ -668,7 +666,7 @@ export function NodePage({ groups, token, username }) {
setUnlinkBusy(true);
setActionMsg('');
try {
- await nodeCall('DELETE', '/api/unlink');
+ await nodeOp('unlink');
setActionMsg(t('node.unlink_done'));
await refresh();
} catch (err) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index a3fb80b..8bf09b4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -67,30 +67,15 @@ export const capabilities = {
};
/**
- * Where the identity keys live.
+ * The app's secret store, as far as the page may know it.
*
- * In a browser: exactly where they live today — IndexedDB and sessionStorage,
- * with the keypair bundle on the node as the way a second browser recovers
- * them, which is finding C4 and is the reason the app exists.
- *
- * In the app: the OS keychain, and no bundle is stored anywhere. That is what
- * closes C4 for a native device — unconditionally for that device, and for the
- * account only once it stops signing in from a browser too.
+ * The store is the main process's (the OS keychain through safeStorage) and it
+ * holds the device's hub key, which the page is never handed. The page used to
+ * be able to read and write it by name; nothing here did, and that was a way to
+ * both read the key and replace it. What is left is whether the OS protects it.
*/
export const secrets = {
available: Boolean(bridge && bridge.secrets),
- async get(name) {
- if (!bridge || !bridge.secrets) return null;
- return bridge.secrets.get(name);
- },
- async set(name, value) {
- if (!bridge || !bridge.secrets) return false;
- return bridge.secrets.set(name, value);
- },
- async clear(name) {
- if (!bridge || !bridge.secrets) return false;
- return bridge.secrets.clear(name);
- },
/**
* Whether the OS is really protecting them.
*
@@ -257,9 +242,15 @@ export const node = {
if (!bridge || !bridge.node) throw new Error('Node bridge not available');
return bridge.node.start(opts);
},
- async call(method, path, body) {
+ /**
+ * One of the local node's operations, by name (`NODE_OPS` in the desktop
+ * client's main.js). The page never names a route: the main process checks
+ * the arguments, builds the request, and asks the person itself before
+ * anything that widens what the node shares.
+ */
+ async op(name, args) {
if (!bridge || !bridge.node) throw new Error('Node bridge not available');
- return bridge.node.call(method, path, body);
+ return bridge.node.op(name, args);
},
async pairingCode() {
return bridge && bridge.node ? bridge.node.pairingCode() : null;
@@ -344,7 +335,7 @@ export async function watchIndexProgress(groupId, onUpdate, { intervalMs = 500 }
for (;;) {
let status;
try {
- status = await node.call('GET', `/api/groups/${groupId}/index-status`);
+ status = await node.op('groupIndexStatus', { groupId });
} catch {
// The node went away mid-poll — stop rather than spin forever; the
// caller's own connection-status handling already covers that case.
@@ -376,12 +367,12 @@ export async function waitForGroupHosted(groupId, onProgress,
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
- const status = await node.call('GET', `/api/groups/${groupId}/index-status`);
+ const status = await node.op('groupIndexStatus', { groupId });
if (onProgress) onProgress(status);
} catch { /* keep waiting — the loopback API can be momentarily busy */ }
try {
- const list = await node.call('GET', '/api/groups');
+ const list = await node.op('groups');
if (Array.isArray(list.groups) && list.groups.some((g) => g.id === groupId)) return;
} catch { /* keep waiting */ }
@@ -420,7 +411,7 @@ export async function waitForRootsIndexed(groupId, onProgress,
for (;;) {
let status;
try {
- status = await node.call('GET', `/api/groups/${groupId}/index-status`);
+ status = await node.op('groupIndexStatus', { groupId });
} catch {
return; // the node went away mid-poll — same stance as watchIndexProgress
}
@@ -507,9 +498,19 @@ export async function setTrayLabels(labels) {
return bridge.setTrayLabels(labels);
}
+/**
+ * Tell the app which language the interface is in. The confirmations its main
+ * process draws for itself are worded from the same catalogues, which it reads
+ * from the packaged files -- only the code crosses the bridge.
+ */
+export async function setUiLocale(code) {
+ if (!bridge || !bridge.setLocale) return false;
+ return bridge.setLocale(code);
+}
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
apiFetch, device, bridgeMessage, folder, rootPicker, node,
- cast, minimizeToTray, setTrayLabels };
+ cast, minimizeToTray, setTrayLabels, setUiLocale };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -519,5 +520,5 @@ if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
bridgeMessage, folder, rootPicker, node,
- cast, minimizeToTray, setTrayLabels };
+ cast, minimizeToTray, setTrayLabels, setUiLocale };
}
diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py
index 5680877..1fb4d3d 100644
--- a/packages/meshbay-hub/tests/test_connect_never_hangs.py
+++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py
@@ -69,7 +69,7 @@ def test_a_timed_out_gathering_still_sends_the_offer():
@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
def test_every_hub_call_from_the_client_has_a_deadline():
source = MAIN.read_text(encoding="utf-8")
- block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0]
assert "AbortSignal.timeout" in block, (
"a hub that accepts the connection and says nothing holds this for "
"as long as the OS allows")
@@ -88,5 +88,5 @@ def test_the_deadline_outlasts_the_hubs_own_longest_call():
@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
def test_a_timeout_says_so_rather_than_saying_fetch_failed():
source = MAIN.read_text(encoding="utf-8")
- block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0]
assert "TimeoutError" in block and "did not answer" in block
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index b9b3319..732ba07 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -18,7 +18,7 @@ import re
from pathlib import Path
import pytest
-from spa_source import transport_files
+from spa_source import STATIC, transport_files
CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
MAIN = CLIENT / "src" / "main.js"
@@ -378,6 +378,81 @@ def test_plain_http_is_refused_except_to_loopback():
assert "127\\." in source and "localhost" in source
+def test_every_channel_answers_only_the_packaged_page():
+ """
+ A channel registered straight on `ipcMain` would answer any frame that got
+ hold of a bridge; `handle()` checks the sender first. So no channel may be
+ registered any other way, and every one the preload names is registered.
+ """
+ source = _main()
+ wrapper = source.split("function handle(channel, fn) {", 1)[1].split("\n}\n", 1)[0]
+ assert "fromOurPage(event)" in wrapper
+ assert source.count("ipcMain.handle(") == 1, "a channel skips the sender check"
+ registered = set(re.findall(r"\n handle\('([\w:-]+)'", source))
+ invoked = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", _preload()))
+ assert invoked <= registered, f"the preload names unregistered channels: {invoked - registered}"
+
+
+def test_the_page_cannot_read_or_replace_the_secret_store():
+ """It holds the device's hub key (§8.2), which the page is never handed."""
+ for channel in ("secrets:get", "secrets:set", "secrets:clear"):
+ assert channel not in _main() and channel not in _preload(), channel
+
+
+def _node_ops() -> dict[str, str]:
+ """Each operation of `NODE_OPS` in main.js, with its source."""
+ block = _main().split("const NODE_OPS = {", 1)[1].split("\n };\n", 1)[0]
+ parts = re.split(r"\n (\w+):", "\n" + block)
+ return dict(zip(parts[1::2], parts[2::2]))
+
+
+def test_the_page_names_node_operations_not_routes():
+ """
+ The page used to hand the main process a method and a path, which made the
+ whole loopback API the page's. Every operation the interface calls exists,
+ and none exists that it does not call — an unused one is surface.
+ """
+ assert "node:call" not in _main() and "node:call" not in _preload()
+ used: set[str] = set()
+ for path in STATIC.glob("*.js"):
+ used |= set(re.findall(r"(?:node\.op|nodeOp)\('(\w+)'", path.read_text(encoding="utf-8")))
+ defined = set(_node_ops())
+ assert used, "no node operation found in the interface"
+ assert used <= defined, f"called but not defined: {used - defined}"
+ assert defined <= used, f"defined but never called: {defined - used}"
+
+
+@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "initGek", "clearDenylist"])
+def test_what_widens_the_node_is_confirmed_natively(op):
+ """Sharing a folder, hosting a group, replacing the key, re-admitting a
+ revoked subject: asked by a dialog the main process draws, which a script in
+ the page cannot answer. A folder chosen in the native picker is its own
+ confirmation."""
+ body = _node_ops()[op]
+ assert "confirmOrRefuse(" in body or "confirmFolder(" in body
+
+
+def test_the_native_dialogs_are_worded_in_every_language():
+ """The words come from the interface's catalogues; a key missing from one is
+ a dialog that shows its key."""
+ keys = set(re.findall(r"(?:confirmOrRefuse|nativeText)\('([\w.]+)'", _main()))
+ assert "native.declined" in keys and "dialog.ok" in keys
+ for catalogue in (STATIC / "locales").glob("*.js"):
+ text = catalogue.read_text(encoding="utf-8")
+ missing = [k for k in keys if f"'{k}':" not in text]
+ assert not missing, f"{catalogue.name} lacks {missing}"
+
+
+def test_the_node_is_never_pointed_at_a_hub_the_page_names():
+ """`node:start` writes the hub this application is signed in to, and a
+ username that cannot break out of a TOML string."""
+ source = _main()
+ assert "opts.hubUrl" not in source
+ provision = source.split("async function provisionFromRequest(opts) {", 1)[1]
+ provision = provision.split("\n }\n", 1)[0]
+ assert "config.hubBase" in provision and "USERNAME_RE.test(username)" in provision
+
+
# ── One interface, one source ───────────────────────────────────────────────
def test_the_interface_is_copied_not_forked():
@@ -420,7 +495,7 @@ def test_automatic_saving_never_opens_a_dialog_for_want_of_a_folder():
system Downloads folder is the answer when there is no other.
"""
source = _main()
- begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0]
+ begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0]
assert "defaultDownloadDir()" in begin, (
"the automatic path has no destination when no folder was chosen")
assert "app.getPath('downloads')" in source
@@ -430,7 +505,7 @@ def test_a_chosen_folder_that_has_gone_is_not_silently_replaced():
"""Someone who picked an external drive should be told it is not there,
not find the film in their home directory a week later."""
source = _main()
- begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0]
+ begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0]
assert "config.downloadDir && !chosen" in begin, (
"a chosen-but-missing folder falls through to the default instead of asking")
assert "showSaveDialog" in begin
diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py
index 93803a0..e960950 100644
--- a/packages/meshbay-hub/tests/test_indexing_dock.py
+++ b/packages/meshbay-hub/tests/test_indexing_dock.py
@@ -190,5 +190,5 @@ def test_the_transport_passes_the_new_counters_through():
def test_the_dock_polls_the_node_wide_route_not_one_group():
source = DOCK.read_text(encoding="utf-8")
- assert "'/api/index-status'" in source
- assert "/index-status`" not in source
+ assert "node.op('indexStatus')" in source
+ assert "groupIndexStatus" not in source