0041. The hosted service: an optional account over the static page
Status: accepted (cloud-accounts, cloud-projects, live-collaboration and cli-cloud changes, 13 October 2026). Amends 0031.
Context
Section titled “Context”The playground is a static page that works with no account and sends nothing (decision 0031).
The owner wants what Excalidraw and Mermaid Live give on top of that: projects that follow a
person between devices and survive a reinstalled browser, sharing, and editing together, with
sign-in through GitHub or Google and nothing of their own to host for identity. The family now
has an identity service (id.meltemi.dev, specified in the Meltemi Web repository) and the IDE
keeps its promise of no accounts, so the account is a layer this product adds, never a gate.
Cloudflare’s free plan offers Workers, D1, R2 and Durable Objects; the owner had Workers in
mind from the start.
Decision
Section titled “Decision”- Local first; the account is a layer. Without an account the page is exactly 0031’s:
work in
localStorage, share links in the fragment, no request beyond its own origin. Signed in, projects live in the cloud as well; the page keeps a local copy and works offline. - One Worker, one origin for the page and the API.
packages/cloudserves the playground’s built site and the API fromdiagrams.meltemi.dev; the sign-in is a navigation toid.meltemi.dev, so the page’s policy still allows connections to its own origin only, and the browser test that checks it keeps passing. - The family’s identity, consumed as a contract. The Worker verifies the session cookie
with the identity service’s public keys through its reference verifier, keeps users by
sub, checks the session at most once an hour, handlesuser.deletedevents, and never sees a provider token. Every host undermeltemi.devshares the cookie and is one site forSameSite, so every state change checks theOriginheader. - A project is a document; the room is its authority. Each cloud project is a Yjs
document (text files as
Y.Text, images by hash) held by one Durable Object, which merges every client’s edits, persists its state, and materialises files to content-addressed blobs (D1 metadata, R2 bytes) on a pause, when the last client leaves and before a version is made. Meltemi’s architecture makes this enough: canvas edits are text edits and layout is deterministic, so synchronising the text synchronises everything. - Versions are explicit and capped. Autosave never creates one; a person names a version (or keeps one with no name), and the service adds one unlabelled version a day when the files changed and one before each restore. Unlabelled versions are thinned (100 at most, and one a week after thirty days), named ones are never removed, and a project holds 200 at most. Content addressing makes a version cost only what changed.
- Collaboration is Yjs over the room. Several people type at once without loss, undo covers each person’s own edits, presence shows who is where; offline edits merge on reconnect. Presence is a small message of the page’s own, relayed by the room with the identity the room knows for the sender and stored nowhere; a person’s identity is never taken from what a peer sends.
- The command line mirrors a repository.
mdg login,logout,whoami,push,pull,publishandprojects, andreview --base cloud, drive the page’s API with an API token: for developers the repository is the truth and the cloud its mirror; for everyone else the cloud is the truth.loginlistens on the loopback, prints a check that the approval page repeats and exchanges a one-time code for a token kept in the user’s configuration folder;MELTEMI_TOKENreplaces it in CI. A token goes only to the host the command resolved (--host,MELTEMI_HOST, else the default), never to the host aproject.jsonnames, and never overhttp:except to the loopback;project.jsonrecords the project (cloud) and a different host fails withcloud.host-mismatch.pushcompares every path with the cloud and asks, or refuses without--force, and carries the revision it compared with; CI pushes on merge with--force --strict(init --ci --cloud).pullchecks every path before writing any. API tokens are redacted (mdg_…) from everything the command line prints. - Functionality before delivery. Everything runs with
wrangler devand tests with in-memory stores and sessions signed with test keys; repositories, pipelines, DNS and secrets are a separate change once the functionality is validated.
Alternatives considered
Section titled “Alternatives considered”- Keeping 0031 as is and pointing people at Git. Portability between devices and sharing with people who have no Git were the owner’s asks.
- A third-party identity platform or passwords. Decided against in the Meltemi Web repository (its decision 0002).
- REST autosave with revisions, and a room only for collaboration. Two write paths, a merge dialog for conflicts between devices, and the same Durable Object anyway for collaboration; the room as the single authority is simpler.
- Operational transformation written here instead of Yjs. No download, but a transforming
server, hand-written collaborative undo and presence; Yjs brings merge and undo by origin for
one MIT download the owner approved. Presence stays the page’s own and small, with no
y-protocolspackage: an object keyed by connection, which the room relays. - GitHub as storage (gists or repositories). Nothing to hold, but no images and a
reposcope; the CLI covers the repository-as-truth case instead. - A state file in each clone with the last pushed revision, to detect a conflict without fetching. It would be per clone, absent in CI and one more thing to explain; fetching the cloud’s files is one request, and the review by id already exists.
- Appending every update to the room’s storage. Safer if the platform evicts an object, and thousands of rows per typing session against a daily budget of a hundred thousand; the room writes its whole state on a throttle (a second, ten at most) instead, and a client resends what an eviction lost, because the room asks for it at every connection.
Consequences
Section titled “Consequences”- The
web-playgroundspecification’s first requirement becomes “a static page, an optional account, one origin”; kept work for cloud projects moves to IndexedDB; undo in a cloud project covers one’s own edits. docs/compatibility.mdstops listing realtime collaboration as a limitation.- Presence is relayed, never stored: the room keeps a client’s last state in its socket’s
attachment, so it survives a hibernation, and drops it when the socket closes or after thirty
seconds of silence (an alarm). Its cost is bounded where it starts: a page sends four states a
second at most, the room relays 20 a second per client and closes a client at 100
(
awareness.rate), a message over 16 KiB closes its sender, and a room holds twenty clients; the platform counts incoming WebSocket messages twenty to one against the free plan’s request budget, and the room’s counters tell how many arrived. - Quotas per account and limits per document bound the free plan; the browser suite measures the rows the Durable Object and D1 write during a minute of typing.
- The room is the only writer of a project’s document and is reachable only through requests the
Worker constructs: the socket takes the session cookie alone, checks
Originitself (a socket is aGET, outside the rule for changes), and no header of a browser is forwarded, so nobody can name their own role. (Since decision 0050 a socket without a cookie is admitted as a guest, with an identity the Worker sets, when a link or a live session’s token admits it.) Every transaction is validated by the room whatever its origin, and every blob is served sandboxed from an address scoped to its project. - Offline means a project that is open when the connection drops: the document is kept in IndexedDB and merges on reconnect. There is no service worker, so the page itself needs a connection to load, and a page started without one cannot ask who is signed in and opens as the anonymous project.
- The page’s Worker runs with
run_worker_first: trueand dispatches onHost: ondiagrams.meltemi.devunrouted paths go to the static assets (which then count as Worker requests against the free plan’s daily budget); the publish host is served by a second, minimal Worker (packages/cloud/src/publish.ts, its ownwrangler.publish.jsonc, R2 and D1 bindings, no assets) that serves/<handle>/<slug>/only, never sets a cookie and answers no API route. /p/<id>answers a 302 to/?project=<id>keeping the query; the page readsprojectandinvitefromlocation.searchand keeps the address, so relative asset URLs andbase-uri 'none'hold; the project’s link stays<origin>/p/<id>.- Files have stable ids: the document keeps
files,pathsandbinarieskeyed by file id, a rename is onepaths.set, and the room, the routes, the binding and the CLI translate id and path (two ids on one path: the lexically smaller keeps it, the other gets<path>~<id>, reported ascollisions). - Published content and sharing follow decision 0042.
id.meltemi.dev,diagrams.meltemi.devand the publish host are deployed as Workers Custom Domains (routes: [{ pattern: "…", custom_domain: true }]), never Routes: Workers on one zone reach each other byfetchonly through Custom Domains (error 1042 otherwise).