aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 11:28:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 12:07:49 +0200
commitb7bf11c077bdd165400cf8d80c5cd1ad4248d854 (patch)
treee80590cd7b798e21a4ac663fd1e653a66e5f3b34 /packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js
parent6348698322e5bfcb80d0ef0dcd833ba4e6179640 (diff)
downloadmeshbay-b7bf11c077bdd165400cf8d80c5cd1ad4248d854.tar.gz
refactor(client): split transport.js into classic scripts
transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js120
1 files changed, 120 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js
new file mode 100644
index 0000000..ca0769d
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js
@@ -0,0 +1,120 @@
+// What a node says about who is in a group, checked rather than trusted: the
+// roster's signatures, and the keys this browser pinned for each account.
+
+/**
+ * Walk each account's devices outwards from the one nobody countersigned.
+ *
+ * A device is *verified* when a chain of real signatures reaches it from that
+ * account's root — the device an operator code admitted, which by definition
+ * has no countersignature and is the trust-on-first-use anchor. Anything the
+ * node lists but cannot evidence stays out of `verified`, so a substituted key
+ * is not laundered into the set merely by being mentioned.
+ *
+ * Devices pinned before the evidence was kept (2026-09-07) carry no signature
+ * and are treated exactly like a root: honest about what they are, rather than
+ * quietly accepted as verified.
+ */
+async function _verifyRoster(payload, nodePk) {
+ const C = window.MeshBayCrypto;
+ const byAccount = new Map();
+ const devices = payload.devices || [];
+
+ const per = new Map();
+ for (const d of devices) {
+ if (!per.has(d.user_id)) per.set(d.user_id, []);
+ per.get(d.user_id).push(d);
+ }
+
+ for (const [userId, list] of per) {
+ // Roots first: no countersigner, or one whose evidence was never stored.
+ const verified = [];
+ const chain = new Map();
+ const pending = [];
+ for (const d of list) {
+ // A root is a device that names **no** countersigner: an operator code
+ // admitted it, and there is nothing to verify.
+ //
+ // Naming one and carrying no proof is *not* a root, and treating it as
+ // one was a hole this file's tests caught: a node that writes the roster
+ // can put any key it likes in an account's row, and if "no signature"
+ // meant "root" it would have been laundered straight into `verified`.
+ // Such a device is unevidenced — which is also the honest reading of one
+ // pinned before the evidence was kept.
+ if (!d.added_by_pk) verified.push(d.pk_ed25519);
+ else pending.push(d);
+ }
+ // Then repeatedly admit anything countersigned by something already in.
+ let progress = true;
+ while (progress && pending.length) {
+ progress = false;
+ for (let i = pending.length - 1; i >= 0; i--) {
+ const d = pending[i];
+ if (!verified.includes(d.added_by_pk)) continue;
+ let ok = false;
+ try {
+ const transcript = C.deviceAddTranscript(
+ payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519,
+ C.b64decode(d.add_nonce), d.add_ts);
+ ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript);
+ } catch { ok = false; }
+ if (ok) {
+ verified.push(d.pk_ed25519);
+ chain.set(d.pk_ed25519, d.added_by_pk);
+ pending.splice(i, 1);
+ progress = true;
+ }
+ }
+ }
+ byAccount.set(userId, {
+ username: (list[0] || {}).username || '',
+ all: list.map(d => d.pk_ed25519),
+ verified,
+ chain,
+ // Listed by the node and not reachable by any chain of signatures.
+ unevidenced: pending.map(d => d.pk_ed25519),
+ });
+ }
+ return { byAccount };
+}
+
+// Which device keys this browser has accepted for each account, per node.
+// localStorage rather than a runtime capability: it is a per-viewer
+// convenience whose loss costs one "first sight" and never a wrong answer —
+// forgetting a pin makes the next key read as `first`, not as verified.
+const _PIN_NS = 'meshbay_account_pins';
+
+function _pinKey(nodePk, userId) {
+ return `${_PIN_NS}:${nodePk || ''}:${userId}`;
+}
+
+async function _readPinnedAccount(nodePk, userId) {
+ try {
+ const raw = localStorage.getItem(_pinKey(nodePk, userId));
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
+async function _writePinnedAccount(nodePk, userId, keys) {
+ try {
+ localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys));
+ } catch { /* private window, or storage refused — one more "first sight" */ }
+}
+
+/**
+ * A wire payload as text.
+ *
+ * A plaintext message arrives as a string from the node; msgpack `bin` arrives
+ * as a Uint8Array. Both have to render.
+ *
+ * This function was deleted once, with an unrelated helper that sat next to it,
+ * and nothing complained: its only caller is inside `_openChatMessage`, whose
+ * rejection the chat panel swallows in a `.catch()` that just marks the page
+ * unloaded. The visible result was a conversation that rendered completely
+ * empty, with no error in the console and the node answering perfectly — found
+ * by `chat_send_probe.py`, not by reading this file.
+ */
+function _asText(payload) {
+ if (payload instanceof Uint8Array) return new TextDecoder().decode(payload);
+ if (payload == null) return '';
+ return String(payload);
+}