Accounts, API, Architecture: guests and demo copies (#428)

- Accounts: what a guest picks on the sign-in screen, what they get and
  do not get, and how an administrator makes a demo copy and offers
  demo material.
- API: the demo routes, the PATCH {demo} of the four file kinds, what
  answers without a session, and a section on demo copies and demo
  material.
- Architecture: DemoStore, MemoryProjectStore, session, guestStores
  and DemoFiles.php.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adminandClaude Opus 5.5 committed 2026-10-16 06:58:43 +02:00
1 parent 4dc3dfe528
commit d29c6fee79
3 files changed
+63 -9

No files matched your search

+28 -7
@@ -10,7 +10,7 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `projects` | `{name}` → project document (201) |
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{error,revision}` when stale |
| DELETE | `projects/{id}` | 204, and the snapshots and the release of the project go with it. **403** `{error}` for an administrator who is not the owner |
| DELETE | `projects/{id}` | 204, and the snapshots, the release and the demo copy of the project go with it. **403** `{error}` for an administrator who is not the owner |
| GET | `projects/{id}/snapshots` | `[{id,name,revision,createdAt}]`, newest first — see [Concepts](Concepts) |
| POST | `projects/{id}/snapshots` | `{name?}` → `{id,name,revision,createdAt}` (201). Copies the project as stored; a blank name becomes the UTC date and time. **422** over 120 characters |
| DELETE | `projects/{id}/snapshots/{sid}` | 204 |
@@ -19,9 +19,14 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `projects/{id}/release` | → `{token,revision,publishedAt}`. Copies the project as stored; publishing again replaces the copy and keeps the token |
| DELETE | `projects/{id}/release` | 204, the link is dead; **404** when the project was not released |
| GET | `releases/{token}` | `{name,publishedAt,project}` — the released copy, **without a session**: the one route besides `auth/*` open to everybody. `project.id` is the token, not the project's id. **404** for an unknown or taken-down token |
| GET | `textures` | `[{file,name,width,height,bytes,updatedAt,owner,deletable}]` — what the image picker offers: the caller's own files and nobody's, an administrator's every file (see below) |
| GET | `projects/{id}/demo` | `{token,revision,publishedAt}`, or `null` while the project has no demo copy (#428). Administrators only |
| POST | `projects/{id}/demo` | → `{token,revision,publishedAt}`. Copies the project as stored, like a release; doing it again replaces the copy and keeps the token. Administrators only |
| DELETE | `projects/{id}/demo` | 204, guests no longer see the project; **404** when it had no demo copy. Administrators only |
| GET | `demos` | `[{token,name,publishedAt}]`, by name: the demo copies a guest picks from, **without a session** |
| GET | `demos/{token}` | `{name,publishedAt,project}`, **without a session**, like `releases/{token}`. **404** for an unknown or removed copy |
| GET | `textures` | `[{file,name,width,height,bytes,updatedAt,owner,deletable,demo}]` — what the image picker offers: the caller's own files and nobody's, an administrator's every file, and everything offered as demo material (see below). Without a session: the demo material alone, `owner` null |
| POST | `textures&name=hero.png` | **raw image bytes** → `{file,name,width,height,bytes}` (201), stored in the caller's folder; **422** for bytes that are no readable image |
| DELETE | `textures/{file}` | 204. `{file}` is the stored name, folder and all. **403** unless it lies in the caller's folder or the caller is an administrator, **404** when there is no such file (asked only after the 403, so a stranger learns nothing of another folder), **409** while a project, snapshot or release of any account uses it |
| DELETE | `textures/{file}` | 204. `{file}` is the stored name, folder and all. **403** unless it lies in the caller's folder or the caller is an administrator, **404** when there is no such file (asked only after the 403, so a stranger learns nothing of another folder), **409** while a project, snapshot, release or demo copy of any account uses it, and for anybody but an administrator while it is offered as demo material |
| GET | `fonts` | `[{file,name,bytes,updatedAt,owner,deletable}]` — what the font picker offers |
| POST | `fonts&name=Serif.ttf` | **raw font bytes** → `{file,name,bytes}` (201); **422** for bytes that are no font |
| DELETE | `fonts/{file}` | 204, **403**, **409** like an image |
@@ -32,8 +37,9 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `videos&name=holiday.mov[&convert=0]` | **raw video bytes** → the same record (201). The name `{folder}/{sha256}.mp4` is the hash of the upload and known at once; the upload is kept as it came, and the worker converts it later (see [Videos](Videos)). With `convert=0` it is only kept, `unconverted`. The same bytes again answer with what is there, a failed one is tried again. **422** for bytes that cannot be a video (the start of an ISO/MP4, Matroska, AVI or MPEG-TS file), **413** above 2 GB. Unlike every other upload it is streamed to disk, so the 256 MB ceiling of the other routes does not apply |
| POST | `videos/{file}/convert` | → the record: an `unconverted` upload is queued, a failed one is tried again, anything else answers as it is. **403** for somebody else's, **404** for no such video |
| DELETE | `videos/{file}` | 204, **403**, **409** like an image; also for one that waits, failed or is only kept. The upload as it came goes with it |
| PATCH | `textures/{file}`, `fonts/{file}`, `sounds/{file}`, `videos/{file}` | `{demo}` → 204: offers the file to everybody as demo material, or takes that back (#428). **404** for no such file, **422** without a boolean `demo`. Administrators only |
| POST | `pixels` | **raw cel bytes** → `{file,bytes}` (201) — the pixels of one layer on one frame of [pixel art](Pixel-Art); **422** for anything that is not a cel. No listing and no delete: a cel is found by the name in the project |
| GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name |
| GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name. Also without a session: a guest's demo may switch one on |
| GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 404 for anything but a bare file name there — see [Plugins](Plugins) |
| GET | `users` | `[{username,role,createdAt,projects:[{id,name,revision,updatedAt}]}]` — every account with its projects, never a password. Administrators only |
| POST | `users` | `{username,password,role}` → `{username,role,createdAt}` (201). **409** for a taken name, **422** for a bad name, a password under 8 characters or an unknown role. Administrators only |
@@ -41,15 +47,16 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| DELETE | `users/{name}` | 204, and the account is signed out everywhere. **409** while it owns projects, **422** for the caller's own account and the last administrator. Administrators only |
**Everything but `auth/*` needs a session** and answers **401** `{"error":"Sign in first"}` without
one. Projects belong to an account: `projects` lists only the caller's own, and somebody else's
one. The exceptions only read: a released game and the demo copies (`releases/…`, `demos`,
`demos/…`), the plugins, and the four file lists, which then hold the demo material alone. Projects belong to an account: `projects` lists only the caller's own, and somebody else's
project answers **404**, not 403 — that an id exists is already more than a stranger should learn.
An **administrator** (#180, #179) reads and saves every project with the same routes, and a save
keeps the project's `owner`; their `projects` list stays their own. Deleting a project stays with
its owner (#272): an administrator gets **403** `{"error":"Only its owner may delete a project"}`,
also for a file without an owner. Snapshots and the release of the project are theirs to manage
all the same. The `users` routes answer anybody but an administrator **403**
`{"error":"Administrators only"}`. Cels and plugins are shared between accounts; being signed in
is still the price of admission. See [Accounts](Accounts).
`{"error":"Administrators only"}`. Cels and plugins are shared between accounts; uploading a cel
still takes a session. See [Accounts](Accounts).
## What the server refuses (#427)
@@ -78,6 +85,20 @@ as somebody signed out, as the owner, as another account and as an administrator
route list at the top of `api/index.php` and fails for a route the matrix does not ask about, so a
new route has to be put there with what it expects.
## Demo copies and demo material (#428)
A guest tries the editor without an account (see [Accounts](Accounts)), and the server writes
nothing for them: there is no guest session, no guest folder and no quota.
- A **demo copy** is a frozen copy of a project in `data/demos/`, stored exactly like a release
(`ReleaseStore` on a directory of its own, 0640). An administrator makes, updates and removes it
from the project's main menu. The project's own id stays hidden: the copy goes out under its token.
- **Demo material** is a list in `data/demo-files.json` (`api/lib/DemoFiles.php`, 0640) of uploaded
files an administrator offers to everybody. The files stay in their owner's folder. Every list
carries them with `demo: true`, for accounts as well as guests. While a file is offered, only an
administrator deletes it, since any account may have put it into a project by then.
- A file a demo copy names counts as used (`FileUsage`), like one a release names.
## Uploaded files belong to an account (#344)
Every account uploads images, fonts, sounds and videos into a **folder of its own**, and the folder is part
+29
@@ -22,6 +22,35 @@ everything it held — its tabs, the project, the running plugins, the console l
sign-in screen is drawn in its place. Signing in builds a fresh editor in the same canvas, so a
second account starts from nothing the first one left behind.
## Trying it without an account
Below the form, **Try without an account** lists the demo projects (#428); picking one opens the
editor as a **guest**. The window title names *Guest* as the account, the place of the save status
in the toolbar says that nothing is saved, and a notice says the same in a sentence.
A guest works on a copy held in the tab. Everything can be changed, played and recorded, and
nothing reaches the server: the editor sends no request that writes, and the server would refuse
one anyway, since there is no session. A reload, or *Sign in…* in the Account menu, throws the
changes away; picking the demo again starts from the copy as it was. What a guest does not get:
- *New project*, *Open project…*, *Snapshots…* and *Release…* in the main menu,
- *Upload* and *Delete* in the pickers, which list the **demo material** alone, and a file dropped
or pasted on the editor,
- *Change password* and *Logout*: the Account menu holds *Sign in…* instead.
A recording a guest makes is saved as a download (an `.mkv` file), like one a released game makes.
**What guests get is the administrators' call.** In a project's main menu an administrator finds
*Demo copy…*: it makes a frozen copy of the project as saved, like a release, and the dialog says
how old the copy is and whether the project has changed since. *Update demo copy* replaces it,
*Remove* takes it away. The copy is listed under the project's name. An empty project is just a
demo that is empty.
In the image, font, sound and video pickers an administrator has **Offer as demo** (and *Stop
offering*) for the picked file. Offered files show up in the pickers of every account and every
guest, marked *demo material* under the list, and only an administrator deletes one while it is
offered. See [API](API) for the routes.
## Changing the password
*Account → Change password* opens a dialog asking for the current password, the new one and its
+6 -2
@@ -153,6 +153,8 @@ src/core/store/ serialize (untrusted JSON → Project), projectPlugins (w
http (one API call, `jsonBody`), ProjectStore/HttpProjectStore (load takes a `prepare` that sees the raw JSON first; a 409 becomes `StaleRevisionError` through `staleRevision`, and restoring a snapshot answers the same way),
SnapshotStore/HttpSnapshotStore (the snapshots of a project, #231),
ReleaseStore/HttpReleaseStore (a project's released copy and the link to it, #266),
DemoStore/HttpDemoStore (the demo copies a guest picks from, and an administrator's half: making one, #428),
MemoryProjectStore (a guest's project: the demo copy, held in the tab and saved nowhere else, #428),
AuthStore/HttpAuthStore (signing in and out, #177; changing the password, #182),
FileStore (what the server holds of one kind: upload and list, no delete) + HttpFileStore (one
generic client over the four file collections) — TextureStore, FontStore, SoundStore and
@@ -332,7 +334,7 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
the hooks are grouped by area in debug/: ui, scene, graph, documents, pixel art, vector art,
render, sound, pattern, song, routing, bake, video, curve — `tools/` sees one flat object),
textProbe (finding widgets by their text — the ground floor of the test hook, shared with the sign-in screen),
auth/LoginScreen + auth/PasswordDialog + auth/loginRules + auth/FormStatus (the sign-in screen and the change-password dialog in the canvas, what either may complain about, and the warning line and busy state both show, #177/#182), autosave,
auth/LoginScreen + auth/PasswordDialog + auth/loginRules + auth/FormStatus (the sign-in screen and the change-password dialog in the canvas, what either may complain about, and the warning line and busy state both show, #177/#182), session (what a guest or an account may do, `sessionAllows`, #428), guestStores (a guest's stores: they list what the server offers and refuse every write before a request is made, #428), autosave,
polygonEdit (points of an outline: what is under the pointer, whether a point is inside,
put / move / take away — scene view and Texture tab; and the same for a ring of a vector art
shape, where a point may carry Bezier handles: `ringGripAt`, splitting a curved segment
@@ -543,7 +545,9 @@ api/ PHP backend: index.php (routing and the sign-in gate; `ro
out as files, `SERVED` for the uploads),
lib/{Auth,UserStore,SessionStore}.php (#177: accounts in data/users.json, sessions in data/sessions/)
lib/SnapshotStore.php (#231: data/snapshots/{projectId}/, one copy of the project per file)
lib/ReleaseStore.php (#266: data/releases/{token}.json and which token a project has)
lib/ReleaseStore.php (#266: data/releases/{token}.json and which token a project has; the same
class on data/demos/ holds the demo copies, #428)
lib/DemoFiles.php (#428: which uploaded files are offered as demo material, data/demo-files.json)
lib/LoginThrottle.php (#427: five wrong passwords from one address, then 15 minutes of
429; data/sign-ins/), lib/Quota.php (#427: 1 GB per account for uploads, projects and
snapshots, 413 for a write that would grow past it)