diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
| commit | 2c0903c648e24b4e2adf20492398e8b67d033b49 (patch) | |
| tree | 0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/src/meshbay_hub/static/transport.js | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| parent | eeda274d751c537f4ecef3087994a16a9517478f (diff) | |
| download | meshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz | |
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3.
The root model replaces the old `upload` flag and group-wide `member_upload`
with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that
both front doors — the loopback API and signed MNP — reach through the same
`ops` functions. MNP goes to 1.1, additively: the roots table now rides on
`index_delta`, so a root added, removed, ejected or plugged reaches every
connected client instead of only whoever reloaded.
The group UI becomes a plugin architecture: an application is a registry
entry in `apps.js` plus its own files, with directories stored generically
by `ops.set_app_directories` under whatever the app is called. A reference
application, hidden behind `?dev=1`, is what makes that claim testable —
adding it is what found the two places still naming apps by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 301 |
1 files changed, 268 insertions, 33 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index ea1e70a..179292e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -72,12 +72,43 @@ function _aborted() { // change anything — `op` is already on every admin_challenge, and this // list is what lets a response two steps later be tied back to the right // one. +// Acks the node *broadcasts* to everyone in the group, which the requester +// therefore also has to be handed. +// +// `_dispatch` resolves an admin ack against the pending request and returns, +// which is right for an op whose caller already knows the value it chose. It is +// wrong for these: every *other* connected client learns the change from the +// broadcast, and the one that asked for it is the only one that does not, +// because its own request swallowed its copy. Found twice — first on the root +// table, then on Chat's directory, where it meant the pane went on showing an +// unsaved-looking draft after a save that had worked. +const BROADCAST_ACK_TYPES = new Set([ + 'root_update_ack', 'root_eject_ack', 'root_plug_ack', + 'root_add_ack', 'root_remove_ack', + 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', +]); + +/** Hand a broadcast ack to the callback that would have had it from a peer. */ +function _replayBroadcast(transport, msg) { + if (msg.type === 'app_directories_ack' && transport._onAppDirectories) { + transport._onAppDirectories(msg.app, msg.directories || []); + } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) { + transport._onChatDirectory(msg.path || ''); + } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { + transport._onChatLinkPreview(Boolean(msg.enabled)); + } else if (transport._onRootsChanged) { + transport._onRootsChanged(msg); + } +} + const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_enabled', 'file_delete', 'dir_delete', - 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', - 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach', + 'apps_enabled', 'set_scan_settings', 'member_revoke', + 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', + 'app_directories', 'chat_directory', 'chat_link_preview', + 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); @@ -217,7 +248,7 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '1.0'; +const MNP_V = '1.1'; const MNP_V_MIN = '1.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's @@ -325,7 +356,30 @@ class MeshBayTransport { set onIndexSync(fn) { this._onIndexSync = fn; } set onIndexDelta(fn) { this._onIndexDelta = fn; } set onUploadPolicy(fn) { this._onUploadPolicy = fn; } + set onRootsChanged(fn) { this._onRootsChanged = fn; } + + /** The MNP version the connected node declared, or '' before a handshake. */ + get nodeVersion() { return this._nodeVersion || ''; } + + /** + * Whether the node speaks the per-root and per-app operations MNP 1.1 added: + * `root_update`/`root_eject`/`root_plug`, `app_directories`, + * `chat_directory`, `chat_link_preview`. + * + * An older node has no equivalent for the root ones at all, and answers the + * app ones through their three predecessors (`video_root`, `audio_root`, + * `photo_roots`). The caller chooses which; what it must not do is send a + * 1.1 message and wait, because an unknown type is logged and dropped. + */ + get supportsAppOps() { + const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || ''); + if (!m) return false; + return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1); + } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } + set onAppDirectories(fn) { this._onAppDirectories = fn; } + set onChatDirectory(fn) { this._onChatDirectory = fn; } + set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } @@ -587,6 +641,12 @@ class MeshBayTransport { // block, because everything below — the join, the proof, the sealed ack // — assumes both sides mean the same thing by each message. _checkNodeVersion(reply); + // Kept, not just checked. Several controls exist only on a node new + // enough to have them, and the alternative to asking is offering a + // button whose message an older node logs as unknown and never answers + // — a 30-second wait ending in a timeout, with nothing on screen to say + // the node simply cannot do this. + this._nodeVersion = String(reply.v || ''); if (!window.MeshBayCrypto) { throw new Error('Node requires GEK proof but no crypto available'); } @@ -1070,7 +1130,7 @@ class MeshBayTransport { * 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/setMemberUpload — an unsigned change would let any + * setAppsEnabled/updateRoot — an unsigned change would let any * member alter outbound third-party network traffic the operator never * agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears * a previously-set custom token; omit it (undefined/null), like @@ -1169,6 +1229,96 @@ class MeshBayTransport { } /** + * Point an application at folder(s) inside the group's shared directories. + * + * One method for every app, keyed by the app's registry name — the same + * generic op the node grew for the same reason (docs/refactor-groups.md + * §1.6). `setVideoRoot`, `setAudioRoot` and `setPhotoRoots` are still here + * and still work; nothing new should call them. + * + * The subject names the app as well as the paths, because an operator shown + * "Media/Films" alone cannot tell which application is about to be pointed + * at it, and two apps' challenges would otherwise be indistinguishable. + * Cleaned and sorted the same way the node does, so both sides build the + * same bytes to sign. + */ + /** + * The same instruction a node too old for `app_directories` understands. + * + * Videos, Music and Photos each had their own message before this, and they + * still work — so an operator on an un-upgraded node keeps the ability they + * had, rather than being handed a control that silently times out. Chat has + * no predecessor, which is why its settings are hidden rather than routed. + */ + async setAppDirectoriesLegacy(appKey, directories, signFn) { + const clean = [...new Set( + (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), + )].sort(); + if (appKey === 'photo') return this.setPhotoRoots(clean, signFn); + // One folder was all these two could carry. Sending several would store + // the first and silently drop the rest, so it is refused instead. + if (clean.length > 1) { + throw new Error( + 'This node is older than this page and can hold one folder per app. ' + + 'Update it, or choose a single folder.'); + } + const one = clean[0] || ''; + if (appKey === 'video') return this.setVideoRoot(one, signFn); + if (appKey === 'music') return this.setAudioRoot(one, signFn); + throw new Error( + 'This node is older than this page and cannot store this app\'s ' + + 'folders. Its operator has to update it.'); + } + + 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; + } + + /** * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3) * — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album @@ -1365,24 +1515,6 @@ class MeshBayTransport { * on the hub is the other half, and neither implies the other. */ /** - * Turn uploading by ordinary members on or off. - * - * Signed by the operator like any other privileged operation — the node - * refuses an unsigned one, which is what stops a member turning it back on. - */ - async setMemberUpload(allowed, signFn) { - const msg = await this._sendAndWait({ - type: 'member_upload', v: '0.1', allowed: Boolean(allowed), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'member_upload', allowed ? 'on' : 'off', signFn); - } - return msg; - } - - /** * 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 @@ -1391,13 +1523,25 @@ class MeshBayTransport { * `_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, + 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', [...apps].sort().join(','), signFn); + msg, 'apps_enabled', [...full].sort().join(','), signFn); } return msg; } @@ -1452,11 +1596,12 @@ class MeshBayTransport { return msg; } - async addRoot(groupId, path, { name, kind, upload } = {}, signFn) { + async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) { const msg = await this._sendAndWait({ - type: 'root_add', v: '0.1', + type: 'root_add', v: '1.1', group_id: groupId, path, - name: name || '', kind: kind || 'generic', upload: !!upload, + name: name || '', kind: kind || 'generic', + writable: !!writable, removable: !!removable, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { @@ -1477,6 +1622,48 @@ class MeshBayTransport { 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, @@ -1612,8 +1799,19 @@ class MeshBayTransport { * 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 } = {}) { + async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) { // The same file twice at once would confuse the node, which keys its own // upload state by name — and would race for the same destination. if (this._uploaders.has(file.name)) { @@ -1664,6 +1862,8 @@ class MeshBayTransport { chunk_index: i, total_chunks: total, data: buf, + ...(root ? { root } : {}), + ...(dir ? { dir } : {}), }); } while (acked < total) { @@ -2161,7 +2361,19 @@ class MeshBayTransport { } else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) { const key = `admin:${msg.type.slice(0, -4)}`; for (const [, handler] of this._pending) { - if (handler._key === key) { handler.resolve(msg); return; } + if (handler._key === key) { + handler.resolve(msg); + // The comment above ("its own caller already updates local state + // from what it sent") is true of every op whose caller passes the + // value it just chose to an onX(next). The root ops are not like + // that: what changes is the whole roots table, which only the node + // can compute — availability, the eject that the plug refused, the + // name it settled on. Returning here left the operator who clicked + // Eject as the one client that never saw it happen, while every + // other peer got the broadcast. So this one type is handed on. + if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg); + return; + } } } @@ -2208,10 +2420,12 @@ class MeshBayTransport { return; } - // The operator changed who may upload. Unsolicited: it arrives at everyone - // connected, not only at whoever asked. It still has to reach a pending - // caller — the operator's own request resolves on this reply — so it falls - // through to the matching below rather than returning here. + // Legacy. An MNP 1.0 node still broadcasts this when its operator changes + // the group-wide upload switch, and its roots carry no `writable` for us + // to read instead — so this is the only answer available from such a node + // and it is still honoured. Nothing here *sends* the message any more: + // per-root RO/RW replaced it, and a current node answers it with a + // deprecation notice and no action. if (msg.type === 'member_upload_ack' && this._onUploadPolicy) { this._onUploadPolicy(Boolean(msg.allowed)); } @@ -2222,6 +2436,18 @@ class MeshBayTransport { this._onAppsEnabled(msg.apps || []); } + // An application was pointed at different folders. One handler for every + // app — the callback is given the app's name and decides. + if (msg.type === 'app_directories_ack' && this._onAppDirectories) { + this._onAppDirectories(msg.app, msg.directories || []); + } + if (msg.type === 'chat_directory_ack' && this._onChatDirectory) { + this._onChatDirectory(msg.path || ''); + } + if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { + this._onChatLinkPreview(Boolean(msg.enabled)); + } + // Node-wide (not per-group) — the operator supplied/cleared a custom // token, or changed the query language. `token_customized` only says // whether one is set, never the token itself. @@ -2277,6 +2503,15 @@ class MeshBayTransport { this._onMusicbrainzEnabled(Boolean(msg.enabled)); } + // A root's flags changed, or one was ejected, plugged, added or removed. + // Broadcast by the node to every peer, so everyone's table updates without + // waiting for the next index_sync. + if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack' + || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack' + || msg.type === 'root_remove_ack') { + if (this._onRootsChanged) this._onRootsChanged(msg); + } + // The operator's node is scanning — never the entries themselves, just // enough to animate a presence dot. Pushed periodically while it runs, // plus once more on the transition back to idle (daemon.py |