diff options
Diffstat (limited to 'packages/meshbay-hub')
49 files changed, 402 insertions, 199 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 67e65f2..83b60f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import issue_access_token @@ -89,6 +89,22 @@ class NodeAnnounceRequest(BaseModel): signature: str | None = None # base64 Ed25519 over the announce message +# How many distinct node keys one account may announce. +# +# M8 closed the half of this that was about *whose* key it is: the announcer now +# proves possession. What it did not close is *how many*. Each new key is a row +# in `nodes` plus a row in the IP log, and the IP log is kept for a year — so an +# account in a loop writes a year of storage on somebody else's disk, having paid +# only for the signatures. +# +# Ten is past what the feature is for. A node is a machine left running: a +# desktop, a laptop, a box in a cupboard, a second home. Someone who genuinely +# reaches it deletes one, which is a thing the operator surface already does — +# and an account that wants an eleventh *identity* rather than an eleventh +# machine is the case this refuses. +MAX_NODES_PER_ACCOUNT = 10 + + @router.post("/announce", status_code=201) async def announce_node( body: NodeAnnounceRequest, @@ -146,6 +162,21 @@ async def announce_node( await db.commit() return {"node_id": node.id} + # Counted only where a row is actually added: re-announcing a key this + # account already holds takes the branch above and must keep working at the + # ceiling, or a node that has reached it can never refresh its address again. + held = (await db.execute( + select(func.count()).select_from(Node) + .where(Node.user_id == current_user.id))).scalar() or 0 + if held >= MAX_NODES_PER_ACCOUNT: + db.add(IPLog(user_id=current_user.id, event="node_announce_refused", + ip_address=seen_from, detail=f"{held} nodes")) + await db.commit() + raise HTTPException( + status_code=409, + detail=f"This account already has {held} nodes, which is the limit of " + f"{MAX_NODES_PER_ACCOUNT}. Remove one you no longer run.") + node = Node( user_id=current_user.id, pk_node=body.pk_node, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 7b2fc8c..0394f53 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -107,7 +107,7 @@ class RegisterRequest(BaseModel): email: str password: str | None = None # deprecated — legacy native clients auth_key: str | None = None # PBKDF2-derived, new clients - # Client-generated account recovery key (docs/auth-confirm.md §4.4). Pure + # Client-generated account recovery key (docs/MESHBAY_DESIGN.md §3.6). Pure # pass-through: appended to the verification e-mail so the user's mailbox # backs it up, then dropped. Never written to any table, never logged. recovery_key: str | None = None @@ -901,7 +901,7 @@ async def verify_email_change( # ── Passphrase change (Flow A) ────────────────────────────────────────────── # -# docs/auth-confirm.md §3. The passphrase derives two independent values on the +# docs/MESHBAY_DESIGN.md §3.6. The passphrase derives two independent values on the # client: auth_key (verified here) and bundle_key (AES-GCM key for the per-node # identity bundles, which live on nodes and never on the hub). The client # re-wraps those bundles from the old bundle_key to the new one on every @@ -1023,7 +1023,7 @@ async def change_password( # ── Passphrase reset (Flow B) ────────────────────────────────────────────── # -# docs/auth-confirm.md §4.2. An e-mail code re-opens hub login for someone who +# docs/MESHBAY_DESIGN.md §3.6. An e-mail code re-opens hub login for someone who # has lost their passphrase. It recovers no group content — that needs the # recovery key, which the client applies on its own after the reset. # reset-request never reveals whether an account exists. diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index 876583e..f2212de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -75,7 +75,7 @@ class CaptchaConfig: # Set this when that check is turned off in the reCAPTCHA console, which is # what the desktop client needs: its page is served from `app://meshbay`, # so the hostname Google sees is not the hub's and never can be. See - # docs/captcha.md §6. + # docs/MESHBAY_DESIGN.md §7.7. allowed_hosts: list[str] = field(default_factory=list) # A solve from a page Google cannot attribute to a domain reports an empty # hostname — `app://meshbay` does, measured live, and so does any other diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index f1fff41..62e4a11 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -201,7 +201,7 @@ class UserDevice(Base): directory *others* read from, where a substituted key was handed the GEK by an honest member. * **It is not a node identity key.** Those are generated per node, pinned - there, and never leave that relationship (`docs/per-node-identity-v1.md`). + there, and never leave that relationship (`docs/MESHBAY_DESIGN.md` §3.2). A device holds one of these *plus* a different key per node, so nothing here correlates a person across operators. diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index aa6b74e..126ed45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -315,7 +315,7 @@ def send_verification_code(to: str, code: str, recovery_key: str | None = None) """ Registration verification e-mail. When `recovery_key` is given it is appended to the body so the recipient's mailbox becomes the backup for it - (docs/auth-confirm.md §4.4). + (docs/MESHBAY_DESIGN.md §3.6). `recovery_key` is a **pass-through**: it is generated on the client, never stored anywhere on the hub, and never logged — only whether one was present. @@ -374,7 +374,7 @@ def send_email_change_code(to: str, code: str) -> None: def send_password_reset_code(to: str, code: str) -> None: """ - Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub + Passphrase-reset code (docs/MESHBAY_DESIGN.md §3.6). This only re-opens hub login; it recovers no group content — that needs the recovery key. """ msg = EmailMessage() diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 92dcb28..fbfb942 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -10,7 +10,7 @@ import * as downloads from './downloads.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; import { - HUB, navigate, session, getCachedGroupIndex, + HUB, navigate, session, purgeGroupIndexCache, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, refreshAccessToken, logoutOnHub, @@ -1252,7 +1252,23 @@ const trayLabels = () => ({ // Catalogues are fetched, so the first render waits for one: mounting earlier // would paint the interface in English and then swap every string. initLocale() // falls back to English rather than rejecting, so this cannot strand the page. +// One sweep, once per browser, to remove what the cross-group search of 2026-08 +// left behind: a cleartext copy of every group's file listing that nothing has +// read since, and that no sign-out removed. Guarded by a flag so it costs one +// transaction ever rather than one per load; a browser that refuses storage +// simply does it again, which is harmless. +const PURGED_KEY = 'meshbay.indexcache.purged'; +const purgeOnce = () => { + try { + if (localStorage.getItem(PURGED_KEY)) return; + } catch { /* no storage: purge anyway, it is idempotent */ } + purgeGroupIndexCache().then(() => { + try { localStorage.setItem(PURGED_KEY, '1'); } catch { /* nothing to remember with */ } + }); +}; + const mount = () => { + purgeOnce(); render(html`<${App} />`, document.getElementById('app')); // Get the download worker registered and this page under its control now, // rather than inside the first click on Download. On Firefox and Safari it is diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js index 461bd57..32f2314 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -70,7 +70,7 @@ const APPS = [ Component: MusicApp, Settings: MusicSettings }, { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp, Settings: PhotoSettings }, - // The reference implementation (docs/refactor-groups.md §4.1). `dev` keeps + // The reference implementation (docs/MESHBAY_DESIGN.md §9.4). `dev` keeps // it out of an operator's way; everything else about it is an ordinary // entry, which is the point. { key: 'helloworld', icon: 'chat', labelKey: 'group.tab_helloworld', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index 04a00af..8958d44 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -301,7 +301,7 @@ export function RegisterPage() { setLoading(true); try { if (window.MeshBayKeys) { - // The account recovery key (docs/auth-confirm.md §4.3/§4.4): generated + // The account recovery key (docs/MESHBAY_DESIGN.md §3.6): generated // here, shown once on the next screen. When the user leaves "e-mail it" // checked, the mnemonic goes in the register body so the hub appends it // to the verification e-mail (and stores it nowhere); otherwise it is @@ -511,7 +511,7 @@ export function RegisterPage() { } -// ── Passphrase reset — Flow B (docs/auth-confirm.md §4) ───────────────────── +// ── Passphrase reset — Flow B (docs/MESHBAY_DESIGN.md §3.6) ───────────────── // // An e-mail code restores hub login. A recovery key, if the user still has one, // restores the per-node identities in the same step: the fan-out reads each diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index b732734..decf916 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -507,11 +507,11 @@ async function downloadEntry(transfers, transport, gek, entry) { * only alternative is to build the whole thing in memory — so that path is * offered but says what it costs first. * - * Lifted out of files-app.js (docs/photos.md §3) so photos-app.js's own + * Lifted out of files-app.js (docs/MESHBAY_DESIGN.md §9.9) so photos-app.js's own * "zip this album" button calls the same implementation rather than a * second one — nothing here is Files-specific once `entries`/`transport`/ * `gek`/`setError` are passed in, the same shared-context shape every app - * already receives (apps.md §2). + * already receives (docs/MESHBAY_DESIGN.md §9.2). */ async function downloadDirectory(transfers, transport, gek, entries, dir, { setError }) { if (!transport || !transport.connected) return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 7629459..3f4212e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -314,7 +314,7 @@ function FilesPanel({ } }, [currentPath]); - // The implementation lives in file-utils.js (docs/photos.md §3) so + // The implementation lives in file-utils.js (docs/MESHBAY_DESIGN.md §9.9) so // photos-app.js's own "zip this album" button can call the same code // rather than a second one. const downloadDirectory = useCallback(async (dir) => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js index f109da7..f0ace6a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js @@ -8,7 +8,7 @@ import { sourceLabel } from './source-merge.js'; * Group names are unique only per owner account (the hub enforces that), so the * `@handle` is what tells two groups called "photos" apart. `owner` is the * owner's username for a local group, or the source hub for a federated one - * (decision 5 in ~/next/groupnames.md); the caller decides which. + * (docs/MESHBAY_DESIGN.md §7.3); the caller decides which. * * `inline` renders "name@owner" on one line, for places that cannot take a * block — a badge, a `confirm()` string built elsewhere. @@ -29,7 +29,7 @@ export function GroupName({ name, owner, inline = false }) { * The Search view merges a file several groups share into one entry, so the * badge under a card cannot always name a group. One source keeps naming it * and keeps linking to it; more than one becomes a count, and *which* one was - * picked is deliberately not shown (docs/refactoring-search.md §5.5). + * picked is deliberately not shown (docs/MESHBAY_DESIGN.md §9.11). * * `entries` is the whole unit — every episode of a show, every track of an * album — not the entry the card was drawn from; `sourceLabel` explains why. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 205fce2..26519da 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -6,7 +6,7 @@ import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; import { - HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, + HUB, session, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; import { APPS, visibleApps } from './apps.js'; @@ -154,10 +154,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // that). Null until the handshake ack arrives. const [scanSettings, setScanSettings] = useState(null); // TMDB on/off + whether a custom token is set, node-wide (not per-group) — - // docs/mediacenter.md §5.5. Null until the handshake ack arrives. + // docs/MESHBAY_DESIGN.md §9.7. Null until the handshake ack arrives. const [tmdbConfig, setTmdbConfig] = useState(null); // Which folders each app works over. One shape for all of them — a list, - // always, even where an app only wants one (docs/refactor-groups.md §1.6): + // always, even where an app only wants one (docs/MESHBAY_DESIGN.md §9.3): // Videos and Music were single values, which meant a library spread over two // drives could not be described at all. Empty means nothing configured yet, // which every app reads as "show nothing", never "the whole group index". @@ -171,7 +171,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // Whether members' cross-group Search lists this group. Not an app setting: // it is about the group as a whole, and it hides nothing from this page. const [searchListed, setSearchListed] = useState(true); - // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. + // MusicBrainz on/off (per-group) — docs/MESHBAY_DESIGN.md §9.8. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); // `op` rides through to the shell — 'replace', 'next' or 'append' // (docs/playlists.md §9.2). It has to be named here: a wrapper that takes @@ -265,10 +265,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setEntries(fresh); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); - cacheGroupIndex(groupId, group ? group.name : groupId, - group ? group.owner_username : null, fresh, - { video: appDirs('video'), music: appDirs('music'), - photo: appDirs('photo') }); }, [groupId, group, appDirs]); // additions/deletions/updates (daemon.py _broadcast_index_change, once @@ -296,10 +292,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const keptIds = new Set(updated.map((e) => e.id)); const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id)); const fresh = updated.concat(additions); - cacheGroupIndex(groupId, group ? group.name : groupId, - group ? group.owner_username : null, fresh, - { video: appDirs('video'), music: appDirs('music'), - photo: appDirs('photo') }); return fresh; }); }, [groupId, group, appDirs]); @@ -320,7 +312,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setDeviceReady(false); gekRef.current = null; if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); - // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later + // Persisted (docs/MESHBAY_DESIGN.md §3.6) so a group joined in a later // session still leaves a recovery-wrapped identity copy on its node. if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey(); if (!session.bundleKey && window.MeshBayKeys) { @@ -669,7 +661,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // the only unambiguous answer once a group can have several writable roots. // Chat has no folder to browse, so it needs one picked for it, and this is // the same rule the node applies when a client names no root at all. It - // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7). + // becomes an operator-chosen directory in phase 2 (docs/MESHBAY_DESIGN.md §9.6). // const writableRoots = useMemo( () => nodeRoots.filter((r) => r.writable && r.available !== false), @@ -735,7 +727,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // rather than written out. Naming them here would mean adding an app // required editing this file, which is the one thing the plugin // architecture is supposed to have removed — and the reference app - // (docs/refactor-groups.md §4.1) is what made the difference visible. + // (docs/MESHBAY_DESIGN.md §9.4) is what made the difference visible. const perAppDirectories = useMemo(() => { const out = {}; for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js index 9058199..be26fbc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js @@ -11,9 +11,10 @@ import { FolderPickerField } from './folder-tree.js'; * app's registry key by the page, and `ops.set_app_directories` stores the row * under that name without knowing what it is. * - * It takes the shared props and no others (`docs/apps.md` §3b), which is what - * `test_app_settings_plugin.py` checks of every pane — including this one, so - * the reference implementation is held to the contract it demonstrates. + * It takes the shared props and no others (`docs/MESHBAY_DESIGN.md` §9.2), + * which is what `test_app_settings_plugin.py` checks of every pane — including + * this one, so the reference implementation is held to the contract it + * demonstrates. */ function HelloWorldSettings({ roots, dirs, settings, saveDirectories }) { const { busy, msg, run } = useSaver(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js index 01b4ed4..98ca8fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js @@ -7,7 +7,7 @@ import { formatSize } from './file-utils.js'; * The smallest application this platform can host, and the proof that adding * one costs nothing outside its own two files. * - * Everything else in `docs/refactor-groups.md` §3 is asserted by tests that + * Everything else in `docs/MESHBAY_DESIGN.md` §9.4 is asserted by tests that * read source. This is the other kind of evidence: an app nobody wrote a line * of plumbing for, that stores directories, appears as a tab, and lists files — * because the registry entry beside it is genuinely all there is. @@ -18,7 +18,7 @@ import { formatSize } from './file-utils.js'; * put a toy app in everybody's group; deleting the app would leave the claim * resting entirely on tests that read text. * - * It takes the standard props — see `docs/apps.md` §2 — and reads + * It takes the standard props — see `docs/MESHBAY_DESIGN.md` §9.2 — and reads * `helloworldDirectories`, which nothing on the node knows about by name: * `ops.set_app_directories` keys the row by whatever the app is called. */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index 81185a5..ba91f00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -81,42 +81,26 @@ function openDB() { }); } -async function cacheGroupIndex(groupId, groupName, groupOwner, entries, roots) { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readwrite'); - tx.objectStore(IDB_STORE).put({ - groupId, groupName, groupOwner, entries, roots: roots || {}, - cachedAt: Date.now(), - }); - await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); - db.close(); - } catch { /* best-effort */ } -} - -async function getCachedGroupIndex(groupId) { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readonly'); - const req = tx.objectStore(IDB_STORE).get(groupId); - const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); - db.close(); - return result || null; - } catch { return null; } -} - -async function getAllCachedIndexes() { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readonly'); - const req = tx.objectStore(IDB_STORE).getAll(); - const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); - db.close(); - return result || []; - } catch { return []; } -} - -async function clearAllCachedIndexes() { +// `group_indexes` held a decrypted copy of every group's index — each file's +// name, path, size, hash and uploader — written on every index and every delta, +// and read by the cross-group search of the time, which searched those records +// instead of dialling anything. +// +// Search has dialled the nodes since 2026-08-28. The reader went with that +// change and the writers stayed, so for weeks the browser kept building a +// cleartext file listing that nothing consulted and no sign-out removed: the key +// database is a different one. It is **L7** — code nothing calls does not sit +// still, it accumulates. +// +// Showing a group's files while its node is unreachable was the only use left +// for such a cache, and it is not wanted: a listing you cannot open is worse +// than an honest absence. +// +// The store itself is left in the schema. Dropping it means a version bump, and +// a version bump means an upgrade another tab can block — which would take +// playlists down with it, since they share this database. Emptying it costs +// nothing and leaves nothing behind. +async function purgeGroupIndexCache() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); @@ -136,7 +120,7 @@ async function clearAllCachedIndexes() { // so importing modules can update either field without this module handing // out a rebindable export. // -// `recoveryKey` (docs/auth-confirm.md §4.3) is the AES key that wraps the +// `recoveryKey` (docs/MESHBAY_DESIGN.md §3.6) is the AES key that wraps the // *recovery* copy of an identity bundle. In-memory only, and set only when the // user has just generated or entered the recovery secret (registration, or the // Flow B screen) — it cannot be re-derived from the passphrase. @@ -169,10 +153,11 @@ async function _loadKey(slot) { return val || null; } catch { return null; } } -// 'bk' = passphrase-derived bundle key; 'rk' = recovery key (docs/auth-confirm.md -// §4.3). Persisting 'rk' is what lets a group joined in a *later* session still -// get a recovery-wrapped identity copy, instead of only groups joined in the -// unbroken session that generated it. Cleared with everything else on sign-out. +// 'bk' = passphrase-derived bundle key; 'rk' = recovery key +// (docs/MESHBAY_DESIGN.md §3.6). Persisting 'rk' is what lets a group joined +// in a *later* session still get a recovery-wrapped identity copy, instead of +// only groups joined in the unbroken session that generated it. Cleared with +// everything else on sign-out. const _storeBundleKey = (key) => _storeKey('bk', key); const _loadBundleKey = () => _loadKey('bk'); const _storeRecoveryKey = (key) => _storeKey('rk', key); @@ -377,7 +362,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { export { HUB, navigate, session, openDB, IDB_PLAYLISTS, - cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, + purgeGroupIndexCache, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index edfa109..879f56f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -191,7 +191,7 @@ async function deriveBundleKeys(password, username) { // ── Account recovery key ───────────────────────────────────────────────────── // -// docs/auth-confirm.md §4.3. A full-entropy secret the user keeps outside the +// docs/MESHBAY_DESIGN.md §3.6. A full-entropy secret the user keeps outside the // passphrase — in their password manager, or (step 3) e-mailed to them. It // wraps a *second* copy of every per-node identity bundle, so a forgotten // passphrase does not strand the account's group identities. @@ -323,7 +323,7 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha const payload = { username, email, auth_key: authKey }; // The recovery mnemonic, when the user opted to have it e-mailed: the hub // appends it to the verification e-mail and stores it nowhere - // (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves. + // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; // reCAPTCHA response, when the hub has a captcha configured. The widget lives // in RegisterPage (auth-page.js); this function just forwards its token. A @@ -347,7 +347,7 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha * bundle goes to that node and nowhere else, and is what any other browser * fetches to become the same person there. When `recoveryKey` is supplied a * second copy wrapped under it rides along, so a forgotten passphrase does not - * strand this identity (docs/auth-confirm.md §4.3). + * strand this identity (docs/MESHBAY_DESIGN.md §3.6). */ async function generateNodeIdentity(bundleKey, recoveryKey) { const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); @@ -465,7 +465,7 @@ async function _bundleKeyPairFields(password, username) { window.MeshBayKeys = { registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes, deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion, - // Exposed for the passphrase change (docs/auth-confirm.md §3): re-wrapping a + // Exposed for the passphrase change (docs/MESHBAY_DESIGN.md §3.6): re-wrapping a // node's identity bundle needs the old key (a {v2,v1} pair, since an old // bundle may be v1) to read it and the new v2 key to write it back. deriveEncryptionKey, deriveEncryptionKeyV1, @@ -473,6 +473,6 @@ window.MeshBayKeys = { // `session.bundleKey` uses this, so `v2hkdf` is never the field one sign-in // path forgot (docs/playlists.md §3.4). deriveBundleKeys, bundleKeyPairFields: _bundleKeyPairFields, - // Account recovery key (docs/auth-confirm.md §4.3). + // Account recovery key (docs/MESHBAY_DESIGN.md §3.6). generateRecoveryKey, deriveRecoveryKey, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js index 8ce2ab6..a9a3c95 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js @@ -8,7 +8,7 @@ import { FolderPickerField } from './folder-tree.js'; * * Same shape as Videos, minus a credential: MusicBrainz's read endpoints need * no API key, only a descriptive User-Agent, and that is one operator identity - * held node-wide rather than a per-group setting (docs/musicbay.md §3.2). + * held node-wide rather than a per-group setting (docs/MESHBAY_DESIGN.md §9.8). * * Several folders, for the same reason Videos has several: a music library * that lives on two drives had no way to say so. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index baa3520..e0308cb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -15,13 +15,13 @@ import * as P from './playlists.js'; // // An album-grid (MusicBrainz-enriched, when a track has no usable embedded // cover) or flat (tag/filename-only) browser for a group's audio files, per -// docs/musicbay.md. Grouping is by `artist`/`album` -- already resolved at -// index time from embedded tags, falling back to filename/folder parsing -// (indexer/enrich_audio.py) -- never guessed here. +// docs/MESHBAY_DESIGN.md §9.8. Grouping is by `artist`/`album` -- already +// resolved at index time from embedded tags, falling back to filename/folder +// parsing (indexer/enrich_audio.py) -- never guessed here. // // Unlike Videos, MusicBrainz is looked up only when a track has no embedded -// cover art at all (musicbay.md section 2.1's tiered trust: tags first, -// filename parsing second, MusicBrainz last) -- most of a real, well-ripped +// cover art at all (docs/MESHBAY_DESIGN.md §9.8's order of trust: tags +// first, filename parsing second, MusicBrainz last) -- most of a real, well-ripped // library already carries good artist/album text and often its own cover, so // this avoids a network round trip most tiles never need. Playback never // touches this file: clicking a track calls the `onPlayQueue` prop the shell @@ -52,7 +52,7 @@ function foldKey(s) { } // Same shape as video-app.js's underVideoRoot: an unset root means "show -// nothing" (docs/musicbay.md's amended §2.1 — the node itself runs no +// nothing" (docs/MESHBAY_DESIGN.md §9.8 — the node itself runs no // tag/cover enrichment for this group before a root is chosen either, // daemon.py's _enrich_new_audio_entries), not "the whole shared tree" — // falling back to that would just show files nothing has enriched. @@ -212,17 +212,16 @@ function useMusicMeta(transportRef, fileId, active) { } // A drawn CD standing in for a cover nothing supplied one for -- most tiles -// in a real, older/well-ripped library land here (musicbay.md's own -// measurement: ~11% embedded art, ~26% once sibling image files are counted -// too), so this is the *default* look of the grid, not a rare fallback, and -// needed to read as a deliberate piece of art rather than a broken image. -// A flat single-color icon (the first version of this) looked exactly like -// "missing", not "no cover" -- an actual disc, with the iridescent sheen a -// real CD's data side has, reads as intentional at a glance. Genuinely -// unique gradient ids: a `<radialGradient>` id is a plain DOM id, and a grid -// full of these renders many instances at once -- reusing one literal id -// would leave every disc after the first pointing at whichever def the -// browser resolves first. +// in a real, older/well-ripped library land here (measured: ~11% embedded +// art, ~26% once sibling image files are counted too), so this is the +// *default* look of the grid, not a rare fallback, and needed to read as a +// deliberate piece of art rather than a broken image. A flat single-color icon +// (the first version of this) looked exactly like "missing", not "no cover" -- +// an actual disc, with the iridescent sheen a real CD's data side has, reads +// as intentional at a glance. Genuinely unique gradient ids: a +// `<radialGradient>` id is a plain DOM id, and a grid full of these renders +// many instances at once -- reusing one literal id would leave every disc +// after the first pointing at whichever def the browser resolves first. let _discIdSeq = 0; function DiscPlaceholder({ cls }) { @@ -517,8 +516,8 @@ function FlatArtistFolder({ artist, onPlayQueue, onMenu }) { ${open && html` <div class="music-flat-children"> ${/* A real artist folder with no album layer at all is common here - -- a pile of loose singles, not one release (musicbay.md - section 2.1's "flat per-artist folder" case). Nesting them + -- a pile of loose singles, not one release (a flat + per-artist folder, docs/MESHBAY_DESIGN.md §9.8). Nesting them one more level behind their own always-empty "Unknown album" row was exactly the friction reported live: an extra, pointless expand before reaching a track that's playable @@ -748,7 +747,7 @@ function MusicApp({ } // foldKey rides along for the Search page's merge unit keys -// (docs/refactoring-search.md §5.2). An album's *display* strings are the +// (docs/MESHBAY_DESIGN.md §9.11). An album's *display* strings are the // first-seen spelling, and which group is seen first is the order its index // happened to arrive in — so keying a unit on them would let the chosen source // change between page loads. The folded key is the one grouping actually used, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index c3a2868..5f2e33c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -8,7 +8,7 @@ import { useStickyBand } from './sticky.js'; import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; /** - * The Music app's persistent player bar (docs/musicbay.md §2.3, §7.2). + * The Music app's persistent player bar (docs/MESHBAY_DESIGN.md §9.8). * * Owned and rendered by app.js — the router's parent — so playback survives * navigating between groups, search, and other pages. Both group-page.js @@ -23,11 +23,12 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; * track is a few megabytes, so it is downloaded and decrypted once through * the same chunk pipeline Files already uses (file-utils.js's * pipelinedDownload), then played from a blob URL — the deliberate - * simplification recorded in musicbay.md §2.2. WMA and Musepack are the one - * exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a browser's - * <audio> element at all, tagged correctly or not, so those two go through - * `transport.requestAudioTranscode` first — a node-side, cached-after-once - * AAC/M4A conversion — before the same download/blob path runs. + * simplification recorded in docs/MESHBAY_DESIGN.md §9.8. WMA and Musepack are + * the one exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a + * browser's <audio> element at all, tagged correctly or not, so those two go + * through `transport.requestAudioTranscode` first — a node-side, + * cached-after-once AAC/M4A conversion — before the same download/blob path + * runs. */ const MIME_BY_EXT = { @@ -35,9 +36,12 @@ const MIME_BY_EXT = { wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4', }; -// Kept in sync with the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS -// (webrtc_server.py) — both name the same two formats no mainstream -// browser's <audio> element decodes natively. +// The same two formats as the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS +// (webrtc_server.py), which is the one that decides: the node refuses a +// transcode request for anything else. This is here so the player does not +// ask for one it knows will be refused — not because it enforces the rule. +// It used to be the only thing that did, and a member's own message never +// passed through it. const NEEDS_TRANSCODE_RE = /\.(wma|mpc)$/i; function guessMime(name) { @@ -271,7 +275,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // Screen Wake Lock, opt-in only (Settings → music_keep_screen_on) and only // while a track is actually playing — off by default because the ordinary // expectation, matching Spotify/Deezer, is that the phone locks on its own - // idle timer while listening (docs/musicbay.md §2.2). Unlike the video + // idle timer while listening (docs/MESHBAY_DESIGN.md §9.8). Unlike the video // player's unconditional lock, this must not fight that default for // everyone who never asked for it; it exists for whoever explicitly wants // to trade battery for riding out the WebRTC screen-lock reconnect gap @@ -368,7 +372,8 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue }, [getConnection, evictOldBlobs]); // Silently warms the cache for the next tracks so pressing "next" doesn't - // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error. + // visibly wait (docs/MESHBAY_DESIGN.md §9.8) — best-effort, never surfaces + // an error. // // More than one: a screen lock can cost the transport several minutes (see // the WebRTC auto-reconnect in transport.js — this is the other half of diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js index 8656fa5..0407e67 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js @@ -6,7 +6,7 @@ import { FolderPickerField } from './folder-tree.js'; /** * The Photos app's operator settings: folders, and nothing else. * - * Photos was always several folders (docs/photos.md §2.1) — a photo library + * Photos was always several folders (docs/MESHBAY_DESIGN.md §9.9) — a photo library * is routinely scattered, with no single natural root — so this pane is what * the other two grew into rather than the exception it used to be. No * third-party service: EXIF is read locally on the node, and nothing about a diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 2c4356c..261f2af 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -12,7 +12,7 @@ import { SourceTag } from './group-name.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // -// docs/photos.md. Unlike Videos/Music: several root folders per group +// docs/MESHBAY_DESIGN.md §9.9. Unlike Videos/Music: several root folders per group // (photoRoots is a list, §2.1), one album-grid view with no mode toggle and // no third-party matching step (§2.3), and per-photo info read from the // file's own EXIF at index time rather than fetched live. Every directory @@ -383,7 +383,7 @@ function PhotosApp({ // groupPhotoAlbums is exported for the Search page, which needs the album a // photo belongs to in order to merge duplicate sources per album rather than -// per file (docs/refactoring-search.md §5.2). It calls this one, never a copy: +// per file (docs/MESHBAY_DESIGN.md §9.11). It calls this one, never a copy: // a second implementation of the album key would keep agreeing with this one // right up until one of them changed. export { PhotosApp, groupPhotoAlbums }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 07e5b6f..cfa4d87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -2,8 +2,9 @@ * What differs between running in a browser and running as an installed app. * * The interface is the same code either way — that is the whole reason Electron - * was chosen over a shell that replaces the engine (docs/desktop-client-v1.md - * §2). What genuinely differs is small and lives here: + * was chosen over a shell that replaces the engine + * (docs/MESHBAY_DESIGN.md §8.2). What genuinely differs is small and lives + * here: * * · **where the hub is.** Served from the hub, it is the current origin. Ship * the interface in a package and it becomes a configured URL, because the diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index 4d452e1..a19b543 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -83,7 +83,7 @@ export function ProfilePage({ user, onLogout }) { setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); }, []); - // ── Passphrase change (docs/auth-confirm.md §3) ───────────────────────── + // ── Passphrase change (docs/MESHBAY_DESIGN.md §3.6) ───────────────────── const [cpOpen, setCpOpen] = useState(false); const [cpOld, setCpOld] = useState(''); const [cpNew, setCpNew] = useState(''); @@ -168,7 +168,7 @@ export function ProfilePage({ user, onLogout }) { } }, [cpOld, cpNew, user]); - // ── Recovery key backfill (docs/auth-confirm.md §4.3) ─────────────────── + // ── Recovery key backfill (docs/MESHBAY_DESIGN.md §3.6) ───────────────── // Enter the recovery key once per browser to add a recovery-wrapped copy of // your identity to every group — covers groups joined before the key was // loaded here. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 26d4e4c..0a96237 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -5,7 +5,7 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { canPreview, downloadEntry } from './file-utils.js'; import { - HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, + HUB, session, hubFetch, ensureFreshToken, _loadBundleKey, } from './hub-client.js'; import { FilesPanel, FilePreview } from './files-app.js'; import { VideoApp, groupVideoEntries } from './video-app.js'; @@ -207,7 +207,7 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { }; // Which of the reader's groups sit on their own node — the tie-breaker // when the same file is announced by several of them - // (docs/refactoring-search.md §5.3). Computed by the node from its own + // (docs/MESHBAY_DESIGN.md §9.11). Computed by the node from its own // record of who it belongs to (webrtc_server.py's _is_node_admin), never // from a hub claim, and deliberately not written to the index cache: it // describes this connection, not the group's content. @@ -295,7 +295,6 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onRe groupName: g.name, groupOwner: g.owner_username, }); - cacheGroupIndex(g.id, g.name, g.owner_username, result.entries, result.roots); // Drawn now, not when this group's neighbours are done. Its index is // already in hand; holding it back until a group that is not answering // has finished not answering is ten seconds of blank page for work that @@ -349,7 +348,7 @@ function cachedDirs(roots, appKey, legacyKey) { // different people invited to different libraries — arrived here as two // entries per file, so a film showed as two poster cards and every episode // twice inside a show. `source-merge.js` folds them on the content hash and -// resolves one source per *unit*. See docs/refactoring-search.md. +// resolves one source per *unit*. See docs/MESHBAY_DESIGN.md §9.11. // // The units come from video-app.js's own `groupVideoEntries`, never from a // second copy of its keys here: a copy would keep agreeing with the original @@ -465,7 +464,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // A group whose connection failed stops being chosen as a merged entry's // source, so a unit fails over to another group that has the file - // (docs/refactoring-search.md §5.4). Without this the merge could make a + // (docs/MESHBAY_DESIGN.md §9.11). Without this the merge could make a // file *less* available than it was before it, which would be a regression // dressed as a feature. // @@ -609,7 +608,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) }, [indexedGroups]); // How a unit's source is chosen, shared by every merged view - // (docs/refactoring-search.md §5.3). `isLocal` reads the flag the node itself + // (docs/MESHBAY_DESIGN.md §9.11). `isLocal` reads the flag the node itself // put in the handshake ack — computed from its own record of who it belongs // to (webrtc_server.py's `_is_node_admin`), never from a hub claim. const mergeOpts = useMemo(() => ({ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js index 51d99cd..9a52059 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js @@ -19,7 +19,7 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); const [globalMute, setGlobalMute] = useState(false); const [defaultTab, setDefaultTab] = useState('chat'); - // Off by default (musicbay.md §2.2): the ordinary expectation, matching + // Off by default (docs/MESHBAY_DESIGN.md §9.8): the ordinary expectation, matching // Spotify/Deezer, is that the phone locks on its own idle timer while // listening. This is for whoever would rather trade battery for it — // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting diff --git a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js index 2c08e06..b6ce0bd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js @@ -6,7 +6,7 @@ * paths are already one entry. The Search page is what creates duplicates, by * concatenating N independently keyed indexes into one list — a directory * shared by two groups then shows every film twice, every episode twice, every - * track twice. See docs/refactoring-search.md. + * track twice. See docs/MESHBAY_DESIGN.md §9.11. * * Two rules decide everything here: * diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2d07fd1..81ae895 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1793,7 +1793,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } margin: 0; } -/* Photos app's root add/remove list (docs/photos.md §2.2) — a set, unlike +/* Photos app's root add/remove list (docs/MESHBAY_DESIGN.md §9.9) — a set, unlike the Videos/Music single-value picker above. */ .settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; } .settings-root-list-item { @@ -4031,7 +4031,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .video-flat-season { padding-left: 24px; margin-bottom: 8px; } .video-flat-season .video-season-header { margin: 6px 0 4px; } -/* Season picker — docs/mediacenter.md §5.4's per-season overview view. +/* Season picker — docs/MESHBAY_DESIGN.md §9.7's per-season overview view. Was a row of pills with `overflow-x: auto`: a show with a dozen seasons hid most of them behind a horizontal scrollbar, worst on the narrow screens that can least afford it. One trigger and a menu is one row high whatever the @@ -4139,7 +4139,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border-radius: 4px; } -/* ── Music app (docs/musicbay.md) ───────────────────────────────────────── +/* ── Music app (docs/MESHBAY_DESIGN.md §9.8) ────────────────────────────── Reuses .video-overlay/.video-top-bar/.video-title/.video-close, .tb-btn/ .tb-search, .video-flat-list/.video-flat-row/.video-flat-info/ .video-flat-title/.video-flat-sub/.video-flat-folder/.video-flat-chevron @@ -4346,7 +4346,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } back rather than compounding with the rail. */ .music-flat-track { padding: 5px 8px; } -/* ── Persistent player bar (docs/musicbay.md §2.3) ──────────────────────── +/* ── Persistent player bar (docs/MESHBAY_DESIGN.md §9.8) ────────────────── `position: sticky`, not `fixed` — deliberately: CLAUDE.md's own history records more than one layout bug from a fixed-position element quietly double-reserving space against a page that also sized itself against the @@ -4571,7 +4571,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .music-player-volume { display: none; } } -/* ── Photos app (photos-app.js, docs/photos.md) ───────────────────────────── */ +/* ── Photos app (photos-app.js, docs/MESHBAY_DESIGN.md §9.9) ──────────────── */ .photo-toolbar { display: flex; @@ -4924,7 +4924,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .folder-field-tbl .sdt-col-dir { font-size: 0.9em; } .folder-field-actions { display: flex; align-items: center; gap: 10px; } -/* HelloWorld (docs/refactor-groups.md §4.1) — the reference app, hidden +/* HelloWorld (docs/MESHBAY_DESIGN.md §9.4) — the reference app, hidden behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and anything decorative here would be a second thing to keep working. */ .hw-list { list-style: none; margin: 12px 0 0; padding: 0; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 72c831f..f4979f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -893,7 +893,7 @@ class MeshBayTransport { openErr = e; // The passphrase key did not open the bundle. If we hold a recovery // key and the node kept a recovery copy, try that — Flow B - // (docs/auth-confirm.md §4.5): recovering an identity after a lost + // (docs/MESHBAY_DESIGN.md §3.6): recovering an identity after a lost // passphrase, before re-wrapping it under the new one. if (this._recoveryKey && kpResp.bundle_enc_recovery) { try { @@ -1357,7 +1357,7 @@ class MeshBayTransport { } /** - * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4). + * 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 @@ -1388,7 +1388,7 @@ class MeshBayTransport { } /** - * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's + * 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. @@ -1445,7 +1445,7 @@ class MeshBayTransport { /** * Drop one file's cached TMDB match so it re-resolves with the node's - * current matcher (§10.1/V13) — the one-click alternative to the full + * 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) { @@ -1466,7 +1466,7 @@ class MeshBayTransport { * 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/mediacenter.md §5.5, §8). `token: ''` explicitly clears + * 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. */ @@ -1598,7 +1598,7 @@ class MeshBayTransport { } /** - * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3) + * 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 @@ -1659,7 +1659,7 @@ class MeshBayTransport { /** * Whether MusicBrainz lookups run for this group at all — per-group from - * the start (docs/musicbay.md §3.2/§6). Signed like setTmdbEnabled. + * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled. */ async setMusicbrainzEnabled(enabled, signFn) { const msg = await this._sendAndWait({ @@ -1833,7 +1833,7 @@ class MeshBayTransport { * Who is in this group and which device keys they hold — verified here, not * taken on the node's word. * - * Tier 2 of `desktop-client-v1.md` §4.8. The node relays, for each device, + * 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 @@ -1843,7 +1843,7 @@ class MeshBayTransport { * 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 `per-node-identity-v1.md`'s and does not move. + * is `docs/MESHBAY_DESIGN.md` §3.2's and does not move. */ async groupRoster() { if (this._roster) return this._roster; @@ -1875,8 +1875,9 @@ class MeshBayTransport { * '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 - * §4.8 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. + * 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; @@ -2658,7 +2659,7 @@ class MeshBayTransport { // 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/desktop-client-v1.md §4. + // countersign anything. See docs/MESHBAY_DESIGN.md §3.3. /** * Ask to be added, and return the code to show the person. @@ -3286,7 +3287,7 @@ class MeshBayTransport { }); } // Same shape: the operator dropped one file's match to have it - // re-resolved (§10.1/V13). No tmdbId — the node re-derives it. + // re-resolved (V13). No tmdbId — the node re-derives it. if (msg.type === 'tmdb_rematch_ack' && this._onTmdbOverride) { this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: '', mediaType: '' }); } @@ -4044,7 +4045,7 @@ function pinnedNodeCount() { // ── Passphrase change: re-wrap every reachable identity bundle ─────────────── // -// docs/auth-confirm.md §3.2. The passphrase-derived bundle_key encrypts this +// 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. @@ -4086,7 +4087,8 @@ function _acWithTimeout(promise, ms, label) { * @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/auth-confirm.md §4.5). + * @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] @@ -4100,7 +4102,7 @@ async function rewrapAllNodes(o) { let oldKey, newKey; if (o.bundleKey) { // "Keep the current passphrase key, just add / refresh the recovery copy" - // — the Profile backfill (docs/auth-confirm.md §4.3). `o.bundleKey` is the + // — 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 { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js index 5c79bc5..4662c25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js @@ -23,7 +23,7 @@ const TMDB_LANGUAGE_BY_LOCALE = { * The TMDB parts are two independent settings that happen to sit together: * the on/off switch is per group, while the API key and the query language * are node-wide, because they are one operator's credential and one shared - * cache (docs/mediacenter.md §5.5). They save separately for that reason. + * cache (docs/MESHBAY_DESIGN.md §9.7). They save separately for that reason. */ function VideoSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) { const { busy, msg, run } = useSaver(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index d4af4ef..d4ec01a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -10,16 +10,16 @@ import { usePager, Pager, pageSizeFrom } from './pager.js'; // ── Videos ─────────────────────────────────────────────────────────────────── // // A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a -// group's video files, per docs/mediacenter.md. Grouping: one card per movie, -// one card per show — shows are grouped by `display_title` (already +// group's video files, per docs/MESHBAY_DESIGN.md §9.7. Grouping: one card per +// movie, one card per show — shows are grouped by `display_title` (already // resolved/corroborated at index time, §3.4), not by folder path, since a // client-side path convention would have to guess how many roots/subfolders // deep a show folder sits, which display_title already settled once. // // TMDB metadata is fetched lazily, only for a tile once it is actually -// visible (LazyTile below) — apps.md §5's virtualization requirement for a -// grid of many tiles. Thumbnails go through the same `file_req`/chunk path -// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing +// visible (LazyTile below) — the virtualization requirement for a grid of +// many tiles. Thumbnails go through the same `file_req`/chunk path +// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb, reusing // chat-app.js's ChatImage pattern. const VIEW_MODE_KEY = 'meshbay_video_view_mode'; @@ -121,7 +121,7 @@ function groupVideoEntries(entries, videoDirectories) { return { movies, shows }; } -// ── lazy-mount tile (apps.md §5 virtualization) ───────────────────────────── +// ── lazy-mount tile (virtualization) ─────────────────────────────────────── const LAZY_TILE_MARGIN = 300; @@ -485,7 +485,7 @@ function OverviewText({ text, reserve }) { `; } -// ── season picker (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ +// ── season picker (docs/MESHBAY_DESIGN.md §9.7's per-season text) ──────────── // // A row of tabs, which this was, scrolls horizontally once a show has more // seasons than fit — a scrollbar nobody finds, hiding the seasons that matter @@ -708,7 +708,7 @@ function VideoDetailModal({ const [rematching, setRematching] = useState(false); const mediaType = show ? 'tv' : 'movie'; - // §10.1/V13: drop this file's cached match on the node and let it + // V13: drop this file's cached match on the node and let it // re-resolve with the current matcher — the one-click alternative to the // full search-and-pick flow above. const doRematch = useCallback(async () => { @@ -779,10 +779,11 @@ function VideoDetailModal({ `} ${/* Both of these act on a TMDB match, and with TMDB off for this group there is none to act on: "Fix match" opens a search the - node answers with an empty result list (§5.7's silent - degradation), and "Rematch" drops a cached match that was - never made. Two buttons that cannot do anything, offered to - the one person who already knows why. */''} + node answers with an empty result list + (docs/MESHBAY_DESIGN.md §9.7's silent degradation), and + "Rematch" drops a cached match that was never made. Two + buttons that cannot do anything, offered to the one person + who already knows why. */''} ${isNodeAdmin && tmdbEnabled && html` <div class="video-admin-actions"> <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}> @@ -1182,9 +1183,10 @@ function VideoApp({ `; } -// MediaThumb and LazyTile are also used by music-app.js (docs/musicbay.md -// §7.1): the same "decrypt a thumb_hash via the chunk path into a cached +// MediaThumb and LazyTile are also used by music-app.js +// (docs/MESHBAY_DESIGN.md §9.8): the same "decrypt a thumb_hash via the +// chunk path into a cached // blob" and "mount only once actually scrolled near" mechanisms apply to a // track's cover art unchanged, so Music imports them here rather than -// re-implementing (apps.md §4's checklist). +// re-implementing (docs/MESHBAY_DESIGN.md §9.4's checklist). export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration }; diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs index 74b4bb3..c05e23b 100644 --- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs +++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs @@ -78,7 +78,6 @@ const shown = []; // [{ at, groups }] — one entry per render the reader gets const session = { bundleKey: 'k' }; const _loadBundleKey = async () => 'k'; -const cacheGroupIndex = () => {}; // One group's index, answered on the clock rather than over a network. const fetchGroupIndex = (groupId) => new Promise((resolve, reject) => { @@ -106,7 +105,7 @@ const lift = (signature) => { }; const make = new Function( - 'session', '_loadBundleKey', 'cacheGroupIndex', 'fetchGroupIndex', 'localStorage', + 'session', '_loadBundleKey', 'fetchGroupIndex', 'localStorage', `const MAX_IN_FLIGHT = ${ceiling[1]}; const DOWN_KEY = 'harness'; ${lift('function lastKnownDown(')} @@ -116,7 +115,7 @@ const make = new Function( return fetchAllIndexes;`, ); const fetchAllIndexes = make( - session, _loadBundleKey, cacheGroupIndex, fetchGroupIndex, globalThis.localStorage); + session, _loadBundleKey, fetchGroupIndex, globalThis.localStorage); // ── The scenario ───────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index d1a4dcb..2be7c03 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -718,3 +718,70 @@ async def test_a_private_groups_node_list_is_for_its_members(client): finally: rev._connected_nodes.pop(node_id, None) rev._node_groups.pop(node_id, None) + + +async def _announce_key(client, user: dict, sk) -> int: + """Announce a *distinct* node key, and return the status code.""" + from meshbay_common.crypto import pk_to_b64 + + pk = pk_to_b64(sk.public_key()) + ts = int(time.time()) + msg = f"meshbay:node_announce:{user['user_id']}:{pk}:{ts}".encode() + r = await client.post("/v1/nodes/announce", json={ + "pk_node": pk, "endpoint_hint": "test", "timestamp": ts, + "signature": base64.b64encode(sk.sign(msg)).decode(), + }, headers={"Authorization": f"Bearer {user['token']}"}) + return r.status_code + + +async def test_one_account_cannot_announce_unlimited_nodes(client, monkeypatch): + """ + Each new node key is a row in `nodes` and a row in the IP log, and the IP log + is kept for a year. Proof of possession (M8) settles *whose* key it is and + says nothing about how many: an account in a loop wrote a year of storage on + the operator's disk having paid only for signatures. + + Two accounts, because the ceiling has to be per account. One that is shared + would let a single member deny every other member the ability to bring a + machine online, which is the same defect with better manners. + """ + from meshbay_hub.api import nodes as nodes_api + + monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 3) + alice = await _make_user(client, "av_nodecap_alice") + bob = await _make_user(client, "av_nodecap_bob") + + keys = [Ed25519PrivateKey.generate() for _ in range(4)] + for sk in keys[:3]: + assert await _announce_key(client, alice, sk) == 201 + + assert await _announce_key(client, alice, keys[3]) == 409, ( + "an account announced past the ceiling") + + # Bob has announced nothing and must be unaffected. + assert await _announce_key(client, bob, Ed25519PrivateKey.generate()) == 201, ( + "one account's ceiling was charged to another's" + ) + + +async def test_a_node_at_the_ceiling_can_still_refresh_its_address(client, monkeypatch): + """ + The ceiling counts rows, so it must be checked only where a row is added. + Applied to every announce, it would freeze the address of every node an + account already runs the moment it reached the limit — and a node that + cannot re-announce is a node nobody can reach after their ISP renumbers + them, which is an outage caused by the protection. + """ + from meshbay_hub.api import nodes as nodes_api + + monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 2) + alice = await _make_user(client, "av_nodecap_refresh") + + keys = [Ed25519PrivateKey.generate() for _ in range(2)] + for sk in keys: + assert await _announce_key(client, alice, sk) == 201 + assert await _announce_key(client, alice, Ed25519PrivateKey.generate()) == 409 + + for sk in keys: + assert await _announce_key(client, alice, sk) == 201, ( + "a node already known could not re-announce at the ceiling") diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 5862ac3..fcf6c4d 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -8,8 +8,8 @@ the design depends on are present in the source, and it fails if one is removed — which is the same treatment `test_downloads.py` gives the three browser-specific save paths, for the same reason. -Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md` -§3. Weak evidence, and the only evidence available without a packaged build; a +Every assertion here corresponds to a sentence in `docs/MESHBAY_DESIGN.md` +§8.2. Weak evidence, and the only evidence available without a packaged build; a person with an installed client is what confirms the rest. """ @@ -370,9 +370,10 @@ def test_plain_http_is_refused_except_to_loopback(): def test_the_interface_is_copied_not_forked(): """ - §2.7: the hub's static directory is the single source. A silent fork is the - only real way to end up maintaining the interface twice, so the copy is - generated and the generated tree is not committed. + docs/MESHBAY_DESIGN.md §8.3: the hub's static directory is the single + source. A silent fork is the only real way to end up maintaining the + interface twice, so the copy is generated and the generated tree is not + committed. """ sync = (CLIENT / "scripts" / "sync-ui.js").read_text(encoding="utf-8") assert "meshbay-hub" in sync and "static" in sync diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index afb85d6..395053b 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -146,9 +146,9 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): assert "if (entry.size > 0)" in src # The zip-directory download started in files-app.js (group-page refactor) - # and was lifted into file-utils.js's downloadDirectory (docs/photos.md - # §3) so photos-app.js's own "zip this album" button calls the same - # implementation rather than a second one. + # and was lifted into file-utils.js's downloadDirectory + # (docs/MESHBAY_DESIGN.md §9.9) so photos-app.js's own "zip this album" + # button calls the same implementation rather than a second one. # Anchored on the call, not on how its result is bound: the assignment # became a bare `target = ...` inside a try when _openDownloadTarget gained # the ability to refuse an oversized download (test_memory_ceiling.py). diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py index a1baf92..451b57b 100644 --- a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py +++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py @@ -1,7 +1,7 @@ """ The reference application, and what it is for. -`docs/refactor-groups.md` claims that adding an application costs a registry +`docs/MESHBAY_DESIGN.md` §9.4 claims that adding an application costs a registry entry and the app's own files — no op, no MNP message, no route, no edit to the pages that render it. Every other test of that claim reads source for the *absence* of app names, which proves nobody wrote a special case for Videos. It diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 78561b6..3c82bb0 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -37,7 +37,7 @@ STATIC_FILES = [ "video-player.js", "video-app.js", "music-app.js", "music-player.js", "photos-app.js", "pager.js", "group-settings.js", - # The per-app settings architecture (docs/refactor-groups.md §3). Reached + # The per-app settings architecture (docs/MESHBAY_DESIGN.md §9.4). Reached # through the apps.js registry rather than imported by name, so a file # left out of this list is one nothing checks — the failure is silent. "settings-ui.js", "folder-tree.js", diff --git a/packages/meshbay-hub/tests/test_no_index_cache.py b/packages/meshbay-hub/tests/test_no_index_cache.py new file mode 100644 index 0000000..c39bf00 --- /dev/null +++ b/packages/meshbay-hub/tests/test_no_index_cache.py @@ -0,0 +1,98 @@ +""" +A group's index is never written to browser storage. + +`group_indexes` was an IndexedDB store holding a decrypted copy of every group's +index — each file's name, path, size, hash and uploader — written on every index +and every delta. The cross-group search of the time read it instead of dialling +anything, which is what it was for. + +Search has dialled the nodes since 2026-08-28. That change removed the reader and +kept the writers, so the browser went on building a cleartext file listing that +nothing consulted, that no sign-out removed (the key database is a different +one), and that grew with every group ever opened. **L7**, at rest. + +Showing a group's files while its node is unreachable is the only thing such a +cache buys, and it is not wanted: a listing you cannot open is worse than an +honest absence. So there is nothing left to read it with, and these tests keep it +that way — a writer reintroduced without a reader would be invisible again, and +the second time it would be invisible for the same reason as the first. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +HUB_CLIENT = STATIC / "hub-client.js" +APP = STATIC / "app.js" + +# The store name, read from the source rather than written down here: renaming it +# must not quietly take these tests out of the picture. +STORE = re.search(r"const IDB_STORE = '([^']+)';", + HUB_CLIENT.read_text(encoding="utf-8")).group(1) + + +def _functions(src: str) -> dict[str, str]: + """Every top-level function in a module, by name.""" + out = {} + starts = [(m.start(), m.group(1)) for m in + re.finditer(r"^(?:async )?function (\w+)\(", src, re.M)] + for i, (at, name) in enumerate(starts): + end = starts[i + 1][0] if i + 1 < len(starts) else len(src) + out[name] = src[at:end] + return out + + +def test_only_the_purge_touches_the_old_store(): + """ + Creating it and emptying it, and nothing else. + + `openDB` still creates the store because dropping it needs a version bump, + and a version bump is an upgrade another tab can block — which would take + playlists down with it, since they share this database. An empty store costs + nothing; the point is that nothing writes to it. + """ + fns = _functions(HUB_CLIENT.read_text(encoding="utf-8")) + touching = sorted(n for n, body in fns.items() if "IDB_STORE" in body) + assert touching == ["openDB", "purgeGroupIndexCache"], ( + f"{touching} touch the {STORE!r} store; only creating and emptying it " + "are allowed, and a write to it is a file listing kept on disk that " + "nothing will ever read") + + +def test_nothing_writes_a_group_index_to_the_store(): + """Stated on the operation rather than on the callers, so a new one is caught.""" + fns = _functions(HUB_CLIENT.read_text(encoding="utf-8")) + purge = fns["purgeGroupIndexCache"] + assert ".clear()" in purge + for write in (".put(", ".add(", ".putAll("): + assert write not in purge, f"the purge does a {write} — it must only clear" + + +def test_no_module_carries_a_cache_writer_any_more(): + """ + The functions are gone, so the way this comes back is a new one. Any export + of hub-client.js whose name is about caching an index is refused here rather + than discovered months later with a store full of filenames. + """ + src = HUB_CLIENT.read_text(encoding="utf-8") + for name in re.findall(r"^(?:async )?function (\w+)\(", src, re.M): + assert not re.search(r"cache.*index|index.*cache", name, re.I) \ + or name == "purgeGroupIndexCache", ( + f"{name} looks like an index cache again — the store it would write " + "to has no reader, and adding one was decided against") + + +def test_the_purge_is_actually_called(): + """ + The defect being cleaned up was a function nobody called. A purge nobody + calls is the same defect wearing the opposite hat: the data stays on every + machine that already has it, and nothing says so. + """ + app = APP.read_text(encoding="utf-8") + assert "purgeGroupIndexCache" in app, "app.js no longer imports the purge" + call = re.search(r"purgeGroupIndexCache\(\)", app) + assert call, "the purge is imported and never called" + mount = app.index("const mount = () => {") + assert "purgeOnce()" in app[mount:mount + 400], ( + "the purge is no longer run at start-up, so a browser that still holds " + "the old store keeps it") diff --git a/packages/meshbay-hub/tests/test_password_change.py b/packages/meshbay-hub/tests/test_password_change.py index 4d2f303..b2fe586 100644 --- a/packages/meshbay-hub/tests/test_password_change.py +++ b/packages/meshbay-hub/tests/test_password_change.py @@ -1,5 +1,5 @@ """ -Passphrase change — Flow A of docs/auth-confirm.md. +Passphrase change — Flow A of docs/MESHBAY_DESIGN.md §3.6. The hub's part is small: re-prove the current passphrase, swap the auth_key verifier, invalidate every other session, keep the caller's. The re-wrapping of diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py index 1273315..823807c 100644 --- a/packages/meshbay-hub/tests/test_password_reset.py +++ b/packages/meshbay-hub/tests/test_password_reset.py @@ -1,5 +1,5 @@ """ -Passphrase reset by e-mail code — Flow B of docs/auth-confirm.md §4.2. +Passphrase reset by e-mail code — Flow B of docs/MESHBAY_DESIGN.md §3.6. The hub's part re-opens sign-in only: it swaps the auth_key verifier, kills every session, and drops every registered device key so a stored one cannot diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py index 07880d0..96dde5d 100644 --- a/packages/meshbay-hub/tests/test_recovery_email.py +++ b/packages/meshbay-hub/tests/test_recovery_email.py @@ -1,5 +1,5 @@ """ -The recovery key in the registration e-mail (docs/auth-confirm.md §4.4). +The recovery key in the registration e-mail (docs/MESHBAY_DESIGN.md §3.6). When the client sends `recovery_key`, the hub appends it to the verification e-mail and stores it nowhere. When it does not, the e-mail carries only the diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py index 378758a..54415f6 100644 --- a/packages/meshbay-hub/tests/test_recovery_key.py +++ b/packages/meshbay-hub/tests/test_recovery_key.py @@ -1,5 +1,5 @@ """ -The account recovery key (docs/auth-confirm.md §4.3). +The account recovery key (docs/MESHBAY_DESIGN.md §3.6). `generateRecoveryKey` / `deriveRecoveryKey` in keyderive.js are run here under node against the real WebCrypto, rather than reimplemented: the mnemonic has to diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index 03d24dc..54ef67a 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -1,6 +1,6 @@ """ `MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out -(docs/auth-confirm.md §3.2, §4.5). +(docs/MESHBAY_DESIGN.md §3.6). The real function is run under node with its two boundaries stubbed: the hub HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is diff --git a/packages/meshbay-hub/tests/test_search_files_unmerged.py b/packages/meshbay-hub/tests/test_search_files_unmerged.py index 6956dde..b84b25d 100644 --- a/packages/meshbay-hub/tests/test_search_files_unmerged.py +++ b/packages/meshbay-hub/tests/test_search_files_unmerged.py @@ -19,7 +19,7 @@ merge. Weak evidence, and the only kind available for the SPA — but the failur it guards against is a one-line edit, which is exactly what a source-reading test catches well. -See docs/refactoring-search.md §6.1. +See docs/MESHBAY_DESIGN.md §9.11. """ import re @@ -54,7 +54,7 @@ def test_the_files_list_is_not_merged(): "fileEntries now goes through the source merge. The Files explorer " "shows one folder per group and a member navigates into it; merging " "two groups' copies of a file would remove it from one of those " - "folders. See docs/refactoring-search.md §6.1") + "folders. See docs/MESHBAY_DESIGN.md §9.11") @pytest.mark.parametrize("name", ["videoEntries", "musicEntries", "photoEntries"]) diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py index 65f85aa..3464902 100644 --- a/packages/meshbay-hub/tests/test_search_media_merge.py +++ b/packages/meshbay-hub/tests/test_search_media_merge.py @@ -23,7 +23,7 @@ with it — so what this counts is what the grid renders. `t()` is stubbed to return its key: `groupMusicEntries` uses it for the two placeholder album names, and a string is not what is under test here. -See docs/refactoring-search.md. +See docs/MESHBAY_DESIGN.md §9.11. """ import json diff --git a/packages/meshbay-hub/tests/test_search_source_merge.py b/packages/meshbay-hub/tests/test_search_source_merge.py index cadf095..b471891 100644 --- a/packages/meshbay-hub/tests/test_search_source_merge.py +++ b/packages/meshbay-hub/tests/test_search_source_merge.py @@ -20,7 +20,7 @@ it has no imports precisely so that it can be, and a copy of the picking rule in a test would keep agreeing with the original right up until one of them changed. -See docs/refactoring-search.md. +See docs/MESHBAY_DESIGN.md §9.11. """ import json diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py index 67eb3e6..94f3aee 100644 --- a/packages/meshbay-hub/tests/test_search_unlisted.py +++ b/packages/meshbay-hub/tests/test_search_unlisted.py @@ -48,10 +48,14 @@ def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable(): body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes") branch = body[body.index("result.unlisted"):] branch = branch[:branch.index("} else if (result)")] - for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"): + # `cacheGroupIndex` used to be on this list. The store it wrote to is gone + # (hub-client.js `purgeGroupIndexCache`), so the way an unlisted group's + # index could now be kept is by being written anywhere at all — which is + # what test_no_group_index_is_written_to_storage guards, for every group. + for forbidden in ("results.set", "unreachable.push"): assert forbidden not in branch, ( - f"an unlisted group reaches `{forbidden}` — it would be shown, " - "cached, or reported as down") + f"an unlisted group reaches `{forbidden}` — it would be shown " + "or reported as down") def test_every_search_view_is_built_from_the_indexed_groups_only(): diff --git a/packages/meshbay-hub/tests/test_sticky_band_ring.py b/packages/meshbay-hub/tests/test_sticky_band_ring.py index 2d57822..e5ef860 100644 --- a/packages/meshbay-hub/tests/test_sticky_band_ring.py +++ b/packages/meshbay-hub/tests/test_sticky_band_ring.py @@ -28,9 +28,10 @@ before `test_sticky_header.py` measured it. Read out of the stylesheet rather than measured in a browser, deliberately. A browser shows the 4px at one width, in one of the states that happen to put something above a band; what has to hold is which bands are in which of two -lists, and that is a fact about the source. `docs/apps.md` sends the author of -a new application here to make its toolbar pin, and this is what says whether -the toolbar they add needs the gap — it does not, and it must not have it. +lists, and that is a fact about the source. `docs/MESHBAY_DESIGN.md` §9.2 sends +the author of a new application here to make its toolbar pin, and this is what +says whether the toolbar they add needs the gap — it does not, and it must not +have it. """ import re diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 9c3a88b..3c2b0b6 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -141,7 +141,7 @@ def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat): def test_every_authorize_admin_op_call_is_registered_in_admin_op_types(transport): """ - Found live (docs/photos.md's photo_roots): `setPhotoRoots` called + Found live (docs/MESHBAY_DESIGN.md §9.9's photo_roots): `setPhotoRoots` called `_authorizeAdminOp(msg, 'photo_roots', ...)` like every other admin op, but `photo_roots` was never added to `ADMIN_OP_TYPES` — so its initial request was never keyed `admin:photo_roots`, the node's `admin_challenge` diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 9471b8a..26c5552 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -3,7 +3,7 @@ The arbitrary ceiling on a directory zip. `downloadDirectory` is the one implementation behind every "download this folder as a zip" button — Files' single folder, Files' multi-folder selection, -and the Photos album button (docs/photos.md §3) — so the limit is checked +and the Photos album button (docs/MESHBAY_DESIGN.md §9.9) — so the limit is checked once, there, and holds for all of them. Three things are worth pinning. That an oversized folder is refused *before* |