| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The figure was a line beside the settings form, which is where it is changed
and not where it is watched. It sits with the other live figures under
Statistics now — four cards and, above them, a banner saying which of the two
ceilings has fallen. The two states are not the same to whoever is reading:
one means newcomers are turned away, the other means somebody locked out of
their account cannot get back in. The settings block keeps a line pointing at
it.
And an operator no longer has to be looking. When a global ceiling is reached
the administrators are notified — in `mail.py`, in its own session, never
raising, because this runs while a request is being refused and an alert that
fails must not turn a refusal into a 500. Once per hour, keyed on a row
rather than a flag in memory: a flood is what spends the budget, so one alert
per refusal would bury the message under its own cause, and a hub that is
refusing mail is a hub somebody is about to restart.
`/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than
leaving the panel to compare two numbers.
Labels in all ten catalogues; `.warn-msg` for the middle state, on the
`--warn` token both themes already define.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
They were constants in two modules, so an operator could not touch them
without editing code and redeploying — and the hour a budget runs out is not
when anyone wants to do that.
`[mail]` in hub.toml carries the defaults; the live values live in
`hub_settings`, read at each use. A missing row falls back to what the
configuration file says, so an instance that never opens the panel behaves as
its file describes. The panel sends only what changed, the hub clamps each
value to a stated range and refuses a key it does not know, and the response
is what gets rendered — so a clamped value is never shown as stored.
`GET /v1/admin/mail` is the other half. There was no way to see any of this:
a refusal was a line in the journal, so an instance that had stopped sending
sign-up codes looked, from the panel, exactly like one with no sign-ups. It
reports the hour's use, what is left for sign-ups, and what is left for
recovery — the difference between those two being the reserved share made
visible.
Labels in all ten catalogues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A second-machine sideload of the MSIX target surfaced three things the
earlier verification round (which only proved the package installs and
runs) had missed:
1. meshbay-node missing from PATH. installer.nsh's customInstall adds
node-runtime\ to HKCU\Environment at install time -- an unelevated
per-user write, never blocked by MSIX's no-elevation rule, only by the
more basic fact that an AppX/MSIX install runs no custom code at all.
packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus
main.js's winEnsureNodeOnPath() do it from the app itself instead, once
per launch, shipped to Full and MSIX (not Light, nothing to add there).
Verified live via the Node inspector protocol: the entry was in
HKCU\Environment\Path after a launch, absent before.
2. A daemon that crashes on startup failed silently. spawnNodeDetached()
used stdio: 'ignore', so a real crash reproduced live (a second instance
colliding with the first on 127.0.0.1:18000) left waitForNode()'s
generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched()
pipes stdio and watches ~2.5s, rejecting immediately with the daemon's
own stderr on an early exit; a survivor has its streams released and
runs fully detached exactly as before. First version bounded the
captured text by line count and a live test showed that cut the actual
OSError line -- two uvicorn/asyncio tracebacks followed it in the real
capture -- so it is bounded by characters instead.
3. No hint that a startup-mode choice exists. The install-time radio page
was the only place this was ever offered, and nothing replaces it now
that no install-time page can exist at all. SetupWelcome (the existing
first-run banner) grew a conditional hint, shown only while a bundled
node is present and neither autostart nor service mode is configured
yet. Considered and rejected: linking straight to the Node page -- its
route is gated on a linked hub node key, false on the exact fresh-install
screen this hint targets, so the link would have been dead on arrival.
New key setup.node_startup_hint, added to all ten locale catalogues.
test_packaging_win.py gained six tests pinning all three (69 total).
Full plan and verification detail: C:\Users\admin\devel\msix-installer.md
section 13 (out of repo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An administrator's deletion answered 409 for any account owning a group,
so an erasure ordered by an authority had to wait on the person it was
about. It now deletes the account's groups with it, then pushes a signed
revocation for the account and for each group to every connected node:
an access token already issued stays valid on a node until it expires,
and the revocation is what makes the nodes refuse the account and close
the groups' sessions now. The action is written to the IP log, and the
confirmation dialog says the groups go too, in all ten catalogues.
The owner's own deletion is unchanged: refused while they own groups,
which they can hand over first (CGU 3.4, privacy statement).
Deleting a group had three partial cascades. The owner's route left
email_verifications behind, and the cleanup of unhosted groups left
notifications, invitations and reports - each an IntegrityError on
PostgreSQL, invisible on SQLite, which does not enforce foreign keys by
default. db/purge.py is now the one implementation: it finds every table
referencing groups.id from the schema, deletes the group's rows and
detaches content reports, which are evidence and outlive the group.
test_group_purge.py turns foreign-key enforcement on for its connection,
seeds every referencing table, and fails without the fix on all three
routes. MESHBAY_DESIGN.md 7.7 states the rule, and now lists the device
keys and swarm sources that e3c68b3 erases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A small link to the hub's legal pages, pinned to the bottom of the
sidebar once signed in. It opens in a new tab: the desktop application
refuses to navigate away from its interface and hands a new window to
the system browser, and in a browser it keeps the session on screen. The
address comes from hubBase(), so it is the legal pages of the hub in use.
The sidebar now sticks under the navigation bar at the window's height;
otherwise, on a long file list, the link would sit at the bottom of the
page. The music bar, pinned to the bottom of the window as well,
publishes its height through useStickyBand as --music-bar-h and the
sidebar stops above it. On a phone the slide-out panel does the same.
test_sidebar_legal_measured.py measures the real stylesheet: the link at
the bottom of the window on a 3000px page, and above the music bar, at
phone and desktop widths. Checked signed in on a local hub in Chrome and
Firefox 155.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The browser sign-in page now carries the project's pitch beside the form:
what MeshBay is, the applications, what it is for, and what meshbay.org
does and never sees, with links to the downloads and the legal pages.
Text on the left and the form on the right on a desktop; one column,
form first, below 1000px. Not shown in the desktop application, whose
user has already downloaded it. All ten catalogues carry the text.
Every claim is held to MESHBAY_DESIGN.md 2.3: the page says content never
reaches the hub, not that the hub can read nothing (T3), and "end-to-end"
means device to node.
Signed out there is no sidebar, so the 960px main column sat at the left
of the window and the sign-in, register and reset forms were centred in
it (x=290 in 1440). `.page-center` pages now lift that cap.
test_welcome_layout_measured.py measures the real stylesheet in Chrome:
no horizontal overflow from 320 to 1440px, form first on narrow screens,
form right of the text on desktops, pair centred in the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`MNP_MIN_SUPPORTED` is the version this build speaks, so `check_version`
refuses everything below it at the handshake. Every capability the client was
gating on the node's version is therefore true of every peer it can reach:
* `supportsSealedUpload` — an upload is sealed or it is not sent;
* `supportsAppOps` — one `app_directories` op, and no `setVideoRoot` /
`setAudioRoot` / `setPhotoRoots` wrappers behind it;
* `supportsTransferSlots` and `Lease._skip()` — a lease is always real, so
there is no branch where a transfer runs without one;
* `legacyNode`, the read-only shared-directories table, and the two hints
telling an operator their node is too old to configure an app.
The version the node declares is still recorded, for diagnostics. Nothing
branches on it, and the comment says so, because a field kept "just in case" is
how the branches came back last time.
`test_mnp_1_0_node_compat.py` goes with them: it existed to hold the fallbacks
in place, and holding a fallback that cannot execute is how a suite starts
lying. The two locale strings for those hints are removed from all ten
catalogues.
Hub suite 872 passed (test_sticky_header deselected — failing before this).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from Chrome, with a screenshot: four downloads with no pause button
and an upload beside them with one, and nothing anywhere saying why.
The reason is real. Without a granted download folder the browser writes
through the service worker — a download it already owns, which cannot be paused
without stalling it somewhere we can neither see nor resume. An upload writes to
the node, which keeps the position, so it is always pausable. But that was
stated only in a Settings line nobody reads on the way to a download, and a gap
where the row above has a button is not an explanation.
So a download that cannot be paused now shows a dimmed pause icon where the
button would be, carrying the reason and the remedy in its tooltip. Not a
button: there is nothing to click, and a disabled one invites the click anyway.
And only where the advice can be taken. Firefox and Safari have no folder to
choose — the streamed path is the only target they have, which is what §6.5 of
~/next/improve-downloads.md costs out — so telling someone there to choose one
would be advice they cannot follow. Nothing is drawn.
Hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported while testing the flag day: pausing an upload put it under "Finished".
"Finished" was defined by exclusion — everything that is not running, queued or
preparing — so it swallowed `paused` the day pausing shipped. A transfer
somebody stopped on purpose then sat beside the ones that are actually over,
offering a resume button in the section of things that cannot be resumed, and
dropped out of the badge, which announced less activity than there was.
Paused is now its own group, in all ten catalogues, and counts as active: it is
not over, the person means to come back to it.
The three filters are lifted out of `app.js` and executed rather than described
in the test, and one case asserts that every status lands in exactly one group
— a state added later that falls into none is a transfer the panel simply does
not show, which is how this one got in.
The same report also said the three running downloads lost their pause buttons
when the upload was paused. That part is **not** explained and **not** fixed:
the store returns `pausable` true and status `running` for all three (new test),
closing an upload lease pumps only the upload queue, and the button's condition
is a pure function of those two. All three say the buttons should have stayed,
so an observation is missing rather than a cause.
Hub suite 864 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from testing 7a: pause worked in the desktop app and no button
appeared in Chrome. That is the design working — without a granted download
folder, "save automatically" means the service worker, and that target is a
download the browser already owns — but nothing anywhere said so, and choosing
a folder looked like a question of where files land.
So the Settings line now says what it costs not to choose one, in all ten
catalogues. It renders only where a folder can be chosen at all, which is
exactly the browsers the advice applies to.
Also pins the tier table the pause button is drawn from: a granted folder, a
save dialog and the desktop sink can be paused, a service-worker stream cannot.
Four cases through the real `_openDownloadTarget`, and one more that reads the
value off the real `downloads.js` rather than a stub of it -- the first version
of these stubs did not carry the field at all, so the cases would have passed
while checking nothing.
Hub suite 847 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the
targets that can actually do it. Resuming across a reload is 7b.
A paused transfer holds **nothing**. Its slot goes back to the node the moment
it stops and resuming rejoins the queue at the tail, because anything else lets
one member close a node by pausing four downloads and going to lunch. So the
lease is taken inside the run loop rather than before it, and pause is refused
outright for a transfer that could not ask for another one.
Resuming is exact rather than approximate: the pipeline stops between two
chunks and never inside one, so what is on disk is always a whole number of
chunks and `fromChunk` is a verified position. The failure mode being avoided
is a file that looks complete and is quietly corrupt.
The target has to survive it, so a pause no longer reaches the `abort()` that a
failure does -- that would delete Electron's `.part` or the file just created in
the granted folder, leaving nothing to continue. And the in-memory fallback
keeps its accumulated chunks rather than starting a second array.
The button is drawn only where the target says it can. A service-worker stream
says no, in its own code and for its own reasons: the browser is already writing
an HTTP response into its own download folder, not feeding it stalls that
download where we cannot see or resume it, and an idle worker is terminated
within seconds. Firefox and Safari therefore keep cancel and get no pause, which
is the decision recorded in §6.5.
Cancelling a paused transfer ends it. A paused run is parked on a promise;
without waking it the row said "cancelled" over work that had not stopped and a
target that was still open.
Six cases, each checked against the unfixed source. Hub suite 842 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Downloads on Firefox failed with "the worker did not answer the download within
15s", every time, for one operator, while the same profile driven from here
succeeded every time. Their own test sequence found it: a freshly started
browser downloaded four files out of four, twice; one Ctrl+F5 and every
attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and
the very first one failed.
A document fetched by a hard reload is loaded with the service worker bypassed.
It can still be claimed afterwards, so `navigator.serviceWorker.controller`
comes back and every check in `_claimController` passes — but the navigations
that document starts keep missing the worker, and the hidden iframe a streamed
download needs is a navigation. On Firefox and Safari that is the only way to
write a file too large to hold in memory, so the download cannot happen at all,
for the life of the page.
Being controlled is not being servable, so priming now asks the question
directly instead of inferring it: a four-byte stream and a hidden iframe,
exactly as a real download would, torn down completely so nothing lands in the
download folder. When it goes unanswered the page reloads once, ordinarily,
which puts it back under the worker. The flag lives in sessionStorage rather
than a variable because it has to survive the reload it triggers, and because a
page that is still unservable afterwards must stop rather than loop.
Also stops telling people to change browser. The message said "use the desktop
app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path
Firefox has no alternative to; all ten catalogues now say to reload first.
The hard reloads were on my instruction: the SPA's HTML is served `no-store`,
so a plain reload has always picked up a new build and Ctrl+F5 was never needed.
Hub suite 834 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Clicking Download produced nothing — no row, no icon, no panel — for as long as
it took to open somewhere to write, and then several rows at once. The streamed
path waits for the worker twice; a Save As dialog waits for a person. The row
was created after that, so the slowest part of a download happened with nothing
on screen to say it had begun.
The store gains a `prepare` step, distinct from `run`, and the order is now:
row, then target, then slot.
That last part is why the obvious fix was wrong. Taking the slot first would let
the row appear immediately, and it was tried this morning: a granted slot has to
be taken up within the node's deadline, opening a target can outlast it, and
three downloads became one. (The diagnosis at the time blamed that ordering for
revocations which were in fact a missing `touch()` call — the revert was right
for the wrong reason.) `makeLease` is called after `prepare` succeeds, never
before.
Three behaviours fall out, each with a test:
- a dismissed dialog leaves nothing behind. `prepare` returning false drops
the row: nothing started, so nothing should remain on screen to explain it;
- the row takes the name the file was actually saved under, once known;
- a refusal above the memory ceiling fails the row that is already there,
rather than creating one to kill it.
`preparing` counts as live everywhere — badge, cancel, clearFinished, and
`_busy`, since closing a transport under a preparing transfer strands it exactly
as under a queued one. Six places asked "is this finished?" and were drifting
apart; there is one definition now.
Two mistakes in the tests, worth the note: one counted positions in an output
array by hand and was one out, which reads exactly like a failing assertion
about the code — the values are tagged now, not indexed. And
test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the
size check the file is about; it now behaves like the real store.
819 hub, 1169 node, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots
since step 2 and nothing asked for one; now the client does, and the panel shows
what is happening.
`transport.openTransfer()` returns a Lease: `acquire()` resolves when the node
grants, `release()` gives it back exactly once, and nothing else in the client
speaks to the node about slots. Whether a node hands out slots is read from the
handshake ack rather than guessed from a timeout — "no answer yet" and "this
node will never answer" are indistinguishable in time, and guessing wrong either
stalls every download or defeats the cap.
Two things exist only because a queue can lie: a watchdog re-asks when a pushed
grant does not arrive (the node is idempotent on `tr`, so asking again is free),
and a grant for a transfer the page has forgotten is handed straight back rather
than held until the node's deadline.
The slot is asked for **after** there is somewhere to write, and that ordering
is load-bearing: opening a target takes thirty seconds of streamed-download
timeouts, or as long as somebody leaves a Save As dialog open, and a grant not
taken up in time is revoked. Moving it earlier looked better and broke three
downloads into one. Pinned by a test.
The panel groups by state — running, waiting, finished — rather than re-sorting a
flat list, so a row moves only when its own state does. The ETA is withheld until
the speed window holds real measurement: a figure from the first two chunks
swings between four seconds and an hour, and people plan around the first number
they see. One live region announces state changes and not progress.
Three silent paths closed on the way: a download refused for want of a user
gesture (a browser grants one file picker per gesture, and downloading three
files is one gesture) now falls back to the streamed path, which needs none; a
click with no connection says so instead of doing nothing at all; and a queued
transfer counts as busy, so a transport is never closed under one that is
waiting for a grant that could then never arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 3 of ~/next/improve-downloads.md. Step 2 built the pools with constants;
this gives them to the operator, in the two scopes they belong to.
**The pools are the machine's.** `[node] max_concurrent_downloads` and
`max_concurrent_uploads`, default 8, on the §2.11 pattern: node.toml for a
fresh install, a roster.db override for immediate effect, editable from the
Node page and from `meshbay-node transfers show|set`, applied live through the
one `set_capacity` step 1 fixed.
**The per-member cap is a group's.** How many transfers one member may run at
once here — on the node like every other group setting (not the hub, which
would have authority over someone else's disk; not node.toml, which is
hand-written and needs a restart), changed by a signed operator instruction
(`OP_TRANSFER_LIMITS`, subject "d=2,u=2" so what is signed names the outcome),
broadcast to the group, and read live by the pools.
That was the one thing step 2's shape could not express: `per_member` was a
single node-wide number. `group_limits` and `member_cap(kind, member)` make it
a lookup — the group's own value if it has one, the node's default otherwise —
and it is deliberately the only dimension that is not node-wide.
Three refusals, each with a test:
- **absent means the default (2), never "unlimited".** A group that predates
the setting coming back unlimited would leave the node-wide pool as the only
control, which is the situation slots exist to end;
- **zero is not "unlimited"**, and is not "this member may not transfer"
either: the floor is one everywhere, and the CLI says to revoke the member
instead;
- **an unreadable row reads as unset**, not as zero — the same discipline the
sealed messages follow, where a payload that does not open must never become
a default state on its own.
`handshake_ack` carries this member's own caps for this group, so the interface
can say "2 of your 2 slots are busy" instead of drawing a bare spinner. Absent
reads as "no limit known" and the hint is not drawn — never as "unlimited",
which would have the interface contradicting the node.
1164 node, 793 hub, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`pipelinedDownload` with no writable allocates `new Array(totalChunks)` and
keeps every decrypted chunk, so whatever `_openDownloadTarget` returns null for
is held whole in RAM. That floor had no upper bound: the
`!window.showSaveFilePicker` branch returned null at any size, so on a browser
without the File System Access API a 20 GB film went to memory whenever the
streamed path did not answer. Nothing logged, nothing refused; the symptom was
the tab dying, with no error attributable to this code.
MEMORY_CEILING is 100 MB and every `return null` in that chain now goes through
a guard that throws above it. The refusal names the size, the limit and why the
streamed path declined, and lands in the transfers panel as a failed transfer
rather than in a console nobody opens.
This is a guard, not a limit on what can be downloaded: with the streamed path
primed and retried (previous commit), a file of any size still goes to disk
progressively on every browser. Two things had to change for that to be true:
- the streamed path is now tried in "ask" mode too, for a file over the ceiling
on a browser with no Save As of its own. The mode decides whether to show a
dialog; it was silently deciding whether a film could be downloaded at all;
- FilePreview had no size check whatsoever — a multi-gigabyte PDF or .csv was
fetched whole, and the text branch decoded all of it to keep 500 000
characters. It refuses above the same ceiling and offers the download.
ZIP_MAX_BYTES (512 MB) and the ceiling do not contradict: the archive limit
bounds the archive, the ceiling bounds what may be built in the page, so a
400 MB zip is allowed when there is somewhere to stream it and refused when the
only route left is memory. The build-in-memory confirmation only appears below
the ceiling now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An arbitrary ceiling, not a technical one: the zip writer streams and holds
one chunk plus a record per file, so it would happily produce a hundred
gigabytes. Past half a gigabyte the honest answer is a subfolder at a time,
or the files individually.
Enforced in file-utils.js's downloadDirectory, which is the one
implementation behind every zip button — Files' single folder, Files'
multi-folder selection, and the Photos album button (docs/photos.md §3).
- Per directory, not per selection: Files zips a whole multi-directory
selection in one click, so an oversized folder is refused and its siblings
still download.
- Before _openDownloadTarget, so no save dialog opens for an archive that is
never going to be written.
- The bound is strict, so a folder of exactly 512 MB still goes through.
- Counted in the 1024-based units formatSize already prints, so the number in
the refusal is the number in the constant.
group.zip_too_large in all ten catalogues. test_zip_size_limit.py runs the
module under Node and pins the refusal, the inclusive bound, and that nothing
is asked or started when a folder is over.
The user guide's "a 40 GB folder costs 40 GB of disk" is no longer true and
now documents the cap instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages have been signed by the sending device since MNP 2.0, but a
reader had no way to know that the device belonged to the account the node
named: the signature proved *a device*, and `sender_id` was still the node's
word. This closes that for any account a client has already seen.
**What was blocking it was not effort — the evidence was not being kept.**
`_do_device_add` verified the countersignature that admits a second device and
stored only `added_by_pk`: *which* key approved, never the proof. And
`device_add_transcript` binds `nonce_node`, the approving connection's handshake
nonce, so even a stored signature was unverifiable by anyone who had not been on
that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added
before the migration's early return — which fires on every roster widened since
2026-08-18, i.e. all of them, so putting them inside it would have meant they
never arrived.
`group_roster_req`/`resp` relays, sealed under a new groupbox purpose and
answered to **any member of the group**, every live device of every active
member with the evidence that admitted it. The node decides nothing: it hands
over evidence and the client walks the chain from each account's root outwards
(`_verifyRoster`). That is deliberate — the node is the party the property holds
against, so it is not asked to assert trust.
Two holes the tests caught while this was being built:
- "no signature" was being treated as a trust root, so a node that writes the
roster could put any key in an account's row and have it laundered straight
into the verified set. A root is a device that names **no** countersigner.
- pinning only the verified subset at first sight raised "key changed" on
legitimate second devices whose countersignature predates this change. First
sight pins everything the node says, because that is what trust-on-first-use
means and an alarm that fires on normal events stops being read.
The property, and it must not be rounded up: **once a client has seen an
account, a node that later substitutes a key for it is detected. Nothing is
gained at first sight**, where there is nothing to compare against — the same
boundary `per-node-identity-v1.md` draws, unmoved.
The cost, stated because it is real: the roster is member-visible, so every
member learns how many devices the others hold and their public keys. It stays
inside the group, the hub is not involved, and it is scoped per group. A member
who cannot see the keys cannot check them.
User-visible surface: one notice, "this account is using a key you have not seen
before", in ten languages. Nothing else.
16 tests — 7 on the node (the evidence is stored, it verifies from the roster
alone, a fabricated device carries none, another group's members are not
disclosed), 9 running the shipped `_verifyRoster` under node against rosters
built by the shipped Python: a chain of three in any order, a signature by the
wrong key, one for another node, one for another account, and two fabricated
devices signing each other admitting nothing.
Tier 3 (operator-signed roster attestation) stays deferred, with nothing
depending on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.
There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.
Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.
Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.
Three prerequisites this needed, each a live defect on its own:
* The peer registry was keyed by user_id, so one account's second device
evicted the first and the broadcast skipped recipients by account — a
person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
(additive, signed, refused unless the key is a live device of this account in
the node's own roster) is what lets the node refuse a member claiming
somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
device linking had already broken deleting your own file from your other
device. It now authorizes against any non-revoked device of `uploader_id`.
Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.
Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The hint was added alongside the .app-save control, when an inert Save
was indistinguishable from an enabled one and the reader had no way to
tell "nothing changed" from "this is broken". The control now reads as
disabled on its own, so the sentence beside it is noise.
Removing it also removes what only existed to carry it: the .app-save-row
wrapper, its two style rules, and settings_app.no_changes in all ten
locales.
test_the_disabled_state_is_visually_distinct sliced the stylesheet on
.app-save-row; it now stops at the closing brace of .app-save:disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects behind one report — "you cannot always click Save, you do not
notice, and it does not work".
**It did not work, for Chat, systematically.** `_dispatch` resolves an admin ack
against the pending request and returns, which is right for an op whose caller
already knows the value it chose. Chat's pane calls `transport.setChatDirectory`
itself, so nothing told `group-page` anything: the node saved it, every *other*
connected client learned it from the broadcast, and the one that asked went on
showing an unsaved-looking draft. Clicking Save again just re-sent it. Same
shape as the root-ack bug, in a different message — so the set is now
`BROADCAST_ACK_TYPES`, named for the property that makes it true, and covers
`chat_directory`, `chat_link_preview` and `app_directories`.
**You could not notice, because Save was not visibly a button.** It carried
`btn btn-small btn-secondary`, and there is no `.btn` rule in the stylesheet at
all — so it took `.btn-secondary`: no background, a transparent border, dim grey
text. Enabled it already looked like a disabled control; disabled it was the
same thing at 40% opacity. Measured on a real page: enabled is now accent on
white, disabled is grey text on a plain border, and an inert one says why
("No changes to save") rather than leaving the reader to guess what the pane
counts as a change. Videos' second Save — the TMDB one — is the same control,
because two Save buttons in one pane that do not look alike is worse than
either looking wrong.
Verified by driving the real pane in Electron through the whole cycle: inert,
pick a folder, live, save, and the node's answer coming back to disable it
again. Four earlier readings said the enabled button was transparent; all four
were taken inside python's directory-listing page, which the app never runs in
— my scaffolding, not the code.
Hub suite only: the node package is untouched by this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every other test of the plugin architecture reads source for the *absence* of
app names. That proves nobody wrote a special case for Videos; it cannot prove
a genuinely new application works, because there was no new application.
This is one. It stores directories, appears as a tab, has a settings pane and
lists files, and the node has never heard its name outside a single allow-list
entry. Two files and one registry line, which is the claim
`docs/refactor-groups.md` §4.1 makes.
It ships hidden behind `?dev=1` (`dev: true` in the registry, the same opt-in
shape as transport.js's `?trace=1`). Registering it normally would put a toy
app in every operator's group; not registering it would prove nothing, since
registration is exactly what is claimed to be sufficient.
**Adding it found two places where the claim was nearly true rather than true,
and both are fixed by making the code less app-specific:**
`group-settings.js` fell back to the whole registry when a group had no
`enabled_apps` yet — which would have turned a hidden app on for everyone. It
asks `availableApps()` now.
`group-page.js` wrote out `videoDirectories` / `musicDirectories` /
`photoDirectories` by hand, so a fifth app would have needed that file edited.
It derives `<key>Directories` from the registry.
Neither was found by reading; both were found by adding the app, which is the
whole reason it exists.
Verified in a real Electron window as well as by the tests: hidden by default,
present with the flag, offered its own settings section, and listing exactly
the files under its configured folder and its subfolders — not the ones beside
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The same `static/` tree is the web page and the application, and Electron does
not implement `window.prompt`: Chromium leaves it to the embedder and Electron
declines. It does not return null — it **throws**. The call sat above its own
try, so clicking produced no folder, no error and nothing on screen to react
to. A dead button, which is exactly how it was reported.
Measured against this repo's own Electron 44 rather than assumed, because the
first two diagnoses this session were reasoned and wrong:
prompt('name?') -> Error: prompt() is not supported.
confirm('sure?') -> opens a real modal
alert('hi') -> opens a real modal
So `confirm` and `alert` stay — a dozen call sites depend on them — and only
`prompt` is banned. `test_no_prompt_in_the_spa.py` holds the whole tree to it,
with the near-misses it must not flag (`mkdir_prompt`, `promptForName`).
The name now comes from a field in the toolbar, which works in both clients and
can show the node's refusal beside the input instead of after a dialog has
closed. Navigating away drops a half-typed name: it would otherwise create the
folder somewhere the person is no longer looking.
The rest of the chain was verified end to end and was sound: `dir_create`
{dir,name} → the node's handler → `dir_create_ack`, and `list_dirs` walks the
filesystem rather than the index, so a folder with nothing in it appears on the
very fetch that follows. The field itself was then driven inside a real
Electron window — typing, Enter, the click, and the icon rendering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects from one screenshot, and a test for the class behind the first.
**The modal was transparent.** `.ftp-panel` asked for `var(--bg-panel,
var(--bg))` and this stylesheet defines neither — the palette is `--bg-base`,
`--bg-surface`, `--bg-raised`. An unknown custom property makes the whole
declaration invalid while the rule around it still applies, so the panel simply
had no background and the page showed straight through it. Three more of mine
were the same: `--bg-hover`, `--bg-input`, `--danger`.
**The field had no layout.** It was built on `.settings-row`, which is
`display:flex; justify-content:space-between` — so a label, a hint and a value
inside one end up spread across a single line in source order, which is how it
read as three unrelated fragments per app. It is its own block now, and the
chosen folders are a table borrowing `.shared-dirs-tbl`: these are lists, and a
wrapped run of chips gives nothing to scan and nowhere to put a per-row remove.
The operator is looking at two lists of directories on one page and they should
read alike.
`test_css_variables.py` is the general form. CSS fails silently and generously
here, and nothing checked. It found four more that predate this branch: the
unread-count badge (`--danger`) had white text on nothing, a transfer link had
no colour, a notification card had no rounding and no unread marker. Fixed, and
`--warn` and `--accent-bg` are promoted from literal fallbacks to real palette
entries at exactly their current light values.
Two of its own regexes were wrong before they were right — a scoped definition
written inline, and one preceded by a comment, were both reported as undefined.
A third check comparing the two palettes fired on `--border-focus`, a focus
ring the themes share deliberately; a heuristic that has to be explained away
on its first run is worse than no test, so it is gone rather than exempted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The SPA is served by the hub, so deploying the hub puts this client in front of
every node — including the ones not updated yet. That window is the normal
state for as long as an operator takes, and for a node someone else runs it may
be indefinite. Three controls were broken across it, and the failure mode is
quiet: an unknown message type is logged by the node and never answered, so the
click produces a thirty-second wait ending in a timeout with nothing on screen
to say the node simply cannot do this.
Files' Upload button read `root.writable`, which a 1.0 node does not send — it
says `upload`, the same answer under the older name. The button disappeared on
every un-upgraded node. It reads both now, and still respects an explicit
`writable: false` rather than falling through to the legacy flag.
The per-app folder pickers spoke `app_directories`. Videos, Music and Photos
each had their own message before that and those still work, so the page
chooses by version: an operator on an older node keeps the ability they had.
`video_root` and `audio_root` hold one folder, so several are refused with a
reason rather than stored as the first and silently truncated.
Root management — writable, removable, eject, plug — has no older equivalent to
route to, so the table goes read-only with a line saying why and pointing at
the `meshbay-node root` commands. Chat's two settings are new with nothing
before them and are hidden the same way.
None of this was inferred from a payload's shape: `_checkNodeVersion` already
parsed the node's version and threw it away, and it is kept now. Coupling a
capability to whether some field happens to be present is how a flag flips
because an unrelated payload changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz,
and one folder picker per app, each with its own draft state and save handler
saying the same thing about a different key. They are one file per app now,
reached through the `apps.js` registry, and the page that renders them names no
application at all: adding one is a registry entry and a settings file.
The line between the two is what makes that true. What every app has — folders
— the page does generically, through one `saveDirectories` bound to the app.
What one app alone has, its pane does itself with the transport it is handed.
An app that only needs directories touches neither `group-settings.js` nor
`group-page.js`, which is `test_app_settings_plugin.py`'s subject.
`settings-ui.js` exists because a pane importing the page that renders it is a
cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at
first render — a component that silently does not appear, the fault already
recorded in CLAUDE.md about hook ordering.
The flat depth-indented `<select>` of every folder in the library becomes a
modal tree. It asks the node for nothing: the tree is derived from paths the
client already holds, so it shows exactly what the group's index contains and
adds no folder-browsing protocol. For Chat's attachment folder — the one
directory that is written to rather than read — read-only roots are greyed
out, so the node's refusal arrives before the operator picks rather than when
somebody sends a file.
Videos and Music take a list of folders. A library on two drives could not be
described before; the only recourse was pointing the app at a parent containing
both, which pulls in everything else under it. The scalar shapes survive on the
wire alone, for a node speaking MNP 1.0, and the client reads them as a
one-element list.
Two things the tests caught that I would not have:
`test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached
through the registry rather than imported by name, they are exactly the files
nothing else would notice changing, and a stale one is served from cache with
no version bump.
And `node --check foo.js` does **not** reliably report a module syntax error:
it accepted `${/* ... */''}` — htm template syntax pasted into a plain object
literal — and reported success. A `.mjs` copy forces the module parser and
reports it. The suite had no syntax check at all, which is how that reached a
file; `test_spa_syntax.py` does it for every module now, and pins that the
loose path is not what it uses.
Suite: 12 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Review of the Phase 1 commit found the RO/RW model sound but three paths
unfinished, each of which broke the flow the phase exists to deliver. Plus
29 test failures it introduced and no coverage for anything it added.
Uploads went to the wrong directory. The node read a `root` field on
file_upload that no client ever sent, so every upload landed in the first
writable root while the Files toolbar offered its button based on the root
being browsed — with two writable roots, uploading from one wrote into the
other. Files now names the root it is showing; Chat names one chosen in the
shell (an operator-configured directory arrives in Phase 2); the node refuses
an unknown name rather than falling back, and refuses read-only and ejected
roots by code.
Shared directories were unreachable on the web. The table read its roots
only from the loopback API, which resolves to "not available" in a browser,
so the section rendered for nobody there — while the Uploads controls it
replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods
beside it were dead. MNP is now the path, loopback the fallback for a local
node with no live connection, and adding a root over MNP takes a typed path
since no web page can browse a remote disk.
Ejecting updated nobody's screen. transport.js resolves an admin ack against
the pending request and returns, which is right for every op whose caller
knows the value it chose; the root acks carry state only the node can compute,
so the operator who clicked Eject was the one client that never saw it happen.
And the ejected flag reached roster.db but was never read back, so a restart
undid it and the next scan read an empty mount point as an erased library.
Also: the member-upload endpoint answered 200 and did nothing (removed); the
wizard ignored the first root's RW switch; reload compared roots on name and
path, so editing writable in node.toml did nothing; the table had no path
column, which is the only thing separating two libraries sharing a basename;
apps_enabled normalisation differed between the two sides of a signed subject.
Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no
coverage at all. test_member_upload_policy.py is replaced by
test_root_writable_policy.py — it tested a removed feature — and every
property worth keeping from it moved rather than being dropped.
Docs: draft-v6 structural decision 9 is annotated as superseded (the operator
can no longer have a directory only they may write to — a real capability
removed, flagged rather than hidden), the man page documents the root verb and
the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong.
Suite: 41 failures before, 13 after — all 13 pre-existing on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Replace the upload boolean with per-root writable/removable/ejected flags.
Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol
messages, live RootSet updates so API always reflects current state, CLI
root subcommand (add/remove/set/list/eject/plug).
Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug
in Files and Settings, upload gated on root.writable, ejected-root filtering
in all media apps, updated Create Group wizard, 10-locale i18n.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The first icon was an arrow dropping into a receptacle, which is the download
glyph -- a vertical stem above a container reads that way whatever the context.
Replaced with a window folding a chevron into itself: no stem, and the frame
says which object is being minimised. Applied to both the nav button and the
panel indicator, which carried the same wrong shape.
The menu now offers Start or Stop for the node daemon, chosen from its actual
state and shown only when there is a daemon to act on: `supported && installed`,
so a machine with no node installed gets no entry rather than a control that
fails when used. `restart` is the start verb -- systemd's restart starts a
stopped unit, and there is no separate one to call.
The three service handlers become named functions so the tray drives exactly
what the Node page drives, instead of a second copy of the systemctl and Task
Scheduler branches. A read of the state that throws is treated as no control
at all.
The menu is rebuilt on a 5s timer while an indicator exists, and again straight
after an action. libappindicator has no "menu is about to open" event, so a menu
built once would show a stale Start/Stop for the life of the process; `systemctl
--user show` costs a few milliseconds.
Locales: tray.start_node / tray.stop_node in all ten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A dedicated monochrome button in the nav, immediately left of the notification
bell, hides the window to a tray indicator. Linux only for now; Windows is
being done on that OS, and the capability is declared per platform so the
button never appears where the desktop shows no indicator -- there it would
hide the window for good.
Hides, never closes: `window-all-closed` quits the app, so closing here would
make "minimise" mean "exit" and drop the session, the transfers and the node
connection. `second-instance` now calls the same restore path, since focusing
a hidden window does nothing visible.
The context menu is not decoration. Under libappindicator -- how GNOME shows a
tray at all, via the AppIndicator extension -- `tray.on('click')` never fires;
the indicator only opens its menu. A tray whose sole affordance was a click
would be inert on the one desktop this targets. The click handler is kept for
desktops that do send it.
Menu labels come from the renderer with the IPC call: the locale files are the
interface's, the main process has no i18n, and a second string table is how two
of them start disagreeing. English fallbacks if none arrive.
The icon lives in src/, not build/: package.json `files` packages only `src/**`
and `ui/**`, so an icon under build/ is present in a dev run and missing from
every installed one. Monochrome, stroked, matching the nav glyph.
Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and
libayatana-appindicator3 present, so the indicator has somewhere to appear.
Not yet run end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Windows-only changes, all found by actually running the previous session's
work rather than by review alone:
- CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes
SetConsoleCtrlHandler) so closing a console window, signing off, or a
system shutdown runs the daemon's real _shutdown() instead of Windows
just ending the process — closing WebRTC sessions and any in-flight
ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays
uncatchable (like SIGKILL), so autostart_run() now spawns with
CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end()
tries CTRL_BREAK_EVENT against the recorded pid first, falling back to
the hard kill only if that doesn't stop it in time.
- Replaced the Node page's two independent autostart/service-mode toggles
with one "start automatically" select (off / at sign-in / as a
background service). The old pair let both be active at once — starting
the daemon twice, at boot and at sign-in — and their layout broke
wrapping inside .node-service's flex row. The new control always removes
whichever mechanism is active before installing the target; platform.py's
service_install() does the same on the CLI side. The "background
service" option disables itself (with a hint pointing at the CLI) when
running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all
assume an installed build's layout — verified live rather than assumed
by actually running those scripts unelevated.
- findNodeBinary() no longer bakes a stray \r into resolved paths. Found by
rebooting after enabling per-user autostart: where.exe listed two
matches, and stdout.trim().split('\n')[0] only strips the whole string's
ends, leaving line one's own trailing \r attached — which landed inside
the Startup .vbs's quoted path and broke it with "Unterminated string
constant" at boot. Fixed by splitting on \r?\n and trimming every line.
- Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no
VC++ Redistributable needed, confirmed by inspecting the built
node-runtime's actual import table rather than assuming. New
docs/windows-build.md: a concise clone-to-installer build guide.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The installer's own mode question is effectively one-shot: customInstall
skips it entirely once the firewall rules already exist, for any reason --
and per-user mode sets those up on its own, with no Scheduled Task involved.
So declining once (or the rules existing from something unrelated, as
happened on a dev machine this session) was a dead end: no reinstall, repair,
or uninstall/reinstall cycle could ever bring the question back, since
uninstall defaults to leaving both alone.
Add the other door in (and out): a checkbox on the Node page, next to the
existing per-user autostart toggle, wired main.js -> preload.js -> platform.js
-> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script
installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so
the two paths can never disagree about what service mode means. The elevation
helper writes a tiny param()-based .ps1 to %TEMP% so the target script path
and its arguments bind through real PowerShell parameters instead of nested
string-quoting.
Also fixes a real pre-existing gap found while checking this: 8 of the 10
locale catalogues (all but en/fr) were missing the autostart/service-mode
keys added in an earlier commit this session (b782886) -- test_locales.py's
key-set-parity check uses a for-loop with an inline assert, so it stopped at
the first mismatch (fr) and never actually reached the other eight. Backfilled
all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The per-user Startup-folder launcher (W3) only ever runs after this user
signs in. A real Windows Service would start earlier, but under
LocalSystem/NetworkService -- accounts with no normal profile, so
%LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it.
Relocating storage to make that work is real surgery, deliberately not
done here.
Instead: a Scheduled Task, created once with admin rights, that runs AS
THIS USER at boot without needing them to sign in first.
`schtasks /create ... /ru <user> /rp ""` with no `/it` registers an S4U
(Service For User) logon -- no password stored anywhere, and unlike
LocalSystem it loads this account's own profile, so config_dir()/
data_dir() need zero changes. The cost: S4U carries no network credential,
which the node never needed -- everything it touches is local disk plus
outbound internet. Creating the task needs admin (a boot trigger touches
system-wide scheduler state, the same reason /sc onlogon needed it);
querying/starting/stopping an existing one does not -- Task Scheduler
grants the owning user that much itself, which is what lets the Node
page's Start/Stop/Restart drive it with no further UAC prompts.
meshbay_node/platform.py
service_install/_remove/_status/_run/_end -- mirrors autostart_* but
for the Scheduled Task; TASK_NAME moved here (was decorative before)
meshbay_node/daemon.py
new `service install|remove|start|stop|status` verb; restart-daemon and
reset now check for the service task too
packaging/win/service.ps1
the installer-side equivalent (extraResource); status/run/end never
self-elevate -- only install/remove do, exactly matching what
Task Scheduler itself requires
packaging/win/service-mode.ps1
ONE elevated helper running service.ps1 + firewall.ps1 together, so
choosing service mode costs exactly one UAC prompt, not two
build/installer.nsh
the install-time choice: "run as a background service?" (one
elevation, both jobs) vs the existing per-user + separate firewall
question. Checked first, unelevated, so re-running setup with
everything already configured asks nothing. Uninstall offers the
matching one-elevation cleanup, default No.
src/main.js
winServiceTaskStatus/Run/End, wired into node:installed,
node:service-status/-stop/-restart and node:start: when the Scheduled
Task exists, drive it; otherwise fall back to the existing per-user
spawn/kill path. This is the hard requirement -- Start/Stop/Restart
from the Node page must work in either mode.
node-page.js / locales
a hint explaining why the per-user autostart toggle is absent when
service mode is active (info.mode from the backend, no new field to
gate on -- it just isn't sent in that case)
package.json: 0.1.0 -> 1.0.0.
Verified: electron-builder compiles the new NSIS choice logic and ships
all three scripts; service.ps1's S4U install fails cleanly (Access
denied) when run unelevated, and its status/run/end never touch "runas".
Cannot verify the elevated success path myself (no admin in this
session) -- that needs a real UAC click. Node suite 843 pass / 25 skip;
test_packaging_win.py pins the one-elevation property, the S4U flags,
and that main.js actually checks the service task in all three handlers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Root cause of both reports: node:service-status's `installed` field was
winAutostartInstalled() -- whether the Startup-folder launcher exists --
not whether the daemon can be managed at all. The Node page gates
Stop/Restart on `installed`, so with no autostart configured (the default
-- nothing installs it automatically) those buttons silently never
rendered, leaving only Start. A perfectly manageable daemon looked
unmanageable because a different, unrelated setting was off.
installed now reflects the actual daemon binary (findNodeBinary()), same
definition node:installed already used; autostart moves to its own field
carrying what installed used to mean.
That field also fixes the other half: there was no way to turn autostart
on except the CLI. NodeServicePanel now shows a toggle-switch next to
Start/Stop/Restart, wired through a new platform.node.autostart
(install/remove) that mirrors the existing service.* pattern -- the
preload/main.js bridge (W3) was already there, just never called from the
interface. English and French strings; other locales fall back to English
per the project's own stated policy (test_locales.py's own docstring).
Verified: node --check on every edited file; hub/node suites green
(863 pass, 0 fail — pre-existing test_locales.py encoding failures on
this Windows checkout are unrelated, reproduced identically on the
clean tree).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phases 5-8 of docs/refactoring-search.md, extending the Videos merge
outward. A library shared by two groups now lists each track once inside
an album and each photo once inside a photo album, and a card served by
several groups says "N sources" instead of naming one of them.
Units come from each application's own grouping, never a copy of its
keys. For Music that meant exporting foldKey: groupMusicEntries folds
case to group but keeps the first-seen spelling to display, and which
group is seen first is whichever index arrived first — so keying a unit
on the display strings would let the chosen source change between page
loads.
A group whose connection fails is marked down and stops being chosen, so
a unit fails over to another group that has the file. Eviction is not a
failure. Every source being down still yields an entry: a tile that
fails to load beats a film that vanished from the grid.
sourceLabel now takes the whole unit rather than one entry. A show's
poster entry is picked for its thumbnail, so a show in two groups whose
cover episode sits in only one of them would have claimed a single
source. SourceTag lives in group-name.js — source-merge.js must keep
importing nothing (its test executes it standalone), and a copy in each
of the three apps is three chances to disagree.
test_search_files_unmerged.py holds the one thing that must not change:
the Files explorer is not merged, because there each group is a folder
and merging would remove a file from one of them. It also asserts the
other three lists are merged, or deleting the merge outright would leave
it passing and saying nothing.
One plan item was dropped as wrong rather than built: the Music queue in
onPreview needed no change. It filters by groupId and is reachable only
from FilesPanel, which is not merged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|