| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The download-to-disk story is three mechanisms — File System Access in
Chrome, a service worker streaming a response in Firefox and Safari, a
blob as the floor — and no test in this repository exercises any of them.
test_downloads.py pins their contracts by reading the source; whether a
browser really writes to disk needs a person with a large file.
One now has: Firefox, 180 MB, written to disk rather than assembled in
the tab. That is the path worth confirming, since it is the only one
Firefox has and it was written blind. It is also not the scale it exists
for, and the guide says which rows of that table are measured and which
are still only designed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.
While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.
**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.
**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.
Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.
It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.
**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.
Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.
Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
of it
Two bugs in what shipped last, and both were mine.
Automatic mode still opened Save As, because with no folder granted the
code fell through to the file picker — while the documentation said it
would use the browser's own download folder. It does that now. Over
512 MB it still asks, since getting there means holding the file in
memory and a tab will not survive a 40 GB blob; Settings is where to stop
it asking again.
Selecting two files downloaded one. They were started without awaiting,
so each asked the browser for a save dialog at once, and a browser allows
exactly one — the rest were rejected and the errors went nowhere. They
are awaited one at a time now, which serializes the dialogs and not the
transfers: each call returns as soon as its transfer is registered.
Then the adjustments. The transfers widget offers Open on a finished
download that went into a granted folder — the bytes go to a new tab, and
that is the whole of what a page can do: no browser lets one start a
desktop application or show a file manager, so the folder half of that
request cannot be built and the guide says so.
The Files toolbar was four controls of three different heights in a row.
It is three groups now — what you can add, where you are, what you can do
with what is here — on one baseline, with icons from the set and a gap
between the dots and the word Actions. Chat comes first among the tabs
and is the one you land on. The three Discover entries in the sidebar
have icons. And a link in a chat message becomes a link: built as an
element and never as markup, http and https only, so `javascript:` is not
one message away from running here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Downloading a selection of twenty files meant twenty Save As dialogs,
which is the wrong answer for the feature that had just been built.
Settings → Downloads now offers saving automatically, and that is the
default; asking every time stays available for people who want it.
The correction worth recording: a web page cannot be given a filesystem
path and cannot read one either. There is no ~/Downloads to configure and
nothing to type, on any operating system — which is also why none of this
will need changing on Windows. What a browser grants is a handle to a
folder the user picked in a dialog, so that is what the setting keeps:
picked once, stored in IndexedDB, re-confirmed once a session because the
grant comes back as a claim rather than a permission. Where no folder has
been granted, and in Firefox and Safari where none can be, files go to
the browser's own download folder — which on most machines is the folder
that was meant all along.
Automatic saving has one risk a dialog does not: it can silently replace
a file. It does not — a taken name gets a suffix before the extension,
`clip (2).mp4`, so a download folder does not fill up with files the
system no longer recognises. That, and the default, are what
test_downloads.py pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
per-row menus
Downloads and uploads were state inside GroupPage. Leaving a group
unmounted the component, its cleanup closed the DataChannel, and a
half-written file was all you had — which is also why only one thing
could be in flight at a time.
They live in a module-level store now. A group page hands its transport
over on the way out rather than closing it, and the last transfer using
it closes it; signing out is the one thing that cancels everything,
because those transfers are moving data on a token about to stop being
ours. The store is plain JavaScript with no browser globals, so
test_transfers.py runs it under Node and pins the parts that are timing
and lifetime rather than markup: that a cancel stops the work instead of
greying out a row, that a stalled transfer reads as stalled rather than
reporting its own historical average, and that a released transport is
closed by the last transfer and not before.
The widget by the bell shows each transfer with its rate and a cancel
button, so the Files panel no longer carries progress bars — you can
watch a 40 GB archive from the chat, or from another group.
Selection replaces the per-row menu: a Select toggle puts checkboxes on
files and folders, and ⋮ Actions acts on what is ticked. Ticks survive
walking into another folder, so a selection can span directories.
Downloads start together and run together. Videos offer Play only — View
did the same thing, which is the sort of duplication that makes people
wonder what the difference is.
Uploads had to become parallel-safe for any of this to mean anything:
their acks were matched by arrival order, so two at once credited each
other's progress. The node names the file in every ack, so they are keyed
by name now — with the same file twice refused, since the node keys its
own upload state that way too.
Two mistakes worth recording. The selection column went into the body
rows and not the header, because that edit matched nothing and I had not
made it assert; the columns were misaligned until a screenshot showed it.
And the Actions menu opened leftwards from a button at the right edge of
the toolbar, half of it off-screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two things a Files panel needs and did not have.
**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.
**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.
zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.
Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.
One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Attaching a group to a node meant hand-editing node.toml with a UUID
copied from a browser URL, restarting, and knowing that gek-init exists.
Nothing in the CLI said so, and on a node reached over SSH there is no
paste buffer to carry a UUID across in the first place.
meshbay-node group add grenet --dir ~/grenet-share
The name is resolved against the operator's groups on the hub by the
daemon, which is the process holding the session. The [[groups]] block is
appended to node.toml as text rather than round-tripped through a TOML
writer: the file is hand-written and its comments explain decisions worth
keeping. The directory is created, and the command says what remains —
restart, then gek-init for that group.
It refuses a name it cannot find by printing the groups it can, with
their ids. That listing is the useful half of the answer and it was
missing everywhere: _daemon_api now renders an `available` list from any
endpoint that offers one.
The key is per group and pairing is not, which is the part that reads as
a gap until it is written down: one paired browser covers every group the
node hosts, while each group's key admits only its own members. §4 of the
user guide now says all three of those in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A description could only be set the moment a group was created, so every
group made before anyone thought of one stayed blank for good. The owner
can now edit it from the group's page, and PATCH /v1/groups/{id} takes it.
That endpoint takes the description and nothing else, deliberately. The
name, the visibility and the join policy are the terms members joined on;
a private group that can quietly become public is not the group they
agreed to be in. Changing those needs a decision about who gets told, not
a field on a form — there is a test saying so.
Separately, the legacy operator key is gone. `admin_pk_ed25519` in
node.toml named the operator before the roster existed and was kept so
that an existing deployment would keep working; nothing uses it, and a
second source of node authority is not something to carry around out of
politeness. Authority is the roster, read fresh on every check.
It is removed rather than ignored: a config that still names the key gets
a warning at startup pointing at the file. Dropping it in silence would
refuse invites and file deletion with a signature error that looks like a
bug somewhere else — which is exactly how finding M3 presented.
Two tests were verifying admin operations by naming a key in the context,
which was the legacy path. They now pair an operator into a roster, the
way an operator does. The authority test anchored on the deleted function
and passed vacuously once it disappeared; it states the invariant against
the verifier and the daemon instead.
Also defined .btn-secondary, used in four places and styled in none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The connection log took the name from a join on `users`, and deletion
tombstones that row — so every record belonging to a deleted account
reported `deleted-3f9a1c`, which is the one answer that helps nobody. The
log is kept for a legal retention period precisely so it can say who did
what; losing the name at deletion kept the data and lost the point of it.
`ip_logs.username` is written as the account is erased, and stays NULL
while the account is alive, where the join is better because it cannot go
stale. The admin view prefers the stored name when there is one: the join
still answers after deletion, just with the tombstone.
Releasing the username for re-registration and keeping it in the log are
separate things, and the guide now says so.
On the node side, the pre-proof audit line records the username the
session already knew, instead of leaving the column empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Account deletion is the headline, in the user guide and in draft-v5 §6.1,
and the important half is what deletion does *not* do. It releases the
username, clears the email and password hash, drops memberships,
notifications, refresh tokens and node registrations, and refuses any
access token still inside its hour. It does not touch a node: files, the
pinned identity and the keypair bundle stay on machines the hub does not
command, which is the same sovereignty §5.5 relies on — so deleting a hub
account is not an erasure request to the operators hosting you. The IP log
survives too, attributable, for its legal retention period. The claims
table in §2 gets a row saying exactly this, adversary by adversary.
Notifications get a section: one entry per conversation rather than per
message, never one for your own message, invitations that clear when you
join, muting that lives on the hub so it works from any browser.
Then the corrections, which is most of the diff. The guide still described
a node HTTP API — `GET /index`, `GET /file/{id}`, an HLS playlist, and a
`player.js` that does not exist — with curl examples inviting the reader
to expose port 19001. That surface was removed in 0.2.0 as findings C1 and
C6, precisely because it served files outside the handshake that decides
what a peer may see. Sections 6, 7 and the API reference now describe MNP
message pairs, and the quickstart says the same in French. Also corrected:
the JWT table advertised a `pk_user` claim that no longer exists (it was
what let the token issuer decide who could delete a file), `/pubkeys` no
longer returns identity keys, and the GEK-distribution endpoints are gone
entirely rather than merely unused.
draft-v5 §5.2 had uploads landing in `.uploads/{user_id}/`; they land in
`uploads/`, chat attachments included. §6.1 now says the hub learns the
author's user_id from chat_notify — a stable identifier, and a metadata
leak worth naming rather than leaving as "by whom".
CLAUDE.md records why the deployed hub broke this week: create_all()
creates missing tables, never missing columns, so a schema change passes
every test (fresh DB per run) and never reaches production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
USERGUIDE said registration submits your public keys "so other members can wrap
GEK bundles for you". Both halves are wrong now: registration creates an account
and nothing else, and nobody wraps anything for a key fetched from the hub. The
API reference and the register body followed the same correction.
CLAUDE.md gains the block a future session needs before touching registration or
anything shaped like a user's public key: keys are born at first contact with a
node and stay there, the hub publishes none, tokens carry no pk_user, and a
scripted signup is now a real account.
Left alone deliberately: first-review.md, docs/poc-v1*.md and poc/spike-results.md
still describe the old JWT and registration. They are records of what was true on
their date, like second-review's verdict table, and draft-v5 is what states the
present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
draft-v5 §2: against an active hub, reading content moves from "❌ H3" to
"❌ T3 (browser) · ✅ native". The defensible sentence becomes "the hub cannot
read your content unless it ships you malicious client code" — T3 is now the only
path, it is an artifact rather than a silent directory lie, and it does not exist
for a native client. New §5.5 describes admission and key delivery, with the four
properties that carry it and the one exception (open-join groups, where the hub
can walk in the front door — a property of open joining, and the setting is read
from node.toml).
Corrected while writing it: §5.1 said the C5b fix stopped a group admin who does
not run the node from inviting, and that the redesign reverses this. It does not,
because delegation was deferred. What changed is the timing — the operator issues
a code and is then out of the loop.
devel-phases-next: 12.1 is done and NOT as written. The plan was key transparency
plus safety numbers; what shipped removes the directory read instead. Safety
numbers make substitution detectable by a human who checks, at first contact,
when there is nothing to check against. 12.2 (served-SPA integrity) is now the
highest-value item in that phase. Phase 14 marked for what landed.
second-review: H3 and M3 annotated closed at the finding, with what actually
closed them. The §7 verdict table is left intact — it is the record of an audit
on a date, and falsifying it would be worse than leaving it — with a note
pointing at draft-v5 §2 for current state.
CLAUDE.md matters most here, being loaded every session: NS4 read "admin_pk_ed25519
auto-pinned from keystore ✅ DONE", which is M3 described as a feature. Rewritten,
with the two fixes that must never be attempted (auto-pin, hub lookup).
QE/deploy/README.md: set-admin-pk retired from the walkthrough; the regression
checklist now exercises pairing, joining by code, recognition without a code, and
revocation.
USERGUIDE.md is beyond the invite work but was actively wrong: it told users to
POST GEK bundles to a hub endpoint deleted in Phase 12, and to re-wrap for every
remaining member on revocation. Both replaced with what the code does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
1. indexer.py: streaming blake3 (8MB chunks) instead of read_bytes().
Large files (initrd.img, ISOs, VM images) no longer load into RAM.
2. QE/demo-v1/run_node.py: call indexer.start() not initial_scan().
initial_scan() alone never starts the watchdog observer — files added
after startup were silently ignored. Added indexer.stop() on shutdown.
3. USERGUIDE.md §8: clarify symmetric vs asymmetric.
Ed25519/X25519 = asymmetric (key pairs). ChaCha20-Poly1305 and
AES-256-GCM = symmetric AEAD 256-bit (content encryption).
ChaCha20 is PRIMARY; AES-GCM is optional browser-compat variant only.
4. pyproject.toml: aioquic, websockets, aiosqlite, slowapi added to
proper package deps (were installed manually, now declared).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
CLAUDE.md: add QE/ to structure, key modules table, server state reference,
security rule updated (QE/ not keypair files), meshbay.org inventory pointer.
devel-phases.md: add milestones 6.6-6.9 (keyderive, bundle, demo scripts,
QUICKSTART rewrite). 81/81 tests.
docs/meshbay-draft-v3.md §6.1.1: new section documenting 3 key generation
strategies (Argon2id CLI, WebCrypto browser+bundle, keystore file) and the
algorithm mismatch caveat between CLI and web registration paths.
docs/USERGUIDE.md §2 Register+Login: replace "generate and persist before
registering" warning with the two clean strategies (derive_keys_from_password
for CLI, keyderive.js + keypair_bundle for browser). Login response updated
with keypair_bundle field.
hub/models.py + users.py + Alembic migration: keypair_bundle column on User,
stored at registration, returned at login (web clients only).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
|
|
QUICKSTART (434 lines): 6-step guide tested against live
https://meshbay.org — demo accounts alice_test/bob_test,
real transfer of README.txt (23ms) and 1MB chunk (275ms recv,
2.4ms decrypt), exact Python commands with measured output.
USERGUIDE (785 lines): 11-section reference — architecture,
account management, group/node config, file sharing, HLS streaming,
security model, moderation/CSAM, troubleshooting, full API table.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|