aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-name.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js69
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/source-merge.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js32
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs5
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py67
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py11
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py6
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_no_index_cache.py98
-rw-r--r--packages/meshbay-hub/tests/test_password_change.py2
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py2
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py2
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_files_unmerged.py4
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_source_merge.py2
-rw-r--r--packages/meshbay-hub/tests/test_search_unlisted.py10
-rw-r--r--packages/meshbay-hub/tests/test_sticky_band_ring.py7
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py2
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*