Document Web Editing

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 (writeDraftpublishDocumentpushDocument) 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 (writeDraftpublishDocumentpushDocument), and pumps live data into it via the existing sync hooks (useDocumentSyncuseDraftResolutionSyncuseCapabilitySyncuseAccountSyncuseVersionLatestSync). 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 issuerdelegaterole) 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 delegatedAccountUid from IndexedDB / useLocalKeyPair() (already client-only).

  • Calls ListCapabilities via the gRPC-Web transport from the browser (cached via React Query, deduped per (docId, delegatedAccountUid)).

  • Returns {canEdit, signingAccountId}. When it flips to true, the toolbar mounts and the machine receives capability.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 (metadatadepsnavigationlocationUidlocationPatheditUideditPathsigningAccountIdcursorPositionupdatedAt). 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:

  1. Reads the editor baseline (selectEditorBaseline) and live blocks (via EditorHandlersContext.getCurrentBlocks()).

  2. Diffs them with the existing compareBlocksWithMap + extractDeletes from frontend/packages/shared/src/utils/document-changes.ts to produce block-level DocumentChange entries.

  3. Adds metadata (setMetadata for name/icon/cover) and navigation (setAttribute operations) using the helpers that desktop already uses.

  4. Calls seedClient.publishDocument({ account, changes, baseVersion: deps, genesis, generation }, signer) — the same call already exercised in production today by auth.tsx:261 for profile updates. The signer comes from getCurrentSigner() (WebCrypto over the locally-held P-256 key, gated to vault-delegated accounts in V1).

  5. On success, deletes the IDB draft and returns the new HMDocument so the machine can transition to loaded with 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.publish flow.

  • Navigation editing (left-rail / outline) via the existing EditNavHeaderPane component, which already routes through change.navigation events.

Code surface

New files (all under frontend/apps/web/app/document-edit/):

  • web-document-actors.ts — createWebDocumentMachine({ getSigner, ipfsPublish }) returning documentMachine.provide({ actors }). Implements writeDraftpublishDocumentpushDocument.

  • web-draft-db.ts — Thin IDB wrapper: getWebDocDraftputWebDocDraftdeleteWebDocDraftlistWebDocDraftsForDoc. Mirrors the existing local-db.ts pattern.

  • 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 using useUnpublishedChangeCount (lifted from frontend/apps/desktop/src/components/editing-toolbar.tsx:73-90 to shared if not already).

  • web-image-upload.ts  —  ipfsPublish(file)  using  filesToIpfsBlobs  + seedClient.publish.

Modified files:

  • frontend/apps/web/app/web-resource-page.tsx — compute  useWebCanEdit(docId) (client-only), build the web machine via useMemo, pass machine and input into <ResourcePage>, render <WebEditingToolbar />, wire the five sync hooks. SSR-safe defaults:  canEdit = false until hydration.

  • frontend/apps/web/app/loaders.ts and app/routes/$.tsx — extend the document fetch to include genesis and generationInfo (needed by the publish call; shape is already exercised in auth.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 accepts machine and input props for DocumentMachineProvider and that DocumentContentComponent reacts to selectIsEditing. No structural change expected; verification only.

  • frontend/packages/shared/src/models/use-editor-gate.ts — verification only; the canEdit: false short-circuit must be honored during SSR / pre-provider renders.

Reused (no change):

  • seedClient.publishDocument — auth.tsx:207, 261

  • getCurrentSigner / signWithKeyPair — auth.tsx:53auth-utils.ts.

  • compareBlocksWithMapextractDeletescreateBlocksMapdocAttributeChange* — frontend/packages/shared/src/utils/document-changes.ts.

  • documentMachineuseDocumentMachineuseEditorGateuseDocumentSyncuseDraftResolutionSyncuseCapabilitySyncuseAccountSyncuseVersionLatestSync — frontend/packages/shared/src/models/*.

  • EditorHandlersContext (setEditableapplyInitialContentplaceCursorgetCurrentBlocks) — already provisioned by the editor.

  • filesToIpfsBlobs + seedClient.publish — commenting.tsx:449-451.

  • draft-media-db.ts IndexedDB media pattern.

Phasing within the PR
  1. Plumb useWebCanEditweb-draft-db, and a skeleton  createWebDocumentMachine (no-op actors) and confirm canEdit correctly toggles editor isEditable.

  2. Implement IDB writeDraft + useDraftResolutionSync reading IDB.

  3. Implement publishDocument for block changes only; verify a paragraph edit publishes and survives reload.

  4. Add metadata edits (name/icon/cover).

  5. Add image upload via ipfsPublish.

  6. Add embeds (expected to work with no extra wiring).

  7. Add navigation editing (already flows through change.navigation).

  8. 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 expected DocumentChange[] 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.mdpnpm typecheckpnpm --filter @shm/web testpnpm --filter @shm/shared test (if useUnpublishedChangeCount is 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 /draft route).

  • 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 pushDocument endpoint (the actor is a no-op in V1).

  • Account Profile blob migration (.ai/todo.md todo #3) — adjacent but separate.

  • New block types beyond what desktop already supports.

Rabbit Holes

  • Capability query lifting. We rely on Documents.ListCapabilities and the existing resolveCapability helper in frontend/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 against ListCapabilities rather 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 ListCapabilities from a clientLoader (still no SSR cost) before mitigating further.

  • genesis / generationInfo in 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.

  • useUnpublishedChangeCount hoist. 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 isEditable reactivity. The machine flips setEditable via EditorHandlersContext. If the web's lazy-loaded DocumentEditor is mounted before the machine provider in some render paths, the first-edit transition could miss. Guard by ensuring the provider wraps the editor in web-resource-page.tsx and 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.uid as the site identity; verify this matches what the rest of the site already treats as "this site."

  • SSR safetyuseWebCanEdit and 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 /draft route 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 canEdit resolution 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.tsx in this path. That existing route is a daemon-side storeBlobs proxy 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