| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from the native client: the reCAPTCHA box renders
"ERROR for site owner: Invalid domain for site key". The web browser is fine.
It is not a client restriction, and the CSP was never what refused — the
script loads, which is why the widget appears at all to say so. reCAPTCHA
validates the hostname of the page the widget is rendered in against the
domain list on the site key, and the desktop client's interface ships inside
the package and is served from `app://meshbay` (main.js: `win.loadURL`). Not a
preference: file:// breaks ES modules and IndexedDB, and the hub must never
become the document origin. So the hostname Google sees is `meshbay`, it is
not on the key's list, and it never can be — the check runs on Google's
servers and nothing client-side reaches it.
The fix turns that check off on the key and does it on the hub instead:
[captcha]
allowed_hosts = ["meshbay.org", "localhost", "meshbay"]
`verify_captcha` refuses a solve whose hostname is not in the list. The
hostname comes from `siteverify` — what Google observed, not what the caller
asserts — so it is a real check against what turning the console setting off
opens, which is a bot rendering the public site key on a page of its own.
Empty (the default) skips it, so an existing hub upgrades unchanged with
reCAPTCHA still doing the origin check. The two settings go together, and
docs/captcha.md §6 says so.
The `meshbay` entry is the weak one and the doc says that too: any Electron
application can claim the same scheme and host, as main.js already records.
What it still costs is a captcha solve per token inside a real Chromium
instead of a token farmed from any web page.
docs/captcha.md §6 replaced. It documented a design that was superseded twice
— an `auth_key`-keyed carve-out that turned out to disable the gate for
everyone, and "works in the Electron client too, both run Chromium", which is
the assumption this bug is made of: reCAPTCHA validates the domain, not the
rendering engine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.node-page` carried `max-width: 700px` of its own while Settings, Profile
and the create-group wizard take `.main`'s. The audit tab is a six-column
table — timestamp, event, user, IP, group, detail — with every fixed-shape
column set `white-space: nowrap` so an IP is never clipped, so at 700px it
scrolled sideways inside `.node-table-scroll` with a couple of hundred pixels
of `.main` empty beside it. Measured at 1024: 596px of table box where the
page had 856px to give.
The rule goes; the class stays as the anchor for the assertions.
`.node-group` and `.settings-section` are already the same rule twice over
(same background, border, radius, padding), so the two pages now line up card
for card.
test_node_page_width_measured.py: the Node page and a Settings page are the
same width at every width from 320 up, their cards the same rectangle, the
audit table no longer wider than its scroller at 1024, and — the narrow case
being the design rather than a regression — the table still scrolling inside
its own box on a phone without pushing the document sideways.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported live: a show with a dozen seasons opened on season 6. Every season
was in the picker and none was missing — the default was wrong.
VideoDetailModal took it from `repEntry.season`. `repEntry` is the show's
"representative entry", which the poster grid picks as
`episodes.find((e) => e.thumb_hash) || episodes[0]`: the first episode that
has a thumbnail, so the card has a fallback frame when TMDB has no poster.
That is from the original Videos commit; the season tabs came later and read
the same entry as "the episode the reader is looking at", which it never was
on that path. Episodes are sorted by (season, episode), so a show whose first
five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg
failing on those files — hands back a season-6 episode.
`defaultSeason(show)` reads the season list and nothing else: the lowest
season present, specials only when there is nothing else, and the lowest
*number* rather than the first entry so it does not quietly depend on
buildSeasons keeping its sort. The effect's dependency on repEntry goes with
it — nothing in it reads that any more.
test_video_default_season.py runs the function in node. No input it takes can
carry a thumbnail, which is the point. docs/mediacenter.md §10.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The previous pass fixed the synopsis and the cast, and the dialog still
jumped: the episode count moves things a fixed-height synopsis cannot reach.
- The body scrolled as a whole, so a thirteen-episode season pushed the modal
to its max-height where a six-episode one had not. `.video-overlay` centres
its child, so the taller modal also *started higher up the screen* — title
bar, close button and all. `.video-detail-steady` (a multi-season show only)
gives the modal a height rather than a max-height, makes the body a flex
column, and hands the leftover to the episode list as the one scrolling
part. A constant-height box is centred in the same place every time, so both
halves settle at once.
- A scrolling season draws a scrollbar where a non-scrolling one draws none,
which is a scrollbar's width of content and re-wrapped the file path above
it, shifting everything below by a line. `scrollbar-gutter: stable`.
- The season panel was clipped by the modal's own `overflow: hidden` whenever
the seasons outran the room under the picker — at a 740px viewport it wanted
320px and had 288, and the rest sat where no scroll could reach it. It is
`position: fixed` now, placed by `placeSeasonPanel()`, which takes the
trigger's rect and the window height, picks whichever side has more room,
and caps the panel to it.
Scoped to multi-season shows throughout: a movie has no season to switch to
and a fixed height would buy it nothing but empty space.
test_video_detail_measured.py now builds each block inside a real
`.video-overlay`, since the centring is half the defect, and asserts the modal
top and height as well as the picker's offset — for a long and a short
synopsis and for a six- and a twenty-four-episode season.
test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and
a window height. Two guards are declarations rather than rectangles and say so
in their docstrings: headless Chrome gives the probe zero-width overlay
scrollbars, so the gutter cannot be measured there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a different season of the same show moved everything under the
synopsis, which is where the season control and the episode list are, so the
thing just clicked was no longer under the pointer.
- The synopsis is exactly three lines for a multi-season show, with a "read
more" link floated into the third line box (-webkit-line-clamp only ever
puts its ellipsis at the end of the last line and leaves no room after it).
Clamped from above and pinned from below to the same number: a constant,
not a range — a season summary runs two lines and the next one twelve, and
a band still reads as a jump. Whether three lines is all of it depends on
the modal's width, so it is measured in the browser and re-measured on a
resize.
- The cast is clamped to two lines.
- SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show
had more seasons than fit, which is close to unusable on a phone. One
trigger reading "Season 5 · 1997" and a menu of every season with its
episode count, one row high whatever the season count.
- media_meta_resp.director was filled from the credits crew's job ==
"Director", a movie shape. TMDB's aggregate tv_credits crew is routinely
empty and never carries that job, so every show answered null and the modal
dropped the line. It now comes from created_by on the show details. Cached
show metadata keeps its null until TMDB_META_TTL_SECS expires or an
operator re-matches.
The facts line is joined rather than concatenated (a title with no rating
used to open with " · ") and carries the show's own year next to the
director; the selected season's air year moved onto the picker.
test_video_detail_measured.py asserts rectangles through layout_probe.py, not
declarations: the picker's offset inside its own modal body is the same pixel
either way, the synopsis and cast heights, where the read-more link lands,
and the open menu at 320 px. Each measured block sits in a whole-pixel-height
container, or two identical layouts an eighth of a pixel apart round to tops
one pixel apart. test_tmdb_show_director.py covers the credit.
docs/mediacenter.md §10.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A group could open on a tab that rendered nothing: no panel, no tab shown
active, and nothing on screen to explain it.
The landing tab is chosen at mount from a preference -- default_tab for the
group, else the account-wide one, else 'chat'. Which applications the group
runs comes from the node, in the handshake ack, several awaits later. A
preference is a preference, not a promise that the app exists here, so the
two disagree in two ordinary cases: the group has Chat disabled while
'chat' is everyone's default, or the reader prefers an app this group does
not run. `apps.map(a => tab === a.key && ...)` then matches nothing.
The first app the group does offer answers both. Two more cases come free:
a preference naming an app that no longer exists, and an operator disabling
the app someone is currently looking at -- enabledApps changes live over
apps_enabled, and being moved to a working tab beats staring at an empty
panel. Settings is exempt: it is not an application, and the create-group
wizard lands on it deliberately.
`const apps` moves above the effect that reads it; a const further down
would be in its temporal dead zone, which is the hook-ordering trap already
recorded in CLAUDE.md.
tests/harness/group_tab_probe.py renders the real GroupPage against a stub
node answering a chosen enabled_apps and reads the tab bar back, over five
cases. With the fix reverted the three fallback cases report no active tab
at all and four of the six tests fail; the two that pass either way are the
ones that must not change -- a group running everything, and a preference
the group does honour (Videos stays selected, so the fallback has not
become "always the first app").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The chat could not be read back: any wheel gesture was undone in the frame
it happened in, and the "jump to latest" button never appeared.
None of the pins in ChatPanel are at fault -- every one of them is guarded
by "only if the reader is at the bottom". The reader never got to stop
being at the bottom.
fit() set the panel's height, read documentElement.scrollHeight back and
subtracted the overflow, so the document alternately did and did not
overflow the window. The page scrollbar appeared and vanished with it and
visualViewport fired resize at every pass -- the event fit() is bound to.
It therefore re-entered itself for the life of the panel: measured at 240
firings in two seconds on a page nobody was touching, against 2 for a bare
document. Each pass ran fitAndPin, which re-pinned the list to the bottom
before the scroll event that would have recorded the gesture was delivered
a frame later, so atBottomRef never went false.
- fit() learns the space below the panel once and remembers it on the
element instead of re-deriving it by writing and measuring back. At the
steady state it writes nothing, so it produces no resize. A real window
resize or an orientation change forgets the learnt value and measures
again (the page under the panel may have reflowed); visualViewport
deliberately does not, since a phone fires it constantly.
- The scroll-to-bottom is now scoped to *arrival*, which is all it was ever
for: opening the group, or coming back to the Chat tab, including the
thumbnails and link-preview cards that keep growing the list for a second
afterwards. It ends when the reader takes hold of the scroll, and the
ResizeObserver disconnects there.
- That release is recorded from the gesture (wheel/touchmove/pointerdown/
keydown), not from the scroll event, which arrives too late to protect
anything.
Unchanged: landing on the newest message, following new messages while
already at the bottom, the "load older" anchor and the unread marker.
tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser
and reads a conversation back; test_chat_scroll_up.py asserts against it.
With the fix reverted, five of its six tests fail and the sixth -- landing
on the newest message -- still passes, which is the property that must not
have been traded away. A structural test cannot see any of this, which is
why it is measured.
test_layout_responsive.py pinned the listener's name and follows the
rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
M6 in the third review was a misread. `add_group_member` accepting a
node-scoped token is deliberate (commit 0443cf8): the node calls
POST /v1/groups/{id}/members/{username} after a CLI `member invite` so
the group shows up in the invitee's SPA, authenticating with a
node-scoped token. `group.admin_id == caller` is the real guard.
An older test (`test_node_scope_blocks_add_member`) asserted the
opposite and had been left red on main; the M6 "fix" (commit 6b38704)
satisfied that test by switching the dependency to `require_user_scope`
— which made `ops.create_invite`'s hub-membership call 403. That
exception is swallowed with a log.warning, so an invited user silently
never lands in group_members and the group is invisible to them.
Reported from live testing (CLI `member invite grenet`, grenet saw
nothing).
Dependency back to `get_current_user`. The stale test now asserts the
intended behaviour: a node token may add a member to its own
operator's group (201) but not to a group it does not own (403).
Third-review M6 marked WITHDRAWN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The SPA shell and its assets went out with no Content-Security-Policy
and no X-Content-Type-Options / Referrer-Policy / X-Frame-Options — so
an injection that reached the SPA (rendered third-party OpenGraph
data, a federated group name, chat content) had nothing stopping it
from loading more code or exfiltrating to any host, and the page
could be framed by any site.
A middleware in `create_app` now adds all four to every response.
`webapp.CSP` is deliberately the *same* policy the desktop client's
protocol handler already enforces on these exact UI files, plus the
two reCAPTCHA hosts the sign-up widget needs: `default-src 'none'`,
`script-src 'self' 'wasm-unsafe-eval' <recaptcha>` (the hub's own
origin is not a script source — T3), `style-src 'self'
'unsafe-inline'` (htm/preact inline `style=` only, nothing executes),
`connect-src 'self' https: wss:`, `frame-ancestors 'none'`,
`base-uri 'none'`, `form-action 'none'`.
The shell's dead `<script>window.__MB_ASSET_V = ...</script>` is
removed (nothing has ever read it) so `script-src` needs no inline
allowance.
Needs verification against the running SPA — a mis-tuned CSP shows as
a blank page — but it matches a policy already proven with these
files under Electron.
Second-review L5 / third-review M5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A registered peer was trusted with more than "advertise your own
public groups":
- `receive_directory` set `source_hub` from `body.hub_id`, so a peer
could relay or spoof a third hub's groups into our directory. It is
now bound to the token's verified `iss`. The push is also capped
(500 groups/request, 2000/peer), rows are type- and length-checked,
and a federated id that collides with a local group is refused so it
cannot shadow one.
- `receive_revocation` forwarded the peer's token to local nodes,
which reject a token signed by another hub's key — a silent no-op,
and there is no local node hosting a federated group anyway. It now
verifies the inner token against the sending peer's key and, for
`target == "group"`, prunes our copy of the peer's directory entry
when `source_hub` matches. A peer cannot revoke our users or a group
it did not advertise.
- The state-changing endpoints (`POST /mhp/directory`, `/mhp/revoke`)
now reject a replayed `jti` within the token's TTL. Audience binding
is unavailable — the sending side that would set `aud` is unbuilt —
and this covers the replay concern in its place; the idempotent
`GET /mhp/directory` is not affected.
Third security review, finding M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every mutating group endpoint depends on require_user_scope except
POST /v1/groups/{group_id}/members/{username}, which depended on
get_current_user — so a node-scoped daemon token (or a stolen one)
whose subject owns the group could add any existing user to it,
contradicting NS7 ("operator manages groups from the browser only").
test_node_auth.py::test_node_scope_blocks_add_member already existed
and was red on main; it passes now.
Third security review, finding M6.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The server only checked the captcha when auth_key was absent — but
every real client (browser included, via the password split) sends
auth_key, so the check was off for everyone, and a bot skipped it by
including the field. The Register form still made humans solve a
widget whose token was never transmitted.
Gate is now unconditional on captcha.enabled. The web client
(registerUser in keyderive.js) forwards captcha.token; RegisterPage
resets the (single-use) token on a failed attempt. The desktop client
shares this UI source and is Chromium, so it renders the same widget
(see the paired meshbay-client commit for the CSP change that allows
it).
Tests: test_register_captcha.py.
Third security review, finding M1 (Option A).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
POST /v1/reports had no authentication and no rate limit, and counted
every raw report row toward AUTO_BLOCK_THRESHOLD regardless of who
sent it or from where — two anonymous requests naming any blake3 hash
added it to the hub-wide content blocklist. A network-wide censorship
and DoS primitive for anyone who learns a public file's hash.
- require a signed-in account (get_current_user)
- rate-limited (10/hour)
- threshold now counts DISTINCT reporting accounts (reporter_id),
one vote per account per hash; raised 2 -> 3
- refused outright (403) when the hub has public groups switched off:
a private-only hub brokers no public content and nothing syncs the
blocklist, so the endpoint would be pure abuse surface
- admin blocklist management (/v1/admin/blocklist*) is untouched, so a
manual block still works regardless of the public-groups setting
Noted while fixing: no node currently consumes ContentBlocklist
(swarm_register checks the separate CSAM list), so the network-wide
block effect was latent — the abuse surface (DB fill, poisoned
moderation signal) was live today.
Tests rewritten in test_moderation.py.
Third security review, finding H2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
admin_patch_user was gated by require_moderator but wrote `role` and
`status` with no further check. A moderator could promote any account
(an accomplice) to admin, demote an existing admin, or set
status="revoked" — a straight path from the moderation role to full
instance control.
Split authorization by field: status between active/suspended stays
at require_moderator (reversible content moderation); role changes,
status="revoked", and touching an admin's account at all now require
user_is_admin(current_user) (new helper in deps.py, alongside the
existing require_admin/require_moderator).
Regression test: test_moderator_cannot_change_roles_or_revoke.
Third security review, finding H1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
| |
All three packages (common, hub, node) bump 0.9.0 -> 0.10.0 together.
Highlights since v0.9.0: email verification for registration, email
change and invitations; passphrase change and account recovery;
reCAPTCHA v2 on Register and Password Reset; node JSON-only control API
with the Node page absorbing the admin dashboard; WebRTC STUN fallback
fix; assorted hub UI fixes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNPfgH6VWcRzJDZGuzy1jJ
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
No-group home/explore message: when the hub offers no public groups,
drop the "browse public groups" invitation and just ask to be invited
by an admin. New home.invite_only key added to all ten catalogues.
Create-group wizard done step: reword the message to point at inviting
members, and send the button to the group's Settings tab (where the
invite form lives) instead of a stale /groups/<id> path that never
matched the router. The landing tab is a one-shot session hint, so the
usual per-user default-tab preference is left untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNPfgH6VWcRzJDZGuzy1jJ
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The cross-group "Search files" view caps its WebRTC connection pool at
MAX_POOL_SIZE but pre-connected every indexed group, so any account with
more groups than the cap thrashed the pool. An evicted transport was
never removed from SearchPage's own groupConns map, so every tile of that
group kept a `_tRef` pointing at a closed transport; MediaThumb and
useMediaMeta bail on a disconnected transport with no retry, so posters
rendered for a moment then fell back to a loading spinner for good. Every
connect also fired the module-wide bumpMediaMetaGeneration(), clearing
every mounted tile's metadata across all groups and flickering the whole
grid through the warm-up walk. A one-group account (grenet) never hit it;
a many-group account (cbesson) always did.
- ConnectionPool takes an onEvict callback; SearchPage prunes groupConns
and rebuilds the entry lists when a connection is evicted or closed.
- MAX_POOL_SIZE 3 -> 12; the pre-connect walk is capped to it. Groups
past the cap connect lazily when a tile scrolls into view (onNeedConn).
- connectGroup() no longer fires the module-wide meta/thumb generation
bumps on a normal connect (kept for operator TMDB override/rematch).
Per-group gen counter + concurrent-caller guard: a group's tiles
refetch once per (re)connect, not once per mounting tile.
- MediaThumb/useMediaMeta take an optional reloadKey; each group's
`_connGen` is threaded through so a tile refetches when its group
reconnects instead of staying stuck on a spinner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Remove the node daemon's server-rendered admin UI (GET / and /audit, the
_render_* helpers and inline templates) and the `meshbay-node ui` CLI verb.
The loopback control API stays; it is now JSON only, ruff-clean, and 453
lines (was 1074). Also drop three never-wired endpoints (/api/config,
/api/chat/history, /ws/chat, plus broadcast_chat) and the pointless
18000/tcp firewall profiles.
The desktop client's Node page (static/node-page.js) takes over what the
dashboard showed, reorganised into six tabs (Overview, Groups, Roster,
Peers, Audit, Settings):
- Overview: version, node id, QUIC port, hub, index-cache maintenance
- Roster: node-wide view with unpin
- Peers and Audit: auto-load on open, no Load button
- Audit: real usernames and group names (resolved from the roster and
node.toml), Previous/Next pagination newest-first, Export CSV of every
matching row
- Settings: node settings, STUN, ICE, denylist, then Unlink from hub
Backend: audit.get_entries gains `offset`; /api/audit and /api/peers
resolve ids to names via a new _display_names helper; CSP tightened to
default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and
2.12 corrected -- the Node page uses the loopback API, not MNP.
One capability is intentionally dropped: browser-based admin on a headless
server. The CLI covers every operation there.
See docs/refactor-node-ui.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
|
| |
|
|
|
|
|
| |
The clause was redundant with the surrounding UI context.
Removed from all 10 locale files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two bugs fixed:
1. `meshbay-node member invite <user>` created a local roster invite
but never told the hub to add the user to group_members, so the
group was invisible in the SPA. The node now calls
POST /v1/groups/{id}/members/{username} after creating the invite,
and the hub endpoint accepts node-scoped tokens (the admin_id
check is the real authorization guard).
2. The WebRTC handshake let a previously-pinned user reconnect without
a code even when a new invite was pending (e.g. after leave + re-invite).
Now any pending invite forces code entry, regardless of existing
member/pin status.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
Server-side verification module, CaptchaConfig in hub.toml,
captcha_site_key exposed via /v1/hub/info, useCaptcha() hook
in the SPA with stable DOM rendering (strength bar always present
to avoid Preact re-ordering the captcha widget). Native clients
(auth_key path) skip captcha. All 10 locales updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The passphrase derives two independent client-side values: auth_key (the
hub verifier) and bundle_key (AES-GCM key for the per-node identity
bundles, which live on nodes and never on the hub). Changing or
recovering a passphrase is therefore two operations — swap the hub
verifier, and re-wrap every reachable node's identity bundle.
Flow A — change a known passphrase (Profile page)
- POST /v1/users/password re-proves the current passphrase, swaps
pw_hash/salt/version, revokes every refresh token and returns a fresh
pair so the tab that made the change stays signed in.
- MeshBayTransport.rewrapAllNodes: for every group's online node, connect
with the old key, read the identity off the handshake, store it back
under the new key. Returns updated / unreachable / failed so the UI can
point at the operator-unpin fallback for the gaps. Always-shown
confirmation dialog listing reachable and unreachable groups.
Recovery key
- keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and
deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>).
- Every per-node identity gets a second copy wrapped under the recovery
key: keypair_bundles.bundle_enc_recovery (node-only column, added in
_SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs),
carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive.
- session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded
on connect, so a group joined in any later session still leaves a
recovery copy.
- Shown once at registration; optionally folded into the verification
e-mail as a pass-through the hub never stores or logs, with an opt-out.
- Profile -> Recovery key re-loads R and backfills every reachable node
via rewrapAllNodes in bundleKey mode (no passphrase re-entry).
Flow B — recover a lost passphrase (#/reset, linked from sign-in)
- POST /v1/users/password/reset-request {username, email}: both must be
the pair on file, checked against the blind email_hash (never
decrypted). A mismatch — wrong e-mail, unknown username, non-active
account — takes the identical no-op path (no code, no mail, same 200),
so it reveals nothing and cannot be used to spray reset mail from a
username alone. 5/min, 1-hour single-use code.
- POST /v1/users/password/reset {username, code, new_auth_key}: same
expiry / attempts / single-use checks as e-mail verification; revokes
every session and deletes every registered device key so a stored one
cannot sign back in past the reset.
- ResetPasswordPage: request code -> code + optional recovery key + new
passphrase -> reset + sign-in -> fan-out. connect() falls back to the
recovery-wrapped copy when the passphrase key cannot open bundle_enc.
Without a recovery key: sign-in is restored and each group needs the
operator-unpin fallback.
Supporting fixes (found in live testing)
- member unpin now also deletes the keypair bundle; connect() mints a
fresh identity when handed a bundle it cannot open (unless _rewrapOnly,
set by rewrapAllNodes), so a rejoin completes instead of dead-ending
before the invite-code prompt.
- A browser with no bundle key gets a passphrase prompt on the group page
instead of a "go back to the browser you registered on" message.
- RegisterPage / LoginPage / ResetPasswordPage trim the username so every
key derivation matches the hub's stored form.
Docs: docs/auth-confirm.md. Locale keys across all ten catalogues.
Tests: test_password_change, test_password_reset, test_recovery_email,
test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus
additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492
passed; node suite 741 passed (the lone test_packaging_units failure is a
pre-existing RPM-spec flake, reproducible on main).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Registration now creates a pending account and sends a 6-digit code via
email; the account activates only after verification. Email changes on
the profile page follow the same flow. Group invitations send a
notification email to the invitee (without revealing their address to
the inviter) containing the invite code and hub link.
Backend: blind HMAC-SHA256 email index for uniqueness without decryption,
mail.py for localhost Postfix delivery, verification endpoints, cleanup
of expired codes and stale pending accounts, startup backfill of
email_hash for existing users.
Frontend: 3-phase register page, inline email change verification on
profile, invite-notify call with status display. All 10 locales updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
| |
Packaging system complete and verified on Ubuntu 26.04 and Fedora 44.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
| |
Admin page is lazy-loaded so non-admin users never fetch it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
Replace checkboxes with the existing toggle-switch component for
notifications, keep-screen-on, and allow-public-groups. Remove the
"Notifications" label from per-group lines, and shorten the
music-keep-screen-on and hub-URL hints.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The ice_interfaces setting (auto-exclude vs explicit whitelist) is now
editable from the Node page, persisted via the settings API and roster,
and hot-swapped at runtime by re-installing the aioice filter.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
The WebRTC transport relied on a single Google STUN server — if it was
unreachable, ICE gathering waited the full 4s timeout. Now four public
servers are used by default (Google ×2, Cloudflare, Mozilla), configurable
via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add|
remove|reset). Changes are hot-swapped on the live transport.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Move NodePage and NodeServicePanel from app.js into node-page.js,
following the existing pattern (group-page.js, search-page.js).
Lazy-loaded via dynamic import so browser users never fetch it.
Replace all MNP/WebRTC calls with platform.node.call() (loopback HTTP
API), eliminating the ~6s ICE gathering delay. The MNP protocol types
and server-side handlers are kept for potential future browser use.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Real franchise / show / release-group names had crept back into test
fixtures, code comments, a docstring and docs/mediacenter.md while fixing
the saga-match and misclassification bugs. Replace them all with invented
placeholders ("Some Saga", "A Different Show") and shape descriptions
("a franchise-origin film", "a 3-season show"). Behaviour and assertions
unchanged; 738 node tests still pass.
Record the rule in CLAUDE.md so it stops recurring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
V12 collapsed movies that TMDB resolved to the same tmdb_id into one
card. With the matcher still imperfect that fuses *different films*:
every numbered entry of a saga whose bare title resolves to the same
base id becomes one card, and two unrelated movies sharing a title do
too (seen live on a 9-film saga and a 2-film pair). A tmdb_id-keyed
merge only works once matching is reliable, which it is not yet.
Movies render one card per file again; VideoDetailModal loses the
`files` prop and the versions list, back to a single Play button;
`.video-version-list` and the `video.versions` key (×10 locales) are
removed. mergedShows (V6) is untouched — the operator's report was
about movies.
docs/mediacenter.md §10.1: V12 un-struck, marked reverted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
| |
.video-fix-match carried `margin: 8px 0` from when it was a standalone
button; now that it sits in the .video-admin-actions flex row next to the
plain .admin-btn "Re-match", `align-items: stretch` (the flex default)
made the margined button 16px shorter than its neighbour — "Re-match"
looked twice as big. Drop the margin (the row already has its own),
pin the font-size to .admin-btn's, and center the row.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
In a real group the node streams every index change to connected clients
as it happens (onIndexSync / onIndexDelta), so a manual re-fetch button
there just duplicates the live push. The cross-group Search page has no
such connection — its file list is a one-shot cached fetch — so the
button belongs there and only there. Gated on a new explicit `showRefresh`
prop that only search-page.js passes; `onRefreshIndex` stays in
commonProps for ChatPanel's own use after an attachment upload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The refresh button (added in 1cb9054, left of the breadcrumb home icon)
was gated on `onRefreshIndex`, which the Search page's embedded FilesPanel
did not pass — so it never showed on the one page it was asked for. The
Search page now passes an `onRefreshIndex` that re-runs its all-groups
index fetch (the only cache it has); the existing progress bar is the
feedback. Unchanged in the normal group Files view.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes,
max_concurrent_streams and transcode_incompatible_video in the Node
management panel. Changes are applied immediately via roster.db and
written back to node.toml so they survive a DB wipe. On startup,
roster overrides take precedence over node.toml defaults.
Draft v6 §2.11 documents the design; MNP gains node_settings_set /
node_settings_set_ack for the browser path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |\ \
| |/
|/| |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
1. The "Search Files" <h2> is removed from the search page — it duplicated
the nav and cost a line of vertical space on mobile. `search.title` is
dropped from all ten locales (its only use).
2. A monochrome refresh button sits left of the Files breadcrumb home
icon. It calls `onRefreshIndex` (re-fetch the group index from the
node — the file list is a view over a cached copy with no other way to
pull a fresh one), spins while in flight, and only renders where an
`onRefreshIndex` is wired (the real group view, not the cross-group
search results). New `refresh` icon; `group.refresh_index` in all ten
locales.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not
tell "this is a movie" from "this video isn't enriched yet" — both have
season/episode None — so during a slow initial scan with a browser on the
Videos tab, every show episode requested was run through the *movie*
search path with its raw filename as the query
(`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second,
until TMDB rate-limited and posters stopped loading. Worse, an
un-enriched show episode's own valid cached "tv" match was treated as
stale (its provisional kind was "movie"), so a file already resolved got
re-queried anyway.
- While a video is un-enriched (no display_title — enrich.py always sets
one), never run a TMDB *search*. Serve the cached match if the content
hash has one, honouring the cached kind ("tv"/"movie") rather than the
provisional split; otherwise answer confidence 0.
- Once enriched, the strict `cached_media_type == media_type` check is
unchanged: an enrichment fix that reclassifies a folder movie->tv still
drops the stale match and re-resolves.
- video-app.js: `useMediaMeta` gains an `enrichSig` dependency
(`entry.display_title`) so the client refetches once the enriched fields
arrive on an index delta — the fileId is a content hash and never
changes, so nothing else would retrigger it.
Not caused by the V8-V13 work; it raised the per-file call count so the
pre-existing race became a visible storm.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A one-click alternative to the full "Fix match" search-and-pick flow, and
reachable without SSH (`meshbay-node video rematch` clears a whole group).
- MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node
logs "unknown type", the button just does nothing). OP_TMDB_REMATCH,
signed like tmdb_override (media_cache is shared node-wide).
- media_cache.drop_tmdb_match(file_id): forgets the match AND the
override marker — deliberately stronger than clear_file_tmdb, since the
operator is explicitly asking for a fresh resolution.
- webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch +
admin-response routing, broadcasts tmdb_rematch_ack.
- transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the
admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack.
- video-app.js: a "Re-match" button beside "Fix match" in the detail
modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one
key in all ten locales.
docs/mediacenter.md §10.1: V8–V13 marked done.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two movie files that TMDB resolves to the same id (the same film at two
resolutions, or the same rip in two folders) now collapse to one poster
card, mirroring the existing show merge — same keying discipline so an
unmerged movie keeps its card and a merge updates props rather than
remounting. The detail modal lists the versions (resolution · duration ·
size), each a Play button, when there is more than one; a single-file
movie is unchanged. New `video.versions` key in all ten locales.
Known edge, noted in §10.1: "Fix match" on a merged movie corrects only
the representative file; the other version un-merges and can be corrected
on its own.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
A batch of wrong poster-grid matches found live on a real library
(2026-08-29): a two-volume film's second part matched the first; a
numbered sequel matched a same-year making-of documentary; several
entries of one franchise matched a single early entry whose localized
TMDB title is the franchise name; one matched nothing. One mechanism:
_tmdb_search returned the first candidate query whose title-similarity
ratio merely cleared 0.6, before alternative_title / the Roman-numeral
variant was ever tried.
Matching:
- title_parse: fold guessit's volume/part number back into display_title
so the parts of a multi-part film stay distinct in the query, the card
and the override.
- _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one
request), otherwise score every candidate query and pick the best. A
year-exact rescue lifts a sub-0.6 top hit to the confidence floor only
when TMDB's own year-filtered result lands exactly on the filename's
year. No local re-ranking of any single result list; no tmdb.py change.
Fix match / rematch:
- _admin_exec_tmdb_override: a movie override touches its own file only
(guessit gives a whole franchise one display_title); a show override
still fans out. Corrected files are marked in media_cache.tmdb_override.
- media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches
drop auto-resolved matches while sparing manual corrections.
- ops.rematch_video + `meshbay-node video rematch` (loopback endpoint +
CLI verb): re-resolve a group's video matches after a matcher fix.
file_tmdb is keyed by content hash and otherwise only pruned on
deletion, so nothing dislodged a cached match before.
- a rename now drops the stale auto match too (daemon
_reenrich_renamed_video_entries).
UI:
- VideoDetailModal shows the source filename and resolved TMDB id; an
unmatched poster gets a badge (3 new video.* i18n keys x 10 locales).
So a wrong match can actually be identified before hitting Fix match.
docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog
(show-branch ladder, year-aware _best_match, wider sequel_variants, the
0.6-0.85 extra calls, movie grid merge, per-card rematch).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
spinner
Three regressions fixed in ChatPanel:
- Scroll pinning: layout effect now depends on [messages, hasMore] so the
"load older" button appearance triggers a re-pin; setHasMore is called
before setMessages to avoid an intermediate render without the button;
fit() is wrapped in fitAndPin() so panel resizing re-pins the scroll.
- Message loading on group switch: fetch effect depends on the status prop
instead of transportRef.current?.connected to avoid racing with
GroupPage's cleanup; loadedRef resets on fetch failure so a retry works.
- Connecting spinner restored in the Chat tab empty state.
Adds structural regression tests (test_chat_scroll_bottom.py) that lock
the dependency arrays and setState ordering so these invariants break
loudly in CI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |/
|
|
|
|
|
|
|
| |
App components were keyed by app name only (e.g. 'chat'), so switching
groups while staying on the same tab reused the component instance —
loadedRef stayed true and messages state was never cleared. Now keyed
by app name + groupId, forcing a clean remount on group change.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wizard (Electron):
- Auto-provisions node config (hub URL + username) from logged-in user
- node:start handles both cold start and restart of misconfigured daemon
- Waits for daemon to reach 'running', auto-links node key on hub
- probeNode accepts intermediate states for wizard progress feedback
Reset (meshbay-node reset):
- Unlinks node key from hub (DELETE /me/node_key, best-effort)
- Stops and disables daemon (systemctl --user disable --now)
- Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay
MusicBrainz contact:
- Resolved from owner's hub email instead of per-node roster config
- Removed musicbrainz_contact UI and WebRTC handshake field
- Removed set_musicbrainz_contact/musicbrainz_contact from roster
Node pairing:
- Added operator pairing banner on NodePage
- Added operator_paired flag to list_groups
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
chat_hist_resp checked only the single oldest pending entry across all
types. When switching tabs, keyed requests (media_meta_req, file_req)
from Videos/Music/Photos stayed in _pending and were older than the
chat_hist entry, so the response was dropped and the chat appeared empty
on return. Now scans for the first chat_hist entry instead. Same fix
applied to index_sync dispatch for consistency.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
Search-page connections (indexing and pool) now time out after 10s
instead of the transport's default 30s, so down nodes are skipped
faster. Downloads show a user-facing error when a group is unreachable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Downloads from search: FilesPanel accepts a getTransport callback for
cross-group file downloads. Select/Download are available in search;
delete and zip are hidden (readOnly). Each download connects to the
entry's group via the pool on demand.
Photos view: PhotosApp reused in search with the same pattern as
Videos/Music — synthetic root, per-entry transport refs, group badges
on album cards. Lightbox uses per-entry transport for full-resolution
image fetches. Zip download hidden in search context.
Pre-connecting: connections to all groups start as soon as indexing
finishes, regardless of the active view. Thumbnails cache across pool
evictions, so they're ready when the user switches views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|