Problem
Today, creating and updating Hypermedia documents only works in the desktop app. The web app at frontend/apps/web already renders documents, supports comments, and signs blobs client-side (account creation, profile updates, comments) — but it has no path for a logged-in user to actually edit a document and publish those changes back to their account.
That gap matters because:
It forces every contributor onto the desktop app, which raises the bar to participate (download, install, sync, key handling).
It limits "vault-delegated" web users — the very identity model we're investing in — to read-only and commenting roles, even though their signing keys are fully capable of authoring.
It blocks lightweight, embedded, and shareable editing flows (open a link → edit → publish) that are core to a hypermedia-native web product.
We aren't blocked on backend or signing capability. The daemon already exposes Documents.CreateDocumentChange and the client-side Documents.PrepareChange path; the web already client-side signs CBOR and publishes via seedClient.publishDocument (used today by auth.tsx for profile updates); the new shared document-machine (@shm/shared/models/document-machine.ts) is the unified state machine that desktop is migrating to and that the legacy /draft page will be retired in favor of. We are blocked on the wiring: the web has no editing UI, no draft store, no capability gate, and no actor that knows how to take editor blocks → diffed DocumentChange[] → signed publish on the web.
This project closes that gap.
Solution
The web app integrates the same document-machine that desktop uses, and provides web-flavored implementations of the actors the machine delegates to: a draft writer that persists to IndexedDB, a publisher that signs with the user's WebCrypto key and submits via the existing client, and a capability gate that respects gateway-vs-custom-domain rules.
Because the machine is already designed as a host-extension contract — it owns the state graph and delegates side effects (writeDraft, publishDocument, pushDocument) to actors the host injects — we don't fork the editor or re-implement state. We connect the web to the existing pipeline.
Architecture
WebResourcePage
└── ResourcePage (frontend/packages/ui/src/resource-page-common.tsx)
└── DocumentMachineProvider(machine = documentMachine.provide({ actors }))
├── DocumentEditor # already lazy-loaded; flips isEditable from machine state
├── WebEditingToolbar (new) # Edit / Publish / Discard + change count
└── EditNavHeaderPane (existing) # outline / left-rail edits
The web instantiates the machine once with three injected actors (writeDraft, publishDocument, pushDocument), and pumps live data into it via the existing sync hooks (useDocumentSync, useDraftResolutionSync, useCapabilitySync, useAccountSync, useVersionLatestSync). The editor is already provisioned to flip isEditable reactively when the machine enters the editingstate.
Identity and capability model
V1 only allows vault-delegated identities to author documents. Local-only browser keys remain comment-only (the local-key path is still useful for low-friction commenting; eligibility for authoring is a separate decision).
How delegated keys are validated by the daemon
A delegated session never sends "the vault's signature." The browser holds a local P-256 keypair, and the delegation itself is a Capability blob (proto: documents/v3alpha/access_control.proto, fields issuer, delegate, role) that the vault signs once during the sign-in callback (auth-session.ts:207-238). That capability is published as a blob and lives in the document graph just like any other blob. From then on, the browser signs Change blobs directly with its local key.
When the daemon receives a signed change, checkWriteAccess (backend/api/documents/v3alpha/documents.go:1847-1858) calls IsValidWriter, which runs (effectively):
if signer pubkey == account-owner pubkey → allow
else look up a Capability blob where
author == account
AND delegate == signer pubkey
AND role IN ('WRITER', 'AGENT')
AND resource scope covers this path
→ allow if found
In other words: capability resolution on the backend is by signer pubkey, not by delegatedAccountUid. The vault delegation blob is what links "this local browser pubkey" to "that account." This means our publish path doesn't need any new server protocol — calling seedClient.publishDocument({account: delegatedAccountUid, changes, capability, ...}, signer) with the existing local signer is exactly correct, provided the capability CID is included on the Ref blob (the client already supports this; see frontend/packages/client/src/client.ts:279-299).
How the web frontend checks "can I edit?" before showing the UI
This is the part we add. The check has to mirror the daemon's rule, but client-side. The Documents.ListCapabilities RPC and the existing resolveCapability helper (frontend/packages/client/src/capability.ts:59-72) already give us what we need:
useWebCanEdit(docId) →
if no userKeyPair.delegatedAccountUid → {canEdit: false}
else:
if docId.uid === delegatedAccountUid → owner, canEdit: true
else: resolveCapability(client, docId.uid, delegatedAccountUid)
if WRITER or AGENT and scope covers docId.path → canEdit: true
else canEdit: false
then site-scope filter:
gateway origin → keep canEdit
custom-domain → require docId.uid === originHomeId.uid
The capability lookup matches delegate against the delegated account UID (not the local pubkey), because on the public capability graph the delegate field of the outer writer-capability is the account UID; the vault's session-signing capability that bridges "browser pubkey → delegated account" is a separate blob handled by the daemon. From the frontend's perspective, "can the delegated account write to this doc?" is the right question, and ListCapabilities answers it directly.
There is no existing dynamic canEdit(docId, accountUid) hook — selectCanEdit (use-document-machine.ts:347) just reads a boolean set at machine init. We are introducing the dynamic resolution.
Where the check runs: client-side only
The capability check runs entirely on the client, after hydration. The Remix loader and SSR render the document strictly read-only with no capability work:
The loader does not call
ListCapabilities.The loader does not include identity-derived data in the SSR payload.
The first SSR pass renders with
canEdit = false; the editor toolbar is not rendered server-side.
After hydration, useWebCanEdit(docId) runs in the browser:
Reads the user's
delegatedAccountUidfrom IndexedDB /useLocalKeyPair()(already client-only).Calls
ListCapabilitiesvia the gRPC-Web transport from the browser (cached via React Query, deduped per(docId, delegatedAccountUid)).Returns
{canEdit, signingAccountId}. When it flips totrue, the toolbar mounts and the machine receivescapability.changed.
This keeps SSR cost flat — no extra RPC, no new loader work, no per-request identity branching — at the cost of a brief "Edit button appears after hydration" delay for eligible users, which is the right trade-off for a read-first surface.
Site-scope rule
Gateway origin (no custom domain): user can edit any doc they have capability for.
Custom-domain site: user can only edit docs under that site's home (
docId.uid === originHomeId.uid), even if they hold capability elsewhere. This keeps per-domain identity expectations intact and avoids "I edited X on site Y" confusion.
The site-scope filter is also pure client logic (origin and originHomeId come from useUniversalAppContext()).
Drafts (local IndexedDB, explicit publish)
We introduce a small IndexedDB store, web-doc-drafts, keyed by draftId. The writeDraft actor persists the editor's blocks plus the rest of the machine's draft payload (metadata, deps, navigation, locationUid, locationPath, editUid, editPath, signingAccountId, cursorPosition, updatedAt). On reload, useDraftResolutionSync reads the latest draft for the document and feeds the machine draft.resolved. There is no autosave-to-server — drafts live only in the user's browser until they hit Publish.
Image attachments inside drafts are persisted using the same IndexedDB-media pattern that comment drafts already use (draft-media-db.ts), so previews survive reload without re-uploading.
Publish path (web signer, no daemon-side signing)
The publishDocument actor:
Reads the editor baseline (
selectEditorBaseline) and live blocks (viaEditorHandlersContext.getCurrentBlocks()).Diffs them with the existing
compareBlocksWithMap+extractDeletesfromfrontend/packages/shared/src/utils/document-changes.tsto produce block-levelDocumentChangeentries.Adds metadata (
setMetadatafor name/icon/cover) and navigation (setAttributeoperations) using the helpers that desktop already uses.Calls
seedClient.publishDocument({ account, changes, baseVersion: deps, genesis, generation }, signer)— the same call already exercised in production today byauth.tsx:261for profile updates. The signer comes fromgetCurrentSigner()(WebCrypto over the locally-held P-256 key, gated to vault-delegated accounts in V1).On success, deletes the IDB draft and returns the new
HMDocumentso the machine can transition toloadedwith refreshed baseline.
pushDocument is a no-op in V1; the web can later invoke a push endpoint if/when one exists.
V1 editing surface
Text and headings (the default editor surface).
Document metadata: name, icon, cover.
Embeds of other Hypermedia documents.
Image upload to IPFS, reusing the comment-editor's
filesToIpfsBlobs+seedClient.publishflow.Navigation editing (left-rail / outline) via the existing
EditNavHeaderPanecomponent, which already routes throughchange.navigationevents.
Code surface
New files (all under frontend/apps/web/app/document-edit/):
web-document-actors.ts—createWebDocumentMachine({ getSigner, ipfsPublish })returningdocumentMachine.provide({ actors }). ImplementswriteDraft,publishDocument,pushDocument.web-draft-db.ts— Thin IDB wrapper:getWebDocDraft,putWebDocDraft,deleteWebDocDraft,listWebDocDraftsForDoc. Mirrors the existinglocal-db.tspattern.use-web-can-edit.ts—useWebCanEdit(docId)returning{canEdit, signingAccountId}per the rules above.web-editing-toolbar.tsx— In-page toolbar: Edit / Publish / Discard, change-count badge usinguseUnpublishedChangeCount(lifted fromfrontend/apps/desktop/src/components/editing-toolbar.tsx:73-90to shared if not already).web-image-upload.ts—ipfsPublish(file)usingfilesToIpfsBlobs+seedClient.publish.
Modified files:
frontend/apps/web/app/web-resource-page.tsx— computeuseWebCanEdit(docId)(client-only), build the web machine viauseMemo, passmachineandinputinto<ResourcePage>, render<WebEditingToolbar />, wire the five sync hooks. SSR-safe defaults:canEdit = falseuntil hydration.frontend/apps/web/app/loaders.tsandapp/routes/$.tsx— extend the document fetch to includegenesisandgenerationInfo(needed by the publish call; shape is already exercised inauth.tsx:266). No new identity/capability work in the loader — capability is resolved client-side only.frontend/packages/ui/src/resource-page-common.tsx— verify it already acceptsmachineandinputprops forDocumentMachineProviderand thatDocumentContentComponentreacts toselectIsEditing. No structural change expected; verification only.frontend/packages/shared/src/models/use-editor-gate.ts— verification only; thecanEdit: falseshort-circuit must be honored during SSR / pre-provider renders.
Reused (no change):
seedClient.publishDocument—auth.tsx:207, 261getCurrentSigner/signWithKeyPair—auth.tsx:53,auth-utils.ts.compareBlocksWithMap,extractDeletes,createBlocksMap,docAttributeChange*—frontend/packages/shared/src/utils/document-changes.ts.documentMachine,useDocumentMachine,useEditorGate,useDocumentSync,useDraftResolutionSync,useCapabilitySync,useAccountSync,useVersionLatestSync—frontend/packages/shared/src/models/*.EditorHandlersContext(setEditable,applyInitialContent,placeCursor,getCurrentBlocks) — already provisioned by the editor.filesToIpfsBlobs+seedClient.publish—commenting.tsx:449-451.draft-media-db.tsIndexedDB media pattern.
Phasing within the PR
Plumb
useWebCanEdit,web-draft-db, and a skeletoncreateWebDocumentMachine(no-op actors) and confirmcanEditcorrectly toggles editorisEditable.Implement IDB
writeDraft+useDraftResolutionSyncreading IDB.Implement
publishDocumentfor block changes only; verify a paragraph edit publishes and survives reload.Add metadata edits (name/icon/cover).
Add image upload via
ipfsPublish.Add embeds (expected to work with no extra wiring).
Add navigation editing (already flows through
change.navigation).Finalize the writer-capability integration and gateway-vs-custom-domain gating.
Verification
Manual smoke against a local daemon, vault-delegated account:
Gateway origin: own home doc → Edit → paragraph edit → IDB draft visible → Publish → reload → content present, draft removed. Repeat for heading, metadata name, image upload (CID resolves), embed insertion, nav add/reorder. Open a doc the user lacks capability for → Edit hidden.
Custom-domain site: same scenarios, plus verify Edit shows only on docs under that site's home and stays hidden for off-site docs even when the user holds capability.
Automated:
Unit tests (Vitest, colocated):
web-document-actors.test.ts(publish builds expectedDocumentChange[]from baseline + new blocks),use-web-can-edit.test.ts(matrix of {gateway, custom-domain} × {owner, writer-cap, none} × {vault-delegated, local-only}),web-draft-db.test.ts(round-trip + delete).Integration: extend
tests/with one Playwright/Vitest scenario for "vault-delegated user edits and publishes a paragraph on the gateway."Per
frontend/AGENTS.md:pnpm typecheck,pnpm --filter @shm/web test,pnpm --filter @shm/shared test(ifuseUnpublishedChangeCountis lifted),pnpm format:write.
Scope
In scope for V1:
Vault-delegated identities authoring documents on the web.
In-place editing on the same page that renders the document (no separate
/draftroute).Local IndexedDB drafts with explicit Publish.
Block edits: paragraphs, headings, embeds.
Document metadata: name, icon, cover.
Image upload to IPFS.
Navigation (left-rail / outline) edits.
Capability gate honoring account-owner + accepted writer capabilities.
Site-scope gate: gateway = anywhere user has capability; custom-domain = under that site's home only.
Tests: unit per actor/hook + at least one integration scenario.
Out of scope for V1 (explicit non-goals):
Authoring with local-only (non-vault) browser keys.
Server-side autosave of drafts; cross-device draft sync.
Real-time collaborative editing.
Conflict-resolution UI beyond the existing remote-update handling already in
documentMachine.A web
pushDocumentendpoint (the actor is a no-op in V1).Account Profile blob migration (
.ai/todo.mdtodo #3) — adjacent but separate.New block types beyond what desktop already supports.
Rabbit Holes
Capability query lifting. We rely on
Documents.ListCapabilitiesand the existingresolveCapabilityhelper infrontend/packages/client/src/capability.ts. If a higher-level hook proves to be desktop-only, we write a minimal web hook (useResolvedCapability(docId, delegatedAccountUid)) directly againstListCapabilitiesrather than lifting desktop machinery. The check is read-only and small.Hydration flicker on Edit visibility. Because capability is resolved client-side after hydration, eligible users will briefly see the document without an Edit affordance before the toolbar appears. We accept this; SSR stays fast and identity-free. If the flicker becomes a UX problem, we can warm
ListCapabilitiesfrom aclientLoader(still no SSR cost) before mitigating further.genesis/generationInfoin the loader. The publish call needs both fields; if the SSR loader currently strips them, we may discover edge cases (older documents missing generation info, server transforms that drop fields). Worst case we read them lazily client-side from the resolved entity instead of from the loader payload.useUnpublishedChangeCounthoist. If it's still desktop-local, hoisting to shared could surface incidental dependencies (image-import helpers, desktop-only utils). Keep the hoist surgical; if it resists, copy a minimal web-flavored version rather than fight the dependency graph.Editor
isEditablereactivity. The machine flipssetEditableviaEditorHandlersContext. If the web's lazy-loadedDocumentEditoris mounted before the machine provider in some render paths, the first-edit transition could miss. Guard by ensuring the provider wraps the editor inweb-resource-page.tsxand verify in the smoke test.Draft growth. Without server-side autosave, drafts can accumulate in IDB across many docs. We need a basic cleanup hook (delete drafts older than N days, or after publish) using the same pattern as comment drafts. If we under-build this, users will hit IDB quota on long-lived sessions.
Custom-domain edge case: the user is signed in via vault delegation but the custom-domain site has its own implicit identity expectation. We rely on
originHomeId.uidas the site identity; verify this matches what the rest of the site already treats as "this site."SSR safety.
useWebCanEditand any IDB access must short-circuit on the server. The existing comment-draft code is the model — follow it strictly; don't introduce new SSR pitfalls.
No-Gos
No daemon-side signing for V1. We use the existing client-side WebCrypto signer end-to-end. No new "let the gateway sign for me" path.
No new
/draftroute on the web. The legacy desktop draft page is being retired; we explicitly do not replicate it.No autosave to server / cross-device draft sync. Drafts are local-only IDB. Cross-device editing is a follow-up.
No collaborative real-time editing. Single-author, optimistic, last-write-wins via the document machine's existing remote-update handling.
No widening of the "who can author" rule beyond vault-delegated + capability. Local-only keys remain comment-only in V1.
No capability lookups in the SSR loader. All
canEditresolution runs in the browser after hydration. SSR remains identity-free and read-only.No backend protocol changes. This is a pure frontend wiring project. If we discover the protocol is missing something, we stop and re-plan rather than amending the API mid-flight.
No bypassing
document-machine. All edit/publish flows go through the machine's actors and events; no ad-hoc "publish from the toolbar" that skips state transitions.No
hm.api.document-update.tsxin this path. That existing route is a daemon-sidestoreBlobsproxy and is not the web-author path; we do not extend or rely on it for V1.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime