Repository navigation
Server API and Storage
roomcad/server/server.py is the whole backend: Python standard library only,
SQLite for storage, reverse-proxied by Caddy under /api/* and bound to
127.0.0.1:8078 so it is never exposed directly.
One shared password, checked server-side, supplied through the
ROOMCAD_PASSWORD environment variable (never in git). POST /api/login sets
an HttpOnly cookie; every other endpoint returns 401 without it. If the
password is not configured the service still starts, but logins fail closed.
The cookie is SameSite=Lax, and on top of that POST and DELETE must be
same-origin: an Origin header — or a Referer standing in for one — has
to name the host the request was actually made to, or the API answers 403 with
the usual error envelope. A request carrying neither header is allowed, because
curl, the test suite and a same-origin form post send none, while a browser
making a cross-site POST always sends Origin. The check covers
POST /api/login deliberately: a cross-site login can force a session onto a
visitor and spend the login throttle.
# log in and keep the cookie
curl -s -c jar.txt -X POST localhost:8078/api/login \
-H 'Content-Type: application/json' -d '{"password":"letmein"}'
# {"ok": true}
# A cross-origin POST is refused — but note that curl above sent no Origin at
# all, which is why it works.
curl -s -X POST localhost:8078/api/login -H 'Origin: https://evil.example' \
-H 'Content-Type: application/json' -d '{"password":"letmein"}'
# {"error": "cross-origin request refused"}
# every later call reuses it
curl -s -b jar.txt localhost:8078/api/roomsOnly a SHA-256 hash of the session token is stored, never the token.
The comparison is constant-time and runs over UTF-8 bytes, not str: Python's
secrets.compare_digest raises TypeError when either side holds a non-ASCII
character, so a password with an accent in it used to kill the handler before the
failure was counted — no response, a dropped connection, and those attempts
escaped the throttle entirely. A non-ASCII password now simply works.
Wrong passwords are throttled per client: 10 failures in 300 s earns 429. The
client is identified from X-Forwarded-For, counted from the end according to
ROOMCAD_PROXY_HOPS (the production deploy sets 1, because two proxies sit in
front of the API).
| Method | Path | Purpose |
|---|---|---|
POST |
/api/login |
exchange the password for a session cookie |
GET |
/api/rooms |
list rooms (latest version of each) |
GET |
/api/load/<name> |
load the latest version, or ?version=N
|
GET |
/api/versions/<name> |
list a room's versions, newest first |
POST |
/api/save |
save a new version → {name, version}
|
DELETE |
/api/rooms/<name> |
delete every version of a room |
GET |
/api/watch/<name> |
Server-Sent Events stream of updates |
POST |
/api/live/<name> |
publish an unsaved live draft |
POST |
/api/live-check/<name> |
"am I still in sync?" — a digest, and the room back only if it has drifted |
GET |
/api/session/last |
the design this browser should reopen |
POST |
/api/session/last |
remember a design for this browser |
GET |
/api/status |
{"count": N} — sessions active in the last 30 s |
Every body must be a JSON object. [], 5 and "x" are valid JSON and were
once handed straight to the handlers, which then reached for .get(...); they are
answered with 400 before any handler sees them. Errors are always
{"error": "<short>"}.
Names are sanitised to [A-Za-z0-9_-]. Saving with an empty name allocates the
next free ternak_roomN inside the same lock as the insert, so two
simultaneous saves can never collide.
# save a design (empty name = allocate a new slot)
curl -s -b jar.txt -X POST localhost:8078/api/save \
-H 'Content-Type: application/json' \
-d '{"name":"","json":"{\"format\":\"com.maria.roomcad-v2.room\",...}","clientId":"cli"}'
# {"name": "ternak_room4", "version": 1}
# list the versions of that room
curl -s -b jar.txt localhost:8078/api/versions/ternak_room4
# [{"version": 1, "savedAt": 1755900000000}]
# fetch one exact version
curl -s -b jar.txt 'localhost:8078/api/load/ternak_room4?version=1'
# follow live updates (stays open, one JSON object per event)
curl -sN -b jar.txt localhost:8078/api/watch/ternak_room4CREATE TABLE rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version INTEGER NOT NULL,
json TEXT NOT NULL,
saved_at INTEGER NOT NULL, -- epoch ms
client_id TEXT,
UNIQUE(name, version)
);
CREATE TABLE browser_sessions (
token_hash TEXT PRIMARY KEY, -- sha256 of the cookie value
last_room_name TEXT,
last_room_version INTEGER,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);-
WAL mode,
synchronous=NORMAL,busy_timeout=5000— concurrent readers never block the writer, and a busy save retries instead of failing. - One connection is shared across request threads (
check_same_thread=False) and every statement is serialised behind a single lock. - On first boot, legacy
.rcadfiles inrooms/are imported as version 1 and removed.
POST /api/save always inserts version = max(version) + 1, starting at
v0 for a file's first save. Nothing is ever overwritten, so any earlier
state can be reopened from the version list.
The room name comes from the client, derived from the design's Room Name, so
saving under a new name creates a new room rather than versioning the old one.
GET /api/rooms lists the most recently saved first.
GET /api/session/last answers it:
- If this browser has a remembered room and that exact version still exists → return it.
- Otherwise → return the project's most recently saved design, and remember it
for this browser (flagged
projectLatest: true). - If the project has no saved rooms at all →
null, and the app opens the built-in demo.
This lives in SQLite rather than local storage, so it survives an API restart and needs no second cookie.
POST /api/live/<name> keeps the newest unsaved draft per room in memory only
and pushes it to every watcher with "live": true. A real save clears it. They
are lost on restart, which is fine — they are drafts. See
Live Collaboration.
Every publish carries a baseSeq, and the server refuses the edit unless it
matches the room's current sequence — answering {"ok": false, "stale": true}
with the room as it actually is. A client that has fallen behind therefore loses
its own stale edit instead of overwriting everyone else's newer work with it.
The sequence is per room and is dropped along with the draft when the room is
deleted. POST /api/live-check/<name> is the other half: a client with nothing
unpublished sends a digest of what it holds, and gets the room back only when it
has drifted.
The database lives at /var/roomcad/rooms.db, or wherever ROOMCAD_DB_PATH
points. roomcad/server/rooms.db.sql is structure only — it deliberately
carries no room content, because a public repository is the wrong place for real
designs. Restore it into an empty file to rebuild the schema, and take the rooms
from a backup of the live database:
ssh root@host "sqlite3 /var/roomcad/rooms.db '.dump'" > rooms-$(date +%F).sqlUse .dump rather than copying rooms.db; in WAL mode the file alone may be
missing recent commits. server.py creates the schema itself at boot, so the SQL
files are a rebuild aid and documentation — not a migration path.