From 094cb34a3f360c1ac4214688a8e7313d965d9a4b Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sun, 13 Sep 2026 22:22:33 +0900 Subject: [PATCH 1/3] feat(example-ui): one account per device, sync-status badge, honest integrity verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- example-ui/README.md | 60 +++-- example-ui/src/App.tsx | 52 ++++- example-ui/src/components/FileBrowser.tsx | 33 ++- example-ui/src/components/IdentityModal.tsx | 245 ++++++++++---------- example-ui/src/components/PreviewModal.tsx | 245 +++++++++++++++----- example-ui/src/components/ShareModal.tsx | 143 +++--------- example-ui/src/index.css | 12 + example-ui/src/pipeline/flows.ts | 23 -- example-ui/src/store/identity.ts | 11 +- example-ui/src/store/registry.ts | 7 + example-ui/src/store/sync.ts | 125 ++++++++++ example-ui/src/types.ts | 17 ++ example-ui/tests-e2e/cross-device.spec.ts | 8 +- example-ui/tests-e2e/full-stack.spec.ts | 71 +++--- example-ui/tests/share-modal.spec.ts | 17 +- 15 files changed, 676 insertions(+), 393 deletions(-) create mode 100644 example-ui/src/store/sync.ts diff --git a/example-ui/README.md b/example-ui/README.md index 3221ec4..fd642b8 100644 --- a/example-ui/README.md +++ b/example-ui/README.md @@ -151,13 +151,13 @@ so the suite cannot go non-deterministic on a model update. | Action | Gateway call | SDK model | | ----------------- | ------------------------------------- | --------------------------------- | -| Create identity | `POST /keypair` | `GenerateKeypair{Input,Output}` | +| Create account | `POST /account-api/accounts` | (monas-account) | | New file / Upload | `POST /content` | `CreateContent{Input,Output}` | | Open / preview | `GET /content/{id}` | `GetContent{Input,Output}` | | Edit contents | `PUT /content/{id}` | `UpdateContent{Input,Output}` | | Delete | `DELETE /content/{id}` | `DeleteContent{Input,Output}` | | Share | `POST /share` | `ShareContent{Input,Output}` | -| Prove access | `POST /share/decrypt` | `DecryptSharedContent{Input,Out}` | +| Import shared | `POST /share/decrypt` | `DecryptSharedContent{Input,Out}` | | Revoke | `POST /share/revoke` | `RevokeShare{Input,Output}` | | Verified read | `POST /state/read` | `ReadContentFromStateNode{In,Out}`| | (history/version) | `POST /state/history`, `/state/...` | `state` models | @@ -197,22 +197,46 @@ Notes on the contract: ## Accounts & the signing key -Create your account from the UI: open the identity chip (top-right) → **Create -account**. With *Register as signing account* checked, the UI sends -`POST /accounts` to **monas-account** (via the `/account-api` proxy), which -registers a **P-256** key. The SDK uses that key to sign state-node requests for -**create / edit / delete**. - -This is needed because the gateway's `/keypair` is stateless — it returns a -fresh keypair (handy for share recipients) but does **not** register a signing -key. So: - -- **Create account** (signing) → `POST /account-api/accounts` → monas-account. -- **Add identity** (keypair-only, e.g. a share recipient) → `POST /api/keypair` - → gateway. - -Sharing (`/share`, `/share/decrypt`, `/share/revoke`) only uses the keypairs the -UI holds, so a recipient identity doesn't need to be a signing account. +A device has **one account**. Open the identity chip (top-right) → **Create +account**: the UI sends `POST /accounts` to **monas-account** (via the +`/account-api` proxy), which generates and keeps a **P-256** key. The SDK signs +every state-node request with that key (create / edit / delete, and a +recipient's reads and writes under a delegated token), and it is the key +other people share *to* — a delegated token's audience is the recipient's +signing key, so a share addressed to any other key could open its envelope but +never read or write the state node. + +monas-account holds exactly one key, which is why the dialog does not offer a +second account or a keypair-only identity: creating another would overwrite +the key monas-account signs with and silently orphan the first. To start over, +remove the account and create a new one (content created under the old key can +then no longer be updated or deleted from this device). + +## Is my copy the newest? (sync status) + +Every synced row carries a sync badge, and the preview repeats it as a one-line +status: + +| Badge | Meaning | +| -------------------- | ----------------------------------------------------------------------- | +| `up to date` | the Content Network head is the version this device holds | +| `newer on network` | someone else wrote after this device's last save/import | +| `synced` | on a Content Network, head not compared yet | +| `can't reach network`| the last check failed (node down, token voided, …); hover for the error | + +The comparison is a **verified read** of the head (`POST /state/read` with +`accept_any_version`): the plaintext is re-derived and re-addressed, and the +resulting plain id is compared with the one this device holds — the owner's +local version, or for a recipient the version it last wrote (else the one the +envelope carried). It runs when a file is opened, on *Check now* / *Read from +state-node*, and in a background sweep every 30 s. When behind, the owner's +*Pull & edit* adopts the head into the local copy first (`POST /state/pull`); +a write-share recipient's *Edit contents* already starts from the head. + +*Verify integrity* is related but narrower: it byte-compares the ciphertext +this gateway stored with the head's. "Not the head" there is the same +*newer on network* condition, not a corruption; the reason string from the SDK +is shown under the badge. ## Sharing with someone on another device diff --git a/example-ui/src/App.tsx b/example-ui/src/App.tsx index ffd6304..bc20d81 100644 --- a/example-ui/src/App.tsx +++ b/example-ui/src/App.tsx @@ -23,6 +23,7 @@ import { folderPath, } from "./store/registry"; import { useIdentities, getActive } from "./store/identity"; +import { checkNetworkHead, checkAllNetworkHeads } from "./store/sync"; import { probeGateway } from "./api/http"; import { uuid, @@ -73,7 +74,7 @@ function mimeFromName(name: string): string { export default function App() { const entries = useEntries(); - const { identities, activeLabel } = useIdentities(); + const { identities } = useIdentities(); const active = getActive(); const [path, setPath] = useState("/"); @@ -96,6 +97,27 @@ export default function App() { return () => clearInterval(t); }, [poll]); + // Sync-status sweep: a verified read of every synced file's head, shortly + // after load and then every 30 s while the gateway is up. Sequential and + // slow on purpose — the state node rate-limits — but it is what lets the + // list say "newer on network" without anyone pressing a button. The first + // run is deferred a few seconds so it never races the initial render (and + // the tests' localStorage seeding, which reloads right after writing). + useEffect(() => { + if (!gatewayUp) return; + let stopped = false; + const sweep = async () => { + if (!stopped) await checkAllNetworkHeads(); + }; + const first = setTimeout(sweep, 5_000); + const t = setInterval(sweep, 30_000); + return () => { + stopped = true; + clearTimeout(first); + clearInterval(t); + }; + }, [gatewayUp]); + // ---- pipeline plumbing ---------------------------------------------- const upsertRun = useCallback((run: RunView) => { setRuns((prev) => { @@ -143,6 +165,14 @@ export default function App() { const liveEntry = (id: string) => allEntries().find((e) => e.id === id); + // Right after this device wrote a version, it *is* the head — record that + // so the row reads "up to date" without a round trip. The Node CID is not + // known here (writes return plain ids); the next check fills it in. + const ownHead = (localId: string) => ({ + networkHead: { localId, checkedAt: Date.now() }, + networkCheckError: undefined, + }); + // ---- actions -------------------------------------------------------- const createFromBytes = async ( name: string, @@ -168,6 +198,7 @@ export default function App() { syncedToStateNode: !!created.remote_content_id, versionCount: 1, shares: [], + ...(created.remote_content_id ? ownHead(created.content_id) : {}), }); // Drop back to folder browsing so the new file is visible at `path` // (it wouldn't match an active filter view yet). @@ -248,9 +279,12 @@ export default function App() { updateEntry(entry.id, { localContentId: pulled.local_content_id, versionCount: entry.versionCount + 1, + ...ownHead(pulled.local_content_id), }); entry = { ...entry, localContentId: pulled.local_content_id }; pushToast("Pulled a newer version written by a recipient into your copy", "info"); + } else { + updateEntry(entry.id, ownHead(pulled.local_content_id)); } text = pulled.content; } else { @@ -281,6 +315,7 @@ export default function App() { sizeBytes, versionCount: entry.versionCount + 1, receivedShare: { ...entry.receivedShare!, writtenVersionId: upd.version_id }, + ...ownHead(upd.version_id), }); pushToast(`“${entry.name}” updated on the owner's Content Network`, "success"); } else { @@ -308,6 +343,7 @@ export default function App() { sizeBytes, versionCount: entry.versionCount + 1, ...(renamed ? { name: renamed } : {}), + ...(entry.syncedToStateNode ? ownHead(upd.version_id) : {}), }); pushToast(`“${renamed || entry.name}” updated`, "success"); } else { @@ -360,6 +396,7 @@ export default function App() { if (ok && ctx.get) { const g = ctx.get as { content: string }; setModal({ type: "preview", entry, contentB64Url: g.content }); + void checkNetworkHead(entry); } else { pushToast("Could not open file", "error"); } @@ -435,6 +472,9 @@ export default function App() { setPath("/"); pushToast(`“${pkg.name}” unwrapped and added to your Drive`, "success"); setModal({ type: "preview", entry, contentB64Url: res.content }); + // The envelope carries the version the owner shared; whether that is + // still the head only the network knows. + void checkNetworkHead(entry); }; const handleShare = async (entry: Entry, input: ShareInput) => { @@ -450,7 +490,6 @@ export default function App() { recipientPublicKeyB64Url: input.recipientPublicKeyB64Url, recipientLabel: input.recipientLabel, permissions: input.permissions, - recipientPrivateKeyB64Url: input.recipientPrivateKeyB64Url, }); const { ok, ctx } = await run("Share", entry.name, specs); if (ok && ctx.share) { @@ -513,6 +552,9 @@ export default function App() { updateEntry(entry.id, { shares, ...(moved ? { localContentId: r.content_id, versionCount: entry.versionCount + 1 } : {}), + // The SDK just re-encrypted under the new CEK and wrote that as the + // head, so whatever id the local record now has is the network head. + ...(r && entry.syncedToStateNode ? ownHead(r.content_id) : {}), }); if (r?.head_pull_error) { @@ -730,8 +772,6 @@ export default function App() { {modal.type === "share" && shareEntry && ( handleEditOpen(e)} onClose={() => setModal({ type: "none" })} /> )} diff --git a/example-ui/src/components/FileBrowser.tsx b/example-ui/src/components/FileBrowser.tsx index a5cb7a9..67ac50c 100644 --- a/example-ui/src/components/FileBrowser.tsx +++ b/example-ui/src/components/FileBrowser.tsx @@ -16,6 +16,30 @@ import { Cloud, Inbox, } from "./icons"; +import { syncStatusOf, describeSync } from "../store/sync"; + +// Where this copy stands against the Content Network head. One badge, four +// states, so a glance at the list says whether "Open" would show the newest +// version or whether someone (the owner, or a writer we shared with) has +// moved the file on since. The check itself runs on open and on a timer in +// App; this only renders what the entry records. +export function SyncBadge({ entry }: { entry: Entry }) { + const s = syncStatusOf(entry); + const d = describeSync(s); + const cls = + s.kind === "current" + ? "synced" + : s.kind === "behind" + ? "behind" + : s.kind === "unreachable" + ? "invalid" + : ""; + return ( + + {s.kind === "checking" ? : } {d.label} + + ); +} function FileTypeIcon({ entry }: { entry: Entry }) { if (entry.kind === "folder") @@ -78,15 +102,12 @@ function Row({ )} {isFile && !received && - (entry.syncedToStateNode ? ( - - synced - - ) : ( + !entry.syncedToStateNode && ( local - ))} + )} + {isFile && entry.syncedToStateNode && } {isFile && entry.shares.length > 0 && ( {entry.shares.length} diff --git a/example-ui/src/components/IdentityModal.tsx b/example-ui/src/components/IdentityModal.tsx index a81b06a..537d9cb 100644 --- a/example-ui/src/components/IdentityModal.tsx +++ b/example-ui/src/components/IdentityModal.tsx @@ -1,49 +1,52 @@ import { useState } from "react"; import { Modal } from "./Modal"; -import { Key, Plus, Check, Trash, Copy } from "./icons"; -import { generateKeypair, createSigningAccount } from "../api/account"; -import { - useIdentities, - addIdentity, - setActive, - removeIdentity, -} from "../store/identity"; +import { Key, Plus, Trash, Copy } from "./icons"; +import { createSigningAccount } from "../api/account"; +import { useIdentities, addIdentity, removeIdentity } from "../store/identity"; import { pushToast } from "./Toast"; import { copyText } from "../sharePackage"; +// One device, one account. monas-account holds exactly one signing key: it is +// what the SDK signs every state-node request with, and the audience of every +// delegated token a share package brings to this device. So there is nothing +// to "switch" between — a second Create would overwrite the key in +// monas-account and silently orphan the first — and a keypair-only identity +// (the gateway's stateless /keypair) can open an envelope but can never read +// or write the state node. The dialog therefore offers exactly one thing: +// this device's account, and a way to replace it. export function IdentityModal({ onClose }: { onClose: () => void }) { - const { identities, activeLabel } = useIdentities(); - const hasSigningAccount = identities.some((i) => i.isSigningAccount); + const { identities } = useIdentities(); + const account = identities.find((i) => i.isSigningAccount) ?? null; + // Identities minted before the dialog was reduced to one account. They + // still open envelopes addressed to them, so they stay removable, not hidden. + const legacy = identities.filter((i) => !i.isSigningAccount); const [label, setLabel] = useState(""); - const [asSigning, setAsSigning] = useState(!hasSigningAccount); const [busy, setBusy] = useState(false); const create = async () => { - const name = label.trim() || `account-${identities.length + 1}`; + const name = label.trim() || "me"; setBusy(true); try { // Always P-256: signing requires it, and the HPKE share envelopes are // DHKEM(P-256) — any other curve would mint a key that cannot receive // a share, a dead end this dialog should not offer. - const res = asSigning - ? await createSigningAccount("secp256r1") - : await generateKeypair("secp256r1"); + const res = await createSigningAccount("secp256r1"); + // monas-account now signs with the new key, so a previous account entry + // would only claim an authority it no longer has. Drop it. + for (const old of identities) if (old.isSigningAccount) removeIdentity(old.label); addIdentity( { label: name, keyType: res.key_type, publicKeyB64Url: res.public_key, privateKeyB64Url: res.private_key, - isSigningAccount: asSigning, + isSigningAccount: true, }, - identities.length === 0 || asSigning, + true, ); setLabel(""); - pushToast( - asSigning ? `Signing account “${name}” created` : `Identity “${name}” created`, - "success", - ); + pushToast(`Account “${name}” created`, "success"); } catch (e) { pushToast((e as Error).message, "error"); } finally { @@ -64,119 +67,121 @@ export function IdentityModal({ onClose }: { onClose: () => void }) { } }; + const renderRow = (id: { label: string; keyType: string; publicKeyB64Url: string }, signing: boolean) => ( +
+ {id.label.slice(0, 2).toUpperCase()} +
+
+ {id.label}{" "} + {signing ? ( + + signing + + ) : ( + + keypair only + + )} +
+
+ {id.keyType} · pub {id.publicKeyB64Url.slice(0, 22)}… +
+ {revealed === id.label && ( +