# ADR-0005: Where museum data lives

- Status: **accepted 2026-08-02** — owner chose Render Postgres (he already
  runs a free instance and is happy to upgrade when it matters)
- Related: ADR-0004 (Lucas Account), `docs/SPACE-MUSEUM-DIRECTION.md` §4,
  §10, execution plan §5

## 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

| Data | Shape | Size | Who writes |
|---|---|---|---|
| Room documents | one JSON doc per room (already `roomDoc.js`) | KBs | owners, via the editor |
| Ownership + visibility | emails, public/private, password hash | bytes | owners |
| Accounts / OTP | already lives in the Lucas Account Worker's own D1 | tiny | the auth service |
| Student artwork sources (`.lpp`) and runtime packages (`.lpr`) | binary bundles | MBs each | students, later |
| Presence (who is in which room) | ephemeral | none | the 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.

- **Render Postgres** (owner's choice 2026-08-02): one bill, one dashboard,
  and it sits next to the process that will hold the WebSocket rooms. Free
  instances expire 30 days after creation and are **deleted** 14 days later
  (Render's documented behaviour), so the plan is to upgrade before that
  date — the owner is fine with the cost — and to keep `npm run rooms:export`
  copies in git as insurance. Nothing in the schema is Render-specific: it is plain
  SQL with a `jsonb` column, so moving to Neon or anywhere else later is a
  dump and a restore.
- **Cloudflare R2** for `.lpp` / `.lpr` / images: S3-compatible, no egress
  fees, and the museum can read blobs straight from a public bucket for
  published content while private drafts stay behind signed URLs.

  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.
- **Cloudflare D1 stays only inside the account service** for OTP codes. Do
  not spread product data across two databases.

Sketch of the whole picture:

```text
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:

- **Everything on Cloudflare (D1 + Durable Objects)** is cheaper and the
  auth service already lives there, but it puts the multiplayer server in a
  programming model the team has never used, while snake-lab already proves
  Node + `ws` on Render. The fixed cost is paid either way.
- **Git as the database** (commit every edit) is tempting because it is free
  and reviewable, and it is exactly right while the owner is the only
  editor. It does not survive a class of students editing concurrently.
- **Postgres for blobs** would work and cost more; R2 exists precisely for
  this.

## 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.
