One Node service for API + WebSocket, Postgres for documents, object storage for art. · Rendered from docs/decisions/0005-data-and-hosting.md in the project repository · view as Markdown

ADR-0005: Where museum data lives

The question

The editor now produces real data — room layouts, which painting is on which face, owners, visibility, and later student artwork. Today it exports JSON and the owner commits it. That is fine for one person building the first rooms; it stops working the moment a class edits.

Multiplayer forces the issue too: a WebSocket server is a process, and a process wants a database near it.

What has to be stored

DataShapeSizeWho writes
Room documentsone JSON doc per room (already roomDoc.js)KBsowners, via the editor
Ownership + visibilityemails, public/private, password hashbytesowners
Accounts / OTPalready lives in the Lucas Account Worker's own D1tinythe auth service
Student artwork sources (.lpp) and runtime packages (.lpr)binary bundlesMBs eachstudents, later
Presence (who is in which room)ephemeralnonethe WebSocket process

Two very different things: small mutable documents and large immutable blobs. Do not put the blobs in the database.

Recommendation

One small Node service on Render — the same process that will hold the WebSocket rooms — serving a JSON API over Postgres, with object storage for blobs.

The browser build remains a separate Render static site. The owner reaffirmed this split on 2026-08-02 after comparing it with snake-lab's single-process deployment: this museum has substantially heavier images, Live Paintings, and 3D assets, so keeping them on the static CDN is worth the extra origin and CORS configuration. The split is a delivery optimization, not a WebSocket requirement. The Node service is named inception-space-api and will serve both /api/* and the future WebSocket endpoint. The site went live at https://is.lucasacademy.org on 2026-08-02 and remains cross-origin from https://inception-space-api.onrender.com; both the API and Lucas Account allowlists include the custom site origin. Cloudflare keeps the is CNAME DNS-only so Render remains the serving CDN and TLS endpoint.

This split is what keeps the database bill near zero. Render's flexible plans bill compute and storage separately: storage is $0.30 per GB per month, raisable at any time in multiples of 5 GB and never reducible (free databases get a fixed 1 GB). Both are "prorated to the second", which means the monthly rate is metered by how long the resource exists at that size — not by how much it is used. An idle database still bills every second; the only ways down are a smaller instance or no instance. Room documents are kilobytes, so a thousand rooms still fit in a fraction of that 1 GB. Put Live Paintings in Postgres instead — megabytes each — and a single class would push storage into gigabytes that can never be given back, while every backup got slower. Blobs belong in R2.

Sketch of the whole picture:

static museum (Render static site)  ──fetch──►  museum API + WS (Render, Node)
        │                                              │
        └── verifies nothing itself                    ├── Postgres (rooms, owners, visibility)
                                                       └── R2 (artwork blobs)
                    sign-in ──► Lucas Account (Cloudflare Worker, own D1, JWKS)
                                                       ▲
                        the API verifies JWTs against that JWKS

Why this and not the alternatives:

Consequences and the order to build it

  1. Keep the editor local-first (JSON export) until the API exists — today's state, nothing blocked.
  2. When the API lands: room documents move to Postgres, the editor saves over HTTP with its JWT, and the API re-checks ownership server-side.
  3. WebSocket presence joins the same process, reusing the same JWT check.
  4. Blobs go to R2 only when student artwork starts flowing (Phase 4+).

Private rooms need this API to be real

A password checked in the browser protects nothing: the room document, and therefore its contents, is already in the visitor's hands. AGENTS.md says this outright — a room password is not an adequate privacy boundary.

So: the editor may set a password now (stored as a salted hash, never in plain text), and the museum may use it as a courtesy gate, but a private room is only genuinely private once the API refuses to serve its document without proof — either a valid owner JWT or the room password exchanged for a short-lived token. Until then, private rooms must not hold anything that would embarrass anyone if it leaked, and the UI must say so.