feat(example-ui): minimal Drive-like UI on monas-sdk/gateway - #47
Draft
somasekimoto wants to merge 45 commits into
Draft
somasekimoto wants to merge 45 commits into
somasekimoto wants to merge 45 commits into
Conversation
A Google-Drive-like web UI for the Monas protocol, built on monas-sdk via the monas-gateway HTTP API. Create / open / edit / rename / share / revoke / delete encrypted files and folders, with a live Protocol activity panel narrating CEK → AES-256-CTR → SHA-256 CID → storage → state-node → HPKE. - React + Vite + TypeScript, hand-built pink design system (no Tailwind). - Single backend: talks to the gateway via the Vite proxy (/api → :3000), plus /account-api → monas-account (:4002) for the P-256 signing key. - State-node operations (version history, latest version, integrity verification) surfaced inside the preview modal. - Local-only files (no Content Network) are handled gracefully; signing account is required up front before content creation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit of every interactive element surfaced controls that look clickable (pink hover) but did nothing. Wire the real ones, remove the dead ones. - Sidebar "My Drive" navigates to the root folder; the "Encrypted files", "On state-node" and "Shared" rows become drive-wide filter views (new `View` type). Active view is reflected in the sidebar; creating a file or folder while in a filter view returns to folder browsing so the new item is visible. - FileBrowser shows the view title instead of breadcrumbs in filter views, with a view-aware empty state. - Remove the unreachable "Public API" gateway preset (RFC 2606 .example URL). - Neutralize the hover/pointer on the current (last) breadcrumb, which was styled clickable but isn't. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/state/latest-version, /state/history and /state/verify-integrity forwarded the caller's Authorization header as-is, but the UI (and any client that relies on the SDK's own signing, like create/update/delete already do) sends only X-Request-Timestamp — so the state node's verify_read_access rejected every read with 401 'Authorization header is required'. Build the read auth the same way the write path does: sign read:content:<timestamp> via monas-account and send Authorization: user:<hex(pubkey)>. An explicitly provided Authorization is still forwarded untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crsl-lib's linear_history(genesis) returns [genesis] even when the node holds nothing for that content. Two paths relied on history emptiness and broke multi-node reads: - ensure_content_local treated the phantom [genesis] as 'already local' and never pulled from members, so reads on a non-member node returned phantom history plus 404 for data/version. - sync_from_peers passed the phantom genesis as since_version; providers skip the Create operation for since==genesis, so a member that missed the initial push fetched '0 operations from 2 providers' forever and never converged (observed on node4). Gate both on has_genesis, which checks the actual DAG node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
node e2e-verify.mjs drives the running UI (vite → gateway → state nodes) through account creation, folder/file create, preview with state-node history + integrity, edit, HPKE share/revoke, and delete, and fails on any error toast or page error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…revoke - verify integrity: pass the SDK-local version id so the SDK compares the state node's ciphertext against its locally stored ciphertext (the state node never sees plaintext, so the old plaintext comparison always failed). - revoke: pass the state-node series id (remote_content_id) for the post-revoke re-encryption sync — the state node doesn't know SDK-local version ids, so revoke's version bump failed and rolled back. Requires the matching monas-sdk changes (fix/state-node-read-relay). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rive # Conflicts: # monas-state-node/src/application_service/state_node_service.rs
…nto feat/example-ui-monas-drive
`Share.recipients` is a `HashMap<KeyId, ShareRecipient>` and `KeyId` is a `Vec<u8>` newtype, so the derived `Serialize` emitted a JSON array where an object key belongs. Every `SledShareRepository::save` therefore failed with `key must be a string`, which means **sharing was completely broken whenever `MONAS_PERSISTENCE_DIR` was set** — i.e. in the documented production configuration. The in-memory repository never serializes, so no existing test exercised the path. KeyId now serializes as a base64url string, matching how it is already represented at the API boundary. No migration is needed: since `save` always failed, no record in the old format was ever written. Found by running the example-ui E2E against a real 4-node cluster with a persistent gateway; `POST /share` returned `Share repository error: storage error: key must be a string`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… read Brings the Drive UI up to the read-integrity stack so every operation works from the browser again. Contract changes absorbed (all breaking, all previously silent 4xx/5xx): - `/share` and `/share/revoke` now require `sender_private_key` — the CEK is wrapped with HPKE in **Auth mode**, which mixes the sender's private key in. - `/share/decrypt` takes `sender_public_key` instead of the self-asserted `sender_key_id`; the recipient TOFU-pins it and rejects later envelopes that don't match. - `KeyEnvelope.key_epoch` is carried through untouched. Revoke rotates the CEK and bumps the epoch, and recipients reject older epochs as rollback replay. - Revoke returns `reissued_envelopes` for the *surviving* recipients. The UI swaps them into its registry — a recipient left on the pre-rotation envelope can no longer decrypt — and reports `token_invalidated_at`, which also voids tokens held by recipients that were not revoked. New capability surfaced: - `POST /state/read` (verified read) was entirely unused by the UI. The preview modal can now read any version back from the state node — relayed to a member when the contacted node isn't one — showing plaintext only after CID recomputation, AES-GCM decryption and a plain-CID recheck. The panel is explicit that this proves payload authenticity but *not* version freshness (issue #59). Also corrects the UI's crypto claims: content encryption moved to AES-256-GCM in #54, but 8 places still advertised the unauthenticated AES-256-CTR. The registry key moves to v3. There is nothing to migrate — the GCM switch invalidates every pre-existing ciphertext, so v2 entries could only fail on open. Verified against a real 4-node local cluster (gateway pointed at the non-member node so reads exercise the relay path): e2e-verify.mjs 17/17, zero error toasts, zero page errors. `tsc --noEmit` and `vite build` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`e2e-verify.mjs` proves the protocol works, but it is a single happy path: it performs ~30 interactions against a UI with ~84 interactive elements, and four components — SettingsModal, PipelinePanel, Sidebar and TopBar — were never touched by any test at all. A control that silently does nothing would not have been caught. Adds a Playwright suite (19 tests, ~22s) covering the P0 scenarios that need no content fixtures, plus the plan it was generated from (specs/ui-coverage.md, 49 scenarios). The two suites are split by what they cost and what they prove: this one verifies the **UI** and performs zero content mutations, seeding a registry entry into localStorage where a scenario needs a file to exist. A create is a real crypto + 4-node round trip, so paying one per scenario would take minutes and mostly re-test what e2e-verify.mjs already covers. Modal structure is asserted with ARIA snapshots rather than CSS selectors, so a dialog's whole control set is checked in one assertion and the tests survive styling changes. Two real bugs surfaced while exploring the app. Both are recorded as `[KNOWN BUG]` tests that assert the current, wrong behaviour — so the suite stays green while the defect is documented, and flips to failing when someone fixes it. Both were verified to actually discriminate by applying the fix and watching them fail: - G-34: with "Paste public key" selected and the field empty, "Wrap CEK & share" stays enabled and does nothing — `submit()` returns early (ShareModal.tsx:53). No toast, no validation. - S-07: "Test connection" calls `saveEndpoints(cfg)` before probing (SettingsModal.tsx:27), so an endpoint edit that was never saved is persisted and survives a reload. "Reset to proxy" only resets component state (SettingsModal.tsx:41) and cannot undo it. Neither is fixed here — that is an app change, not a test change. Authored with Playwright Agents (planner/generator). The agents run at authoring time only; what runs is ordinary deterministic Playwright code, with no model in the execution loop. Agent definitions are gitignored as they are per-developer and must be regenerated when Playwright is updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ersisting Fixes the two defects the UI suite surfaced, and flips those tests from documenting the bugs to guarding against them. **Share button was a dead click.** Both branches of `ShareModal.submit()` return early when their input is missing, but the button stayed enabled — so with "Paste public key" selected and the field empty, clicking it produced nothing: no toast, no run, no validation, modal still open. The user had no way to tell what went wrong. The button is now gated on a `recipientReady` check covering both modes (the identity branch had the same hole when no identity matched), with a title and an inline hint saying what is missing. **Testing an endpoint silently committed it.** `probeGateway()` could only read the endpoint back out of storage, so `SettingsModal.test()` called `saveEndpoints(cfg)` first. Merely testing an endpoint therefore persisted it: an edit you never saved survived a reload, and "Reset to proxy" only resets component state, so the dialog offered no way to undo it. `probeGateway` now accepts the candidate URL, so the probe has no side effect and the two defects collapse into one fix. Test changes: S-07 and G-34 lose their `[KNOWN BUG]` framing and now assert the correct behaviour. S-06b no longer needs its save-based cleanup, since a failed probe leaves nothing behind. S-07b is new — it pins that the probe reports on the endpoint *being tested* rather than the one in use, by testing the direct :3000 preset, which is CORS-blocked from the browser and must report unreachable; a ✓ there would mean the probe had drifted back to measuring the active endpoint. Verified by re-introducing each bug and watching the guards fail (4 tests), then restoring: 20/20 UI tests pass, and e2e-verify.mjs still passes 17/17 against a real 4-node cluster with no error toasts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urneys Two controls promised something the protocol does not do: - The identity dialog offered secp256k1. Signing accounts must be P-256, and the share envelopes are HPKE DHKEM(P-256), so a secp256k1 identity could never receive a share — a dead end the dialog should not offer. All keys are P-256 now and the selector is gone. - File rows offered Rename. It only rewrote the local registry; a file's name reaches the SDK through an update, so the rename never left the browser. Renaming a file is done through "Edit contents", whose name field the update flow carries. Folders keep Rename — they are local organization and never touch the protocol. The one-shot e2e-verify.mjs script is replaced by a Playwright suite (`npm run test:e2e`) holding three journeys against real nodes: the content lifecycle (create → preview → verify integrity → verified read → edit → old-version read → reload → delete), the sharing lifecycle (share to a local identity with the HPKE round-trip proof, share to a pasted external key, revoke with envelope reissue), and folders + binary upload + filter views + cascade delete. Same stack as before, but expressed as tests: per-test isolation, real assertions, and failures that name the step instead of a line of console output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
The verified read let you pick any version from a dropdown, and every
choice other than the newest failed:
decrypted content does not match local_content_id ... pass the local
content id that corresponds to the version being read (HTTP 409)
The check re-derives the plaintext and compares it against the local
content id, and each version has its own. The registry keeps only the
current one, so the UI has no way to name an older version's id — the
dropdown offered choices it could not honour, and the control looked
broken rather than honest. It now reads the newest version only.
Two e2e expectations were also wrong rather than the code:
- A create writes two versions, not one (the content, then the owner's
access policy). The suite expected one because versions written in the
same second used to collapse onto a single CID — the sync bug fixed in
#70. With that fixed, two is the correct count.
- The pasted-key step read `public_key` off the gateway reply directly,
but every reply is wrapped in the SDK envelope, so the key is under
`data` and `fill()` received undefined.
Verified against the deployed cluster: J-1 and J-3 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
Proving access decrypts as the recipient, and the SDK stores the CEK it
recovers under the content id — the same slot the owner reads from. A
later revoke rotates the CEK, and the owner is left holding the
recipient's stale copy:
decryption failed with the locally stored CEK: the key may be stale
after a CEK rotation, or your access may have been revoked
Reproduced at the API level: share → prove access → revoke → the owner's
read fails; the same sequence without the proof succeeds. It only bites
when one process is both owner and recipient, which is exactly what this
demo UI does.
The proof is worth keeping — it shows the HPKE round trip really works —
so it stays, but off by default and with its cost stated next to the
checkbox instead of discovered after a revoke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
Both intervals were hardcoded (30s sync, 300s redundancy). The binary also overrode sync_interval_secs, so even the Default could not be reached from outside. Read SYNC_INTERVAL_SECS and REDUNDANCY_INTERVAL_SECS in Default, following the existing MIN_REPLICATION_FACTOR / CAPACITY_THRESHOLD_BYTES convention, and drop the binary's override so the env vars win. Defaults are unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
Production nodes peg the CPU for one to eight minutes every few hours with no log output, then either recover or get replaced by ECS. Every CRDT read and write path has been timed against a copy of the production database and is sub-millisecond, so the stall is somewhere else. Add a 10s runtime heartbeat, log the tokio worker count at startup (0.5 vCPU likely means a single worker, where one blocking task stalls the whole process), and log start/end with elapsed time around the periodic sync, redundancy check, outbox retry, and the swarm loop's maintenance tick. The last line before a silence names the culprit. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
… 20 per second tower_governor's per_second(n) sets the replenish interval to n seconds; it is not a rate. The per-IP limiter written as per_second(20) let a client through once every 20 seconds after its 40-request burst, and the node-wide one written as per_second(100) once every 100 seconds. Any client doing more than a handful of operations then saw "Too Many Requests! Wait for Ns" for minutes — which is why every e2e run after the first failed on the second journey. Use per_millisecond(1000 / rate) for both. The node-wide limiter also used the builder's default PeerIpKeyExtractor, so behind the load balancer every client shared one bucket keyed on the balancer's address; GlobalKeyExtractor is what makes it node-wide. The new test spends the burst, waits 150ms, and requires permits to have replenished. It fails against the old interval (0 replenished) and passes against the new one. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
… is re-dialled at its new address The peer store only learned the address we dialled: ConnectionEstablished records nothing for inbound connections, since a remote's ephemeral port is not dialable. A peer that connected to us after moving — which is how members reach the bootstrap node, and how any node reaches a peer ECS has just restarted on a new IP — never updated its entry. The store kept the old address, maintain_connectivity dialled only the first address it held per peer, and every 30s tick re-dialled the dead one while the cluster ran split until the other side happened to dial in. Identify carries the peer's own listen addresses, the freshest information there is. Replace the store entry with them whenever one arrives, and give maintain_connectivity every address it holds for a peer in a single DialOpts so a stale entry cannot shadow a live one. Seen on every one of the seven ECS restarts over 2026-09-04/05; the split was what made the second e2e run's sharing journey fail after a restart. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
The Identify-based refresh (2ca60ec) only heals a stale peer-store entry once the two peers connect. After ECS moved node3 and node4 while they were apart, each held only the other's dead IP: every dial to the other spent the 20s transport timeout on it, the two never connected, Identify never fired, and the entry was never refreshed. node4's periodic sync took 600s (30 contents x one dead dial each) for as long as we watched. Treat the store as the cache it is: on OutgoingConnectionError, drop the addresses the swarm reports as failed (Transport: every address tried was dead; WrongPeerId: the IP now belongs to another task). A peer left with no addresses is removed and comes back when it dials us or a lookup finds it. With the dead address gone the next dial falls through to what Kademlia learned from a third party instead of timing out, and the maintenance and query dials are no longer rejected by DisconnectedAndNotDialing behind a permanently in-flight dead dial. Also: - replace() sorts announced addresses, names first: Identify hands them over in hash order, so the same announcement looked like a change on every round and rewrote the file; and when the per-peer cap bites it is now the IPs that go, not the /dns4/ name that outlives them. - "Peer store refreshed" is logged at info with the resulting addresses; at debug it was invisible in production, so the 2ca60ec fix could not be confirmed or refuted from logs. Tests: unit tests for forget/order, and a swarm-level test that seeds a dead address in known_peers.json, dials the peer, and requires the address to be gone from disk after the next flush. Verified failing without the swarm-loop change. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
A Fargate task's IP dies with the task, so any peer that remembered it holds a dead address after a restart. The binary already supports --external-address, but the container entrypoint had no way to pass it. EXTERNAL_ADDRESS (comma-separated multiaddrs) now maps to --external-address, and the terraform task definition sets it to /dns4/<node>.<namespace>/tcp/<p2p_port> — the service-discovery name ECS already maintains. Identify announces it alongside the listen IPs, peers persist it first (see PeerStore::replace), and libp2p re-resolves it on every dial, so a moved peer is reachable at the same stored address. The entrypoint also forwards "$@" so a task definition can add flags without rebuilding the image. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
tokio::time::interval defaults to MissedTickBehavior::Burst: every tick missed while a run overran fires back to back once it finishes. node3's sync took 600s per run for five hours while node4 was unreachable; the moment node4 came back, node3 ran ~70 syncs in a row. The redundancy check and outbox retry share the same shape. Delay instead: one run, then resume the cadence from now. The liveness heartbeat deliberately keeps Burst — its burst after a stall is the starvation signature we look for in the logs. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…nnect Seen on the first rollout with b093f30: while node1 restarted, node3 dropped /dns4/node1.monas.local along with node1's dead IP. A name that did not answer is a peer that is down right now, not an address that has gone stale — Cloud Map re-points it the moment the task is back, and it is the one hint that survives the restart, so it must survive the restart window too. forget_unreachable (Transport failure) now skips names; forget_wrong_peer (WrongPeerId) drops the address whatever its form, since then the name really does belong to another identity. The log line names which. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…ort shared Until now a share only worked inside one browser: the envelope stayed in the owner's entry.shares, the pipeline step "Deliver envelope to recipient" did nothing, and there was no way for a recipient to receive anything — the only decrypt path was the "Prove access" checkbox, which unwraps as a recipient identity held in the same browser. Owner side - Identities: "Copy public key" per identity (falls back to revealing the full key for manual selection when the clipboard API is unavailable). - Share dialog: every recipient row gets "Copy package"; the package for the grant added or re-wrapped last is shown as text so it can always be selected and pasted into a chat. The package is one self-describing JSON document (kind: monas-share, v: 1) carrying the file's name/type/ size, the owner's content id and Content Network id, sender and recipient public keys, recipient KeyId, permissions, the KeyEnvelope and the delegated token. Recipient side - Sidebar "Import shared": paste the package, see what it is and which local identity it is addressed to (matched by public key — no dropdown to pick wrong), unwrap it through POST /share/decrypt, and it lands in the Drive as a "shared with me" entry. Open re-unwraps the kept envelope; the row menu offers Open and "Remove from my Drive" only — edit/share/network delete belong to the owner. - Re-importing a package for the same content (a re-wrapped envelope after the owner revoked someone else) replaces the entry. - Preview explains that the state-node panels are off for received files: reads there are signed with this device's account key, and the state node grants non-owners only via a delegated token, which the SDK does not yet combine with the signature. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…kage Two browser contexts, each bound to its own gateway + monas-account pair (separate localStorage, CEK store and signing key), exchange only what a person would paste into a chat: Bob's public key one way, Alice's share package the other. Bob's gateway unwraps the package, shows the plaintext, survives a reload from the kept envelope, imports the re-wrapped package after Alice revokes a third recipient (same entry, no twin), and is refused the pre-rotation package as a stale key_epoch. Cleanup checks the recipient-side "Remove from my Drive" leaves the owner's file alone. - vite proxies /api2 and /account-api2 to the second pair; scripts/second-device.sh runs it (:3001 / :4003, own persistence dir). - The Share dialog's recipient-key textarea is no longer the only `textarea.input` once a grant exists (the package is shown in one too); J-2 and J-4 select it by its field label instead. - Received-share delete confirmation says what it does: removes the local entry only. - README: the cross-device flow, the package format, what it does not do yet (state-node reads as a recipient are SDK work), and how to run J-4. Passed against the production 4-node cluster: J-4 48.7s, J-1..J-3 green, UI suite 20/20. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…, delegated auth, any-version read A recipient on another device could unwrap the envelope but never read the content back from a state node. Three gaps, all on the SDK side: 1. The delegated token was issued for the owner's *local* content id, while the state node matches capabilities against monas://content/<remote id>. ShareContentInput gains remote_content_id (same distinction as UpdateContentInput / RevokeShareInput); the token is issued for it. Revoke now also reissues a token for every surviving recipient alongside the re-wrapped envelope — revoke moves the state node's min_valid_issued_at, so their old tokens are void too. The state node only accepts iat > min_valid_issued_at; if the issuer's clock lands on the boundary the SDK waits for the next second and issues again (bounded by 5s). 2. With an Authorization header present, the read path skipped signing, yet the state node demands a request signature for every token kind and verifies it against the token's aud key. resolve_state_read_auth now signs with the account key whenever no signature was supplied, keeping a supplied Bearer token instead of replacing it with user:. 3. read_content_from_state_node re-derives the plain CID and requires it to equal local_content_id — which a recipient only knows for the version that was shared. accept_any_version keeps local_content_id as the CEK selector, skips only that comparison (Node CID recompute and AES-GCM authentication stay), and returns the derived id. monas-content: verify_and_decrypt_relay_read_any_version, and VerifiedRead carries plain_content_id. Tests: content-layer any-version read; SDK integration tests for the token resource, the Bearer-keeping signed read, a recipient reading a version written after the share, and survivor token reissue across the boundary. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
The share ACL is stored under the local version id. An edit produces a new id and copied the CEK to it but not the ACL, so from the SDK's point of view every recipient vanished on edit: a later share created a fresh ACL with only the new recipient, and revoking anyone then rotated the CEK without re-wrapping the earlier recipients — silently locking them out. update_content now saves the ACL under the new id (the domain already had Share::with_new_content_id for exactly this). Test: share → edit → share another → revoke the other; the first recipient must be among the reissued envelopes. Fails without the fix. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
… own state node With the SDK now issuing the delegated token for the Content Network id, signing delegated reads, and accepting newer versions, the preview of a received file gets its state-node panel back: latest version, history and the verified read run with the token from the share package (Authorization: Bearer), acceptAnyVersion on, and the result says when the plaintext addresses a newer version than the one shared. Integrity is hidden for received files (the recipient's gateway holds no ciphertext to compare). - Share passes remote_content_id so the token is usable; revoke stores the reissued token on the surviving grant so its re-wrapped package carries it (the old one was voided by the revoke). - Importing a package for a Content Network already in the Drive replaces the entry and switches its content id to the package's — the owner's id changes with every edit, and the SDK files the CEK under the new one. - Import warns when the addressed identity is not the signing account: the envelope will open, but the state node verifies the token against the key that signs requests. J-4 now runs Alice against node1 and Bob against node2: Bob reads the shared version from node2, is refused with the voided token after a revoke, reads again with the reissued one, is refused the stale package, and reads Alice's post-share edit. 57s against the production cluster. J-1..J-3 and the UI suite stay green. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…ersion id The pin that records the sender's key, the accepted key_epoch and the CEK was stored under the owner's content id. That id changes with every edit, so once the owner had edited, a pre-rotation envelope naming the old id found no record and was accepted — the rotation replay check was defeated by one edit. The sender and the epoch belong to the series. DecryptSharedContentInput gains remote_content_id; the pin is kept under it (falling back to content_id for local-only content). The verified read looks the pin up by series first, then by version id for callers that never passed one. The UI passes the package's Content Network id on import and on reopen. Test: share → recipient processes v1/epoch 0 → owner edits → shares and revokes another recipient → recipient processes v2/epoch 1 → replaying the v1 envelope is refused as stale. Fails without the change. J-4 now runs its stale-package step after the owner's edit. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…ersion A read+write share granted a write token, but nothing could use it: the recipient's device has no content record (only the CEK and sender pin from the envelope), the SDK's update path requires one, and the state node's non-owner write path had never been driven end to end. Recipient write — `MonasController::update_shared_content` (`PUT /share/content/:id` on the gateway): encrypt the new plaintext under the CEK from the sender pin (`ContentService::encrypt_with_cek`, no local record needed), sign `update:<id>:<ts>:<digest>` with this device's account key like every write, but keep `Authorization: Bearer <delegated token>` instead of the owner's `user:` id. The state node authorises on the token's `content/write` capability, verifies the signature against the token's `aud`, and refuses the write once a revoke has moved `min_valid_issued_at` past the token. The returned `version_id` is the plaintext's id, derived as the owner would. Owner pull — the recipient's version lives only on the state node, and every owner-side operation that re-publishes from the local record (`update_content`, and the `reencrypt` inside `revoke_share`) would overwrite it with stale local plaintext; the revoke case silently rolled the file back to the owner's last save. `pull_content_from_state_node` (`POST /state/pull`) runs the verified read (Node CID recomputed, AES-GCM under the owner's own CEK), and if the head's plain id differs from the local one adopts it as the newest local version (`ContentService::adopt_version`, keeping the state node's ciphertext byte for byte so integrity checks stay true) and carries the share ACL over. `revoke_share` pulls by itself before rotating; if that pull fails the revoke still proceeds on the local copy — a writer must never be able to block revocation — and reports it in `head_pull_error`. `carry_share_acl` no longer overwrites an ACL that already exists at the new id: re-adopting from an older id would otherwise replace the current recipient set with the older one (found by the revoke test that passes the pre-pull id). Also: the ureq agent now returns non-2xx responses instead of failing the call, so every state node / account refusal reaches the per-call status mapping (`try_state_node_http_error` etc.) that was already written for it but never ran — a revoked token's 403 came back from the gateway as an Internal 500 with the state node's reason discarded. The issuer call, which relied on the old behaviour, now checks the status explicitly. Tests: recipient write (headers, body decrypts under the owner's CEK, recipient reads its own version back), bearer required, imported share required, 403 keeps its status and reason on both write paths, owner pull adopts / is idempotent, revoke after a recipient write rotates the recipient's version (the survivor's re-wrapped envelope carries it) and accepts the pre-pull id, failed pull is reported. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…sion as the owner Recipient side: a write share's row menu gains "Edit contents". The editor loads the owner's newest version from the state node with the delegated token (not the version the envelope carried), and "Re-encrypt & save" goes to `PUT /share/content/:networkId` — the gateway encrypts under the CEK it pinned at import, signs with this device's account key and writes to the owner's Content Network. The entry keeps the envelope's id as `localContentId` (what "Open" decrypts) and records `writtenVersionId`, so the preview can tell "your edit" from "the owner edited since sharing". Owner side: the verified read now always accepts any version and labels the outcome — "newer than your copy — a recipient with write access has edited since your last save". "Edit contents" on a synced file calls `POST /state/pull` first, so the editor opens on the recipient's version and the update applies over it instead of overwriting it; the revoke handler picks up the moved local id (the SDK pulls before rotating) and surfaces `head_pull_error` as an error toast when it could not. J-4 now shares read+write, has Bob edit (the editor shows Alice's latest, his write reports the token grant), has Alice read his version from her node and pull it into her copy via the editor, then has Alice revoke Bob: his next edit cannot even load (voided token), the save is refused with HTTP 403, and Alice's node still serves his last authorised version. Passes against node1/node2 (1.4 min); J-1..J-3 and the UI suite unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
example-ui/.vite/deps_temp_*/package.json is a temp dir vite leaves behind when its dependency pre-bundling is interrupted; one got committed by accident. Ignore .vite entirely. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
…ntegrity verdicts Accounts. monas-account holds exactly one signing key, and a delegated token's audience is that key — so a share addressed to any other identity could open its envelope but never read or write the state node. The dialog stopped pretending otherwise: no "register as signing account" checkbox, no keypair-only identities, no "Use" switch. One Create account per device; remove and recreate to start over. Legacy keypair-only identities still show (as "keypair only", removable) so nothing already in localStorage silently disappears. The Share dialog is paste-a-public-key only: with one identity there was nothing to pick, and "prove access" decrypted as the recipient into the owner's CEK slot, which a later revoke then broke. Sync status. Every synced row now carries one of up to date / newer on network / synced (unchecked) / can't reach network, and the preview repeats it as a one-line status with "Check now" and, when behind, "Pull & edit". The comparison is a verified read of the Content Network head: the plain id its plaintext re-addresses to against the id this device holds (the owner's local version, or a recipient's last write / envelope version). It runs on open, on demand, and in a 30 s background sweep (sequential — the state node rate-limits). Writes this device makes record themselves as the head so the badge is right without a round trip. Integrity. "Verify integrity" reported a bare red "invalid" with the SDK's reason in a right-aligned kv row nobody read. It now classifies the reason: "not the head — newer version on the network" (amber, not a corruption), "no local ciphertext" (this gateway's store was reset), and only otherwise "invalid". The reason string is shown under the badge in every non-valid case. Tests: G-34 aria snapshot follows the simplified Share dialog; J-2 shares to two pasted keys instead of a local identity; J-4 asserts Alice's row goes behind → current across Bob's write and her pull. UI suite 20/20, J-1..J-4 pass against node1/node2. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ff reached
A revoke advances `min_valid_issued_at` on the committing node and pushes the
new policy to the other members best-effort; a push failure was a `warn!` and
the call returned success. But each member authorizes a write against its
own copy of the policy (`update_content_inner` commits on the local view), so
a member the push did not reach keeps accepting writes under the voided
tokens until its next periodic sync — and the UI, on top, asserted "the
revoked recipient cannot write in between". Seen live: a revoked recipient's
PUT landed on node2 ~10 s after node1 had revoked (see
docs/revoke-write-bypass-investigation.md).
This change does not close that window — a writer must never be able to hold
up a revocation, and closing it needs either quorum on both sides or a
policy-aware merge (options A/B in the investigation doc; B is tracked
separately). It makes the outcome truthful:
- state-node: `invalidate_tokens` retries each push once and returns
`InvalidateTokensOutcome { new_min_valid_issued_at, notified_members,
unreached_members, relayed }`. The HTTP response always serializes the
three propagation fields, so a client can tell "reached everyone" from
"this node predates the report" (fields absent). Relayed requests report
`relayed: true` with unknown (empty) lists — the relay protocol carries
only success.
- sdk: `RevokeShareOutput.token_invalidation_reach`; `None` for a legacy
node's response rather than an empty list that would read as "all
reached".
- example-ui: the revoke pipeline gains a "Cutoff propagation" step
(optional — the revoke itself succeeded) that names unreached members and
the ~30 s sync window, distinguishes relayed/legacy "unknown", and an
error toast when propagation is incomplete. The "cannot write in between"
wording is gone.
Tests: state-node (unreached member reported with retry count, self never
pushed; all-notified case), sdk (legacy vs. reporting response parsing).
J-4 unchanged and passing.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…cannot erase a revoke A version's payload holds the content body and the access policy together, and crsl-lib merged concurrent versions by copying the newest one whole. That treats the policy as part of the body, and it breaks in both directions whenever a revoke and a write run concurrently — a member the revoke has not reached yet, a partition, plain sync lag: - write newer than the revoke: the write's node carries the policy it inherited, i.e. the pre-revoke `min_valid_issued_at`. The merge copies that node whole and the revoke is gone from the head. The revoked recipient is not "able to write once in a window" — after that one write the cutoff is back to 0 and they can keep writing. - revoke newer than the write: the revoke's node carries the body it inherited. The merge copies *that* whole and a legitimate write that was never in conflict with anything is dropped. The fix keeps the payload as it is — one DAG, one version CID binding body and policy — and changes only how a merge node is computed. crsl-lib now takes an application merge policy (`Repo::with_merge_policy`, with each head's parent payloads in `ResolveInput`); `MonasMergePolicy` merges field by field: the body is LWW among the heads that actually changed it against their parent, `min_valid_issued_at` is the max across heads, owner and content id are carried. Every head read (`get_latest`, `get_access_policy`, the policy copy in `update_content`, the body copy in `update_access_policy`) first converges concurrent heads via `Repo::merge_heads`, so a reader sees what the policy decides rather than one branch's tip, and a new version starts from the converged value. `AccessPolicy::raise_min_valid_issued_at` is the monotone step the merge uses; it ignores a lower value instead of erroring, since a merge has no notion of "current". `ContentAccessControl::merge` (Sled side) already expressed this intent but sits outside the authorization path; it is left untouched. This decides what a merge node contains, not whether a head should have been accepted. A write a stale member let through under a since-voided token still wins the body if it is the newest write; ruling it out needs the write to carry its token so the merge can check it against the merged cutoff — tracked separately. The merge policy is per process and does not travel with the data, so all members of a Content Network must run the same one; there is no mixed-version deployment to protect yet, so no policy_type versioning is added. Tests: MonasMergePolicy unit (write-after-revoke keeps cutoff, revoke-after-write keeps write, two writes LWW, two revokes max, orphan head counts as a write, order-independent); two-replica integration through CrslCrdtRepository for both orderings, syncing both ways and asserting each replica's policy and body after convergence. crsl-lib: 0fbabbf (feat/injectable-merge-policy). Co-Authored-By: Claude Code <noreply@anthropic.com>
fix(state-node): merge concurrent versions field by field so a write cannot erase a revoke
fix: complete PR75 review fixes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(example-ui): minimal Drive-like UI on monas-sdk / monas-gateway
A minimal, Google-Drive-like web UI (
example-ui/) demonstrating the Monasprotocol end to end through the monas-gateway HTTP API (which embeds
monas-sdk). React + Vite + TypeScript, no Tailwind — a hand-built pink
design system.
What it does
POST /accounts→monas-account) and add keypair-only identities for sharing
(
POST /keypair→ gateway).(re-encrypt + new version), rename, delete. Folders are logical paths.
/share), an unwrap+decrypt round-trip proof(
/share/decrypt), and revoke (/share/revoke).integrity verification (
/state/history,/state/latest-version,/state/verify-integrity) surfaced inside the preview modal.CEK → AES-256-CTR → SHA-256 CID → storage → state-node sync → HPKE — pairing
one real gateway call with illustrative protocol phases.
Contract details
/api/*, Vite-proxied to:3000), plus/account-api/*(→:4002) for creating the signing key.ApiResponse<T>envelope; state-touching calls sendX-Request-Timestamp.Run
Notes for review
localStorage(thegateway has no folder/listing concept).
(
src/api/stateNode.ts) into the preview modal, fixes alocalStoragekeymismatch, guards content creation behind a signing account, and lets the
edit modal rename a file (carried to the SDK on the next content edit).
tsc --noEmit && vite buildpasses; secret scans (gitleaks + git-secrets)are clean. CI is Rust-only (
example-uiis not in the Cargo workspace), sothis PR does not affect the existing CI jobs.
example-ui/recording/harness (mock gateway + Playwright demorecorder) is gitignored and not part of this PR. A walkthrough recording can
be attached in a comment below.
Draft PR — feedback welcome on the contract usage and UX.
🤖 Generated with Claude Code