Skip to content

feat(example-ui): minimal Drive-like UI on monas-sdk/gateway - #47

Draft
somasekimoto wants to merge 45 commits into
mainfrom
feat/example-ui-monas-drive
Draft

somasekimoto wants to merge 45 commits into
mainfrom
feat/example-ui-monas-drive

Conversation

@somasekimoto

Copy link
Copy Markdown
Contributor

feat(example-ui): minimal Drive-like UI on monas-sdk / monas-gateway

A minimal, Google-Drive-like web UI (example-ui/) demonstrating the Monas
protocol 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

  • Accounts: create a P-256 signing account (POST /accounts →
    monas-account) and add keypair-only identities for sharing
    (POST /keypair → gateway).
  • Files & folders: create, open/preview (fetch + decrypt), edit
    (re-encrypt + new version), rename, delete. Folders are logical paths.
  • Sharing: HPKE key-wrap (/share), an unwrap+decrypt round-trip proof
    (/share/decrypt), and revoke (/share/revoke).
  • State-node ops in the preview: version history, latest version, and
    integrity verification (/state/history, /state/latest-version,
    /state/verify-integrity) surfaced inside the preview modal.
  • Protocol activity panel: every action animates the underlying steps —
    CEK → AES-256-CTR → SHA-256 CID → storage → state-node sync → HPKE — pairing
    one real gateway call with illustrative protocol phases.

Contract details

  • Talks only to the gateway (/api/*, Vite-proxied to :3000), plus
    /account-api/* (→ :4002) for creating the signing key.
  • Content/keys exchanged as base64url; responses unwrapped from the SDK
    ApiResponse<T> envelope; state-touching calls send X-Request-Timestamp.

Run

cd example-ui
npm install
npm run dev          # http://localhost:5173  (gateway must be running on :3000)

Notes for review

  • The UI keeps its own file registry / identities in localStorage (the
    gateway has no folder/listing concept).
  • This branch wires up the previously-unused state-node API
    (src/api/stateNode.ts) into the preview modal, fixes a localStorage key
    mismatch, 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 build passes; secret scans (gitleaks + git-secrets)
    are clean. CI is Rust-only (example-ui is not in the Cargo workspace), so
    this PR does not affect the existing CI jobs.
  • The local example-ui/recording/ harness (mock gateway + Playwright demo
    recorder) 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

somasekimoto and others added 6 commits May 27, 2026 18:34
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>
somasekimoto and others added 23 commits July 5, 2026 00:56
…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
`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
somasekimoto and others added 11 commits September 6, 2026 04:41
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant