aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js451
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js309
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js224
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js342
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-media.js284
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js86
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js152
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js120
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js228
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js2163
11 files changed, 2223 insertions, 2145 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 054d04a..626c639 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -192,6 +192,15 @@ _HTML = """\
<script src="/a/{v}/keyderive.js"></script>
<script src="/a/{v}/crypto.js"></script>
<script src="/a/{v}/transport.js"></script>
+ <script src="/a/{v}/transport-codec.js"></script>
+ <script src="/a/{v}/transport-roster.js"></script>
+ <script src="/a/{v}/transport-pins.js"></script>
+ <script src="/a/{v}/transport-chat.js"></script>
+ <script src="/a/{v}/transport-media.js"></script>
+ <script src="/a/{v}/transport-admin.js"></script>
+ <script src="/a/{v}/transport-upload.js"></script>
+ <script src="/a/{v}/transport-devices.js"></script>
+ <script src="/a/{v}/transport-rewrap.js"></script>
<script type="module" src="/a/{v}/app.js"></script>
</body>
</html>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
new file mode 100644
index 0000000..03a0f33
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -0,0 +1,451 @@
+// The operator's side: signed operations (the two-step challenge), the node's
+// settings and roots, members and invitations.
+//
+// Methods of MeshBayTransport, copied onto its prototype by extendTransport
+// (transport.js, which the shell loads first).
+
+extendTransport(class {
+ /**
+ * Pair this browser with the node using a one-time code (M3, and the same
+ * substitution as H3).
+ *
+ * The node has no way to know which key belongs to its operator unless someone
+ * tells it locally — asking the hub would let the hub name itself node
+ * administrator. The code comes from `meshbay-node operator pair`, over SSH, and
+ * the hub never sees it.
+ */
+ async pairOperator(userId, code) {
+ if (!this._connected) throw new Error('Not connected to the node');
+ if (!userId) throw new Error('Missing user id');
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ // Both public keys are derived from OUR OWN secret keys, never read back from
+ // the hub: signing a public key the directory handed us would reintroduce the
+ // substitution this whole mechanism exists to close.
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ // group_id is empty: operator authority is node-wide, not per group.
+ const transcript = C.joinTranscript(
+ this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused');
+ if (resp.type !== 'join_result' || !resp.ok) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`);
+ err.reason = reason;
+ throw err;
+ }
+ this.memberRole = 'operator';
+ return resp;
+ }
+
+ async setAppDirectories(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ const msg = await this._sendAndWait({
+ type: 'app_directories', v: '1.1', app: appKey, directories: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Whether this group's files appear in members' cross-group Search.
+ * Presentation only — opening the group lists everything regardless.
+ */
+ async setSearchListed(listed, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'search_listed', v: '3.0', listed: Boolean(listed),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'search_listed', listed ? 'on' : 'off', signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Authorize a privileged node operation with the user's Ed25519 identity key.
+ *
+ * The client rebuilds the signed transcript from the challenge fields and refuses
+ * to sign unless the operation and subject match what the user actually asked for.
+ * Previously the node sent 32 opaque random bytes and the client signed them
+ * blind, which let any peer obtain a signature over content of its choosing
+ * (finding H5).
+ */
+ async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) {
+ if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) {
+ throw new Error(
+ `Refusing to sign: node asked to authorize "${challenge.op}" on ` +
+ `"${challenge.subject}", but the requested action was "${expectedOp}" ` +
+ `on "${expectedSubject}"`);
+ }
+ if (!signFn) throw new Error('Admin challenge received but no signing key available');
+
+ const transcript = window.MeshBayCrypto.adminTranscript(
+ challenge.op, challenge.node_pk, challenge.group_id,
+ challenge.subject, challenge.nonce, challenge.ts);
+
+ const signature = await signFn(transcript);
+ console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id,
+ '— sending admin_response');
+ const ack = await this._sendAndWait({
+ type: 'admin_response',
+ v: '0.1',
+ op_id: challenge.op_id,
+ signature,
+ // Not read by the node (_do_admin_response only looks at op_id and
+ // signature) — carried so _sendAndWait can key this reply by op, the
+ // same way the admin_challenge that preceded it was keyed. Without
+ // it, two admin_response replies in flight together (e.g. one op's
+ // app_directories_ack arriving while another's apps_enabled_ack is still
+ // pending) are matched by nothing more than arrival order.
+ op: challenge.op,
+ });
+ console.log('[MeshBay] _authorizeAdminOp:', challenge.op, 'admin_response reply =', ack);
+ if (ack.type === 'error') throw new Error(ack.detail);
+ return ack;
+ }
+
+ async deleteFile(fileId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'file_delete',
+ v: '0.1',
+ file_id: fileId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Remove an empty directory. Operator only, and the node checks that — this
+ * signs with the identity it pinned for us, exactly like deleting a file.
+ */
+ async deleteDirectory(dir, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'dir_delete',
+ v: '0.1',
+ dir,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // `dir`, not msg.subject: comparing the node's answer against itself is
+ // no check at all, and the point of this one is that we know what we
+ // asked for without being told.
+ return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Stop this node serving the group key to someone. Operator only.
+ *
+ * Only the node can do this: its roster decides who it serves. Removing them
+ * on the hub is the other half, and neither implies the other.
+ */
+ /**
+ * Turn a group "application" (Chat, Files, ...) on or off for everyone.
+ *
+ * Takes the whole set in one signed message rather than one op per app, so
+ * ticking several boxes in Settings costs one signature. `apps` is sorted
+ * and joined the same way on the node before it is shown for signing —
+ * `_authorizeAdminOp` below checks the two match.
+ */
+ async setAppsEnabled(apps, signFn) {
+ // Files cannot be turned off — MNP permits root exploration regardless of
+ // this list, so hiding the tab only ever misled — and the node adds it if
+ // it is missing. That normalisation has to happen *here too*: the subject
+ // below is rebuilt from what this client sent, and compared byte for byte
+ // against what the node put in the challenge. A list arriving here without
+ // `files` would produce two different strings and `_authorizeAdminOp`
+ // would refuse to sign an op the operator did ask for. It is reachable
+ // only from a caller that builds the list from something other than the
+ // node's own answer, which is exactly the kind of caller a later phase
+ // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic
+ // script and cannot import it.)
+ const full = apps.includes('files') ? [...apps] : ['files', ...apps];
+ const msg = await this._sendAndWait({
+ type: 'apps_enabled', v: '0.1', apps: full,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'apps_enabled', [...full].sort().join(','), signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * How often the node's reconciliation backstop runs, and how long it
+ * waits after a file's last write before hashing it (indexer.py
+ * DirectoryIndexer). Whole seconds only: the node builds the signing
+ * subject with Python's `%g` (drops a trailing ".0"), and the simplest
+ * way to always match it byte-for-byte from JS is to never send a
+ * fractional value in the first place.
+ */
+ async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) {
+ const reconcile = Math.round(reconcileIntervalSecs);
+ const debounce = Math.round(debounceSecs);
+ const msg = await this._sendAndWait({
+ type: 'set_scan_settings', v: '0.1',
+ reconcile_interval_secs: reconcile, debounce_secs: debounce,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn);
+ }
+ return msg;
+ }
+
+ async revokeMember(userId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'member_revoke', v: '0.1', user_id: userId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn);
+ }
+ return msg;
+ }
+
+ // ── Node management (D5) ───────────────────────────────────────────────
+
+ async fetchNodeStatus() {
+ const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async updateNodeSettings(settings) {
+ const msg = await this._sendAndWait({
+ type: 'node_settings_set', v: '0.1', settings,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_add', v: '1.1',
+ group_id: groupId, path,
+ name: name || '', kind: kind || 'generic',
+ writable: !!writable, removable: !!removable,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_add', path, signFn);
+ }
+ return msg;
+ }
+
+ async removeRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_remove', v: '0.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
+ const updates = [];
+ if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
+ if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
+ const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
+ const msg = await this._sendAndWait({
+ type: 'root_update', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ ...(writable !== undefined && { writable }),
+ ...(removable !== undefined && { removable }),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
+ }
+ return msg;
+ }
+
+ async ejectRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_eject', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async plugRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_plug', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async unpinMember(userId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'member_unpin', v: '0.1', user_id: userId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn);
+ }
+ return msg;
+ }
+
+ async rotateGek(groupId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'gek_rotate', v: '0.1', group_id: groupId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn);
+ }
+ return msg;
+ }
+
+ async fetchRoster(groupId) {
+ const msg = await this._sendAndWait({
+ type: 'roster_read', v: '0.1', group_id: groupId || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async fetchDenylist() {
+ const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async clearDenylist(subject) {
+ const msg = await this._sendAndWait({
+ type: 'denylist_clear', v: '0.1', subject: subject || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async attachGroup(name, sharedDir, uploadDir, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'group_attach', v: '0.1',
+ name, shared_dir: sharedDir, upload_dir: uploadDir || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'group_attach', name, signFn);
+ }
+ return msg;
+ }
+
+ async detachGroup(name, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'group_detach', v: '0.1', name,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'group_detach', name, signFn);
+ }
+ return msg;
+ }
+
+ async reloadConfig() {
+ const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Ask the node for a one-time pairing code admitting `userId` to this group.
+ *
+ * This replaces wrapping the group key in the browser. We no longer fetch the
+ * invitee's public key from the hub, so the hub can no longer answer with its own
+ * and be handed the group key (H3). The node wraps the key later, itself, for a
+ * key the invitee proves possession of.
+ *
+ * Returns {code, expires_at} — the code is displayed once and passed to the
+ * invitee out of band.
+ */
+ async createInvite(userId, groupId, username, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'invite_create',
+ v: '0.1',
+ user_id: userId,
+ group_id: groupId,
+ username: username || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * A code bound to no account, for an invitation link (MNP 3.4). Signed like
+ * any invitation, and the subject the operator signs is the outcome: a link
+ * into this group, `link:<group>`, and nothing else.
+ */
+ async createLinkInvite(groupId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'invite_link_create', v: '0.1', group_id: groupId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn);
+ }
+ return msg;
+ }
+
+ /** Take back an unredeemed invitation link, by the handle it was issued with. */
+ async cancelLinkInvite(inviteId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'invite_cancel', v: '0.1', invite_id: inviteId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn);
+ }
+ return msg;
+ }
+});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
new file mode 100644
index 0000000..d6d733f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -0,0 +1,309 @@
+// The group chat: history, sending, link previews, and the epoch keys that
+// seal it.
+//
+// Methods of MeshBayTransport, copied onto its prototype by extendTransport
+// (transport.js, which the shell loads first).
+
+extendTransport(class {
+ /**
+ * Unfurl a URL pasted in chat. The node fetches it (the browser cannot —
+ * CSP and CORS — and would leak every reader's IP), parses an OpenGraph
+ * card, and caches any image in its thumb store; `image_thumb_hash` then
+ * rides the normal file_req path like a poster. `ok: false` means "no
+ * preview" (blocked, unreachable, not HTML) — the caller just shows the
+ * bare link. Keyed by url: a message with several links fires one each.
+ */
+ async fetchLinkPreview(url) {
+ const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Where chat attachments are written.
+ *
+ * Its own message rather than `setAppDirectories('chat', ...)`: this one is
+ * a destination, and the node refuses a read-only root for it. A caller
+ * reaching for the generic form would get a refusal it has no reason to
+ * expect, so the difference is in the name.
+ */
+ async setChatDirectory(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({
+ type: 'chat_directory', v: '1.1', path: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn);
+ }
+ return msg;
+ }
+
+ /** Whether the node unfurls links members post in this group's chat. */
+ async setChatLinkPreview(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Open a new chat epoch by hand. Operator only, and signed.
+ *
+ * Not a switch — there is nothing to turn on. The removals that matter open
+ * an epoch by themselves; this is the operator saying "move the key anyway",
+ * the same instruction as `rotateGek` and signed for the same reason.
+ */
+ async rotateChatEpoch(signFn) {
+ // This connection's own group, not a parameter. Every settings pane takes
+ // the same props by design (`test_app_settings_plugin.py`), so reaching for
+ // a `groupId` here would make the loop that renders them conditional — and
+ // the transport already knows which group it is connected to.
+ const groupId = this._groupId || '';
+ const msg = await this._sendAndWait({
+ type: 'chat_epoch', v: '2.0', group_id: groupId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * A page of chat history, newest first by default.
+ *
+ * `before` is a message id, not a timestamp: it pages backwards from the
+ * newest, which is the direction a conversation is read. Asking without it
+ * used to mean `since: 0`, which paged *forwards* from the very first message
+ * — so a busy group opened on its oldest page and never showed the recent
+ * exchange.
+ *
+ * Returns { messages, hasMore } — hasMore says whether anything older exists,
+ * so the "load older" control knows when to stop offering.
+ */
+ async fetchChatHistory({ before = null, limit = 100 } = {}) {
+ const msg = await this._sendAndWait({
+ type: 'chat_hist',
+ v: '0.2',
+ before: before,
+ limit: limit,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ const rows = msg.messages || [];
+ const messages = [];
+ for (const row of rows) messages.push(await this._openChatMessage(row));
+ return { messages, hasMore: !!msg.has_more };
+ }
+
+ /**
+ * Turn one stored or relayed chat message into what the panel renders.
+ *
+ * **The one place that decides how a message is read.** Live messages and
+ * history arrive by different routes and used to be shaped at each of them;
+ * with a `format` column and more than one way to read a payload, two copies
+ * of that decision is two places to get it wrong, and the disagreement would
+ * show up only in history.
+ *
+ * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8,
+ * which mangled anything that was not text. A message that cannot be read
+ * comes back marked rather than thrown away: a gap in a conversation the
+ * reader can see is honest, and silently dropping messages is not.
+ */
+ async _openChatMessage(row) {
+ const base = {
+ id: row.id,
+ sender_id: row.sender_id,
+ sender_name: row.sender_name || '',
+ timestamp: row.timestamp,
+ thread_id: row.thread_id,
+ };
+ const format = row.format || 0;
+ if (format === 0) {
+ return { ...base, payload: _asText(row.payload) };
+ }
+ if (format !== 1) {
+ return { ...base, payload: '', unreadable: 'format' };
+ }
+ return this._openSealedChat(base, row);
+ }
+
+ /**
+ * Send one message, sealed under this group's current chat epoch key and
+ * signed with this device's key.
+ *
+ * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node
+ * refuses one, so a fallback here could only ever produce a refusal the user
+ * cannot act on — and a client that quietly posted in clear into a group
+ * whose members believe their chat is encrypted is the downgrade the whole
+ * design is about not having.
+ *
+ * `sender_name` goes **inside** the envelope. On the wire it is a field any
+ * peer can set to anything, and the node caches it to render history — so
+ * display-name spoofing is free while chat is plaintext. Sealed and signed,
+ * it is as authenticated as the message it names.
+ *
+ * Refuses rather than falls back. A client that cannot seal must not quietly
+ * post in clear into a group whose members believe their chat is encrypted;
+ * the node refuses it too, and the two refusals agreeing is the point.
+ */
+ async sendChat(text, iteration, threadId, senderName) {
+ const keys = await this.chatKeys();
+ const epoch = this.chatEpoch || keys.current;
+ const epochKey = keys.byEpoch.get(epoch);
+ if (!epochKey) throw new Error('No chat key for this group — reconnect');
+ if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) {
+ throw new Error('This device is not identified to the node — reconnect');
+ }
+
+ const C = window.MeshBayCrypto;
+ const gid = this._groupId || '';
+ const plaintext = msgpack_encode({
+ text: String(text),
+ thread_id: threadId || null,
+ sender_name: senderName || '',
+ sent_at: Math.floor(Date.now() / 1000),
+ });
+ const { nonce, ct } = await C.sealChat(
+ epochKey, gid, epoch, this.devicePk, plaintext);
+ const device = C.b64decode(this.devicePk);
+ const sig = C.b64decode(await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64,
+ C.chatSigningTranscript(gid, epoch, device, nonce, ct)));
+
+ const msg = await this._sendAndWait({
+ type: 'chat_msg',
+ v: '2.0',
+ format: 1,
+ epoch,
+ ct,
+ nonce,
+ device,
+ sig,
+ thread_id: threadId || null,
+ // Deliberately absent: the display name is inside the envelope now.
+ sender_name: null,
+ });
+ // Every other request in this file refuses an `error` reply; this one
+ // returned it as though the node had accepted the message. It never
+ // mattered while a refusal reached the wrong caller anyway — now that a
+ // reply finds the request that made it, a message the node rejected would
+ // otherwise appear in the conversation as sent. It rejects more of them
+ // than it used to: a stale epoch, an envelope the node dislikes, or a
+ // device claim that is not this connection's all come back as `error`.
+ if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused');
+ return msg;
+ }
+
+ /**
+ * This group's chat epoch moved.
+ *
+ * An epoch opens when somebody is removed, and a client that kept sealing
+ * under the retired key would be writing messages the group can still read
+ * but that the removed member could read too. Dropping the cached keys is
+ * what makes the next send fetch the new one.
+ */
+ _applyChatEpoch(msg) {
+ if (msg.epoch) this.chatEpoch = msg.epoch;
+ this._chatKeys = null;
+ this._chatKeysInFlight = null;
+ if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch);
+ }
+
+ /**
+ * Every chat epoch key for this group, fetched once per connection.
+ *
+ * Every epoch, not just the current one — that is what lets a device linked
+ * this morning read a conversation from last year, and a member who joined
+ * yesterday read the history the group already had. The node decides which
+ * epochs a member is entitled to; this asks for what it is given.
+ */
+ async chatKeys() {
+ if (this._chatKeys) return this._chatKeys;
+ if (this._chatKeysInFlight) return this._chatKeysInFlight;
+
+ this._chatKeysInFlight = (async () => {
+ const resp = await this._sendAndWait({
+ type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '',
+ });
+ if (resp.type === 'error') throw new Error(resp.detail);
+ // Sealed under a group-derived subkey. A payload that does not open is
+ // not "no keys" — it is a peer we cannot talk to, and treating it as an
+ // empty set would present an encrypted group as one with no history.
+ const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
+ this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp));
+ const byEpoch = new Map();
+ for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key);
+ this._chatKeys = { byEpoch, current: payload.current || 0 };
+ return this._chatKeys;
+ })();
+ try {
+ return await this._chatKeysInFlight;
+ } finally {
+ this._chatKeysInFlight = null;
+ }
+ }
+
+ /**
+ * Open one sealed message, or mark it unreadable and say why.
+ *
+ * Authorship is established **before** decryption: the signature is over the
+ * ciphertext, so a message that does not verify is never rendered as having
+ * been written by the account it claims — which is the whole point of signing
+ * rather than trusting the node's `sender_id`.
+ *
+ * An unreadable message is kept and marked, never dropped. A gap the reader
+ * can see is honest; a conversation quietly missing messages is not.
+ */
+ async _openSealedChat(base, row) {
+ const C = window.MeshBayCrypto;
+ const gid = this._groupId || '';
+ const epoch = row.epoch || 0;
+ const device = row.device;
+ const nonce = row.nonce;
+ const ct = row.ct;
+ if (!device || !nonce || !ct || !row.sig) {
+ return { ...base, payload: '', unreadable: 'envelope' };
+ }
+
+ if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) {
+ return { ...base, payload: '', unreadable: 'signature' };
+ }
+
+ let keys;
+ try {
+ keys = await this.chatKeys();
+ } catch {
+ return { ...base, payload: '', unreadable: 'keys' };
+ }
+ const epochKey = keys.byEpoch.get(epoch);
+ if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' };
+
+ const deviceB64 = C.b64encode(device);
+ try {
+ const plain = msgpack_decode(
+ await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct));
+ // The signature proves *a device* wrote this. Whether that device belongs
+ // to the account the node named is a separate question, and one this
+ // client answers for itself from the roster (Tier 2) rather than taking
+ // `sender_id` on trust. `changed` is the only value worth a notice.
+ const trust = await this.accountDeviceStatus(base.sender_id, deviceB64);
+ return {
+ ...base,
+ payload: String(plain.text || ''),
+ sender_name: plain.sender_name || base.sender_name,
+ thread_id: plain.thread_id ?? base.thread_id,
+ device: deviceB64,
+ verified: true,
+ trust,
+ };
+ } catch {
+ return { ...base, payload: '', unreadable: 'decrypt' };
+ }
+ }
+});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js
new file mode 100644
index 0000000..eba0461
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-codec.js
@@ -0,0 +1,224 @@
+// The wire codec: the subset of msgpack MNP uses (maps, strings, integers,
+// binary, arrays, null), and hex for the ids the transport mints.
+
+// ── Minimal msgpack encode/decode ────────────────────────────────────────────
+// Covers the subset used by MNP: maps, strings, integers, binary, arrays, null.
+
+function msgpack_encode(obj) {
+ const parts = [];
+ _encodeValue(obj, parts);
+ const total = parts.reduce((s, p) => s + p.length, 0);
+ const result = new Uint8Array(total);
+ let off = 0;
+ for (const p of parts) { result.set(p, off); off += p.length; }
+ return result;
+}
+
+function _encodeValue(val, parts) {
+ if (val === null || val === undefined) {
+ parts.push(new Uint8Array([0xc0]));
+ } else if (typeof val === 'boolean') {
+ parts.push(new Uint8Array([val ? 0xc3 : 0xc2]));
+ } else if (typeof val === 'number') {
+ if (Number.isInteger(val)) {
+ if (val >= 0 && val <= 127) {
+ parts.push(new Uint8Array([val]));
+ } else if (val >= 0 && val <= 0xff) {
+ parts.push(new Uint8Array([0xcc, val]));
+ } else if (val >= 0 && val <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xcd;
+ new DataView(b.buffer).setUint16(1, val, false);
+ parts.push(b);
+ } else if (val >= 0 && val <= 0xffffffff) {
+ const b = new Uint8Array(5); b[0] = 0xce;
+ new DataView(b.buffer).setUint32(1, val, false);
+ parts.push(b);
+ } else if (val >= 0 && val <= Number.MAX_SAFE_INTEGER) {
+ // Same split as the 0xcf decoder case above, in reverse — without
+ // this, a value over 0xffffffff fell to the plain int32 branch
+ // below and silently wrapped to a wrong, unrelated number instead
+ // of failing loudly.
+ const b = new Uint8Array(9); b[0] = 0xcf;
+ const dv = new DataView(b.buffer);
+ dv.setUint32(1, Math.floor(val / 4294967296), false);
+ dv.setUint32(5, val % 4294967296, false);
+ parts.push(b);
+ } else if (val >= -32 && val < 0) {
+ parts.push(new Uint8Array([val & 0xff]));
+ } else if (val >= -128 && val < 0) {
+ const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff;
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xd2;
+ new DataView(b.buffer).setInt32(1, val, false);
+ parts.push(b);
+ }
+ } else {
+ const b = new Uint8Array(9); b[0] = 0xcb;
+ new DataView(b.buffer).setFloat64(1, val, false);
+ parts.push(b);
+ }
+ } else if (typeof val === 'string') {
+ const encoded = new TextEncoder().encode(val);
+ if (encoded.length <= 31) {
+ parts.push(new Uint8Array([0xa0 | encoded.length]));
+ } else if (encoded.length <= 0xff) {
+ parts.push(new Uint8Array([0xd9, encoded.length]));
+ } else if (encoded.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xda;
+ new DataView(b.buffer).setUint16(1, encoded.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdb;
+ new DataView(b.buffer).setUint32(1, encoded.length, false);
+ parts.push(b);
+ }
+ parts.push(encoded);
+ } else if (val instanceof Uint8Array) {
+ if (val.length <= 0xff) {
+ parts.push(new Uint8Array([0xc4, val.length]));
+ } else if (val.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xc5;
+ new DataView(b.buffer).setUint16(1, val.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xc6;
+ new DataView(b.buffer).setUint32(1, val.length, false);
+ parts.push(b);
+ }
+ parts.push(val);
+ } else if (Array.isArray(val)) {
+ if (val.length <= 15) {
+ parts.push(new Uint8Array([0x90 | val.length]));
+ } else if (val.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xdc;
+ new DataView(b.buffer).setUint16(1, val.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdd;
+ new DataView(b.buffer).setUint32(1, val.length, false);
+ parts.push(b);
+ }
+ for (const item of val) _encodeValue(item, parts);
+ } else if (typeof val === 'object') {
+ const keys = Object.keys(val);
+ if (keys.length <= 15) {
+ parts.push(new Uint8Array([0x80 | keys.length]));
+ } else if (keys.length <= 0xffff) {
+ const b = new Uint8Array(3); b[0] = 0xde;
+ new DataView(b.buffer).setUint16(1, keys.length, false);
+ parts.push(b);
+ } else {
+ const b = new Uint8Array(5); b[0] = 0xdf;
+ new DataView(b.buffer).setUint32(1, keys.length, false);
+ parts.push(b);
+ }
+ for (const k of keys) {
+ _encodeValue(k, parts);
+ _encodeValue(val[k], parts);
+ }
+ }
+}
+
+function msgpack_decode(buf) {
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ const [val] = _decodeValue(buf, view, 0);
+ return val;
+}
+
+function _decodeValue(buf, view, offset) {
+ const byte = buf[offset];
+
+ if (byte <= 0x7f) return [byte, offset + 1];
+ if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1];
+ if ((byte & 0xa0) === 0xa0) {
+ const len = byte & 0x1f;
+ return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len];
+ }
+ if ((byte & 0xf0) === 0x90) {
+ const len = byte & 0x0f;
+ return _decodeArray(buf, view, offset + 1, len);
+ }
+ if ((byte & 0xf0) === 0x80) {
+ const len = byte & 0x0f;
+ return _decodeMap(buf, view, offset + 1, len);
+ }
+
+ switch (byte) {
+ case 0xc0: return [null, offset + 1];
+ case 0xc2: return [false, offset + 1];
+ case 0xc3: return [true, offset + 1];
+ case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; }
+ case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; }
+ case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; }
+ case 0xcc: return [buf[offset + 1], offset + 2];
+ case 0xcd: return [view.getUint16(offset + 1, false), offset + 3];
+ case 0xce: return [view.getUint32(offset + 1, false), offset + 5];
+ // uint64/int64 — never emitted by this file's own encoder (a JS number
+ // above 0xffffffff falls to float64 there), but the node's real msgpack
+ // library sends a plain uint64 for any Python int over ~4.3 billion, and
+ // a raw byte count crosses that easily (found live: IndexProgress.
+ // scanned_bytes/total_bytes in the handshake ack, indexer.py, for a
+ // group whose total library size exceeds ~4 GB). Split into two 32-bit
+ // halves rather than DataView's getBigUint64/getBigInt64 — a BigInt
+ // would silently poison every arithmetic use of these fields elsewhere
+ // (percentage math, comparisons) — and every real byte count fits in a
+ // plain JS number well under Number.MAX_SAFE_INTEGER (2^53).
+ case 0xcf: {
+ const hi = view.getUint32(offset + 1, false);
+ const lo = view.getUint32(offset + 5, false);
+ return [hi * 4294967296 + lo, offset + 9];
+ }
+ case 0xd3: {
+ const hi = view.getInt32(offset + 1, false);
+ const lo = view.getUint32(offset + 5, false);
+ return [hi * 4294967296 + lo, offset + 9];
+ }
+ case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9];
+ case 0xd0: return [view.getInt8(offset + 1), offset + 2];
+ case 0xd1: return [view.getInt16(offset + 1, false), offset + 3];
+ case 0xd2: return [view.getInt32(offset + 1, false), offset + 5];
+ case 0xd9: {
+ const len = buf[offset + 1];
+ return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len];
+ }
+ case 0xda: {
+ const len = view.getUint16(offset + 1, false);
+ return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len];
+ }
+ case 0xdb: {
+ const len = view.getUint32(offset + 1, false);
+ return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len];
+ }
+ case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); }
+ case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); }
+ case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); }
+ case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); }
+ default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`);
+ }
+}
+
+function _decodeArray(buf, view, offset, count) {
+ const arr = [];
+ for (let i = 0; i < count; i++) {
+ const [val, newOff] = _decodeValue(buf, view, offset);
+ arr.push(val);
+ offset = newOff;
+ }
+ return [arr, offset];
+}
+
+function _decodeMap(buf, view, offset, count) {
+ const obj = {};
+ for (let i = 0; i < count; i++) {
+ const [key, off1] = _decodeValue(buf, view, offset);
+ const [val, off2] = _decodeValue(buf, view, off1);
+ obj[key] = val;
+ offset = off2;
+ }
+ return [obj, offset];
+}
+
+function _hex(bytes) {
+ return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
new file mode 100644
index 0000000..4291cf8
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -0,0 +1,342 @@
+// An account's devices and what they hold: joining a group, linking a device,
+// the identity bundles and the per-account blobs (playlists).
+//
+// Methods of MeshBayTransport, copied onto its prototype by extendTransport
+// (transport.js, which the shell loads first).
+
+extendTransport(class {
+ /**
+ * Who is in this group and which device keys they hold — verified here, not
+ * taken on the node's word.
+ *
+ * Tier 2 of `docs/MESHBAY_DESIGN.md` §3.3. The node relays, for each device,
+ * the already-pinned key that countersigned it and the signature itself; this
+ * walks that from each account's first device outwards and keeps only the
+ * devices it could actually reach. A device the node asserts but cannot
+ * evidence is reported as unverified rather than dropped — the reader is
+ * shown a gap, never a silent absence.
+ *
+ * The property this buys, stated exactly: once a client has seen an account,
+ * a node that later substitutes a key for it is **detected**. It buys nothing
+ * at first sight, where there is nothing to compare against — that boundary
+ * is `docs/MESHBAY_DESIGN.md` §3.2's and does not move.
+ */
+ async groupRoster() {
+ if (this._roster) return this._roster;
+ if (this._rosterInFlight) return this._rosterInFlight;
+
+ this._rosterInFlight = (async () => {
+ const resp = await this._sendAndWait({
+ type: 'group_roster_req', v: '2.0', group_id: this._groupId || '',
+ });
+ if (resp.type === 'error') throw new Error(resp.detail);
+ const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
+ this._gekRaw, 'roster', 'group_roster_resp', this._groupId || '', resp));
+ this._roster = await _verifyRoster(payload, this.nodePk);
+ return this._roster;
+ })();
+ try {
+ return await this._rosterInFlight;
+ } finally {
+ this._rosterInFlight = null;
+ }
+ }
+
+ /**
+ * How this client regards `devicePk` as a device of `userId`.
+ *
+ * 'pinned' seen before, and the same key — nothing to say
+ * 'linked' new, and countersigned by a key already pinned for it
+ * 'first' first sight of this account: trust on first use
+ * 'changed' a key this account has not shown before and cannot evidence
+ *
+ * Only `changed` is worth a person's attention, and it is the one notice
+ * docs/MESHBAY_DESIGN.md §3.3 budgets for. `first` is not an alarm — every
+ * account is new once, and treating that as a warning is how a warning stops
+ * being read.
+ */
+ async accountDeviceStatus(userId, devicePk) {
+ let roster;
+ try {
+ roster = await this.groupRoster();
+ } catch {
+ return 'unknown';
+ }
+ const known = await _readPinnedAccount(this.nodePk, userId);
+ const entry = roster.byAccount.get(userId);
+ if (known && known.includes(devicePk)) return 'pinned';
+ if (!known) {
+ // First sight, so **everything the node says** is pinned — not only what
+ // a chain reaches. There is nothing to compare against yet: that is what
+ // trust-on-first-use means, and pinning only the verified subset would
+ // raise "key changed" on a legitimate second device whose
+ // countersignature simply predates it being kept. What TOFU buys is that
+ // a substitution *later* is visible; it cannot buy anything now.
+ if (entry) await _writePinnedAccount(this.nodePk, userId, entry.all);
+ return entry && entry.all.includes(devicePk) ? 'first' : 'changed';
+ }
+ if (entry && entry.verified.includes(devicePk)
+ && entry.chain.get(devicePk)
+ && known.includes(entry.chain.get(devicePk))) {
+ // Countersigned by a key we already trust for this account: a second
+ // device of someone we know, admitted without anybody comparing digits.
+ await _writePinnedAccount(this.nodePk, userId,
+ [...new Set([...known, devicePk])]);
+ return 'linked';
+ }
+ return 'changed';
+ }
+
+ /**
+ * Ask the node to recognise us and hand over the group key.
+ *
+ * Sent when we hold no GEK for a group. `code` is needed only the first time
+ * this node sees this account (and not at all in an open-join group).
+ */
+ async joinGroup(userId, groupId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ const transcript = C.joinTranscript(
+ this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: groupId || '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
+ if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
+ // The UI reacts to `code_required` by asking for one; everything else is
+ // shown as-is.
+ err.reason = reason;
+ throw err;
+ }
+
+ // Unwrap with our own secret key — the node wrapped for the public key we
+ // just proved we hold, so nobody else can open this.
+ const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
+ const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
+ const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
+ this._gekRaw = gekRaw;
+ // What the node's roster says this identity is, which is not what the hub
+ // says: `operator` here means this browser's key was paired with the node,
+ // not merely that the account owns it.
+ this.memberRole = resp.role || '';
+ return gekRaw;
+ }
+
+ // ── Device linking ─────────────────────────────────────────────────────
+
+ /**
+ * Ask to be added, and return the code to show the person.
+ *
+ * They read it off this screen and type it into a device already paired with
+ * this node. The code is hashed together with our own keys, so that other
+ * device cannot be handed a substituted key and sign for it by mistake.
+ */
+ async requestDeviceAdd(userId) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+
+ // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code
+ // so it reads and types the same way.
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
+ const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
+ const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
+
+ const codeHash = await C.deviceCodeHash(
+ C.normalizeCode(code), pkEdB64, pkXB64);
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceRequestTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'device_add_request', v: '0.1',
+ pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return { code, expiresAt: resp.expires_at };
+ }
+
+ /**
+ * Approve a device waiting with this code.
+ *
+ * The node is a mailbox: it is asked for a request matching
+ * sha256(code ‖ keys), and the keys in that hash came from the device that
+ * filed it. A node returning something else produces no match, so there is
+ * nothing to sign and nothing for a person to misread.
+ */
+ async approveDevice(userId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+ const C = window.MeshBayCrypto;
+ const normalized = C.normalizeCode(code);
+
+ // The code never leaves this browser. The node lists what is pending, each
+ // with the hash the requesting device computed over the code and its own
+ // keys; we recompute and keep the one that matches. A node offering
+ // fabricated keys would have to produce a hash matching sha256(code ‖
+ // fabricated) — and it does not know the code.
+ const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
+ if (listed.type === 'error') throw new Error(listed.detail || 'Not found');
+
+ let match = null;
+ for (const req of listed.requests || []) {
+ const expect = await C.deviceCodeHash(
+ normalized, req.pk_ed25519, req.pk_x25519);
+ if (expect === req.code_hash) { match = req; break; }
+ }
+ if (!match) {
+ throw new Error('No device is waiting with that code');
+ }
+ return this._countersign(userId, match.code_hash,
+ match.pk_ed25519, match.pk_x25519);
+ }
+
+ async _countersign(userId, codeHash, pkEdB64, pkXB64) {
+ const C = window.MeshBayCrypto;
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceAddTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+ const resp = await this._sendAndWait({
+ type: 'device_add', v: '0.1',
+ pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return resp;
+ }
+
+ async listDevices() {
+ const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return { devices: resp.devices || [], pending: resp.pending || 0 };
+ }
+
+ /** Retire a device — a lost laptop. Countersigned like an addition. */
+ async revokeDevice(userId, pkEdB64, pkXB64) {
+ const C = window.MeshBayCrypto;
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceAddTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+ const resp = await this._sendAndWait({
+ type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return resp;
+ }
+
+ /**
+ * Withdraw our key backup from this node.
+ *
+ * The counterpart of storeKeypairBundle: turning the setting off has to remove
+ * what is already stored, not merely stop adding to it — otherwise the blob
+ * stays on every node the account has ever joined (C4).
+ */
+ async deleteKeypairBundle() {
+ const msg = await this._sendAndWait({
+ type: 'keypair_bundle_delete', v: '0.1',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ async storeKeypairBundle(bundleEnc, recoveryEnc) {
+ const msg = await this._sendAndWait({
+ type: 'keypair_bundle_store',
+ v: '0.1',
+ bundle_enc: bundleEnc,
+ // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain
+ // re-backup; the node keeps any copy it already holds.
+ ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ // ── Per-account blobs (playlists) ──────────────────────────────────────
+
+ async storeUserBlob(kind, rev, blobEnc) {
+ const msg = await this._sendAndWait({
+ type: 'user_blob_store', v: '0.1',
+ kind, rev, blob_enc: blobEnc,
+ });
+ // A refused store is the size cap or the account quota, and the node says
+ // which. Thrown rather than swallowed: the caller has to be able to tell
+ // the reader that this playlist did not save.
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * One blob, or `{ rev: null, blob_enc: null }` when this account has never
+ * written that kind here — which is the ordinary state of a node the reader
+ * has just joined, and must not read as a failure.
+ */
+ async fetchUserBlob(kind) {
+ const msg = await this._sendAndWait({
+ type: 'user_blob_fetch', v: '0.1', kind,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return { rev: msg.rev ?? null, blob_enc: msg.blob_enc ?? null };
+ }
+
+ /**
+ * Which kinds this node holds, and at what revision — never a payload.
+ *
+ * What a client that has lost its local state needs: playlist ids are
+ * client-generated, so there is nothing to fetch by name until this says
+ * what the names are.
+ */
+ async listUserBlobs() {
+ const msg = await this._sendAndWait({
+ type: 'user_blob_list', v: '0.1',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg.blobs || [];
+ }
+
+ async deleteUserBlob(kind) {
+ const msg = await this._sendAndWait({
+ type: 'user_blob_delete', v: '0.1', kind,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
new file mode 100644
index 0000000..f94eb40
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
@@ -0,0 +1,284 @@
+// What the media apps ask the node for: TMDB and MusicBrainz metadata, audio
+// transcoding, subtitles, and the video stream.
+//
+// Methods of MeshBayTransport, copied onto its prototype by extendTransport
+// (transport.js, which the shell loads first).
+
+extendTransport(class {
+ /**
+ * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7).
+ * Keyed by the entry's own `id` (its content hash) — never a path: a
+ * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so
+ * two files sharing a folder (any multi-episode season) would resolve to
+ * whichever entry the node's index happened to return first (found live
+ * via the Music app's identical bug, 2026-08-25 — see apps/video_meta.py's
+ * `_do_media_meta_request`).
+ * `confidence: 0` (no tmdb_id, no fields) means no confident match —
+ * the caller falls back to a thumbnail-only card (§4.1), not an error.
+ */
+ async fetchMediaMeta(fileId) {
+ const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7's
+ * per-season view) — a show's own tmdb_meta is one static field that does
+ * not necessarily describe every season alike, found live: a 3-season
+ * show whose overview read as season-3-specific for every season.
+ * Keyed like media_meta_req: a season-tab bar can fire a request per tab
+ * before the previous one lands, and matching by arrival order would hand
+ * one season's data to a different season's tab whenever two responses
+ * reordered.
+ */
+ async fetchSeasonMeta(tmdbId, season) {
+ const msg = await this._sendAndWait({
+ type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Raw TMDB search candidates for an operator correcting a wrong automatic
+ * match — unlike fetchMediaMeta, this never collapses to one best guess:
+ * a human picks from several, so several is the point. Read-only, not an
+ * admin op: it looks nothing up in this node's own state and changes
+ * nothing, so it needs no signature (mirrors why media_meta_req isn't
+ * signed either).
+ */
+ async searchTmdb(mediaType, query) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Correct a wrong automatic TMDB match. Signed like
+ * setTmdbConfig: it replaces what every member sees for a show/movie,
+ * node-wide (media_cache is shared, not per-viewer) — an unsigned
+ * override would let any member vandalize another show's metadata.
+ * Applies to every file sharing the representative one's display_title,
+ * not just the file the operator happened to be looking at (apps/
+ * video_meta.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path
+ * — same reasoning as fetchMediaMeta above.
+ */
+ async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`;
+ return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Drop one file's cached TMDB match so it re-resolves with the node's
+ * current matcher (V13) — the one-click alternative to the full
+ * search-and-pick flow. Signed for the same reason as overrideTmdbMatch.
+ */
+ async rematchTmdbMatch(fileId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_rematch', v: '0.7', file_id: fileId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Set/clear a custom TMDB API token, and/or set the language TMDB is
+ * queried in (e.g. "fr-FR") — one for the whole node, since both are one
+ * operator's shared credential/cache, not a per-group concern (see
+ * setTmdbEnabled below for the per-group on/off switch). Signed like
+ * setAppsEnabled/updateRoot — an unsigned change would let any
+ * member alter outbound third-party network traffic the operator never
+ * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears
+ * a previously-set custom token; omit it (undefined/null), like
+ * `language`, to leave whatever is stored unchanged.
+ */
+ async setTmdbConfig(token, language, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_config', v: '0.7',
+ token: token === undefined ? null : token,
+ language: language === undefined ? null : language,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Must match the node's subject byte-for-byte (apps/video_meta.py
+ // _do_tmdb_config) — the token itself is never part of the subject
+ // (it would end up in the audit log in plaintext), only whether one
+ // was supplied. The language is not a secret, so it appears as-is.
+ const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Whether TMDB lookups run for this group at all — per-group (2026-08-24,
+ * used to be node-wide): a real media-library group and a test/demo group
+ * on the same node need not share the decision to spend TMDB quota and
+ * make outbound requests. Signed like the rest — it decides whether
+ * this group's members' Videos tab ever makes outbound TMDB traffic.
+ */
+ async setTmdbEnabled(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Must match the node's subject byte-for-byte (apps/video_meta.py
+ // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's
+ // lowercase.
+ const subject = enabled ? 'True' : 'False';
+ return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8)
+ * — same shape as fetchMediaMeta, minus a season/episode concept:
+ * album-level (release), resolved from the track's own artist/album
+ * fields already in the index. Keyed by the track's own `id` (content
+ * hash), not a path — a path names the *folder* a track is in, and an
+ * album is one folder with many tracks in it; three unrelated albums
+ * shared one folder's track's cover before this fix (found live,
+ * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz
+ * off for this group, or nothing configured) — the caller falls back to
+ * the embedded/no cover it already had, not an error.
+ */
+ async fetchMusicMeta(fileId) {
+ const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Server-side transcode of a Music-app file the browser's own <audio>
+ * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns
+ * `{ hash, size, mime }` — the *cache* hash to pull through the normal
+ * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own
+ * id, the same indirection already used for a TMDB poster or a
+ * MusicBrainz cover. Cached node-side after the first call, but ffmpeg
+ * still has to run at least once and a transcode slot can be busy, so
+ * this gets a longer timeout than the metadata lookups above.
+ */
+ async requestAudioTranscode(fileId) {
+ const msg = await this._sendAndWait(
+ { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000);
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * One embedded subtitle track, extracted node-side to WebVTT. Returns
+ * `{ hash, size, mime, track }` — the *cache* hash to pull through the
+ * ordinary file_req/chunk path, the same indirection as an audio transcode
+ * or a TMDB poster, and cached node-side under the file's own id so a film
+ * is extracted once rather than once per viewing.
+ *
+ * `track` is the ordinal the node published in `stream_init.subtitle_tracks`
+ * and is passed back untouched: it counts every subtitle stream in the
+ * container, including the bitmap ones that are never listed, so it is not
+ * a position in the list this client received.
+ */
+ async requestSubtitle(fileId, track) {
+ // Generous because the node's own bound is, and for the same reason: the
+ // extraction demuxes the whole container, measured at 9.8 s per GB on an
+ // external disk — 71 s for a 7.3 GB film, and more for a 4K one. The node
+ // always answers, with a refusal if its own budget runs out, so this is a
+ // backstop against a peer that has gone silent rather than a deadline for
+ // the work. It is paid once per film: every later viewing is cached.
+ const msg = await this._sendAndWait(
+ { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 900000);
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Whether MusicBrainz lookups run for this group at all — per-group from
+ * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled.
+ */
+ async setMusicbrainzEnabled(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Python's f"{bool}" is "True"/"False", not JS's lowercase — must
+ // match apps/music.py _do_musicbrainz_enabled byte-for-byte.
+ const subject = enabled ? 'True' : 'False';
+ return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Ask for a video stream, and say how much we can take.
+ *
+ * `credits` bounds what is in flight. Without it the node pushes the whole
+ * film as fast as ffmpeg produces it and the browser holds all of it while
+ * MediaSource consumes a segment at a time — which is fine for a clip and
+ * fatal for anything worth streaming.
+ */
+ requestStream(fileId, credits = STREAM_CREDITS, start = 0, audioTrack = null) {
+ // `start` is a seek: the node retires whatever this session was streaming
+ // and spawns ffmpeg again from there. Omitted or zero is the film's
+ // beginning, which is what an 0.1 node understands.
+ //
+ // `audioTrack` is omitted entirely unless the caller has one, and the
+ // caller only has one because a `stream_init` listed the tracks. A node
+ // too old to enumerate them is therefore never sent a field it would
+ // ignore — which matters more here than it looks: ignoring it would not
+ // degrade the stream, it would serve a different language without saying
+ // so.
+ const req = { type: 'stream_req', v: '0.1', file_id: fileId, credits, start };
+ if (Number.isInteger(audioTrack) && audioTrack >= 0) {
+ req.audio_track = audioTrack;
+ }
+ console.log('[stream] sending stream_req start:', start, 'credits:', credits,
+ 'audio_track:', req.audio_track ?? '-');
+ this._send(req);
+ }
+
+ /** Room for `n` more segments. */
+ grantStreamCredit(n = 1) {
+ if (!this._connected) return;
+ console.log('[stream] grant credit:', n);
+ this._send({ type: 'stream_more', v: '0.1', n });
+ }
+
+ /**
+ * Tell the node what the player sees.
+ *
+ * A hang on a phone is unreadable from here: there is no console to open and
+ * the node's own log shows a stream it is feeding perfectly well. This puts
+ * the two halves in one file. The node only logs it.
+ */
+ sendStreamDiag(diag) {
+ if (!this._connected) return;
+ try { this._send({ type: 'client_diag', v: '0.1', ...diag }); } catch { /* gone */ }
+ }
+
+ /**
+ * Nobody is watching any more.
+ *
+ * Closing the viewer used to say nothing to the node, which went on
+ * transcoding and holding one of its two slots until the credit timeout — so
+ * the next video answered "server busy".
+ */
+ stopStream() {
+ if (!this._connected) return;
+ try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ }
+ }
+});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js
new file mode 100644
index 0000000..c0699dc
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-pins.js
@@ -0,0 +1,86 @@
+// Which node this browser has met under which key (trust on first use), and
+// the version range a node declares at the handshake.
+
+// ── Node identity pinning (11.5.8) ───────────────────────────────────────────
+
+const NODE_PIN_PREFIX = 'mb_nodepin_';
+
+/**
+ * The node's half of the version range, from `handshake_challenge`.
+ *
+ * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no
+ * range at all is a node that predates negotiation — that is every 0.x node, and
+ * none of them can serve a sealed index or a sealed ack — so it is refused here
+ * rather than left to fail later as a message that will not open.
+ */
+function _checkNodeVersion(reply) {
+ const parse = (v) => {
+ const m = /^(\d+)\.(\d+)$/.exec(String(v || ''));
+ return m ? [Number(m[1]), Number(m[2])] : null;
+ };
+ const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]);
+ const fail = (reason, message) => {
+ const e = new Error(message);
+ e.reason = reason;
+ throw e;
+ };
+
+ const theirs = parse(reply.v);
+ if (!theirs) {
+ fail('node_version_unreadable',
+ 'The node did not declare a readable protocol version.');
+ }
+ // No declared minimum means "only what I speak" — the correct reading of a
+ // node from before this field existed.
+ const theirMin = parse(reply.v_min) || theirs;
+ if (cmp(theirs, parse(MNP_V_MIN)) < 0) {
+ fail('node_too_old',
+ 'This node is running an older MeshBay than this page needs. '
+ + 'Its operator has to update it.');
+ }
+ if (cmp(theirMin, parse(MNP_V)) > 0) {
+ fail('client_too_old',
+ 'This page is older than the node it is talking to. '
+ + 'Reload to pick up the current version.');
+ }
+}
+
+function _checkNodePin(nodeId, nodePk) {
+ if (!nodeId || !nodePk) return;
+ const key = NODE_PIN_PREFIX + nodeId;
+
+ let pinned = null;
+ try { pinned = localStorage.getItem(key); } catch { return; }
+
+ if (pinned === null) {
+ try { localStorage.setItem(key, nodePk); } catch {}
+ return;
+ }
+ if (pinned !== nodePk) {
+ throw new Error(
+ 'This node\'s identity key has changed. That is expected only if its ' +
+ 'operator reinstalled the node — otherwise someone may be impersonating ' +
+ 'it. Verify with the operator out of band, then clear the pin in ' +
+ 'Settings to accept the new key.');
+ }
+}
+
+/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */
+function clearNodePin(nodeId) {
+ try {
+ if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId);
+ else {
+ for (const k of Object.keys(localStorage))
+ if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k);
+ }
+ } catch {}
+}
+
+function pinnedNodeCount() {
+ try {
+ return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length;
+ } catch { return 0; }
+}
+
+MeshBayTransport.clearNodePin = clearNodePin;
+MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
new file mode 100644
index 0000000..6ae51d1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -0,0 +1,152 @@
+// A passphrase change, carried to every node that holds this account's
+// identity bundle.
+
+// ── Passphrase change: re-wrap every reachable identity bundle ───────────────
+//
+// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this
+// account's per-node identity on every node it has joined. Changing the
+// passphrase changes that key, so each bundle must be read with the old key and
+// written back with the new one — on the node, while both keys are in hand.
+//
+// The reachable set is the online nodes of the account's current groups. A node
+// that is offline, or belongs to a group left since, cannot be reached here and
+// is reported so the caller can tell the user to ask that group's operator to
+// unpin them and issue a fresh code (§3.4).
+
+function _acHubFetch(hubUrl, path, init) {
+ const p = typeof window !== 'undefined' && window.MeshBayPlatform;
+ const url = (hubUrl || '') + path;
+ return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init);
+}
+
+async function _acHubGet(hubUrl, token, path) {
+ const r = await _acHubFetch(hubUrl, path, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!r.ok) throw new Error(`${path} → ${r.status}`);
+ return r.json();
+}
+
+function _acWithTimeout(promise, ms, label) {
+ let timer;
+ return Promise.race([
+ promise.finally(() => clearTimeout(timer)),
+ new Promise((_, rej) => {
+ timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms);
+ }),
+ ]);
+}
+
+/**
+ * @param {object} o
+ * @param {string} o.hubUrl same base the SPA uses for the hub
+ * @param {string} o.token a fresh access token
+ * @param {string} o.username
+ * @param {string} o.userId
+ * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
+ * @param {string} o.newPassphrase
+ * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
+ * docs/MESHBAY_DESIGN.md §3.6).
+ * When given, the recovery-wrapped copy is read where the
+ * passphrase copy cannot be, and a fresh one is written back.
+ * @param {(p:{done:number,total:number})=>void} [o.onProgress]
+ * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>}
+ */
+async function rewrapAllNodes(o) {
+ const K = window.MeshBayKeys;
+ if (!K || !K.deriveEncryptionKey) {
+ throw new Error('key module unavailable');
+ }
+ let oldKey, newKey;
+ if (o.bundleKey) {
+ // "Keep the current passphrase key, just add / refresh the recovery copy"
+ // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the
+ // live {v2,v1} session key, so no passphrase is needed.
+ oldKey = newKey = o.bundleKey;
+ } else {
+ // Flow B has no old passphrase; connect will fail the passphrase decrypt and
+ // fall back to the recovery copy, so a placeholder key is fine for `oldKey`.
+ const oldPass = o.oldPassphrase || o.newPassphrase;
+ oldKey = {
+ v2: await K.deriveEncryptionKey(oldPass, o.username),
+ v1: await K.deriveEncryptionKeyV1(oldPass, o.username),
+ };
+ newKey = {
+ v2: await K.deriveEncryptionKey(o.newPassphrase, o.username),
+ v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username),
+ };
+ }
+ const recoveryKey = o.recoveryKey
+ ? await K.deriveRecoveryKey(o.recoveryKey, o.username)
+ : null;
+
+ const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine');
+ const groups = mine.groups || (Array.isArray(mine) ? mine : []);
+ const updated = [], unreachable = [], failed = [];
+
+ for (const g of groups) {
+ const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name;
+ let nodes = [];
+ try {
+ const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`);
+ nodes = nd.nodes || [];
+ } catch (e) {
+ failed.push({ groupId: g.id, name: label, reason: e.message });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ continue;
+ }
+ if (nodes.length === 0) {
+ unreachable.push({ groupId: g.id, name: label, reason: 'node offline' });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ continue;
+ }
+
+ let anyOk = false, lastErr = null;
+ for (const n of nodes) {
+ const tp = new MeshBayTransport(o.hubUrl, o.token);
+ // Recover the *existing* identity or report this node — never mint a new
+ // one just because the stored bundle would not open.
+ tp._rewrapOnly = true;
+ try {
+ await _acWithTimeout(
+ tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
+ o.username, o.userId, null, recoveryKey),
+ 30000, 'connect');
+ if (tp.newNodeBundle) {
+ // No identity existed on this node — connect just minted one under
+ // the old key. Don't persist it: the next time this group is opened
+ // the normal flow creates one under the current key, and storing it
+ // here could also walk back a deliberate bundle withdrawal. Nothing
+ // is stranded, so this node needs no fix.
+ anyOk = true;
+ continue;
+ }
+ const sk = tp.sessionKeys;
+ if (!sk) { lastErr = new Error('identity not recovered'); continue; }
+ const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0));
+ const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0));
+ const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2);
+ // In Flow B, refresh the recovery copy too (same R) so the node's
+ // passphrase copy and recovery copy stay in step.
+ const reRecovery = recoveryKey
+ ? await K.encryptBundleWithKey(skEd, skX, recoveryKey)
+ : null;
+ await tp.storeKeypairBundle(reEnc, reRecovery);
+ anyOk = true;
+ } catch (e) {
+ lastErr = e;
+ } finally {
+ try { tp.close(); } catch { /* already gone */ }
+ }
+ }
+
+ if (anyOk) updated.push({ groupId: g.id, name: label });
+ else failed.push({ groupId: g.id, name: label,
+ reason: (lastErr && lastErr.message) || 'unreachable' });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ }
+
+ return { updated, unreachable, failed, newBundleKey: newKey };
+}
+
+MeshBayTransport.rewrapAllNodes = rewrapAllNodes;
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);
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js
new file mode 100644
index 0000000..9f4b531
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-upload.js
@@ -0,0 +1,228 @@
+// Uploads: a whole file, sealed chunk by chunk with several in flight, and the
+// folders it lands in.
+//
+// Methods of MeshBayTransport, copied onto its prototype by extendTransport
+// (transport.js, which the shell loads first).
+
+extendTransport(class {
+ /**
+ * Push a whole file, several chunks in flight at once.
+ *
+ * One chunk per round trip is 48 KB of throughput per RTT no matter how much
+ * bandwidth there is: 4.8 MB/s on a 10 ms path, 480 KB/s on a 100 ms one, and
+ * the sender is idle for almost all of it — which also keeps SCTP's congestion
+ * window shut, so the transport never gets a chance to speed up either. A
+ * window of chunks makes the rate depend on bandwidth rather than distance.
+ *
+ * Order is not at risk: a DataChannel is ordered and reliable by default, and
+ * the node refuses any chunk that is not the one it expects next.
+ *
+ * The node decides where this lands (uploads/) and under what name — it finds a
+ * free one rather than replacing anything. The ack says which, and that is what
+ * this returns.
+ *
+ * `dir` names the folder to upload into, as a virtual path
+ * (`Media/Films/1999`) — where the sender is actually looking. The node
+ * resolves it against the group's own roots, which refuses `..`, absolute
+ * segments and anything escaping its root; it is a place among the group's
+ * folders, never a path on the operator's filesystem.
+ *
+ * `root` is the older, coarser form: the root's name and nothing below it.
+ * Kept because a node that predates `dir` reads it, and because Chat has no
+ * folder on screen to name. Omitting both leaves the node to pick, which it
+ * only does for a client old enough to have had one destination.
+ */
+ async uploadFile(file, { chunkSize, onProgress, signal, root, dir,
+ tr = '' } = {}) {
+ // The same file twice at once would confuse the node, which keys its own
+ // upload state by folder and name — and would race for the same
+ // destination. The guard uses the same key: by name alone, a dropped folder
+ // holding a `cover.jpg` in two albums failed the second one for nothing.
+ const inFlightKey = `${dir || ''}/${file.name}`;
+ if (this._inFlightUploads.has(inFlightKey)) {
+ throw new Error(`${file.name} is already being uploaded`);
+ }
+ if (!this._gekRaw) throw new Error('This group has no key on this device');
+ const C = window.MeshBayCrypto;
+ const groupId = (this._connectArgs && this._connectArgs.groupId) || '';
+ this._inFlightUploads.add(inFlightKey);
+ const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16)));
+ const size = chunkSize || UPLOAD_CHUNK_SIZE;
+ const total = Math.max(1, Math.ceil(file.size / size));
+ let acked = 0;
+ let stored = null;
+ let failure = null;
+
+ const acks = [];
+ const wake = () => {
+ acked += 1;
+ if (onProgress) onProgress(Math.min(file.size, acked * size), file.size);
+ const waiter = acks.shift();
+ if (waiter) waiter();
+ };
+ // "Where am I?" — resolved by the node's answer to the probe chunk below,
+ // or by anything that says this node cannot answer it.
+ let settleProbe = null;
+ const probed = new Promise((r) => { settleProbe = r; });
+ const answerProbe = (from) => {
+ if (!settleProbe) return false;
+ const done = settleProbe;
+ settleProbe = null;
+ done(from);
+ return true;
+ };
+ this._uploaders.set(uploadId, (msg) => {
+ if (msg.type === 'error') {
+ // A node that predates the probe refuses its index. That is not a
+ // failure — it is the answer "start from the beginning", which is what
+ // this client did before there was anything to ask.
+ if (answerProbe(0)) return;
+ failure = new Error(msg.detail || 'Upload refused');
+ wake();
+ return;
+ }
+ // The ack is sealed too — `stored_as` and the folder it landed in name
+ // the operator's content. Opening it is what makes the result usable, so
+ // a failure here fails the upload rather than being swallowed: a chat
+ // attachment that cannot learn its stored name would point at nothing.
+ C.openGroup(this._gekRaw, 'upload', 'file_upload_ack', groupId, msg)
+ .then((plain) => {
+ const payload = msgpack_decode(plain);
+ if (payload.stored_as) stored = payload;
+ // Only the probe's answer carries this, so the two are told apart
+ // without trusting the index the node echoed back in clear.
+ if (typeof payload.resume_from === 'number') return answerProbe(payload.resume_from);
+ return false;
+ })
+ .catch((e) => {
+ failure = new Error(
+ `The node's upload reply did not open under the group key (${e.message})`);
+ return false;
+ })
+ // A probe's answer is not a chunk: waking here would credit the
+ // progress bar with a chunk that was never sent.
+ .then((wasProbe) => { if (!wasProbe) wake(); });
+ });
+
+ const nextAck = () => new Promise(r => acks.push(r));
+
+ try {
+ // Ask before sending anything. An upload interrupted at 99% used to start
+ // again from zero, because the node kept its position on the connection
+ // that was lost — see `uploads.py`. The question goes inside the seal, as
+ // a chunk with no bytes, because naming the file on a clear message is
+ // exactly what sealing this path was for.
+ // Sealed first, spread second — the same shape as the chunk loop below,
+ // and not only for symmetry: `test_the_upload_itself_is_sealed` reads
+ // this call and fails if a filename appears in it, which is how it can
+ // tell a field outside the seal from one inside it.
+ const probeSealed = await C.sealGroup(
+ this._gekRaw, 'upload', 'file_upload', groupId,
+ msgpack_encode({ filename: file.name, data: new Uint8Array(0),
+ dir: dir || '', root: root || '' }));
+ this._send({
+ type: 'file_upload',
+ v: '0.1',
+ upload_id: uploadId,
+ chunk_index: UPLOAD_PROBE_INDEX,
+ total_chunks: total,
+ ...(tr ? { tr } : {}),
+ ...probeSealed,
+ });
+ // Bounded: a node that answers neither the probe nor its refusal must not
+ // leave an upload waiting for ever. Starting over is always safe.
+ let from = await Promise.race([
+ probed,
+ new Promise((r) => setTimeout(() => { answerProbe(0); r(0); },
+ UPLOAD_PROBE_TIMEOUT_MS)),
+ ]);
+ // Defensive: a node reporting a position at or past the end would have
+ // renamed the file and dropped its state, so this cannot happen — and if
+ // it does, sending everything again is the answer that cannot corrupt.
+ if (!(from > 0) || from >= total) from = 0;
+ if (from > 0) {
+ acked = from;
+ if (onProgress) onProgress(Math.min(file.size, from * size), file.size);
+ }
+
+ for (let i = from; i < total; i++) {
+ if (signal && signal.aborted) throw _aborted();
+ // Between two chunks, never inside one — the node refuses a chunk that
+ // is not the one it expects, so a position is the only thing worth
+ // remembering. Nothing is recorded here beyond that: the node holds the
+ // real position, and the probe above is what asks for it on the way
+ // back in, which makes resuming correct even across a reconnect.
+ if (signal && signal.paused) {
+ signal.resumeFrom = i;
+ const paused = new Error('Paused');
+ paused.name = 'PausedError';
+ throw paused;
+ }
+ // Backpressure: without it the whole file lands in the browser's send
+ // buffer in seconds and the progress bar becomes a work of fiction.
+ while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) {
+ if (signal && signal.aborted) throw _aborted();
+ await new Promise(r => setTimeout(r, 20));
+ }
+ while (i - acked >= UPLOAD_WINDOW) {
+ await nextAck();
+ if (failure) throw failure;
+ }
+ if (failure) throw failure;
+
+ const buf = new Uint8Array(
+ await file.slice(i * size, (i + 1) * size).arrayBuffer());
+ // The name, the destination and the bytes go inside the seal together.
+ // Mirrors `file_upload_wire` in meshbay_common/protocol.py; only the
+ // fields the node routes on stay outside it.
+ const sealed = await C.sealGroup(
+ this._gekRaw, 'upload', 'file_upload', groupId,
+ msgpack_encode({ filename: file.name, data: buf,
+ dir: dir || '', root: root || '' }));
+ this._send({
+ type: 'file_upload',
+ v: '0.1',
+ upload_id: uploadId,
+ chunk_index: i,
+ total_chunks: total,
+ ...(tr ? { tr } : {}),
+ ...sealed,
+ });
+ }
+ while (acked < total) {
+ await nextAck();
+ if (failure) throw failure;
+ }
+ } finally {
+ this._uploaders.delete(uploadId);
+ this._inFlightUploads.delete(inFlightKey);
+ }
+ return stored || {};
+ }
+
+ /** Create a directory under the current one. Any member may. */
+ async createDirectory(dir, name) {
+ const msg = await this._sendAndWait({
+ type: 'dir_create', v: '0.1', dir: dir || '', name,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+});
+
+// 48 KB is what fits comfortably in one SCTP message across stacks; the window is
+// what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in
+// flight, which saturates any path up to roughly 100 Mb/s at 100 ms.
+const UPLOAD_CHUNK_SIZE = 48 * 1024;
+const UPLOAD_WINDOW = 32;
+// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather
+// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in
+// meshbay_common/protocol.py; the node writes nothing and answers with
+// `resume_from`, and one that predates it refuses the index, which reads as
+// "start from the beginning".
+const UPLOAD_PROBE_INDEX = -1;
+// How long to wait for that answer before assuming there is none. A node that
+// answers neither the probe nor its refusal must not leave an upload waiting
+// for ever, and starting over is always safe.
+const UPLOAD_PROBE_TIMEOUT_MS = 5000;
+const UPLOAD_BUFFER_HIGH = 1024 * 1024;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 817f182..202db94 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -14,6 +14,10 @@
* const index = await transport.fetchIndex();
* const chunk = await transport.fetchChunk(fileId, 0);
* transport.close();
+ *
+ * This file is the core — connection, reconnection, leases, dispatch. The rest
+ * of MeshBayTransport's methods, and the free functions they use, are in the
+ * transport-*.js scripts the shell loads after it (see "Parts" at the end).
*/
async function _pkFromSk(skPkcs8B64) {
@@ -35,23 +39,6 @@ async function _pkEdFromSk(skPkcs8B64) {
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
-// 48 KB is what fits comfortably in one SCTP message across stacks; the window is
-// what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in
-// flight, which saturates any path up to roughly 100 Mb/s at 100 ms.
-const UPLOAD_CHUNK_SIZE = 48 * 1024;
-const UPLOAD_WINDOW = 32;
-// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather
-// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in
-// meshbay_common/protocol.py; the node writes nothing and answers with
-// `resume_from`, and one that predates it refuses the index, which reads as
-// "start from the beginning".
-const UPLOAD_PROBE_INDEX = -1;
-// How long to wait for that answer before assuming there is none. A node that
-// answers neither the probe nor its refusal must not leave an upload waiting
-// for ever, and starting over is always safe.
-const UPLOAD_PROBE_TIMEOUT_MS = 5000;
-const UPLOAD_BUFFER_HIGH = 1024 * 1024;
-
// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a
// slow link and small enough that nothing accumulates.
// How long to collect ICE candidates before sending the offer anyway. Long
@@ -119,7 +106,7 @@ function _aborted() {
}
// Every request type that goes through the two-step admin_challenge /
-// admin_response flow (_authorizeAdminOp below) — one entry per
+// admin_response flow (_authorizeAdminOp, transport-admin.js) — one entry per
// `_authorizeAdminOp(msg, expectedOp, ...)` call site. Found live: enabling
// the Music app and then saving its root folder in the same Settings visit
// (the new merged Directories section makes this a natural, fast
@@ -1340,60 +1327,6 @@ class MeshBayTransport {
}
/**
- * Pair this browser with the node using a one-time code (M3, and the same
- * substitution as H3).
- *
- * The node has no way to know which key belongs to its operator unless someone
- * tells it locally — asking the hub would let the hub name itself node
- * administrator. The code comes from `meshbay-node operator pair`, over SSH, and
- * the hub never sees it.
- */
- async pairOperator(userId, code) {
- if (!this._connected) throw new Error('Not connected to the node');
- if (!userId) throw new Error('Missing user id');
- if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
- throw new Error('Identity keys unavailable in this browser — sign in again');
- }
- if (!this._nonceNode || !this.nodePk) {
- throw new Error('Handshake incomplete — reconnect and retry');
- }
-
- const C = window.MeshBayCrypto;
- // Both public keys are derived from OUR OWN secret keys, never read back from
- // the hub: signing a public key the directory handed us would reintroduce the
- // substitution this whole mechanism exists to close.
- const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
- const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
- const ts = Math.floor(Date.now() / 1000);
-
- // group_id is empty: operator authority is node-wide, not per group.
- const transcript = C.joinTranscript(
- this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
-
- const resp = await this._sendAndWait({
- type: 'join_request',
- v: '0.1',
- group_id: '',
- pk_ed25519: pkEdB64,
- pk_x25519: pkXB64,
- code: code || '',
- ts,
- sig,
- });
-
- if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused');
- if (resp.type !== 'join_result' || !resp.ok) {
- const reason = resp.reason || 'unknown';
- const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`);
- err.reason = reason;
- throw err;
- }
- this.memberRole = 'operator';
- return resp;
- }
-
- /**
* The full index. Resolves with the sealed payload already opened —
* `_applyIndexMessage` does that before it hands the message to whoever is
* waiting, so both the reply to this call and the node's own unsolicited
@@ -1452,383 +1385,6 @@ class MeshBayTransport {
}
/**
- * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7).
- * Keyed by the entry's own `id` (its content hash) — never a path: a
- * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so
- * two files sharing a folder (any multi-episode season) would resolve to
- * whichever entry the node's index happened to return first (found live
- * via the Music app's identical bug, 2026-08-25 — see apps/video_meta.py's
- * `_do_media_meta_request`).
- * `confidence: 0` (no tmdb_id, no fields) means no confident match —
- * the caller falls back to a thumbnail-only card (§4.1), not an error.
- */
- async fetchMediaMeta(fileId) {
- const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Unfurl a URL pasted in chat. The node fetches it (the browser cannot —
- * CSP and CORS — and would leak every reader's IP), parses an OpenGraph
- * card, and caches any image in its thumb store; `image_thumb_hash` then
- * rides the normal file_req path like a poster. `ok: false` means "no
- * preview" (blocked, unreachable, not HTML) — the caller just shows the
- * bare link. Keyed by url: a message with several links fires one each.
- */
- async fetchLinkPreview(url) {
- const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7's
- * per-season view) — a show's own tmdb_meta is one static field that does
- * not necessarily describe every season alike, found live: a 3-season
- * show whose overview read as season-3-specific for every season.
- * Keyed like media_meta_req: a season-tab bar can fire a request per tab
- * before the previous one lands, and matching by arrival order would hand
- * one season's data to a different season's tab whenever two responses
- * reordered.
- */
- async fetchSeasonMeta(tmdbId, season) {
- const msg = await this._sendAndWait({
- type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Raw TMDB search candidates for an operator correcting a wrong automatic
- * match — unlike fetchMediaMeta, this never collapses to one best guess:
- * a human picks from several, so several is the point. Read-only, not an
- * admin op: it looks nothing up in this node's own state and changes
- * nothing, so it needs no signature (mirrors why media_meta_req isn't
- * signed either).
- */
- async searchTmdb(mediaType, query) {
- const msg = await this._sendAndWait({
- type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Correct a wrong automatic TMDB match. Signed like
- * setTmdbConfig: it replaces what every member sees for a show/movie,
- * node-wide (media_cache is shared, not per-viewer) — an unsigned
- * override would let any member vandalize another show's metadata.
- * Applies to every file sharing the representative one's display_title,
- * not just the file the operator happened to be looking at (apps/
- * video_meta.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path
- * — same reasoning as fetchMediaMeta above.
- */
- async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) {
- const msg = await this._sendAndWait({
- type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`;
- return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn);
- }
- return msg;
- }
-
- /**
- * Drop one file's cached TMDB match so it re-resolves with the node's
- * current matcher (V13) — the one-click alternative to the full
- * search-and-pick flow. Signed for the same reason as overrideTmdbMatch.
- */
- async rematchTmdbMatch(fileId, signFn) {
- const msg = await this._sendAndWait({
- type: 'tmdb_rematch', v: '0.7', file_id: fileId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn);
- }
- return msg;
- }
-
- /**
- * Set/clear a custom TMDB API token, and/or set the language TMDB is
- * queried in (e.g. "fr-FR") — one for the whole node, since both are one
- * operator's shared credential/cache, not a per-group concern (see
- * setTmdbEnabled below for the per-group on/off switch). Signed like
- * setAppsEnabled/updateRoot — an unsigned change would let any
- * member alter outbound third-party network traffic the operator never
- * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears
- * a previously-set custom token; omit it (undefined/null), like
- * `language`, to leave whatever is stored unchanged.
- */
- async setTmdbConfig(token, language, signFn) {
- const msg = await this._sendAndWait({
- type: 'tmdb_config', v: '0.7',
- token: token === undefined ? null : token,
- language: language === undefined ? null : language,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // Must match the node's subject byte-for-byte (apps/video_meta.py
- // _do_tmdb_config) — the token itself is never part of the subject
- // (it would end up in the audit log in plaintext), only whether one
- // was supplied. The language is not a secret, so it appears as-is.
- const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
- return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
- }
- return msg;
- }
-
- /**
- * Whether TMDB lookups run for this group at all — per-group (2026-08-24,
- * used to be node-wide): a real media-library group and a test/demo group
- * on the same node need not share the decision to spend TMDB quota and
- * make outbound requests. Signed like the rest — it decides whether
- * this group's members' Videos tab ever makes outbound TMDB traffic.
- */
- async setTmdbEnabled(enabled, signFn) {
- const msg = await this._sendAndWait({
- type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // Must match the node's subject byte-for-byte (apps/video_meta.py
- // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's
- // lowercase.
- const subject = enabled ? 'True' : 'False';
- return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn);
- }
- return msg;
- }
-
- async setAppDirectories(appKey, directories, signFn) {
- const clean = [...new Set(
- (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
- )].sort();
- const msg = await this._sendAndWait({
- type: 'app_directories', v: '1.1', app: appKey, directories: clean,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn);
- }
- return msg;
- }
-
- /**
- * Where chat attachments are written.
- *
- * Its own message rather than `setAppDirectories('chat', ...)`: this one is
- * a destination, and the node refuses a read-only root for it. A caller
- * reaching for the generic form would get a refusal it has no reason to
- * expect, so the difference is in the name.
- */
- async setChatDirectory(path, signFn) {
- const clean = (path || '').replace(/^\/+|\/+$/g, '');
- const msg = await this._sendAndWait({
- type: 'chat_directory', v: '1.1', path: clean,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn);
- }
- return msg;
- }
-
- /** Whether the node unfurls links members post in this group's chat. */
- async setChatLinkPreview(enabled, signFn) {
- const msg = await this._sendAndWait({
- type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn);
- }
- return msg;
- }
-
- /**
- * Whether this group's files appear in members' cross-group Search.
- * Presentation only — opening the group lists everything regardless.
- */
- async setSearchListed(listed, signFn) {
- const msg = await this._sendAndWait({
- type: 'search_listed', v: '3.0', listed: Boolean(listed),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'search_listed', listed ? 'on' : 'off', signFn);
- }
- return msg;
- }
-
- /**
- * Open a new chat epoch by hand. Operator only, and signed.
- *
- * Not a switch — there is nothing to turn on. The removals that matter open
- * an epoch by themselves; this is the operator saying "move the key anyway",
- * the same instruction as `rotateGek` and signed for the same reason.
- */
- async rotateChatEpoch(signFn) {
- // This connection's own group, not a parameter. Every settings pane takes
- // the same props by design (`test_app_settings_plugin.py`), so reaching for
- // a `groupId` here would make the loop that renders them conditional — and
- // the transport already knows which group it is connected to.
- const groupId = this._groupId || '';
- const msg = await this._sendAndWait({
- type: 'chat_epoch', v: '2.0', group_id: groupId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn);
- }
- return msg;
- }
-
- /**
- * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8)
- * — same shape as fetchMediaMeta, minus a season/episode concept:
- * album-level (release), resolved from the track's own artist/album
- * fields already in the index. Keyed by the track's own `id` (content
- * hash), not a path — a path names the *folder* a track is in, and an
- * album is one folder with many tracks in it; three unrelated albums
- * shared one folder's track's cover before this fix (found live,
- * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz
- * off for this group, or nothing configured) — the caller falls back to
- * the embedded/no cover it already had, not an error.
- */
- async fetchMusicMeta(fileId) {
- const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Server-side transcode of a Music-app file the browser's own <audio>
- * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns
- * `{ hash, size, mime }` — the *cache* hash to pull through the normal
- * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own
- * id, the same indirection already used for a TMDB poster or a
- * MusicBrainz cover. Cached node-side after the first call, but ffmpeg
- * still has to run at least once and a transcode slot can be busy, so
- * this gets a longer timeout than the metadata lookups above.
- */
- async requestAudioTranscode(fileId) {
- const msg = await this._sendAndWait(
- { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000);
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * One embedded subtitle track, extracted node-side to WebVTT. Returns
- * `{ hash, size, mime, track }` — the *cache* hash to pull through the
- * ordinary file_req/chunk path, the same indirection as an audio transcode
- * or a TMDB poster, and cached node-side under the file's own id so a film
- * is extracted once rather than once per viewing.
- *
- * `track` is the ordinal the node published in `stream_init.subtitle_tracks`
- * and is passed back untouched: it counts every subtitle stream in the
- * container, including the bitmap ones that are never listed, so it is not
- * a position in the list this client received.
- */
- async requestSubtitle(fileId, track) {
- // Generous because the node's own bound is, and for the same reason: the
- // extraction demuxes the whole container, measured at 9.8 s per GB on an
- // external disk — 71 s for a 7.3 GB film, and more for a 4K one. The node
- // always answers, with a refusal if its own budget runs out, so this is a
- // backstop against a peer that has gone silent rather than a deadline for
- // the work. It is paid once per film: every later viewing is cached.
- const msg = await this._sendAndWait(
- { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 900000);
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Whether MusicBrainz lookups run for this group at all — per-group from
- * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled.
- */
- async setMusicbrainzEnabled(enabled, signFn) {
- const msg = await this._sendAndWait({
- type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // Python's f"{bool}" is "True"/"False", not JS's lowercase — must
- // match apps/music.py _do_musicbrainz_enabled byte-for-byte.
- const subject = enabled ? 'True' : 'False';
- return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn);
- }
- return msg;
- }
-
- /**
- * A page of chat history, newest first by default.
- *
- * `before` is a message id, not a timestamp: it pages backwards from the
- * newest, which is the direction a conversation is read. Asking without it
- * used to mean `since: 0`, which paged *forwards* from the very first message
- * — so a busy group opened on its oldest page and never showed the recent
- * exchange.
- *
- * Returns { messages, hasMore } — hasMore says whether anything older exists,
- * so the "load older" control knows when to stop offering.
- */
- async fetchChatHistory({ before = null, limit = 100 } = {}) {
- const msg = await this._sendAndWait({
- type: 'chat_hist',
- v: '0.2',
- before: before,
- limit: limit,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- const rows = msg.messages || [];
- const messages = [];
- for (const row of rows) messages.push(await this._openChatMessage(row));
- return { messages, hasMore: !!msg.has_more };
- }
-
- /**
- * Turn one stored or relayed chat message into what the panel renders.
- *
- * **The one place that decides how a message is read.** Live messages and
- * history arrive by different routes and used to be shaped at each of them;
- * with a `format` column and more than one way to read a payload, two copies
- * of that decision is two places to get it wrong, and the disagreement would
- * show up only in history.
- *
- * `payload` is bytes on the wire now — the node stopped decoding it as UTF-8,
- * which mangled anything that was not text. A message that cannot be read
- * comes back marked rather than thrown away: a gap in a conversation the
- * reader can see is honest, and silently dropping messages is not.
- */
- async _openChatMessage(row) {
- const base = {
- id: row.id,
- sender_id: row.sender_id,
- sender_name: row.sender_name || '',
- timestamp: row.timestamp,
- thread_id: row.thread_id,
- };
- const format = row.format || 0;
- if (format === 0) {
- return { ...base, payload: _asText(row.payload) };
- }
- if (format !== 1) {
- return { ...base, payload: '', unreadable: 'format' };
- }
- return this._openSealedChat(base, row);
- }
-
- /**
* Liveness on this already-open channel. Resolves with the round trip in ms,
* rejects on timeout — a DataChannel whose peer vanished without closing
* still reads as connected, and nothing else here notices until a real
@@ -1842,1093 +1398,12 @@ class MeshBayTransport {
return Math.round(performance.now() - started);
}
- /**
- * Send one message, sealed under this group's current chat epoch key and
- * signed with this device's key.
- *
- * There is no plaintext path. MNP 2.0 has no unencrypted chat and the node
- * refuses one, so a fallback here could only ever produce a refusal the user
- * cannot act on — and a client that quietly posted in clear into a group
- * whose members believe their chat is encrypted is the downgrade the whole
- * design is about not having.
- *
- * `sender_name` goes **inside** the envelope. On the wire it is a field any
- * peer can set to anything, and the node caches it to render history — so
- * display-name spoofing is free while chat is plaintext. Sealed and signed,
- * it is as authenticated as the message it names.
- *
- * Refuses rather than falls back. A client that cannot seal must not quietly
- * post in clear into a group whose members believe their chat is encrypted;
- * the node refuses it too, and the two refusals agreeing is the point.
- */
- async sendChat(text, iteration, threadId, senderName) {
- const keys = await this.chatKeys();
- const epoch = this.chatEpoch || keys.current;
- const epochKey = keys.byEpoch.get(epoch);
- if (!epochKey) throw new Error('No chat key for this group — reconnect');
- if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) {
- throw new Error('This device is not identified to the node — reconnect');
- }
-
- const C = window.MeshBayCrypto;
- const gid = this._groupId || '';
- const plaintext = msgpack_encode({
- text: String(text),
- thread_id: threadId || null,
- sender_name: senderName || '',
- sent_at: Math.floor(Date.now() / 1000),
- });
- const { nonce, ct } = await C.sealChat(
- epochKey, gid, epoch, this.devicePk, plaintext);
- const device = C.b64decode(this.devicePk);
- const sig = C.b64decode(await window.MeshBayKeys.signBytes(
- this._sessionKeys.skEdB64,
- C.chatSigningTranscript(gid, epoch, device, nonce, ct)));
-
- const msg = await this._sendAndWait({
- type: 'chat_msg',
- v: '2.0',
- format: 1,
- epoch,
- ct,
- nonce,
- device,
- sig,
- thread_id: threadId || null,
- // Deliberately absent: the display name is inside the envelope now.
- sender_name: null,
- });
- // Every other request in this file refuses an `error` reply; this one
- // returned it as though the node had accepted the message. It never
- // mattered while a refusal reached the wrong caller anyway — now that a
- // reply finds the request that made it, a message the node rejected would
- // otherwise appear in the conversation as sent. It rejects more of them
- // than it used to: a stale epoch, an envelope the node dislikes, or a
- // device claim that is not this connection's all come back as `error`.
- if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused');
- return msg;
- }
-
- /**
- * This group's chat epoch moved.
- *
- * An epoch opens when somebody is removed, and a client that kept sealing
- * under the retired key would be writing messages the group can still read
- * but that the removed member could read too. Dropping the cached keys is
- * what makes the next send fetch the new one.
- */
- _applyChatEpoch(msg) {
- if (msg.epoch) this.chatEpoch = msg.epoch;
- this._chatKeys = null;
- this._chatKeysInFlight = null;
- if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch);
- }
-
- /**
- * Who is in this group and which device keys they hold — verified here, not
- * taken on the node's word.
- *
- * Tier 2 of `docs/MESHBAY_DESIGN.md` §3.3. The node relays, for each device,
- * the already-pinned key that countersigned it and the signature itself; this
- * walks that from each account's first device outwards and keeps only the
- * devices it could actually reach. A device the node asserts but cannot
- * evidence is reported as unverified rather than dropped — the reader is
- * shown a gap, never a silent absence.
- *
- * The property this buys, stated exactly: once a client has seen an account,
- * a node that later substitutes a key for it is **detected**. It buys nothing
- * at first sight, where there is nothing to compare against — that boundary
- * is `docs/MESHBAY_DESIGN.md` §3.2's and does not move.
- */
- async groupRoster() {
- if (this._roster) return this._roster;
- if (this._rosterInFlight) return this._rosterInFlight;
-
- this._rosterInFlight = (async () => {
- const resp = await this._sendAndWait({
- type: 'group_roster_req', v: '2.0', group_id: this._groupId || '',
- });
- if (resp.type === 'error') throw new Error(resp.detail);
- const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
- this._gekRaw, 'roster', 'group_roster_resp', this._groupId || '', resp));
- this._roster = await _verifyRoster(payload, this.nodePk);
- return this._roster;
- })();
- try {
- return await this._rosterInFlight;
- } finally {
- this._rosterInFlight = null;
- }
- }
-
- /**
- * How this client regards `devicePk` as a device of `userId`.
- *
- * 'pinned' seen before, and the same key — nothing to say
- * 'linked' new, and countersigned by a key already pinned for it
- * 'first' first sight of this account: trust on first use
- * 'changed' a key this account has not shown before and cannot evidence
- *
- * Only `changed` is worth a person's attention, and it is the one notice
- * docs/MESHBAY_DESIGN.md §3.3 budgets for. `first` is not an alarm — every
- * account is new once, and treating that as a warning is how a warning stops
- * being read.
- */
- async accountDeviceStatus(userId, devicePk) {
- let roster;
- try {
- roster = await this.groupRoster();
- } catch {
- return 'unknown';
- }
- const known = await _readPinnedAccount(this.nodePk, userId);
- const entry = roster.byAccount.get(userId);
- if (known && known.includes(devicePk)) return 'pinned';
- if (!known) {
- // First sight, so **everything the node says** is pinned — not only what
- // a chain reaches. There is nothing to compare against yet: that is what
- // trust-on-first-use means, and pinning only the verified subset would
- // raise "key changed" on a legitimate second device whose
- // countersignature simply predates it being kept. What TOFU buys is that
- // a substitution *later* is visible; it cannot buy anything now.
- if (entry) await _writePinnedAccount(this.nodePk, userId, entry.all);
- return entry && entry.all.includes(devicePk) ? 'first' : 'changed';
- }
- if (entry && entry.verified.includes(devicePk)
- && entry.chain.get(devicePk)
- && known.includes(entry.chain.get(devicePk))) {
- // Countersigned by a key we already trust for this account: a second
- // device of someone we know, admitted without anybody comparing digits.
- await _writePinnedAccount(this.nodePk, userId,
- [...new Set([...known, devicePk])]);
- return 'linked';
- }
- return 'changed';
- }
-
- /**
- * Every chat epoch key for this group, fetched once per connection.
- *
- * Every epoch, not just the current one — that is what lets a device linked
- * this morning read a conversation from last year, and a member who joined
- * yesterday read the history the group already had. The node decides which
- * epochs a member is entitled to; this asks for what it is given.
- */
- async chatKeys() {
- if (this._chatKeys) return this._chatKeys;
- if (this._chatKeysInFlight) return this._chatKeysInFlight;
-
- this._chatKeysInFlight = (async () => {
- const resp = await this._sendAndWait({
- type: 'chat_keys_req', v: '2.0', group_id: this._groupId || '',
- });
- if (resp.type === 'error') throw new Error(resp.detail);
- // Sealed under a group-derived subkey. A payload that does not open is
- // not "no keys" — it is a peer we cannot talk to, and treating it as an
- // empty set would present an encrypted group as one with no history.
- const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
- this._gekRaw, 'chat_keys', 'chat_keys_resp', this._groupId || '', resp));
- const byEpoch = new Map();
- for (const e of payload.epochs || []) byEpoch.set(e.epoch, e.key);
- this._chatKeys = { byEpoch, current: payload.current || 0 };
- return this._chatKeys;
- })();
- try {
- return await this._chatKeysInFlight;
- } finally {
- this._chatKeysInFlight = null;
- }
- }
-
- /**
- * Open one sealed message, or mark it unreadable and say why.
- *
- * Authorship is established **before** decryption: the signature is over the
- * ciphertext, so a message that does not verify is never rendered as having
- * been written by the account it claims — which is the whole point of signing
- * rather than trusting the node's `sender_id`.
- *
- * An unreadable message is kept and marked, never dropped. A gap the reader
- * can see is honest; a conversation quietly missing messages is not.
- */
- async _openSealedChat(base, row) {
- const C = window.MeshBayCrypto;
- const gid = this._groupId || '';
- const epoch = row.epoch || 0;
- const device = row.device;
- const nonce = row.nonce;
- const ct = row.ct;
- if (!device || !nonce || !ct || !row.sig) {
- return { ...base, payload: '', unreadable: 'envelope' };
- }
-
- if (!await C.verifyChatSignature(device, gid, epoch, nonce, ct, row.sig)) {
- return { ...base, payload: '', unreadable: 'signature' };
- }
-
- let keys;
- try {
- keys = await this.chatKeys();
- } catch {
- return { ...base, payload: '', unreadable: 'keys' };
- }
- const epochKey = keys.byEpoch.get(epoch);
- if (!epochKey) return { ...base, payload: '', unreadable: 'epoch' };
-
- const deviceB64 = C.b64encode(device);
- try {
- const plain = msgpack_decode(
- await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct));
- // The signature proves *a device* wrote this. Whether that device belongs
- // to the account the node named is a separate question, and one this
- // client answers for itself from the roster (Tier 2) rather than taking
- // `sender_id` on trust. `changed` is the only value worth a notice.
- const trust = await this.accountDeviceStatus(base.sender_id, deviceB64);
- return {
- ...base,
- payload: String(plain.text || ''),
- sender_name: plain.sender_name || base.sender_name,
- thread_id: plain.thread_id ?? base.thread_id,
- device: deviceB64,
- verified: true,
- trust,
- };
- } catch {
- return { ...base, payload: '', unreadable: 'decrypt' };
- }
- }
-
- /**
- * Authorize a privileged node operation with the user's Ed25519 identity key.
- *
- * The client rebuilds the signed transcript from the challenge fields and refuses
- * to sign unless the operation and subject match what the user actually asked for.
- * Previously the node sent 32 opaque random bytes and the client signed them
- * blind, which let any peer obtain a signature over content of its choosing
- * (finding H5).
- */
- async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) {
- if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) {
- throw new Error(
- `Refusing to sign: node asked to authorize "${challenge.op}" on ` +
- `"${challenge.subject}", but the requested action was "${expectedOp}" ` +
- `on "${expectedSubject}"`);
- }
- if (!signFn) throw new Error('Admin challenge received but no signing key available');
-
- const transcript = window.MeshBayCrypto.adminTranscript(
- challenge.op, challenge.node_pk, challenge.group_id,
- challenge.subject, challenge.nonce, challenge.ts);
-
- const signature = await signFn(transcript);
- console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id,
- '— sending admin_response');
- const ack = await this._sendAndWait({
- type: 'admin_response',
- v: '0.1',
- op_id: challenge.op_id,
- signature,
- // Not read by the node (_do_admin_response only looks at op_id and
- // signature) — carried so _sendAndWait can key this reply by op, the
- // same way the admin_challenge that preceded it was keyed. Without
- // it, two admin_response replies in flight together (e.g. one op's
- // app_directories_ack arriving while another's apps_enabled_ack is still
- // pending) are matched by nothing more than arrival order.
- op: challenge.op,
- });
- console.log('[MeshBay] _authorizeAdminOp:', challenge.op, 'admin_response reply =', ack);
- if (ack.type === 'error') throw new Error(ack.detail);
- return ack;
- }
-
- async deleteFile(fileId, signFn) {
- const msg = await this._sendAndWait({
- type: 'file_delete',
- v: '0.1',
- file_id: fileId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn);
- }
- return msg;
- }
-
- /**
- * Remove an empty directory. Operator only, and the node checks that — this
- * signs with the identity it pinned for us, exactly like deleting a file.
- */
- async deleteDirectory(dir, signFn) {
- const msg = await this._sendAndWait({
- type: 'dir_delete',
- v: '0.1',
- dir,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // `dir`, not msg.subject: comparing the node's answer against itself is
- // no check at all, and the point of this one is that we know what we
- // asked for without being told.
- return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn);
- }
- return msg;
- }
-
- /**
- * Stop this node serving the group key to someone. Operator only.
- *
- * Only the node can do this: its roster decides who it serves. Removing them
- * on the hub is the other half, and neither implies the other.
- */
- /**
- * Turn a group "application" (Chat, Files, ...) on or off for everyone.
- *
- * Takes the whole set in one signed message rather than one op per app, so
- * ticking several boxes in Settings costs one signature. `apps` is sorted
- * and joined the same way on the node before it is shown for signing —
- * `_authorizeAdminOp` below checks the two match.
- */
- async setAppsEnabled(apps, signFn) {
- // Files cannot be turned off — MNP permits root exploration regardless of
- // this list, so hiding the tab only ever misled — and the node adds it if
- // it is missing. That normalisation has to happen *here too*: the subject
- // below is rebuilt from what this client sent, and compared byte for byte
- // against what the node put in the challenge. A list arriving here without
- // `files` would produce two different strings and `_authorizeAdminOp`
- // would refuse to sign an op the operator did ask for. It is reachable
- // only from a caller that builds the list from something other than the
- // node's own answer, which is exactly the kind of caller a later phase
- // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic
- // script and cannot import it.)
- const full = apps.includes('files') ? [...apps] : ['files', ...apps];
- const msg = await this._sendAndWait({
- type: 'apps_enabled', v: '0.1', apps: full,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'apps_enabled', [...full].sort().join(','), signFn);
- }
- return msg;
- }
-
- /**
- * How often the node's reconciliation backstop runs, and how long it
- * waits after a file's last write before hashing it (indexer.py
- * DirectoryIndexer). Whole seconds only: the node builds the signing
- * subject with Python's `%g` (drops a trailing ".0"), and the simplest
- * way to always match it byte-for-byte from JS is to never send a
- * fractional value in the first place.
- */
- async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) {
- const reconcile = Math.round(reconcileIntervalSecs);
- const debounce = Math.round(debounceSecs);
- const msg = await this._sendAndWait({
- type: 'set_scan_settings', v: '0.1',
- reconcile_interval_secs: reconcile, debounce_secs: debounce,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn);
- }
- return msg;
- }
-
- async revokeMember(userId, signFn) {
- const msg = await this._sendAndWait({
- type: 'member_revoke', v: '0.1', user_id: userId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn);
- }
- return msg;
- }
-
- // ── Node management (D5) ───────────────────────────────────────────────
-
- async fetchNodeStatus() {
- const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async updateNodeSettings(settings) {
- const msg = await this._sendAndWait({
- type: 'node_settings_set', v: '0.1', settings,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
- const msg = await this._sendAndWait({
- type: 'root_add', v: '1.1',
- group_id: groupId, path,
- name: name || '', kind: kind || 'generic',
- writable: !!writable, removable: !!removable,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_add', path, signFn);
- }
- return msg;
- }
-
- async removeRoot(groupId, rootName, signFn) {
- const msg = await this._sendAndWait({
- type: 'root_remove', v: '0.1',
- group_id: groupId, root_name: rootName,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn);
- }
- return msg;
- }
-
- async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
- const updates = [];
- if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
- if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
- const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
- const msg = await this._sendAndWait({
- type: 'root_update', v: '1.1',
- group_id: groupId, root_name: rootName,
- ...(writable !== undefined && { writable }),
- ...(removable !== undefined && { removable }),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
- }
- return msg;
- }
-
- async ejectRoot(groupId, rootName, signFn) {
- const msg = await this._sendAndWait({
- type: 'root_eject', v: '1.1',
- group_id: groupId, root_name: rootName,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn);
- }
- return msg;
- }
-
- async plugRoot(groupId, rootName, signFn) {
- const msg = await this._sendAndWait({
- type: 'root_plug', v: '1.1',
- group_id: groupId, root_name: rootName,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn);
- }
- return msg;
- }
-
- async unpinMember(userId, signFn) {
- const msg = await this._sendAndWait({
- type: 'member_unpin', v: '0.1', user_id: userId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn);
- }
- return msg;
- }
-
- async rotateGek(groupId, signFn) {
- const msg = await this._sendAndWait({
- type: 'gek_rotate', v: '0.1', group_id: groupId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn);
- }
- return msg;
- }
-
- async fetchRoster(groupId) {
- const msg = await this._sendAndWait({
- type: 'roster_read', v: '0.1', group_id: groupId || '',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async fetchDenylist() {
- const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async clearDenylist(subject) {
- const msg = await this._sendAndWait({
- type: 'denylist_clear', v: '0.1', subject: subject || '',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async attachGroup(name, sharedDir, uploadDir, signFn) {
- const msg = await this._sendAndWait({
- type: 'group_attach', v: '0.1',
- name, shared_dir: sharedDir, upload_dir: uploadDir || '',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'group_attach', name, signFn);
- }
- return msg;
- }
-
- async detachGroup(name, signFn) {
- const msg = await this._sendAndWait({
- type: 'group_detach', v: '0.1', name,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'group_detach', name, signFn);
- }
- return msg;
- }
-
- async reloadConfig() {
- const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Ask for a video stream, and say how much we can take.
- *
- * `credits` bounds what is in flight. Without it the node pushes the whole
- * film as fast as ffmpeg produces it and the browser holds all of it while
- * MediaSource consumes a segment at a time — which is fine for a clip and
- * fatal for anything worth streaming.
- */
- requestStream(fileId, credits = STREAM_CREDITS, start = 0, audioTrack = null) {
- // `start` is a seek: the node retires whatever this session was streaming
- // and spawns ffmpeg again from there. Omitted or zero is the film's
- // beginning, which is what an 0.1 node understands.
- //
- // `audioTrack` is omitted entirely unless the caller has one, and the
- // caller only has one because a `stream_init` listed the tracks. A node
- // too old to enumerate them is therefore never sent a field it would
- // ignore — which matters more here than it looks: ignoring it would not
- // degrade the stream, it would serve a different language without saying
- // so.
- const req = { type: 'stream_req', v: '0.1', file_id: fileId, credits, start };
- if (Number.isInteger(audioTrack) && audioTrack >= 0) {
- req.audio_track = audioTrack;
- }
- console.log('[stream] sending stream_req start:', start, 'credits:', credits,
- 'audio_track:', req.audio_track ?? '-');
- this._send(req);
- }
-
- /** Room for `n` more segments. */
- grantStreamCredit(n = 1) {
- if (!this._connected) return;
- console.log('[stream] grant credit:', n);
- this._send({ type: 'stream_more', v: '0.1', n });
- }
-
- /**
- * Tell the node what the player sees.
- *
- * A hang on a phone is unreadable from here: there is no console to open and
- * the node's own log shows a stream it is feeding perfectly well. This puts
- * the two halves in one file. The node only logs it.
- */
- sendStreamDiag(diag) {
- if (!this._connected) return;
- try { this._send({ type: 'client_diag', v: '0.1', ...diag }); } catch { /* gone */ }
- }
-
- /**
- * Nobody is watching any more.
- *
- * Closing the viewer used to say nothing to the node, which went on
- * transcoding and holding one of its two slots until the credit timeout — so
- * the next video answered "server busy".
- */
- stopStream() {
- if (!this._connected) return;
- try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ }
- }
-
- /**
- * Push a whole file, several chunks in flight at once.
- *
- * One chunk per round trip is 48 KB of throughput per RTT no matter how much
- * bandwidth there is: 4.8 MB/s on a 10 ms path, 480 KB/s on a 100 ms one, and
- * the sender is idle for almost all of it — which also keeps SCTP's congestion
- * window shut, so the transport never gets a chance to speed up either. A
- * window of chunks makes the rate depend on bandwidth rather than distance.
- *
- * Order is not at risk: a DataChannel is ordered and reliable by default, and
- * the node refuses any chunk that is not the one it expects next.
- *
- * The node decides where this lands (uploads/) and under what name — it finds a
- * free one rather than replacing anything. The ack says which, and that is what
- * this returns.
- *
- * `dir` names the folder to upload into, as a virtual path
- * (`Media/Films/1999`) — where the sender is actually looking. The node
- * resolves it against the group's own roots, which refuses `..`, absolute
- * segments and anything escaping its root; it is a place among the group's
- * folders, never a path on the operator's filesystem.
- *
- * `root` is the older, coarser form: the root's name and nothing below it.
- * Kept because a node that predates `dir` reads it, and because Chat has no
- * folder on screen to name. Omitting both leaves the node to pick, which it
- * only does for a client old enough to have had one destination.
- */
- async uploadFile(file, { chunkSize, onProgress, signal, root, dir,
- tr = '' } = {}) {
- // The same file twice at once would confuse the node, which keys its own
- // upload state by folder and name — and would race for the same
- // destination. The guard uses the same key: by name alone, a dropped folder
- // holding a `cover.jpg` in two albums failed the second one for nothing.
- const inFlightKey = `${dir || ''}/${file.name}`;
- if (this._inFlightUploads.has(inFlightKey)) {
- throw new Error(`${file.name} is already being uploaded`);
- }
- if (!this._gekRaw) throw new Error('This group has no key on this device');
- const C = window.MeshBayCrypto;
- const groupId = (this._connectArgs && this._connectArgs.groupId) || '';
- this._inFlightUploads.add(inFlightKey);
- const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16)));
- const size = chunkSize || UPLOAD_CHUNK_SIZE;
- const total = Math.max(1, Math.ceil(file.size / size));
- let acked = 0;
- let stored = null;
- let failure = null;
-
- const acks = [];
- const wake = () => {
- acked += 1;
- if (onProgress) onProgress(Math.min(file.size, acked * size), file.size);
- const waiter = acks.shift();
- if (waiter) waiter();
- };
- // "Where am I?" — resolved by the node's answer to the probe chunk below,
- // or by anything that says this node cannot answer it.
- let settleProbe = null;
- const probed = new Promise((r) => { settleProbe = r; });
- const answerProbe = (from) => {
- if (!settleProbe) return false;
- const done = settleProbe;
- settleProbe = null;
- done(from);
- return true;
- };
- this._uploaders.set(uploadId, (msg) => {
- if (msg.type === 'error') {
- // A node that predates the probe refuses its index. That is not a
- // failure — it is the answer "start from the beginning", which is what
- // this client did before there was anything to ask.
- if (answerProbe(0)) return;
- failure = new Error(msg.detail || 'Upload refused');
- wake();
- return;
- }
- // The ack is sealed too — `stored_as` and the folder it landed in name
- // the operator's content. Opening it is what makes the result usable, so
- // a failure here fails the upload rather than being swallowed: a chat
- // attachment that cannot learn its stored name would point at nothing.
- C.openGroup(this._gekRaw, 'upload', 'file_upload_ack', groupId, msg)
- .then((plain) => {
- const payload = msgpack_decode(plain);
- if (payload.stored_as) stored = payload;
- // Only the probe's answer carries this, so the two are told apart
- // without trusting the index the node echoed back in clear.
- if (typeof payload.resume_from === 'number') return answerProbe(payload.resume_from);
- return false;
- })
- .catch((e) => {
- failure = new Error(
- `The node's upload reply did not open under the group key (${e.message})`);
- return false;
- })
- // A probe's answer is not a chunk: waking here would credit the
- // progress bar with a chunk that was never sent.
- .then((wasProbe) => { if (!wasProbe) wake(); });
- });
-
- const nextAck = () => new Promise(r => acks.push(r));
-
- try {
- // Ask before sending anything. An upload interrupted at 99% used to start
- // again from zero, because the node kept its position on the connection
- // that was lost — see `uploads.py`. The question goes inside the seal, as
- // a chunk with no bytes, because naming the file on a clear message is
- // exactly what sealing this path was for.
- // Sealed first, spread second — the same shape as the chunk loop below,
- // and not only for symmetry: `test_the_upload_itself_is_sealed` reads
- // this call and fails if a filename appears in it, which is how it can
- // tell a field outside the seal from one inside it.
- const probeSealed = await C.sealGroup(
- this._gekRaw, 'upload', 'file_upload', groupId,
- msgpack_encode({ filename: file.name, data: new Uint8Array(0),
- dir: dir || '', root: root || '' }));
- this._send({
- type: 'file_upload',
- v: '0.1',
- upload_id: uploadId,
- chunk_index: UPLOAD_PROBE_INDEX,
- total_chunks: total,
- ...(tr ? { tr } : {}),
- ...probeSealed,
- });
- // Bounded: a node that answers neither the probe nor its refusal must not
- // leave an upload waiting for ever. Starting over is always safe.
- let from = await Promise.race([
- probed,
- new Promise((r) => setTimeout(() => { answerProbe(0); r(0); },
- UPLOAD_PROBE_TIMEOUT_MS)),
- ]);
- // Defensive: a node reporting a position at or past the end would have
- // renamed the file and dropped its state, so this cannot happen — and if
- // it does, sending everything again is the answer that cannot corrupt.
- if (!(from > 0) || from >= total) from = 0;
- if (from > 0) {
- acked = from;
- if (onProgress) onProgress(Math.min(file.size, from * size), file.size);
- }
-
- for (let i = from; i < total; i++) {
- if (signal && signal.aborted) throw _aborted();
- // Between two chunks, never inside one — the node refuses a chunk that
- // is not the one it expects, so a position is the only thing worth
- // remembering. Nothing is recorded here beyond that: the node holds the
- // real position, and the probe above is what asks for it on the way
- // back in, which makes resuming correct even across a reconnect.
- if (signal && signal.paused) {
- signal.resumeFrom = i;
- const paused = new Error('Paused');
- paused.name = 'PausedError';
- throw paused;
- }
- // Backpressure: without it the whole file lands in the browser's send
- // buffer in seconds and the progress bar becomes a work of fiction.
- while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) {
- if (signal && signal.aborted) throw _aborted();
- await new Promise(r => setTimeout(r, 20));
- }
- while (i - acked >= UPLOAD_WINDOW) {
- await nextAck();
- if (failure) throw failure;
- }
- if (failure) throw failure;
-
- const buf = new Uint8Array(
- await file.slice(i * size, (i + 1) * size).arrayBuffer());
- // The name, the destination and the bytes go inside the seal together.
- // Mirrors `file_upload_wire` in meshbay_common/protocol.py; only the
- // fields the node routes on stay outside it.
- const sealed = await C.sealGroup(
- this._gekRaw, 'upload', 'file_upload', groupId,
- msgpack_encode({ filename: file.name, data: buf,
- dir: dir || '', root: root || '' }));
- this._send({
- type: 'file_upload',
- v: '0.1',
- upload_id: uploadId,
- chunk_index: i,
- total_chunks: total,
- ...(tr ? { tr } : {}),
- ...sealed,
- });
- }
- while (acked < total) {
- await nextAck();
- if (failure) throw failure;
- }
- } finally {
- this._uploaders.delete(uploadId);
- this._inFlightUploads.delete(inFlightKey);
- }
- return stored || {};
- }
-
- /** Create a directory under the current one. Any member may. */
- async createDirectory(dir, name) {
- const msg = await this._sendAndWait({
- type: 'dir_create', v: '0.1', dir: dir || '', name,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * Ask the node for a one-time pairing code admitting `userId` to this group.
- *
- * This replaces wrapping the group key in the browser. We no longer fetch the
- * invitee's public key from the hub, so the hub can no longer answer with its own
- * and be handed the group key (H3). The node wraps the key later, itself, for a
- * key the invitee proves possession of.
- *
- * Returns {code, expires_at} — the code is displayed once and passed to the
- * invitee out of band.
- */
- async createInvite(userId, groupId, username, signFn) {
- const msg = await this._sendAndWait({
- type: 'invite_create',
- v: '0.1',
- user_id: userId,
- group_id: groupId,
- username: username || '',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
- }
- return msg;
- }
-
- /**
- * A code bound to no account, for an invitation link (MNP 3.4). Signed like
- * any invitation, and the subject the operator signs is the outcome: a link
- * into this group, `link:<group>`, and nothing else.
- */
- async createLinkInvite(groupId, signFn) {
- const msg = await this._sendAndWait({
- type: 'invite_link_create', v: '0.1', group_id: groupId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn);
- }
- return msg;
- }
-
- /** Take back an unredeemed invitation link, by the handle it was issued with. */
- async cancelLinkInvite(inviteId, signFn) {
- const msg = await this._sendAndWait({
- type: 'invite_cancel', v: '0.1', invite_id: inviteId,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn);
- }
- return msg;
- }
-
- /**
- * Ask the node to recognise us and hand over the group key.
- *
- * Sent when we hold no GEK for a group. `code` is needed only the first time
- * this node sees this account (and not at all in an open-join group).
- */
- async joinGroup(userId, groupId, code) {
- if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
- throw new Error('Identity keys unavailable in this browser — sign in again');
- }
- if (!this._nonceNode || !this.nodePk) {
- throw new Error('Handshake incomplete — reconnect and retry');
- }
-
- const C = window.MeshBayCrypto;
- const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
- const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
- const ts = Math.floor(Date.now() / 1000);
-
- const transcript = C.joinTranscript(
- this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
-
- const resp = await this._sendAndWait({
- type: 'join_request',
- v: '0.1',
- group_id: groupId || '',
- pk_ed25519: pkEdB64,
- pk_x25519: pkXB64,
- code: code || '',
- ts,
- sig,
- });
-
- if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
- if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
- const reason = resp.reason || 'unknown';
- const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
- // The UI reacts to `code_required` by asking for one; everything else is
- // shown as-is.
- err.reason = reason;
- throw err;
- }
-
- // Unwrap with our own secret key — the node wrapped for the public key we
- // just proved we hold, so nobody else can open this.
- const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
- const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
- const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
- this._gekRaw = gekRaw;
- // What the node's roster says this identity is, which is not what the hub
- // says: `operator` here means this browser's key was paired with the node,
- // not merely that the account owns it.
- this.memberRole = resp.role || '';
- return gekRaw;
- }
-
- // ── Device linking ─────────────────────────────────────────────────────
//
// Identity keys are per node, so a browser and a desktop client are two keys
// on one account here. A new one is admitted by a key this node already
// pinned — never by the hub, which holds no user keys and so cannot
// countersign anything. See docs/MESHBAY_DESIGN.md §3.3.
- /**
- * Ask to be added, and return the code to show the person.
- *
- * They read it off this screen and type it into a device already paired with
- * this node. The code is hashed together with our own keys, so that other
- * device cannot be handed a substituted key and sign for it by mistake.
- */
- async requestDeviceAdd(userId) {
- if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
- throw new Error('Identity keys unavailable in this browser — sign in again');
- }
- if (!this._nonceNode || !this.nodePk) {
- throw new Error('Handshake incomplete — reconnect and retry');
- }
- const C = window.MeshBayCrypto;
- const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
- const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
-
- // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code
- // so it reads and types the same way.
- const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
- const bytes = crypto.getRandomValues(new Uint8Array(8));
- const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
- const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
-
- const codeHash = await C.deviceCodeHash(
- C.normalizeCode(code), pkEdB64, pkXB64);
- const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceRequestTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts);
- const sig = await window.MeshBayKeys.signBytes(
- this._sessionKeys.skEdB64, transcript);
-
- const resp = await this._sendAndWait({
- type: 'device_add_request', v: '0.1',
- pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
- });
- if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
- return { code, expiresAt: resp.expires_at };
- }
-
- /**
- * Approve a device waiting with this code.
- *
- * The node is a mailbox: it is asked for a request matching
- * sha256(code ‖ keys), and the keys in that hash came from the device that
- * filed it. A node returning something else produces no match, so there is
- * nothing to sign and nothing for a person to misread.
- */
- async approveDevice(userId, code) {
- if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
- throw new Error('Identity keys unavailable in this browser — sign in again');
- }
- if (!this._nonceNode || !this.nodePk) {
- throw new Error('Handshake incomplete — reconnect and retry');
- }
- const C = window.MeshBayCrypto;
- const normalized = C.normalizeCode(code);
-
- // The code never leaves this browser. The node lists what is pending, each
- // with the hash the requesting device computed over the code and its own
- // keys; we recompute and keep the one that matches. A node offering
- // fabricated keys would have to produce a hash matching sha256(code ‖
- // fabricated) — and it does not know the code.
- const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
- if (listed.type === 'error') throw new Error(listed.detail || 'Not found');
-
- let match = null;
- for (const req of listed.requests || []) {
- const expect = await C.deviceCodeHash(
- normalized, req.pk_ed25519, req.pk_x25519);
- if (expect === req.code_hash) { match = req; break; }
- }
- if (!match) {
- throw new Error('No device is waiting with that code');
- }
- return this._countersign(userId, match.code_hash,
- match.pk_ed25519, match.pk_x25519);
- }
-
- async _countersign(userId, codeHash, pkEdB64, pkXB64) {
- const C = window.MeshBayCrypto;
- const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceAddTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await window.MeshBayKeys.signBytes(
- this._sessionKeys.skEdB64, transcript);
- const resp = await this._sendAndWait({
- type: 'device_add', v: '0.1',
- pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
- });
- if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
- return resp;
- }
-
- async listDevices() {
- const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' });
- if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
- return { devices: resp.devices || [], pending: resp.pending || 0 };
- }
-
- /** Retire a device — a lost laptop. Countersigned like an addition. */
- async revokeDevice(userId, pkEdB64, pkXB64) {
- const C = window.MeshBayCrypto;
- const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceAddTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await window.MeshBayKeys.signBytes(
- this._sessionKeys.skEdB64, transcript);
- const resp = await this._sendAndWait({
- type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
- });
- if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
- return resp;
- }
-
- /**
- * Withdraw our key backup from this node.
- *
- * The counterpart of storeKeypairBundle: turning the setting off has to remove
- * what is already stored, not merely stop adding to it — otherwise the blob
- * stays on every node the account has ever joined (C4).
- */
- async deleteKeypairBundle() {
- const msg = await this._sendAndWait({
- type: 'keypair_bundle_delete', v: '0.1',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- async storeKeypairBundle(bundleEnc, recoveryEnc) {
- const msg = await this._sendAndWait({
- type: 'keypair_bundle_store',
- v: '0.1',
- bundle_enc: bundleEnc,
- // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain
- // re-backup; the node keeps any copy it already holds.
- ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- // ── Per-account blobs (playlists) ──────────────────────────────────────
//
// MNP 3.1, docs/playlists.md §8.1. The node stores bytes it cannot read and
// hands them back; `user_id` is never sent, because the node takes it from
@@ -2940,54 +1415,6 @@ class MeshBayTransport {
// bottom of _handleMessage — that branch exists for nodes too old to stamp
// `req_id`, and no node old enough to skip it knows these messages at all.
- async storeUserBlob(kind, rev, blobEnc) {
- const msg = await this._sendAndWait({
- type: 'user_blob_store', v: '0.1',
- kind, rev, blob_enc: blobEnc,
- });
- // A refused store is the size cap or the account quota, and the node says
- // which. Thrown rather than swallowed: the caller has to be able to tell
- // the reader that this playlist did not save.
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
- /**
- * One blob, or `{ rev: null, blob_enc: null }` when this account has never
- * written that kind here — which is the ordinary state of a node the reader
- * has just joined, and must not read as a failure.
- */
- async fetchUserBlob(kind) {
- const msg = await this._sendAndWait({
- type: 'user_blob_fetch', v: '0.1', kind,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return { rev: msg.rev ?? null, blob_enc: msg.blob_enc ?? null };
- }
-
- /**
- * Which kinds this node holds, and at what revision — never a payload.
- *
- * What a client that has lost its local state needs: playlist ids are
- * client-generated, so there is nothing to fetch by name until this says
- * what the names are.
- */
- async listUserBlobs() {
- const msg = await this._sendAndWait({
- type: 'user_blob_list', v: '0.1',
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg.blobs || [];
- }
-
- async deleteUserBlob(kind) {
- const msg = await this._sendAndWait({
- type: 'user_blob_delete', v: '0.1', kind,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- return msg;
- }
-
get gekRaw() { return this._gekRaw; }
/**
@@ -3736,346 +2163,6 @@ class MeshBayTransport {
}
/**
- * 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);
-}
-
-// ── Minimal msgpack encode/decode ────────────────────────────────────────────
-// Covers the subset used by MNP: maps, strings, integers, binary, arrays, null.
-
-function msgpack_encode(obj) {
- const parts = [];
- _encodeValue(obj, parts);
- const total = parts.reduce((s, p) => s + p.length, 0);
- const result = new Uint8Array(total);
- let off = 0;
- for (const p of parts) { result.set(p, off); off += p.length; }
- return result;
-}
-
-function _encodeValue(val, parts) {
- if (val === null || val === undefined) {
- parts.push(new Uint8Array([0xc0]));
- } else if (typeof val === 'boolean') {
- parts.push(new Uint8Array([val ? 0xc3 : 0xc2]));
- } else if (typeof val === 'number') {
- if (Number.isInteger(val)) {
- if (val >= 0 && val <= 127) {
- parts.push(new Uint8Array([val]));
- } else if (val >= 0 && val <= 0xff) {
- parts.push(new Uint8Array([0xcc, val]));
- } else if (val >= 0 && val <= 0xffff) {
- const b = new Uint8Array(3); b[0] = 0xcd;
- new DataView(b.buffer).setUint16(1, val, false);
- parts.push(b);
- } else if (val >= 0 && val <= 0xffffffff) {
- const b = new Uint8Array(5); b[0] = 0xce;
- new DataView(b.buffer).setUint32(1, val, false);
- parts.push(b);
- } else if (val >= 0 && val <= Number.MAX_SAFE_INTEGER) {
- // Same split as the 0xcf decoder case above, in reverse — without
- // this, a value over 0xffffffff fell to the plain int32 branch
- // below and silently wrapped to a wrong, unrelated number instead
- // of failing loudly.
- const b = new Uint8Array(9); b[0] = 0xcf;
- const dv = new DataView(b.buffer);
- dv.setUint32(1, Math.floor(val / 4294967296), false);
- dv.setUint32(5, val % 4294967296, false);
- parts.push(b);
- } else if (val >= -32 && val < 0) {
- parts.push(new Uint8Array([val & 0xff]));
- } else if (val >= -128 && val < 0) {
- const b = new Uint8Array(2); b[0] = 0xd0; b[1] = val & 0xff;
- parts.push(b);
- } else {
- const b = new Uint8Array(5); b[0] = 0xd2;
- new DataView(b.buffer).setInt32(1, val, false);
- parts.push(b);
- }
- } else {
- const b = new Uint8Array(9); b[0] = 0xcb;
- new DataView(b.buffer).setFloat64(1, val, false);
- parts.push(b);
- }
- } else if (typeof val === 'string') {
- const encoded = new TextEncoder().encode(val);
- if (encoded.length <= 31) {
- parts.push(new Uint8Array([0xa0 | encoded.length]));
- } else if (encoded.length <= 0xff) {
- parts.push(new Uint8Array([0xd9, encoded.length]));
- } else if (encoded.length <= 0xffff) {
- const b = new Uint8Array(3); b[0] = 0xda;
- new DataView(b.buffer).setUint16(1, encoded.length, false);
- parts.push(b);
- } else {
- const b = new Uint8Array(5); b[0] = 0xdb;
- new DataView(b.buffer).setUint32(1, encoded.length, false);
- parts.push(b);
- }
- parts.push(encoded);
- } else if (val instanceof Uint8Array) {
- if (val.length <= 0xff) {
- parts.push(new Uint8Array([0xc4, val.length]));
- } else if (val.length <= 0xffff) {
- const b = new Uint8Array(3); b[0] = 0xc5;
- new DataView(b.buffer).setUint16(1, val.length, false);
- parts.push(b);
- } else {
- const b = new Uint8Array(5); b[0] = 0xc6;
- new DataView(b.buffer).setUint32(1, val.length, false);
- parts.push(b);
- }
- parts.push(val);
- } else if (Array.isArray(val)) {
- if (val.length <= 15) {
- parts.push(new Uint8Array([0x90 | val.length]));
- } else if (val.length <= 0xffff) {
- const b = new Uint8Array(3); b[0] = 0xdc;
- new DataView(b.buffer).setUint16(1, val.length, false);
- parts.push(b);
- } else {
- const b = new Uint8Array(5); b[0] = 0xdd;
- new DataView(b.buffer).setUint32(1, val.length, false);
- parts.push(b);
- }
- for (const item of val) _encodeValue(item, parts);
- } else if (typeof val === 'object') {
- const keys = Object.keys(val);
- if (keys.length <= 15) {
- parts.push(new Uint8Array([0x80 | keys.length]));
- } else if (keys.length <= 0xffff) {
- const b = new Uint8Array(3); b[0] = 0xde;
- new DataView(b.buffer).setUint16(1, keys.length, false);
- parts.push(b);
- } else {
- const b = new Uint8Array(5); b[0] = 0xdf;
- new DataView(b.buffer).setUint32(1, keys.length, false);
- parts.push(b);
- }
- for (const k of keys) {
- _encodeValue(k, parts);
- _encodeValue(val[k], parts);
- }
- }
-}
-
-function msgpack_decode(buf) {
- const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
- const [val] = _decodeValue(buf, view, 0);
- return val;
-}
-
-function _decodeValue(buf, view, offset) {
- const byte = buf[offset];
-
- if (byte <= 0x7f) return [byte, offset + 1];
- if ((byte & 0xe0) === 0xe0) return [byte - 256, offset + 1];
- if ((byte & 0xa0) === 0xa0) {
- const len = byte & 0x1f;
- return [new TextDecoder().decode(buf.slice(offset + 1, offset + 1 + len)), offset + 1 + len];
- }
- if ((byte & 0xf0) === 0x90) {
- const len = byte & 0x0f;
- return _decodeArray(buf, view, offset + 1, len);
- }
- if ((byte & 0xf0) === 0x80) {
- const len = byte & 0x0f;
- return _decodeMap(buf, view, offset + 1, len);
- }
-
- switch (byte) {
- case 0xc0: return [null, offset + 1];
- case 0xc2: return [false, offset + 1];
- case 0xc3: return [true, offset + 1];
- case 0xc4: { const len = buf[offset + 1]; return [buf.slice(offset + 2, offset + 2 + len), offset + 2 + len]; }
- case 0xc5: { const len = view.getUint16(offset + 1, false); return [buf.slice(offset + 3, offset + 3 + len), offset + 3 + len]; }
- case 0xc6: { const len = view.getUint32(offset + 1, false); return [buf.slice(offset + 5, offset + 5 + len), offset + 5 + len]; }
- case 0xcc: return [buf[offset + 1], offset + 2];
- case 0xcd: return [view.getUint16(offset + 1, false), offset + 3];
- case 0xce: return [view.getUint32(offset + 1, false), offset + 5];
- // uint64/int64 — never emitted by this file's own encoder (a JS number
- // above 0xffffffff falls to float64 there), but the node's real msgpack
- // library sends a plain uint64 for any Python int over ~4.3 billion, and
- // a raw byte count crosses that easily (found live: IndexProgress.
- // scanned_bytes/total_bytes in the handshake ack, indexer.py, for a
- // group whose total library size exceeds ~4 GB). Split into two 32-bit
- // halves rather than DataView's getBigUint64/getBigInt64 — a BigInt
- // would silently poison every arithmetic use of these fields elsewhere
- // (percentage math, comparisons) — and every real byte count fits in a
- // plain JS number well under Number.MAX_SAFE_INTEGER (2^53).
- case 0xcf: {
- const hi = view.getUint32(offset + 1, false);
- const lo = view.getUint32(offset + 5, false);
- return [hi * 4294967296 + lo, offset + 9];
- }
- case 0xd3: {
- const hi = view.getInt32(offset + 1, false);
- const lo = view.getUint32(offset + 5, false);
- return [hi * 4294967296 + lo, offset + 9];
- }
- case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9];
- case 0xd0: return [view.getInt8(offset + 1), offset + 2];
- case 0xd1: return [view.getInt16(offset + 1, false), offset + 3];
- case 0xd2: return [view.getInt32(offset + 1, false), offset + 5];
- case 0xd9: {
- const len = buf[offset + 1];
- return [new TextDecoder().decode(buf.slice(offset + 2, offset + 2 + len)), offset + 2 + len];
- }
- case 0xda: {
- const len = view.getUint16(offset + 1, false);
- return [new TextDecoder().decode(buf.slice(offset + 3, offset + 3 + len)), offset + 3 + len];
- }
- case 0xdb: {
- const len = view.getUint32(offset + 1, false);
- return [new TextDecoder().decode(buf.slice(offset + 5, offset + 5 + len)), offset + 5 + len];
- }
- case 0xdc: { const len = view.getUint16(offset + 1, false); return _decodeArray(buf, view, offset + 3, len); }
- case 0xdd: { const len = view.getUint32(offset + 1, false); return _decodeArray(buf, view, offset + 5, len); }
- case 0xde: { const len = view.getUint16(offset + 1, false); return _decodeMap(buf, view, offset + 3, len); }
- case 0xdf: { const len = view.getUint32(offset + 1, false); return _decodeMap(buf, view, offset + 5, len); }
- default: throw new Error(`Unknown msgpack type: 0x${byte.toString(16)}`);
- }
-}
-
-function _decodeArray(buf, view, offset, count) {
- const arr = [];
- for (let i = 0; i < count; i++) {
- const [val, newOff] = _decodeValue(buf, view, offset);
- arr.push(val);
- offset = newOff;
- }
- return [arr, offset];
-}
-
-function _decodeMap(buf, view, offset, count) {
- const obj = {};
- for (let i = 0; i < count; i++) {
- const [key, off1] = _decodeValue(buf, view, offset);
- const [val, off2] = _decodeValue(buf, view, off1);
- obj[key] = val;
- offset = off2;
- }
- return [obj, offset];
-}
-
-function _hex(bytes) {
- return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
-}
-
-/**
* Why a code from an invitation link must not go to this node, or null.
*
* `link_other_node` is the caller's cue to try the next node the hub listed,
@@ -4131,237 +2218,23 @@ function _extractDtlsFingerprint(sdp) {
return bytes;
}
-// ── Node identity pinning (11.5.8) ───────────────────────────────────────────
-
-const NODE_PIN_PREFIX = 'mb_nodepin_';
-
-/**
- * The node's half of the version range, from `handshake_challenge`.
- *
- * Mirrors meshbay_common/handshake.py::check_version(). A node that declares no
- * range at all is a node that predates negotiation — that is every 0.x node, and
- * none of them can serve a sealed index or a sealed ack — so it is refused here
- * rather than left to fail later as a message that will not open.
- */
-function _checkNodeVersion(reply) {
- const parse = (v) => {
- const m = /^(\d+)\.(\d+)$/.exec(String(v || ''));
- return m ? [Number(m[1]), Number(m[2])] : null;
- };
- const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]);
- const fail = (reason, message) => {
- const e = new Error(message);
- e.reason = reason;
- throw e;
- };
-
- const theirs = parse(reply.v);
- if (!theirs) {
- fail('node_version_unreadable',
- 'The node did not declare a readable protocol version.');
- }
- // No declared minimum means "only what I speak" — the correct reading of a
- // node from before this field existed.
- const theirMin = parse(reply.v_min) || theirs;
- if (cmp(theirs, parse(MNP_V_MIN)) < 0) {
- fail('node_too_old',
- 'This node is running an older MeshBay than this page needs. '
- + 'Its operator has to update it.');
- }
- if (cmp(theirMin, parse(MNP_V)) > 0) {
- fail('client_too_old',
- 'This page is older than the node it is talking to. '
- + 'Reload to pick up the current version.');
- }
-}
-
-function _checkNodePin(nodeId, nodePk) {
- if (!nodeId || !nodePk) return;
- const key = NODE_PIN_PREFIX + nodeId;
-
- let pinned = null;
- try { pinned = localStorage.getItem(key); } catch { return; }
-
- if (pinned === null) {
- try { localStorage.setItem(key, nodePk); } catch {}
- return;
- }
- if (pinned !== nodePk) {
- throw new Error(
- 'This node\'s identity key has changed. That is expected only if its ' +
- 'operator reinstalled the node — otherwise someone may be impersonating ' +
- 'it. Verify with the operator out of band, then clear the pin in ' +
- 'Settings to accept the new key.');
- }
-}
-
-/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */
-function clearNodePin(nodeId) {
- try {
- if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId);
- else {
- for (const k of Object.keys(localStorage))
- if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k);
- }
- } catch {}
-}
-
-function pinnedNodeCount() {
- try {
- return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length;
- } catch { return 0; }
-}
-
-// ── Passphrase change: re-wrap every reachable identity bundle ───────────────
-//
-// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this
-// account's per-node identity on every node it has joined. Changing the
-// passphrase changes that key, so each bundle must be read with the old key and
-// written back with the new one — on the node, while both keys are in hand.
+// ── Parts ───────────────────────────────────────────────────────────────────
//
-// The reachable set is the online nodes of the account's current groups. A node
-// that is offline, or belongs to a group left since, cannot be reached here and
-// is reported so the caller can tell the user to ask that group's operator to
-// unpin them and issue a fresh code (§3.4).
-
-function _acHubFetch(hubUrl, path, init) {
- const p = typeof window !== 'undefined' && window.MeshBayPlatform;
- const url = (hubUrl || '') + path;
- return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init);
-}
-
-async function _acHubGet(hubUrl, token, path) {
- const r = await _acHubFetch(hubUrl, path, {
- headers: { Authorization: `Bearer ${token}` },
- });
- if (!r.ok) throw new Error(`${path} → ${r.status}`);
- return r.json();
-}
-
-function _acWithTimeout(promise, ms, label) {
- let timer;
- return Promise.race([
- promise.finally(() => clearTimeout(timer)),
- new Promise((_, rej) => {
- timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms);
- }),
- ]);
-}
-
-/**
- * @param {object} o
- * @param {string} o.hubUrl same base the SPA uses for the hub
- * @param {string} o.token a fresh access token
- * @param {string} o.username
- * @param {string} o.userId
- * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
- * @param {string} o.newPassphrase
- * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
- * docs/MESHBAY_DESIGN.md §3.6).
- * When given, the recovery-wrapped copy is read where the
- * passphrase copy cannot be, and a fresh one is written back.
- * @param {(p:{done:number,total:number})=>void} [o.onProgress]
- * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>}
- */
-async function rewrapAllNodes(o) {
- const K = window.MeshBayKeys;
- if (!K || !K.deriveEncryptionKey) {
- throw new Error('key module unavailable');
- }
- let oldKey, newKey;
- if (o.bundleKey) {
- // "Keep the current passphrase key, just add / refresh the recovery copy"
- // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the
- // live {v2,v1} session key, so no passphrase is needed.
- oldKey = newKey = o.bundleKey;
- } else {
- // Flow B has no old passphrase; connect will fail the passphrase decrypt and
- // fall back to the recovery copy, so a placeholder key is fine for `oldKey`.
- const oldPass = o.oldPassphrase || o.newPassphrase;
- oldKey = {
- v2: await K.deriveEncryptionKey(oldPass, o.username),
- v1: await K.deriveEncryptionKeyV1(oldPass, o.username),
- };
- newKey = {
- v2: await K.deriveEncryptionKey(o.newPassphrase, o.username),
- v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username),
- };
- }
- const recoveryKey = o.recoveryKey
- ? await K.deriveRecoveryKey(o.recoveryKey, o.username)
- : null;
-
- const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine');
- const groups = mine.groups || (Array.isArray(mine) ? mine : []);
- const updated = [], unreachable = [], failed = [];
-
- for (const g of groups) {
- const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name;
- let nodes = [];
- try {
- const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`);
- nodes = nd.nodes || [];
- } catch (e) {
- failed.push({ groupId: g.id, name: label, reason: e.message });
- if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
- continue;
- }
- if (nodes.length === 0) {
- unreachable.push({ groupId: g.id, name: label, reason: 'node offline' });
- if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
- continue;
+// MeshBayTransport's methods are written across several classic scripts, loaded
+// after this one in the shell's order (api/webapp.py, and the desktop client's
+// scripts/index.html): each declares its share in a class of its own, verbatim,
+// and hands it here to be copied onto the prototype. A name defined twice is a
+// mistake in the split, not an override, and fails loudly at load.
+function extendTransport(part) {
+ for (const name of Object.getOwnPropertyNames(part.prototype)) {
+ if (name === 'constructor') continue;
+ if (Object.prototype.hasOwnProperty.call(MeshBayTransport.prototype, name)) {
+ throw new Error(`MeshBayTransport.${name} is defined twice`);
}
-
- let anyOk = false, lastErr = null;
- for (const n of nodes) {
- const tp = new MeshBayTransport(o.hubUrl, o.token);
- // Recover the *existing* identity or report this node — never mint a new
- // one just because the stored bundle would not open.
- tp._rewrapOnly = true;
- try {
- await _acWithTimeout(
- tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
- o.username, o.userId, null, recoveryKey),
- 30000, 'connect');
- if (tp.newNodeBundle) {
- // No identity existed on this node — connect just minted one under
- // the old key. Don't persist it: the next time this group is opened
- // the normal flow creates one under the current key, and storing it
- // here could also walk back a deliberate bundle withdrawal. Nothing
- // is stranded, so this node needs no fix.
- anyOk = true;
- continue;
- }
- const sk = tp.sessionKeys;
- if (!sk) { lastErr = new Error('identity not recovered'); continue; }
- const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0));
- const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0));
- const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2);
- // In Flow B, refresh the recovery copy too (same R) so the node's
- // passphrase copy and recovery copy stay in step.
- const reRecovery = recoveryKey
- ? await K.encryptBundleWithKey(skEd, skX, recoveryKey)
- : null;
- await tp.storeKeypairBundle(reEnc, reRecovery);
- anyOk = true;
- } catch (e) {
- lastErr = e;
- } finally {
- try { tp.close(); } catch { /* already gone */ }
- }
- }
-
- if (anyOk) updated.push({ groupId: g.id, name: label });
- else failed.push({ groupId: g.id, name: label,
- reason: (lastErr && lastErr.message) || 'unreachable' });
- if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ Object.defineProperty(MeshBayTransport.prototype, name,
+ Object.getOwnPropertyDescriptor(part.prototype, name));
}
-
- return { updated, unreachable, failed, newBundleKey: newKey };
}
// Export
-MeshBayTransport.clearNodePin = clearNodePin;
-MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
-MeshBayTransport.rewrapAllNodes = rewrapAllNodes;
window.MeshBayTransport = MeshBayTransport;