<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/api/webapp.py, branch v0.9.0</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=v0.9.0</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=v0.9.0'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-30T23:06:06Z</updated>
<entry>
<title>refactor(ui): extract Explore, Login, Register and CreateGroup from app.js</title>
<updated>2026-08-30T23:06:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T23:06:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0c2754d2d4cb1905d5e324f56fbc64e4afae526f'/>
<id>urn:sha1:0c2754d2d4cb1905d5e324f56fbc64e4afae526f</id>
<content type='text'>
ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/
FirstRunPage → auth-page.js (static import, LoginPage receives onLogin
as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded
via dynamic import(), same pattern as AdminPage/NodePage).

app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the
three new files and the previously missing extracted pages. Test fixtures
updated to follow the moved components.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(hub): no-store on the SPA HTML shell — pull-to-refresh wasn't enough</title>
<updated>2026-08-26T10:16:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-26T10:16:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=03c2c0deaebee645bd61dfb8d4e7bd9d942e6408'/>
<id>urn:sha1:03c2c0deaebee645bd61dfb8d4e7bd9d942e6408</id>
<content type='text'>
Likely root cause of a very confusing test result: the /app HTML response
had no Cache-Control at all, so a browser that decided to cache it
heuristically could keep re-serving the SAME old page (old ASSET_V, old JS)
indefinitely — a normal reload, pull-to-refresh included, has no reason to
override a cache entry it still considers fresh. Every static asset already
gets a fresh URL from ASSET_V precisely so a change is visible, but that
only matters if the HTML naming that URL is itself refetched. no-store
forces every navigation here to hit the network.
</content>
</entry>
<entry>
<title>feat: add Photos group app</title>
<updated>2026-08-25T09:46:17Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-25T09:46:17Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2fcdd07d1e5d331ad02b723f1c45603a0989c264'/>
<id>urn:sha1:2fcdd07d1e5d331ad02b723f1c45603a0989c264</id>
<content type='text'>
A new group application (docs/apps.md's plug-in mechanism), following the
plan in docs/photos.md. Unlike Videos/Music: several photo roots per group
instead of one (photo_roots is a set, one signed op replaces it whole),
a single album-grid view with no third-party matching step, and per-photo
info read from the file's own EXIF at index time — no metadata service,
no credential, no outbound network call at all.

Protocol (meshbay-common, MNP 0.10 -&gt; 0.11, additive): `taken_at`/`camera`
on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`.

Node: roster.py stores photo_roots as a group_settings entry (JSON list,
same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the
whole set in one op, same pattern as apps_enabled; a new PhotoEnricher
(indexer/enrich_photo.py) runs Pillow in its own small bounded pool,
separate from the video/audio pools, producing a resized thumbnail plus
the two EXIF fields — never GPS, checked by a grep-based regression test.

Client: photos-app.js — one album card per directory containing images,
a per-album photo grid, and a lightbox with next/previous (keyboard and
buttons), zoom in/out/fit/100% starting from the actual on-screen fit
percentage, and a "zip this album" button reusing files-app.js's own zip
mechanism (lifted into file-utils.js's downloadDirectory so both call the
same implementation). group-settings.js gets an add/remove multi-root
picker, distinct from Videos/Music's single-value one.

Bugs found and fixed before this ever shipped, worth keeping the story of:

- enrich_photo.py read width/height from the raw image *before* applying
  EXIF orientation correction, and read DateTimeOriginal off the plain
  0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which
  Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict
  round-trips through Pillow either way, which is exactly what would have
  hidden both bugs; the regression test builds EXIF with piexif instead,
  matching what real hardware produces.
- photos-app.js's album grouping stripped a trailing path segment from
  entry.path under the assumption it still carried a filename — it
  doesn't (files-app.js's own convention: e.path is already the
  containing directory), so every album collapsed one level into its
  parent. Found live against a real multi-folder library.
- transport.js's ADMIN_OP_TYPES allowlist (already the fix for an
  identical bug on video_root/apps_enabled, see 4783d81) was missing
  photo_roots: its admin_challenge matched no pending request and was
  silently dropped, so saving a photo root just timed out after 30s with
  no error.
- daemon.py pruned a thumbnail when its file left the index (root removed
  or reconfigured) but never forgot the content hash was "already
  attempted" — the same bytes reappearing under a renamed/relocated root
  (an operator's real workflow) were then permanently skipped, forever,
  with nothing to indicate why. Discarding the attempt alongside the
  cache entry on prune is what makes pruning actually reversible.
- packages/meshbay-client's app:// protocol handler served every file
  with no Cache-Control header, so Chromium was free to serve a stale
  cached copy indefinitely — none of several `npm run sync-ui` + reload
  cycles during development actually picked up the new code until the
  renderer's disk cache was cleared by hand. Now sends Cache-Control:
  no-store.
- the lightbox's zoomed image used flex centering (align-items/
  justify-content: center) combined with overflow: auto — a well-known
  trap where the browser centers overflowing content by shifting it, and
  the leading half of that overflow (here, the top of a zoomed photo)
  sits outside what the scrollport can actually reach. Reported live as
  "unusable". Fixed by switching to top/left alignment once zoomed.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
</content>
</entry>
<entry>
<title>feat(hub): Music app client — album grid, flat list, persistent player</title>
<updated>2026-08-24T15:39:41Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-24T15:39:41Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8c780b9928db8145b7fe18ecd137384ccfe25d8f'/>
<id>urn:sha1:8c780b9928db8145b7fe18ecd137384ccfe25d8f</id>
<content type='text'>
Implements the client half of docs/musicbay.md against MNP 0.8:

- music-app.js: album grid (grouped by artist -&gt; album, from index-time
  artist/album fields) or flat folder view, per-group localStorage
  toggle like Videos. MusicBrainz (music_meta_req) is only looked up
  when a track has no embedded cover at all — well-tagged files never
  trigger a network call, unlike Videos where TMDB is unconditional.
  Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than
  duplicating the chunk-path thumbnail decode + virtualization.
- music-player.js: the persistent player bar — queue, shuffle (Fisher-
  Yates, keeps the current track in place when toggled), repeat (off/
  all/one), volume (localStorage), prev/next, a one-track prefetch
  cache. No MSE, no node-side streaming: a track is downloaded and
  decrypted once via file-utils.js's pipelinedDownload, same chunk
  pipeline Files already uses, then played from a blob URL.
- group-page.js: owns musicQueue/musicbrainzConfig state and renders
  MusicPlayerBar outside the tab-switched area — deliberately, so
  playback survives navigating to Chat/Files, the same reasoning the
  video/preview modals are shell-owned rather than app-owned.
- apps.js: registers "music". transport.js: fetchMusicMeta (keyed by
  path, same reordering-hazard fix as fetchMediaMeta),
  setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring
  TMDB's), and the three new ack handlers. group-settings.js: a
  MusicBrainz settings section (contact string, per-group toggle) —
  the existing Applications checklist already picks up "music" for
  free, per apps.md's own claim.
- icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume,
  drawn in the same stroked style as the existing set.
- i18n: group.tab_music, the music.* and settings_node.musicbrainz_*
  keys, translated (not just copied) across all ten locales, Polish
  carrying full one/few/many/other plural forms for music.n_tracks.
- webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and
  test_transport_contracts.py's SPLIT_FILES gain the two new files.

Full suite (common + hub + node): 1116 passed, no regressions.
`npm run sync-ui` in meshbay-client confirmed both files copied.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
</content>
</entry>
<entry>
<title>feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)</title>
<updated>2026-08-24T08:04:46Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-24T08:04:46Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6af05abf410bbd038ce7fa6915a659defc509071'/>
<id>urn:sha1:6af05abf410bbd038ce7fa6915a659defc509071</id>
<content type='text'>
Implements docs/mediacenter.md: a "Videos" group application built on the
existing files index rather than a separate catalogue. On the node side,
new indexer enrichment (technical probe, filename/season parsing, thumbnail
generation) runs per-file once an operator has chosen a video_root for the
group, plus a TMDB client for on-demand poster/metadata lookups (never
client-side, thumbnails delivered over the existing chunk path). On the hub
side, a new video-app.js renders a lazily-mounted poster grid or a
thumbnail-only flat list, with TMDB entirely optional per group.

Along the way: the global apps registry now drives Settings' default-tab
picker instead of a hardcoded list, and the video_root is configured from
group Settings (like uploads) rather than from Files, with the node
refusing to run any TMDB/thumbnail work until one is set.

Fixes several bugs found via live testing against a real library, notably
a race between two effects writing the same "image ready" state that could
leave a poster grid spinning forever on a same-tab revisit — see
mediacenter.md §5.4 for the full account of each one.
</content>
</entry>
<entry>
<title>feat(hub): split the group UI into a pluggable "applications" architecture</title>
<updated>2026-08-23T13:15:35Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-23T13:15:35Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83'/>
<id>urn:sha1:9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83</id>
<content type='text'>
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).

Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.

Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:

- group-page.js called onRefreshAuth on a stale-token handshake rejection,
  but app.js never imported refreshAccessToken from hub-client.js — so a
  brand new member (including a group's own creator) hit "Not a member of
  this group" and the retry silently failed, throwing before it could
  refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
  it from i18n.js. Opening Chat on a group with real messages threw mid-
  render; uncaught, that appears to wedge Preact's render scheduler, so
  every button on the page stopped responding until reload.

Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
</content>
</entry>
<entry>
<title>feat(ui): the M of the wordmark is the logo</title>
<updated>2026-08-19T00:54:05Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-19T00:54:05Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3a54cce7df3bf86e0e900865b880855845c5f766'/>
<id>urn:sha1:3a54cce7df3bf86e0e900865b880855845c5f766</id>
<content type='text'>
The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay`
beside it stays text.

**The source artwork is now in git.** It was in `QE/`, which is gitignored, so
it would not have survived a clone — and the tree is about to be moved to
another machine. `assets/brand/` holds the original and the `convert` command
that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown
at 30, so it survives a 2× screen). Verified: the documented command reproduces
the committed file byte for byte.

The derived file is in `_ASSETS`, which is what the `/a/&lt;hash&gt;/` fingerprint is
computed from. A file missing from that list is a file whose change never moves
the URL, so a browser holding the old one never asks again — the comment on that
list says so, and a logo is exactly the kind of asset one would forget.

Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and
the application's `app://` scheme both come out right without either being named
in the source.

**The two sizes are independent, and that took four rounds to get right.** The
picture's height was written in `em` — a fraction of the lettering — so every
adjustment to the text resized the picture by the same stroke and the ratio
between them could never change. The operator spotted the coupling before I did
("si on change la taille du M, tu vas encore tout décaler"). The height is in
pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move
one thing each. The vertical nudge went with it — it was correcting a
misalignment I was causing myself.

Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat
brighter than the picture next to it, which made the two read as different
materials rather than one object.

A note on how this went wrong first: I checked the result on a mock HTML page I
wrote, not in the application. The picture was loading the whole time, but at
25px against a 29px line box it was *smaller* than the letters, so the wordmark
read as unchanged and the report was "none of what I asked for was done". Every
size since has been measured in the running window over CDP.

883 tests pass.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(client): the platform seam, and an Electron shell that has never been run</title>
<updated>2026-08-18T07:42:34Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T07:42:34Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=30e855f55f1d920b25da0bdd8e538c249d3c0c26'/>
<id>urn:sha1:30e855f55f1d920b25da0bdd8e538c249d3c0c26</id>
<content type='text'>
Stage D, and the honest half of it.

D1 — the seam (done, and verified)
----------------------------------
`static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is
built with the same base, so one address has one source. In a browser it returns
'' and every path stays relative to the origin that served the page — the
acceptance criterion for this split was "the browser SPA behaves identically",
and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the
content hash and a cached browser would never ask for it.

D2 — the shell (written, never launched)
-----------------------------------------
**There is no npm on this machine. Electron was never installed and
`packages/meshbay-client/` has not been run once.** That is stated here rather
than discovered later.

What is there: a main process serving the packaged interface over a privileged
`app://` scheme (`secure` and `standard` are not cosmetic — without them the
service worker refuses to register and streamed downloads break silently), a
preload exposing an enumerated bridge that never passes a filesystem path, a
window with `sandbox`, `contextIsolation` and no node integration, navigation
away from the package refused, and a CSP where the hub is reachable over
connect-src and is not a script source. The hub address arrives as a process
argument because `platform.hubBase()` runs before anything can await.

`test_desktop_shell.py` pins each of those by reading the source — the treatment
`test_downloads.py` already gives the three browser save paths. It catches a
property being removed and proves nothing about the application running. Two
were checked by breaking them.

The interface is *copied* into the package by `build/sync-ui.js` from the hub's
static directory, and `ui/` is gitignored: a silent fork is the only real way to
end up maintaining the interface twice.

D3 — partial
------------
The bridge, and the part worth having now: safeStorage's backend is reported
rather than assumed. On Linux it falls back to a fixed key when no keyring is
running, silently — someone who believes the OS is holding their keys is told
when it is not. The native key lifecycle belongs with D4 and needs a running
application to mean anything.

D8 — partial, and a real defect found
--------------------------------------
`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry
`User=`; systemd refuses the file, so the packaged unit could never have
started. Nothing noticed because nobody had built and installed the RPM.

Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service`
that a person enables themselves without a password — which is what lets the
desktop client install a node without asking for one. It carries ExecReload, so
`meshbay-node reload` does not have to stop a service somebody is streaming from,
and documents the drop-in for a drive outside the home, RequiresMountsFor
included.

798 tests pass; e2e.py still passes end to end. Nothing here was built or
launched: no npm, no rpmbuild.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(hub): serve the SPA under a fingerprint of what it is</title>
<updated>2026-08-16T18:57:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5dea19d9950518887be7eb14696f600ee023dbbd'/>
<id>urn:sha1:5dea19d9950518887be7eb14696f600ee023dbbd</id>
<content type='text'>
`Cache-Control: no-cache` requires a browser to revalidate, but it only binds
one that asks. A browser that cached app.js before that header existed applies
heuristic freshness instead — a fraction of the file's age, which for a file
dated weeks ago is days — and never asks. It then runs an old player against a
new node.

That cost most of a session. A phone kept a player without the read-ahead bound
and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound
had been written to remove, for an hour after the bounded player went live. A
fix that is written, tested, deployed and served, and still not what runs, is
indistinguishable from a fix that does not work.

The whole module graph now lives under `/a/&lt;content-hash&gt;/`. A path prefix
rather than a query string, because relative imports inherit it: `app.js`
reaching for `./i18n.js` gets the build it was written against, and never a
mixture of two — which does not render a stale page, it fails to link. The URL
changes with the content, so those may be cached hard.

`sw.js` stays at the root. Its scope is its own path, and under the prefix it
would no longer control the pages whose downloads it exists to intercept.
</content>
</entry>
<entry>
<title>feat(client): Argon2id for the keypair bundle, and remove the backup toggle</title>
<updated>2026-08-14T10:42:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T10:42:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=86563d5db0ae19fc58336b71a5c29a8712973590'/>
<id>urn:sha1:86563d5db0ae19fc58336b71a5c29a8712973590</id>
<content type='text'>
Two corrections to yesterday's judgement, in the order they matter.

**The toggle is gone.** Asked to make the remote key backup optional, I shipped a
setting whose "off" position meant: no second browser, ever, and clearing your
storage destroys the account. I wrote the warning that says so without drawing
the conclusion. A control whose only effect is to break the ordinary case is not
a control, and removing an exposure by removing the feature is not a fix. Every
browser backs its keys up again, unconditionally.

**The exposure is fixed where it actually lives: the KDF.** The keypair bundle
rests on every node whose group its owner joins, protected by the passphrase
alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is
exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and
Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only
one of them forces an attacker to find 64 MB per guess.

So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly
build (no external host — the CSP forbids one, and 12.2 will tighten it further).
Parameters chosen by measurement through that build: 19 MB is OWASP's floor at
118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should.

What this buys, stated honestly: cracking a bundle yields the owner's identity
keys, and with them content on OTHER nodes and the ability to sign as them — not
the content on the operator's own node, which they host in the clear by design.
Argon2id raises that price steeply; it does not remove it, and a weak passphrase
still loses. Hence the floor raised to 12 characters and ~60 bits in the same
breath, which can only be enforced client-side: with the password split (T1) the
hub never sees a passphrase.

Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old
form is still readable, and is re-encrypted the first time a browser backs it up.
Both keys are derived at sign-in, because which one a bundle needs is only known
once it is read and the passphrase is deliberately not kept around.

Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in
QE — so a parity test holds them byte-identical. A disagreement would not look
like an error; it would look like an account nobody can open.

keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing
your data from a node, exercised end to end, and it will belong to a deliberate
"forget me on this node" action rather than a setting that quietly disables
multi-device.

Verified against the live deployment: the full workflow passes, including
recovering keys on a second client from the passphrase alone.

Tests: 341.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
