clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / PERSISTENCE.md
PERSISTENCE.md 12.8K rendered
1# diagram.zip persistence23Status: implemented end to end. The editor, Worker API, D1/R2 model, browser4encryption, stable render URLs, and conflict/fork/share flows have automated5coverage. Production deployment uses the repository release workflow.67## Product contract89diagram.zip does not require accounts. Drafts remain local until the user10chooses Publish or Encrypt & Publish. Save as File exports an editable enriched11SVG without creating an alias. Possession is the access model for aliases:1213| Diagram | Read | Write |14| --- | --- | --- |15| Open | Alias URL | Alias URL plus write capability |16| Locked | Alias URL plus password | Alias URL plus password and write capability |1718There are no owners, users, sessions, ACLs, or recovery flows. Losing a write19capability means losing the ability to update that alias. Losing a password20means losing the ability to decrypt a locked diagram.2122Editable SVG files remain visible images and carry a versioned Diagram.zip23document in metadata and `data-*` attributes. The importer accepts local files,24drag and drop, pasted SVG, and HTTP(S) or data URLs. It rejects ordinary,25ambiguous, unsupported, or lossy SVG instead of guessing, and limits input to265 MiB. File exports are plaintext and are not encrypted backups.2728Read links are short and stable:2930```text31https://diagram.zip/d/{alias_id}32```3334An edit link adds the capability in the URL fragment so browsers do not send it35in an HTTP request:3637```text38https://diagram.zip/d/{alias_id}#w={write_capability}39```4041The app imports that capability into local storage under the alias ID and42immediately removes the fragment from the address bar. Copying the normal page43URL therefore copies a read link, not an edit link.4445The existing self-contained diagram hash format is not part of the new public46contract and does not need a compatibility path.4748Anonymous SVG and Markdown embeds use a separate self-contained URL:4950```text51https://diagram.zip/svg/{compressed_editable_svg}52```5354The URL contains the rendered SVG and its editable Diagram.zip document. It is55immutable, creates no alias, and writes nothing to D1 or R2. The shell inflates56the bounded payload, revalidates and sanitizes the enriched SVG, and returns it57with an SVG content type and restrictive CSP. Oversized URLs fail closed and58the UI directs the user to Save as File or Publish.5960## Identifiers and capabilities6162- `alias_id`: 12 cryptographically random bytes encoded as unpadded Base64URL,63 exactly 16 characters. It is public and optimized for short links.64- `content_id`: SHA-256 of the canonical stored content bytes, encoded as65 unpadded Base64URL, exactly 43 characters. It is internal and immutable.66- `render_id`: SHA-256 of the render-affecting content and metadata, encoded the67 same way. It excludes the password key envelope, so changing only a password68 does not invalidate unchanged renders.69- `write_capability`: 32 cryptographically random bytes encoded as unpadded70 Base64URL, exactly 43 characters. The browser generates it. The API receives71 it only as a Bearer credential and D1 stores only its SHA-256 digest.7273The alias ID is deliberately not authorization. The 256-bit write capability74is.7576## Storage ownership7778Cloudflare D1 owns small, mutable records:7980- the alias-to-content pointer;81- the server-owned revision used for compare-and-swap updates;82- plaintext metadata for open aliases or encrypted metadata for locked aliases;83- the write-capability digest;84- the locked-diagram key envelope.8586Cloudflare R2 owns immutable bytes:8788```text89contents/open/{content_id}.json90contents/locked/{content_id}.enc91renders/open/{renderer_unit}/{renderer_build}/{render_id}.svg92renders/open/{renderer_unit}/{renderer_build}/{render_id}.png93renders/locked/{renderer_unit}/{renderer_build}/{render_id}.svg.enc94renders/locked/{renderer_unit}/{renderer_build}/{render_id}.png.enc95render-heads/open/{render_id}.{svg|png}.json96render-heads/locked/{render_id}.{svg|png}.json97```9899Each small render-head manifest records the owning unit, exact build, explicit100translation pipeline, and immutable object key. R2 object existence is the101render-cache source of truth; there is no D1 row per render. Draft previews102remain transient and use the current renderer. Only an explicit Publish or103Encrypt & Publish action writes immutable content and advances an alias.104105The schema is executable in106`services/api/migrations/0001_aliases.sql`. Open content contains `type`,107`source`, `options`, and presentation settings. Alias metadata contains `title`108and `description`, so metadata can change without manufacturing a new content109ID.110111## Persistence API112113The persistence Worker uses the same-origin `/api/v1/*` route. The editor sends114each transient render to the selected engine subdomain. An HTTP engine uses115`https://{engine}.render.diagram.zip/v1/svg`. A client engine uses its sandboxed116renderer frame. No transient render uses the Fly.io application or a shared117render proxy.118119### Create120121```http122POST /api/v1/aliases123Authorization: Bearer {write_capability}124Content-Type: application/json125126{127 "mode": "open",128 "diagram": {129 "type": "d2",130 "source": "a -> b",131 "options": {},132 "presentation": { "background": "", "padding": 0, "frame": false, "appearance": "raw" }133 },134 "metadata": { "title": "A to B", "description": "" }135}136```137138The response is `201 Created`, includes `Location`, and uses `ETag: "1"`.139The capability is never returned by the API.140141### Read142143```http144GET /api/v1/aliases/{alias_id}145```146147Open reads are public and return the assembled alias, metadata, and diagram.148The revision is also returned as a strong `ETag`.149150### Update151152```http153PUT /api/v1/aliases/{alias_id}154Authorization: Bearer {write_capability}155If-Match: "{revision}"156Content-Type: application/json157```158159The API hashes and compares the fixed-size capability digest, writes the160immutable content object, inserts its content row, and conditionally advances161the alias. A stale revision returns `412 Precondition Failed`; a missing162precondition returns `428 Precondition Required`.163164R2 and D1 cannot share a transaction. Writes therefore happen in the safe165direction: immutable R2 object, D1 content row, alias pointer. Failure can leave166an unreferenced object, but never an alias that points at missing content.167Orphans can be collected later with a grace period.168169### Durable renders170171After a successful publish, the browser stores one canonical safe SVG and, where172the browser can rasterize it, a PNG:173174```http175PUT /api/v1/aliases/{alias_id}/renders/{svg|png}176Authorization: Bearer {write_capability}177If-Match: "{revision}"178X-Render-Id: {render_id}179X-Renderer-Unit: vegalite180X-Renderer-Build: vegalite-6.4.3-vega-6.3.1-unit-1181X-Renderer-Pipeline: vegalite,vega182```183184The SVG upload must use the current Diagram.zip canonical schema. The API185validates and deterministically serializes it before R2 accepts it. The186revision and render ID prevent a slow render from being attached after the187alias has changed. Open bytes use their image media type. Locked bytes are188AES-GCM ciphertext encoded as JSON. Open embeds use the stable URL189`/api/v1/aliases/{alias_id}/renders/svg`; locked aliases return a clear 403 from190that route. An unlocked browser can fetch opaque bytes with `?encrypted=1` and191decrypt them with the same in-memory bundle key.192193When a writable, unchanged alias opens, the editor compares the current194renderer identity with the published render head. If they differ, it silently195renders the saved source and uploads fresh SVG/PNG bytes through the same196guarded `PUT`. This advances the render head without changing the alias revision197or inventing a second `/update` mutation route. Dirty diagrams are never198refreshed, so unpublished source cannot be attached to a published render ID.199200An open SVG read may add an `appearance` query parameter:201202```text203/api/v1/aliases/{alias_id}/renders/svg?appearance=auto-framed204```205206The API materializes the requested appearance from the canonical R2 object. It207does not store a second SVG. The appearance has its own ETag. Locked renders208cannot use this server-side path because the API cannot decrypt their SVG.209210The published presentation records the selected appearance. The editor places211the same appearance on stable SVG and Markdown links. `raw` uses the renderer212canvas controls. A shared appearance ignores those legacy controls and213materializes its own palette and, for framed appearances, its own canvas.214215An anonymous draft instead prepares the selected transparent or framed216geometry, embeds self-contained palette CSS, and packs the deterministic217editable SVG into `/svg/{compressed_editable_svg}`. The root218`data-dz-appearance` is the palette selector. The route validates and sanitizes219the packed SVG, then synchronizes that one value from the embedded220`diagram.presentation.appearance`. It does not proxy a render request. Frame221padding and the outer `viewBox` remain export-time geometry because CSS cannot222change SVG bounds.223224## Editor and share UX225226Open drafts continue to autosave locally and render after the existing debounce.227They do not write to D1 or R2. Locked drafts are deliberately not written to228local storage as plaintext; they persist only when the user explicitly229publishes.230231The root route is a local-first launcher. Its recent index stores metadata only;232local document bodies use separate keys and published aliases are refetched.233Changing format preserves the parked per-format drafts inside the local document234body. The legacy single anonymous draft is migrated into this model on first235boot.236237The left pane separates diagram Source from schema-validated Details JSON.238Details owns title, description, and presentation values. Invalid Details text239remains recoverable while file export, publishing, sharing, privacy, and type240changes stay blocked.241242- New draft: **Save as File** downloads an editable SVG and creates no alias.243- New draft: **Copy SVG URL** and **Copy as Markdown** use a packed editable SVG244 URL and create no alias.245- New draft: **Publish** creates an alias and changes the browser to its read URL.246- Writable alias: **Publish** updates with the last revision.247- Writable unchanged alias: stale cached SVG/PNG bytes refresh automatically248 when the owner opens the diagram, without creating a diagram revision.249- Local document: the launcher can reopen, duplicate, or explicitly delete it.250- Read-only alias: edits form a local fork; **Publish a Copy** creates a new alias.251- Conflict: offer **Reload published** or **Publish as new**. Never overwrite silently.252- Share, open: copy read link, copy edit link, SVG link, or Markdown.253- Share, locked: copy password-required read link or password-and-capability edit254 link. SVG and Markdown embeds are visibly disabled with the explanation that255 encrypted diagrams cannot be embedded.256257The compact header keeps title, local-change state, render status, and a format258button. The format button or **Command-K / Control-K** opens the command palette.259The palette owns format changes, file export, publishing, copy/share actions,260view commands, open, new, and documentation. **Command-S / Control-S** always261means Save as File; publishing remains an explicit command.262263The password and edit capability should be sent through separate channels when264practical. The edit link itself is a bearer credential and the UI must label it265that way.266267## Locked diagrams268269The password-lock interaction follows the useful shape demonstrated by270[textarea PR #40](https://github.com/antonmedv/textarea/pull/40): a native271password dialog, confirmation when locking, and retry on decryption failure.272Its URL-contained ciphertext format is not reused.273274Instead, the browser generates a random 256-bit bundle key. Source, metadata,275and persisted SVG/PNG bytes are encrypted with AES-256-GCM using a unique random27696-bit IV for every object. A password-derived key wraps the bundle key, so a277password change rewrites only the key envelope rather than every object.278279The versioned envelope records the KDF algorithm, salt, and work parameters so280the KDF can be strengthened without a storage migration. The current native Web281Crypto implementation uses PBKDF2-HMAC-SHA-256 with a random 128-bit salt and282600,000 iterations. Passwords and unwrapped bundle keys never leave the browser283and are not written to local storage. The renderer may receive plaintext284transiently to produce an image, but durable storage receives only ciphertext.285286## Abuse and operational boundaries287288- No alias listing, search, or public content-upload endpoint.289- Strict identifier grammar, JSON media type, a one MiB alias request ceiling,290 and a 12 MiB render ceiling.291- Rate limiting belongs on the same-origin Worker route before broad launch.292- Logs must not include `Authorization`, request bodies, passwords, key293 envelopes, or fragments.294- Render keys include the owning unit and renderer-build identifier, while the295 render-head records translated pipelines, so upgrades never serve stale296 output under a current key.297- Direct R2 public access remains disabled; reads go through controlled routes.