| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Twenty-four files, about 17 000 lines: the two architecture drafts, the three
security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4
archive, the deprecated user guide and the stale quickstart. Their content is in
MESHBAY_DESIGN.md, and git history holds the originals.
The reason to delete rather than keep bannered: a document that is superseded but
present still gets read, and a reader cannot always tell which of two accounts of
one mechanism is the live one. That was the argument for retiring the user guide
rather than repairing it, and it applies to the whole set.
What made this safe is the concordance. Roughly 290 comments and docstrings cite
these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11`
— and section 16 maps every one onto its replacement, so not a single comment
needs editing to stay followable. It now says plainly that the files are gone and
where to recover them, and it gained rows for the three reviews (their findings
are section 13), and for the two guides.
Four kept documents pointed into the set and were repointed first: `playlists.md`
(nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`,
and CLAUDE.md's example. No dangling reference remains outside section 16.
Two files were dropped from the list after checking what they hold. `HTTPS.md` is
an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and
MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it;
the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is
the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and
section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work.
There is no user guide now, and section 0.1 says so rather than leaving a reader
to discover it.
Suites green: 2258 passed, 4 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Twenty-three documents that MESHBAY_DESIGN.md absorbs gain a header saying so
and naming the sections their content went to. None is deleted: code comments,
tests and the documents themselves cite their sections and their labels, and
each records reasoning a synthesis compresses.
The header states the precedence, because two documents describing one system
will disagree eventually: where a draft disagrees with MESHBAY_DESIGN.md the
design document is right, and where either disagrees with the code the code is.
Seven status lines were corrected on the way through, all of them claiming less
than the truth. Videos, Music, Photos, partial-read hashing and account recovery
were headed "proposal, not implemented" months after they shipped; the desktop
client said "nothing here is implemented" with stages A through D running; draft
v6 still called itself the current specification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Implements docs/mediacenter.md: a "Videos" group application built on the
existing files index rather than a separate catalogue. On the node side,
new indexer enrichment (technical probe, filename/season parsing, thumbnail
generation) runs per-file once an operator has chosen a video_root for the
group, plus a TMDB client for on-demand poster/metadata lookups (never
client-side, thumbnails delivered over the existing chunk path). On the hub
side, a new video-app.js renders a lazily-mounted poster grid or a
thumbnail-only flat list, with TMDB entirely optional per group.
Along the way: the global apps registry now drives Settings' default-tab
picker instead of a hardcoded list, and the video_root is configured from
group Settings (like uploads) rather than from Files, with the node
refusing to run any TMDB/thumbnail work until one is set.
Fixes several bugs found via live testing against a real library, notably
a race between two effects writing the same "image ready" state that could
leave a poster grid spinning forever on a same-tab revisit — see
mediacenter.md §5.4 for the full account of each one.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A group where every member may add files stays the default. Some groups want a
library the operator curates, and until now the only way to get one was to
designate no upload root at all — which refuses the operator too.
**The node enforces it; the interface merely stops offering it.** The Upload
button in the Files toolbar and the paperclip in the chat composer both
disappear, which is a courtesy to the people who are not trying. The control is
`_do_file_upload` refusing with `member_upload_off`, so a member on an old tab,
or one speaking MNP directly, gets the same answer. There is a test for each,
and the enforcement test is in the node package rather than beside the UI one so
nobody reads the hidden button as the mechanism.
**Changing it is a signed operator instruction** — `OP_MEMBER_UPLOAD`, on the
same path as removing a member. An unsigned one would let any member turn it
back on and make the setting a suggestion. The transcript's subject is `on` or
`off`: what the operator is shown before signing has to name the outcome, not
the operation.
**It lives on the node**, in a new `group_settings` table in `roster.db`. Not
the hub, which has no business deciding who may write to someone else's disk.
Not `node.toml` either: that file is hand-written and full of comments recording
decisions, `ops.py` appends to it rather than round-tripping it through a
writer, and a setting toggled from a panel must not rewrite the operator's file
or need a restart. The value is cached in the group context because the upload
path is synchronous, and the signed operation updates both — storing it without
applying it would make the panel say one thing while the node did another.
**Absent means allowed**, at every layer: no row in the table, no key in the
context, no field in `handshake_ack`. An older node and an older client both
behave exactly as before, and upgrading never silently closes a group. Each of
those three has its own test, because they fail independently.
The operator is always exempt — otherwise turning it off locks them out of their
own node with a config file and a restart as the only way back. `is_node_admin`
was being computed in two places by then and is now one function, since two
copies of "is this the operator" is how the ack and the gate come to disagree.
A change reaches everyone already connected via `member_upload_ack`, so the
button goes without a reconnection. That message is both a broadcast and the
reply to the request that caused it, which is why the client does not return
early on it.
Docs updated for a cold start: draft-v6 §2.1b and change 9, a new "Where Phase
13 stands" section in CLAUDE.md recording what is built, deployed and still
missing, the module map row, and desktop-client-v1 §10b on the Settings tab and
where group settings live.
883 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Full-screen was denied, and the denial was invisible.** The permission
handler was written from a true sentence — nothing here needs a camera, a
microphone or a location — and implemented as `callback(false)` for everything.
Chromium's own video controls ask for the `fullscreen` permission, so a film
could not be watched full-screen.
What made it hard to find, and what the test now pins: **a denied `fullscreen`
does not reject.** `requestFullscreen()` returns a promise that never settles.
No exception, no console message, nothing in the renderer that mentions a
permission — the button just does nothing. Measured rather than reasoned: the
probe reported `NEVER SETTLED` while the main process, instrumented for one run,
logged `PERMISSION ASKED: fullscreen`. After the fix the same probe reports
`granted` with `document.fullscreenElement` set.
The handler now enumerates what is *granted* — `fullscreen`, and nothing else —
so a camera, a microphone, a location, notifications and MIDI are still refused
and whatever Chromium adds next arrives refused rather than quietly allowed.
`Permissions.query` takes the other handler, so both now answer from the one
list instead of eventually disagreeing.
The old test asserted `callback(false)`, which is to say it locked in the bug.
It is replaced by three: what must stay denied, that `fullscreen` is granted,
and that both handlers read the same list.
**"Save automatically" opened a dialog.** The automatic path required a folder
to have been chosen first, and on a new profile nobody has chosen one — so the
very first download fell through to Save As, which is the one thing the setting
promises not to do. A browser does not make you pick a folder before it will
save a file; the system Downloads folder is the answer when there is no other.
Verified on a fresh profile with a home of its own: no dialog, 1024 bytes on
disk, destination reported as the default (`/home/…/Téléchargements` on this
machine, via the localized XDG directory).
A folder that *was* chosen and has since gone still asks. Silently redirecting
those files is worse than a dialog: someone who picked an external drive wants
to be told it is not there, not to find the film in their home directory a week
later. Settings shows the effective destination either way, and offers "forget"
only for a folder somebody actually chose.
813 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Downloads were going through RAM.** `_openDownloadTarget` tries a granted
folder, then a service worker, then its floor: collect the whole file in the
page and hand the browser a blob. Both of the first two are absent in the
desktop application — `showDirectoryPicker` does not exist, and Chromium refuses
a service worker on a custom scheme — so every download under 512 MB took the
floor. A gigabyte of film meant a gigabyte of RAM, and the only visible symptom
was a Save As dialog at the *end* rather than the start, which is what the
operator noticed and asked about.
The main process now streams to disk: it honours "save automatically" with a
folder chosen once and no dialog, never overwrites (a colliding name gets a
suffix), awaits each write so the renderer cannot outrun the disk and queue the
file in memory anyway, and unlinks a cancelled download rather than leaving a
truncated file that looks complete to whoever opens it next. Settings now offers
the native folder picker instead of saying downloads are unsupported.
Measured in the running application: the file on disk grows 256 KB → 512 KB →
768 KB → 1 MB as the chunks arrive, and an aborted download leaves nothing
behind.
**A permanent scrollbar on sign-in.** `.layout` and `.page-center` each reserved
`100vh - 52px`, and `.page-center` sits inside `main`'s 24px vertical padding —
so the page overflowed by exactly 48px at every window size. Found by measuring
in the app rather than reading the stylesheet: `scrollHeight` 819 against a 771
viewport, then the bottom edge of every element. The centring page brings its
own padding, so main's is dropped for it and the duplicated arithmetic goes
rather than growing a third term. Now `scrollHeight == innerHeight`, no
overflowing elements.
**The first-run screen was unstyled.** It used a class name I invented
(`auth-page`) that appears nowhere in the stylesheet, so it had no card and the
button sat against the input. It now uses the same `page-center` + `login-card`
markup as sign-in, which is where the 12px gap comes from. The sign-in link in
the nav is hidden until a hub is chosen — it led to a page that could not work.
809 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
The last thing that could only be checked on a real desktop. The application was
quit and relaunched on Ubuntu GNOME and signed in with the device key alone;
safeStorage is genuinely keyring-backed (secrets.bin carries Chromium's v11
prefix, where the fixed-key fallback would write v10).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
All three came from the operator running the application on Ubuntu GNOME. None
would have been found by anything already in the suite.
**A second copy of the hub address.** `keyderive.js` carried
`const HUB = '' // same origin` — true of a page the hub served, false of one
loaded from a package, where the origin is `app://meshbay` and `/v1/users/register`
resolves against the application's own protocol handler. **Sign-up and sign-in,
the first two things anybody does, failed with "Not found."** The seam was
changed in `app.js` and in the signalling call and this was missed: the same
shape as the duplicate `MNP_VERSION` in `protocol.py`, a second copy of a
constant that is harmless until the context changes. `test_hub_address_seam.py`
refuses any file that decides where the hub is, and any `fetch('/v1/…')`
relative to the page origin.
**A window handler reading a variable another path reassigns.** Changing the hub
closes one window and opens another; `closed` arrives *after* the replacement is
assigned, so the outgoing window nulled the reference to the incoming one and
its `ready-to-show` crashed on it — a modal "A JavaScript error occurred in the
main process". Every handler now belongs to the window it was created with. The
CDP test wrote `config.json` in advance, so it never took the one path that
creates a second window; it does now, starting from an empty user-data dir.
**A first run that could not be undone.** The hub address was accepted on
anything URL-shaped and there was no way to change it afterwards — the prompt
only appears when none is set, so a typo meant editing JSON by hand. `https`
typed at a hub speaking `http` produced `TypeError: fetch failed`, which names
nothing. Now: the address is probed before being written, failures say which
URL and why ("does not speak https. If this hub is on your own machine, it is
probably http"), Settings can change it, and Electron's
"Error invoking remote method" wrapper is stripped from what a person reads.
Verified on the operator's desktop: **safeStorage really uses the GNOME
keyring** — Settings reports `gnome-libsecret`, and `secrets.bin` is written
0600 with Chromium's `v11` prefix, the marker for keyring-backed encryption
(the fixed-key fallback writes `v10`). Headless, the same code reports
`unavailable` and refuses to store rather than downgrading in silence, which is
now explained in Settings instead of shown as a bare word.
Unrelated but found while testing: `test_locales.py` assigned to
`globalThis.navigator`, which is read-only from Node 22. The client's build
already requires Node 22+, so the first CI machine configured for it would have
failed these tests for no visible reason. 809 tests pass on Node 18 and Node 24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
D4. The passphrase stays the account's credential and its only recovery path;
what changes is that it is not asked for on every launch.
**The renderer never holds the device key.** It is generated, stored and used
entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on
request. Same rule as the save dialog, for the same reason: the renderer is the
part of this application that parses decrypted content from nodes, which is
attacker-controlled input. And this key is *not* a per-node identity key — those
are generated per node and never leave that relationship, so nothing here
correlates a person across operators.
First run asks which hub, with no default. A client that picks its own hub is a
client that can be pointed at one, and the address is the whole of what this
application trusts a hub for — the interface comes from the package.
Verified against a hub running this code, not against the deployed one:
register 201 → passphrase login 200 → device register 201 → **device sign-in 200
with a real session** → `/v1/users/me` 200 → a stranger's key 401. The
signature was also checked directly against the hub's own Python verifier before
any of that.
Inside the running application, over the debugging protocol: the bridge reaches
the main process, the renderer calls the hub **through it** (200 — the CORS fix
working end to end), and a call to a host that is not the configured hub is
refused.
**Not verified:** safeStorage persisting the key. This session has no secret
service, and standing one up in xvfb did not succeed. The application behaves
correctly there — it *refuses* rather than storing unprotected, and now says so
in Settings, which is a real case rather than a hypothetical one since it is
exactly what a headless or minimal desktop looks like.
Worth remembering for next time: meshbay.org runs whatever was last deployed. It
answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had
0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what
tests hub changes. Nothing was deployed to production for this.
799 tests pass; e2e.py passes end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts
over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM
loaded, and no console errors. Three statements in the design were wrong, and
only launching it found them.
**A CSP in a `<meta>` tag silently drops `frame-ancestors`.** Chromium says so
in the console. A policy carrying a directive that does nothing is worse than
one without it, so the policy is sent as a header by the protocol handler —
which is also the only thing serving the interface, so one source instead of
two.
**`secure: true` is not what makes the service worker register.** Chromium
refuses a worker on a custom scheme whatever its privileges: "The URL protocol
of the current origin ('app://meshbay') is not supported". The application has
no service worker and needs none — it saves through a native dialog, which is
the better of the two paths. `sw.js` stays in the package because the same files
serve the browser, where it is one of only three ways to write a large file.
What `secure: true` is actually for was measured at the same time: without it
**the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:`
URL and every algorithm failed with TypeError, AES-GCM included — which is why
the probe was rewritten before believing its answer. X25519 and Ed25519 are both
present on Chromium 148, settling the version floor left open as O6.
**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS
refuses it. The hub has *no CORS middleware at all* — its API is reachable from
no web origin whatever — and that is worth keeping. Widening it for
`app://meshbay` would be worse than it looks: that origin is not a credential,
since any Electron application can claim the same scheme and host name.
So every hub call leaves from the main process, exactly as saving a file does,
and it refuses any origin that is not the hub the user signed in to.
`platform.apiFetch()` is `fetch` in a browser and the bridge in the application,
so no caller has to know which it got. `transport.js` reaches it through a
global because it is a classic script, not a module — the alternative was a
second fetch path, which is how two callers of one hub start disagreeing about
how to reach it.
Verified from inside Electron: the main process gets 200 from
/v1/hub/version, the renderer is refused by CORS, and **a script served by the
hub is refused by the policy** — T3's mitigation demonstrated rather than
asserted.
Build note, written into the README because it will bite the next person:
**Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download
script `require()`s an ESM module, which Node gained in 22. Node 24 LTS,
checksum-verified against nodejs.org, is what this was built with.
package-lock.json is committed; builds use `npm ci`, not `npm install`.
799 tests pass, e2e.py still passes end to end. The session harness needed a
platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the
adapter is now part of the environment it models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage D, and the honest half of it.
D1 — the seam (done, and verified)
----------------------------------
`static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is
built with the same base, so one address has one source. In a browser it returns
'' and every path stays relative to the origin that served the page — the
acceptance criterion for this split was "the browser SPA behaves identically",
and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the
content hash and a cached browser would never ask for it.
D2 — the shell (written, never launched)
-----------------------------------------
**There is no npm on this machine. Electron was never installed and
`packages/meshbay-client/` has not been run once.** That is stated here rather
than discovered later.
What is there: a main process serving the packaged interface over a privileged
`app://` scheme (`secure` and `standard` are not cosmetic — without them the
service worker refuses to register and streamed downloads break silently), a
preload exposing an enumerated bridge that never passes a filesystem path, a
window with `sandbox`, `contextIsolation` and no node integration, navigation
away from the package refused, and a CSP where the hub is reachable over
connect-src and is not a script source. The hub address arrives as a process
argument because `platform.hubBase()` runs before anything can await.
`test_desktop_shell.py` pins each of those by reading the source — the treatment
`test_downloads.py` already gives the three browser save paths. It catches a
property being removed and proves nothing about the application running. Two
were checked by breaking them.
The interface is *copied* into the package by `build/sync-ui.js` from the hub's
static directory, and `ui/` is gitignored: a silent fork is the only real way to
end up maintaining the interface twice.
D3 — partial
------------
The bridge, and the part worth having now: safeStorage's backend is reported
rather than assumed. On Linux it falls back to a fixed key when no keyring is
running, silently — someone who believes the OS is holding their keys is told
when it is not. The native key lifecycle belongs with D4 and needs a running
application to mean anything.
D8 — partial, and a real defect found
--------------------------------------
`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry
`User=`; systemd refuses the file, so the packaged unit could never have
started. Nothing noticed because nobody had built and installed the RPM.
Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service`
that a person enables themselves without a password — which is what lets the
desktop client install a node without asking for one. It carries ExecReload, so
`meshbay-node reload` does not have to stop a service somebody is streaming from,
and documents the drop-in for a drive outside the home, RequiresMountsFor
included.
798 tests pass; e2e.py still passes end to end. Nothing here was built or
launched: no npm, no rpmbuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage C. Identity keys are per node, so a browser and a desktop client are two
keys on one account there — and the node refused the second where it accepted
the first. Without this, an account created natively could never be opened in a
browser without an operator code per node, and "a native client must not prevent
web use" would have been dead on arrival.
Device linking (node)
---------------------
`identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The
old shape did `INSERT OR REPLACE`, so a second device overwrote the first
silently; SQLite cannot change a primary key in place, so the table is rebuilt.
Existing pins are carried over — verified against a live roster with 10 of them,
nobody re-pairs.
A new device files a request bound by `sha256(code ‖ its own keys)`, and a key
the node **already pinned** countersigns it. The hub cannot: it has stored no
user keys since 2026-08-14, which is what makes this safe to do without an
operator in the loop.
**The code never reaches the node.** It lists this account's pending requests
with their stored hashes; the approver recomputes and keeps the match. A node
offering fabricated keys would have to produce a hash over a code it has never
seen. Nothing rests on a human comparing digits — that ritual was dropped in
12.1 as "correct, unusable as the default" and must not return by the back door.
The design document had the approver look a request up *by* its hash, which is
circular: computing it needs the keys being asked about. Corrected in both.
Revocation marks rather than deletes, because a deleted row is a key the node
would happily pin again — which is the laptop somebody just reported lost. Your
last device cannot be revoked: coming back would need an operator's code.
Hub — the only change in the whole plan
---------------------------------------
`POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as
`/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New
`user_devices` table with an Alembic migration, because `create_all()` is not
one.
This is **not** the key directory that was H3, and the tests say so: nothing
reads it but the hub, no group key is ever wrapped for one, and it is a
different key from the per-node identities. What it does cost is metadata — the
hub now knows how many devices an account has and when each last signed in.
Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an
installed client meets a newer hub the day the interface ships in a package, and
that is cheap now and awkward to retrofit.
Browser
-------
The `key_changed` refusal becomes `unknown_device` and offers a linking code
instead of telling someone to find their operator. The Members panel lists this
account's devices here, approves one by code, and retires one.
773 tests pass. `e2e.py` gained a step that links a device end to end against
the live deployment — file, list, recompute, countersign, then open the group
with the new keys and no code — and it also gained `recv_type`, because a step
that assumes the next message is its own answer reads an ack left by the step
before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
A design discussion on 2026-08-17 settled Phase 13 and, in doing so, changed
four things the spec states. v6 restates only those; v5 stays authoritative for
everything it does not touch, per the convention v5 itself used with v4.
What changed:
* The native shell is **Electron**, not pywebview — structural decision 18
reversed. The SPA depends on Chromium-class APIs (WebRTC, WebCrypto
X25519/Ed25519, MSE, Service Workers), so keeping Chromium keeps
transport.js, crypto.js, keyderive.js, downloads.js and sw.js *as the
client*. A system webview meant reimplementing ~2500-3000 lines. The old
"69 % reused" figure was measured against an app.js of ~2600 lines; it is
4586.
* A group's content is **several named roots**, not one directory, because
the planned video and audio libraries will not live in one folder on one
disk.
* **Device linking**: one person may hold several devices on a node,
admitted by a key the node already pinned and bound by a one-time code the
new device generates. Without it a native client is refused where a browser
is not, and an account created natively could never be opened in a browser.
* **Authorship is authenticated, not asserted** — chat senders sign, uploads
have a provable owner, and delete authorization moves from the uploading
key to the account.
And one rule v5 assumed without writing down: **group-related server state
lives on the node.** Verified for multi-root — SwarmSource carries hashes and
endpoints, no paths.
Also here: the Caddy configuration, which was a snippet in the roadmap that
would have broken the SPA (it predates /a/<hash>/ asset versioning and would
have 404ed /sw.js, silently killing streamed downloads on Firefox and Safari);
and downloads.html, which becomes a security page once a release key exists.
Phase 15 was re-read against device linking and is wrong as written: a sender
key must be per **device**, never per person, or two devices sharing a chain
produce key and nonce reuse — C1 again, one level down. senderkeys.py already
fails this silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|