summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 13:57:44 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 13:57:44 +0200
commit7adbf5163f0193d80bb5578dc875eba811f864bb (patch)
tree95b6fc62835536a41f1fc47e646e904b27f9a042
parent4b74ff3fc54f35fbc41d9c0f7d2b4ec89475154a (diff)
downloadmeshbay-7adbf5163f0193d80bb5578dc875eba811f864bb.tar.gz
fix(hub): the asset fingerprint covers every file under static/
Everything under static/ is served at /a/<hash>/ with a year's `immutable`, but the hash was computed from a hand-kept list of 43 top-level modules. The ten catalogues and vendor/ were not on it, nor was anything the guarding test could see: it globbed *.js at the top level only. A change confined to the catalogues therefore kept the hash, and a phone went on showing a heading that had been rewritten and deployed - pull-to-refresh fetched the no-store shell, which was current, and never refetched en.js at a URL that had not moved. The fingerprint now hashes every file under static/, path and content, so a change, a rename or a new file moves the version with nothing to register. _ASSETS is gone, and CLAUDE.md, MESHBAY_DESIGN.md 9.4 step 6, assets/brand/README.md and docs/playlists.md no longer ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
-rw-r--r--CLAUDE.md8
-rw-r--r--assets/brand/README.md9
-rw-r--r--docs/MESHBAY_DESIGN.md7
-rw-r--r--docs/playlists.md3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py52
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py57
6 files changed, 66 insertions, 70 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 824a3ae..f2a3363 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -549,7 +549,11 @@ do. Read them before writing anything that touches the same mechanism.
fix can be written, tested, deployed, served and still not be what runs, which
is indistinguishable from a fix that does not work. The whole module graph is
now served under `/a/<content-hash>/` so relative imports inherit the prefix and
- no cache can serve yesterday's build or half of each. `test_asset_versioning.py`
+ no cache can serve yesterday's build or half of each. The hash covers every file
+ under `static/`: it once covered a hand-kept list of top-level modules, the
+ catalogues were not on it, and a heading rewritten only in `en.js` stayed on a
+ phone after the deploy — immutable for a year at a URL that had not moved.
+ `test_asset_versioning.py`
- **Redeploying during someone else's test destroys the evidence.** A node deploy
restarts the daemon, which kills every live WebRTC session — the tester sees
@@ -664,7 +668,7 @@ here are kept only where they are a rule about *editing* the code.
| Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 |
| Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 |
| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6 |
-| Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()`, `_ASSETS` | the whole module graph is served under `/a/<hash>/`. **A new static file must be added to `_ASSETS`** |
+| Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a/<hash>/`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register |
| Token lifetimes | `meshbay_hub/config.py` — `[jwt]` | 4 h access, 30 days refresh. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there |
### Browser / desktop UI (`meshbay_hub/static/`)
diff --git a/assets/brand/README.md b/assets/brand/README.md
index b673589..1b37292 100644
--- a/assets/brand/README.md
+++ b/assets/brand/README.md
@@ -22,11 +22,10 @@ convert assets/brand/meshbay-M-transparent.png \
- `-resize x72` is 72 px tall for a picture displayed at 30 px — enough for a
2× screen, and 56 KB becomes 10 KB.
-The derived file is listed in `_ASSETS` in `meshbay_hub/api/webapp.py`. It has
-to be: that list is what the `/a/<hash>/` fingerprint is computed from, so a
-file missing from it is a file whose change never moves the URL, and a browser
-holding the old one never asks again. Regenerate the picture, and the wordmark
-changes for everyone; forget the list, and it changes for nobody.
+The derived file lives under `static/`, and every file there feeds the
+`/a/<hash>/` fingerprint (`_asset_version()` in `meshbay_hub/api/webapp.py`):
+regenerate the picture and the URL moves, so the wordmark changes for everyone
+without anything to register.
The M is a picture standing in for the letter M; `eshBay` beside it is text.
Their sizes are set independently in `style.css` — `.nav-brand { font-size }`
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index d24d7a9..472d5ab 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1963,9 +1963,10 @@ There is no folder-browsing protocol and this does not add one.
5. **i18n:** at minimum a tab label key **in all ten catalogues**. A settings key
added to the client must be added ten times; write the table and generate the
insert.
-6. **Add both files to the asset fingerprint list.** A module reached through the
- registry is not imported by name anywhere, so nothing else would notice it
- changing and a browser would keep serving the cached copy.
+6. **Nothing to register for caching.** Every file under `static/` feeds the
+ `/a/<hash>/` fingerprint, subdirectories included, so a new or changed module
+ moves the URL by itself — which matters for a module reached through the
+ registry, since nothing imports it by name.
7. **Add the file to the test file-set lists** — hook ordering, sticky headers,
transport contracts. A file missing from those lists is never checked, which
fails silently rather than loudly.
diff --git a/docs/playlists.md b/docs/playlists.md
index 8e57546..485011a 100644
--- a/docs/playlists.md
+++ b/docs/playlists.md
@@ -439,7 +439,8 @@ throws on first access, at a point far from the version constant.
1. `playlists.js` (data) and `playlists-page.js` (UI).
2. Route + sidebar entry in `app.js`.
-3. `webapp.py`'s `_ASSETS` tuple — both new files.
+3. Nothing for caching: every file under `static/` feeds the `/a/<hash>/`
+ fingerprint.
4. `test_hook_ordering.py` (`STATIC_FILES`) and
`test_transport_contracts.py` (`SPLIT_FILES`) — both new files.
5. i18n keys in all ten `static/locales/*.js`; `test_locales.py` holds them
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 05485e0..6ac5cdb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -21,40 +21,9 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
-# Assets the shell pulls in, in load order. Everything else is imported by
-# app.js from a relative path, which inherits the `/a/<hash>/` prefix the shell
-# loaded app.js under — so the whole module graph moves together.
-# Every module the page loads. A file missing from here is a file whose change
-# does not move the URL, so a browser holding the old one never asks for it —
-# which is the failure this list exists to prevent, and it is silent.
-_ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
- "i18n.js", "downloads.js", "transfers.js", "zipstream.js",
- "platform.js", "meshbay-m.png",
- # Split out of app.js by the group-page refactor — each imported by
- # app.js or group-page.js, so a change to any of them is a change
- # to what the browser must fetch.
- "icon.js", "file-utils.js", "hub-client.js", "apps.js",
- "source-merge.js", "sticky.js",
- "chat-app.js", "files-app.js", "video-player.js", "video-app.js",
- "music-app.js", "music-player.js", "photos-app.js",
- "group-settings.js", "group-page.js",
- # The per-app settings architecture (docs/refactor-groups.md §3):
- # the shared widgets, the folder picker, and one settings pane per
- # app. Reached through the `apps.js` registry rather than imported
- # by name anywhere, which is exactly why they have to be listed —
- # nothing else would notice one of them changing.
- "settings-ui.js", "folder-tree.js",
- "chat-app-settings.js", "video-app-settings.js",
- "music-app-settings.js", "photos-app-settings.js",
- # The reference app (docs/refactor-groups.md §4.1). Hidden behind
- # `?dev=1` client-side, but it is still served and still cached, so
- # it participates in the hash like anything else here.
- "helloworld-app.js", "helloworld-app-settings.js",
- # Pages extracted from app.js — statically imported or lazy-loaded,
- # but all must participate in the content hash.
- "auth-page.js", "explore-page.js", "create-group-page.js",
- "admin-page.js", "node-page.js", "group-name.js",
- "search-page.js", "settings-page.js", "profile-page.js")
+# The shell loads a few assets by name; everything else is imported by app.js
+# from a relative path, which inherits the `/a/<hash>/` prefix the shell loaded
+# app.js under — so the whole module graph moves together.
def _asset_version() -> str:
@@ -67,12 +36,19 @@ def _asset_version() -> str:
for as long as that lasts, which is indistinguishable from the fix not
working. Changing the URL is the only thing that reaches such a browser,
and a content hash changes it exactly when the content changes.
+
+ **Every file under the static directory**, because that is what `/a/<hash>/`
+ serves — with a year's `immutable`, so a file outside the hash is a file a
+ browser never fetches again. The fingerprint used to cover a hand-kept list
+ of top-level modules, and the translations and `vendor/` were not on it: a
+ change confined to the catalogues kept the hash, and a phone went on showing
+ a heading that had been rewritten and deployed. The path is hashed with the
+ content, so a rename or a new file moves the version too.
"""
h = hashlib.sha256()
- for name in _ASSETS:
- path = STATIC_DIR / name
- if path.exists():
- h.update(path.read_bytes())
+ for path in sorted(p for p in STATIC_DIR.rglob("*") if p.is_file()):
+ h.update(path.relative_to(STATIC_DIR).as_posix().encode() + b"\0")
+ h.update(path.read_bytes())
return h.hexdigest()[:12]
diff --git a/packages/meshbay-hub/tests/test_asset_versioning.py b/packages/meshbay-hub/tests/test_asset_versioning.py
index 3d84d99..0c93f5a 100644
--- a/packages/meshbay-hub/tests/test_asset_versioning.py
+++ b/packages/meshbay-hub/tests/test_asset_versioning.py
@@ -23,7 +23,7 @@ import re
import pytest
from fastapi.testclient import TestClient
-from meshbay_hub.api.webapp import _ASSETS, ASSET_V, STATIC_DIR, _asset_version
+from meshbay_hub.api.webapp import ASSET_V, STATIC_DIR, _asset_version
from meshbay_hub.app import create_app
@@ -103,26 +103,41 @@ def test_the_fingerprint_follows_the_content(tmp_path, monkeypatch):
assert _asset_version() == before, "the fingerprint is not reproducible"
-def test_every_static_script_participates_in_the_fingerprint():
+@pytest.mark.parametrize("relative", ["locales/en.js", "vendor/htm-preact.js", "style.css"])
+def test_a_change_anywhere_under_the_prefix_moves_the_fingerprint(relative):
"""
- `_ASSETS` is hand-maintained, and forgetting an entry fails silently: the
- file is imported by the page, so the browser fetches it, but it does not
- feed the content hash — so a change confined to that one file ships at the
- URL a cache already holds. Nothing errors, and the symptom is a fix that
- "doesn't work" on exactly the machines that visited before.
+ Everything under the static directory is served at `/a/<hash>/` and cached
+ as immutable, so everything has to feed the hash — subdirectories included.
- Found by `source-merge.js`, added to the Search view and left out of the
- list. It happened to be harmless that day because `search-page.js` changed
- in the same commit and *is* listed — which is the worst way for this to go
- unnoticed. The checklist in docs/apps.md §4 step 5 names this trap; this
- enforces it instead of relying on remembering.
-
- `sw.js` is the one deliberate exclusion — the service worker is served
- unversioned on purpose (`test_the_service_worker_is_not_versioned`).
+ The fingerprint used to cover a hand-kept list of top-level modules, and
+ the check that guarded the list globbed `*.js` at the top level only. The
+ catalogues and `vendor/` were on neither: a change confined to the English
+ catalogue shipped at the URL a phone already held, and it went on showing a
+ heading that had been rewritten and deployed. Pull-to-refresh fetches the
+ shell, which is `no-store` and was current; it does not refetch an
+ immutable file whose URL has not moved.
"""
- on_disk = {p.name for p in STATIC_DIR.glob("*.js")} - {"sw.js"}
- missing = sorted(on_disk - set(_ASSETS))
- assert not missing, (
- f"static scripts missing from webapp._ASSETS: {missing}. A change to "
- "one of these will not move the asset URL, so a browser that cached "
- "the page keeps running the old copy")
+ before = _asset_version()
+ target = STATIC_DIR / relative
+ original = target.read_bytes()
+ try:
+ target.write_bytes(original + b"\n/* touched */\n")
+ assert _asset_version() != before, (
+ f"the fingerprint did not move when {relative} did, so a browser "
+ "that cached it keeps the old copy for a year")
+ finally:
+ target.write_bytes(original)
+ assert _asset_version() == before
+
+
+def test_a_new_file_moves_the_fingerprint():
+ """A rename or an addition changes what is served even when no existing
+ file's bytes do."""
+ before = _asset_version()
+ extra = STATIC_DIR / "locales" / "zz-test-only.js"
+ try:
+ extra.write_text("export default {};\n")
+ assert _asset_version() != before
+ finally:
+ extra.unlink()
+ assert _asset_version() == before