The web Studio: gamewright host and the Studio in a browser
The web Studio is Gamewright Studio's own Godot web export, the same UI as on the desktop
(ADR-0012), run in a browser as the client of gamewright host (ADR-0013). People reach it at
gamewright.diy/app/studio/: Vercel passes /app on to the host on Fly.io.
gamewright host is the core half: sign-in by an emailed code, each person's games on the host's
volume, a session daemon (gamewright serve) on the game they have open, the Studio's web export,
and an admin console for the host's owners. It is Reelwright's host, ported and trimmed;
core/UPSTREAM.md lists every file, its Reelwright commit and what changed. The client half (the
Studio's web mode and export) and deploying both to Fly.io come after the host's sections.
What came over, and what did not
Ported: the account store (host/store.ts, one SQLite file, node:sqlite), sign-in and invites
(accounts.ts), mail through Resend (mail.ts), the owners' settings (settings.ts), WebSocket
tickets (tickets.ts), each person's sealed Anthropic key (keys.ts, claude.ts), the daemon
pool and its grant pipe (daemons.ts, grant.ts), the HTTP server and its pages (server.ts,
files.ts, html.ts, pages.ts), the workspace (workspace.ts), disk and machine figures
(usage.ts, system.ts), the admin console (console.ts, console-pages.ts) and the command
(cli/host.ts).
Left in Reelwright: sharing a game with other people, invitations to it, the collaboration API, devices for the installed Studio, Claude over MCP and the plan bridge, render watching and render quotas, notifications and digests, uploads, and release downloads (releases go to a public repository of builds, ADR-0013). A game is its owner's alone, so a daemon works for one person.
Games are made by Gamewright's own new (core/src/cli/new.ts), in process, into
<data>/users/<user id>/games/<game id>/, the same three ways in as the desktop Studio's New game.
Routes
Every route lives under BASE_PATH (/app in production, where Vercel passes
gamewright.diy/app/* on to the host); /healthz also answers at the root.
| Route | What it does |
|---|---|
GET /healthz |
{ok, version, daemons} |
GET /login |
the sign-in page (?invite=, ?email=, ?next=) |
POST /api/auth/request {email} |
mails a six-digit code to an address that may sign in: {ok, email}, else 400/403/429/502/503 {error, message} |
POST /api/auth/verify {email, code} |
sets the gw_session cookie (HttpOnly, Secure, SameSite=Lax, path the base, 30 days): {ok, user} |
POST /api/logout |
ends the session (a form gets a redirect to /login) |
GET /api/me |
{user: {id, email, name, role, owner?}, announcement} |
GET /api/starts |
exactly gamewright new --list --json: {samples: [{name, title, template, description}], templates: [{id, name, description, starter}]} |
GET /api/games |
{games: [{id, title, template, modifiedAt, open}]}, the latest changed first |
POST /api/games {title, start} |
start is "empty", "starter:<template folder or tpl. id>" or "sample:<name>"; title may be "" for a sample (its own title). 201 {game: {id, title}}; 400 bad_start/workspace; 403 quota_games/quota_storage |
POST /api/games/<id>/open |
starts or reuses the game's daemon and makes it current: the session body below |
GET /api/session |
the session body for the game opened last, or {user, game: null, announcement} with no game yet |
GET /api/file?path=<absolute path> |
a file inside the person's own folder, with ranges and ETags; anything but a raster picture, a sound or a video goes out with Content-Security-Policy: sandbox (files.ts sandboxed), so a page or SVG a game holds never runs with the person's session on this origin |
GET /api/claude |
{claude: {available, connected, key?: "sk-ant-…WXYZ", connectedAt?, problem?, spentThisMonth, monthlyCap?}} |
POST /api/claude/key {key} |
checks the key with Anthropic (a free model listing) and keeps it sealed; the person's daemons get it at once and their sockets close (1012) |
DELETE /api/claude/key |
forgets it, the same way |
GET /studio/… |
the Studio's web export (WEB_STUDIO_DIR, default build/studio/web), cross-origin isolated, never framed by another site (frame-ancestors 'self'); a stand-in page while none is installed |
GET/POST /admin/… |
the admin console, for ADMIN_EMAILS only (a 404 for anyone else) |
WS /ws |
the WebSocket to the current game's daemon (cookie), or /ws?ticket=… on WS_ORIGIN |
The session body: {user, game: {id, title}, ws, token: "service", announcement}. ws is
<base>/ws on the page's own origin, or with WS_ORIGIN an absolute wss://…/app/ws?ticket=…
that opens once, within a minute, for this user and this daemon: the client asks
GET /api/session again before every reconnect. The browser's first frame must be
session.hello with token: "service"; the host puts the daemon's own token in its place and
changes nothing else (HelloParams stays {token, client, protocol}). Anything else first closes
the socket with 4001.
Writes to /api/claude/key must carry X-Gamewright-Studio: 1, JSON, and no other site's
Origin or Sec-Fetch-Site. Every other state-changing request is refused from another site's
Origin, and the console's forms take POST alone, from its own pages (Sec-Fetch-Site). Signing
out closes the person's open sockets (1008). The next of the sign-in page is a plain path or
the Studio, and every value a page puts in an inline script is escaped for it.
Sign-in limits
A code works once, for ten minutes, and dies after five wrong guesses. Wrong codes also count
against the address across codes: the tenth in an hour locks it for an hour (address_locked,
in the audit log), so a new code does not wipe the slate. An address may ask for five codes in
15 minutes and an IP for 30 an hour; both are the owners' settings, since everyone behind one
network (or the proxy) shares an IP. Behind Fly.io the client's address is Fly's own
Fly-Client-IP, never X-Forwarded-For.
Quotas
Every account has 20 games and 2048 MB unless an owner sets otherwise (the console's settings
and each account's page; empty is no limit). One person's creates run one at a time, so many at
once cannot all pass the games quota. Their folder is measured before their socket opens and
again when that is a minute old; while it is over their storage, the frames that write
(pack.bake, pack.verify, director.send, tools.call, docs.edit) are answered by the host
with quota.storage and plain words, and the daemon never hears them. The host reads a frame's
method as the daemon's JSON parser does (a name with an escaped letter is the same name), on
every text frame while the folder is over; a frame that does not parse goes on, and the daemon
refuses it.
Backpressure
The host pipes each socket to its daemon frame for frame. Past 4 MB held for the other side, the sending side is not read until what is held drains under 1 MB, both ways; a side past 32 MB is closed with 1013. A person flooding frames waits on their own connection.
The person's key, on the daemon
A daemon the host starts runs gamewright serve --pack <game> --grant-fd 3. When its owner's
socket joins, the host writes one line on fd 3, {"userId", "anthropicKey"?, "room"?: {"usd", "what"}}, and again whenever what they may spend changes (a call counted, their cap, the
ceiling). The daemon keeps the key in memory (core/src/director/hosted.ts); the Director's key is
that one and nothing else, never ANTHROPIC_API_KEY, the keychain or a key file. A hello waits
up to 3 s for the first grant, so its capabilities say director exactly when a key is there.
key.set and key.forget answer key.hosted: on the web the key is connected in the Studio's
Claude settings.
A turn's budget is the least of what it asked and the grant's room: what is left of the
person's monthly cap (set in the console) and the host's ceiling. With nothing left,
director.send answers director.over_cap. Each priced call is a stdout line of the daemon's,
{"spend": {"session", "model", "usd"}}; the host checks it and counts it in
<data>/host/accounts/<user id>/spend/<YYYY-MM>.jsonl with its own time, then writes a fresh
grant.
It counts at most 5 lines a second from one daemon and 200 for one turn (a line's session; a
real turn makes at most 24 calls, each a second or more); the rest of a flood is dropped, with
one log line for each stretch of it. Past 256 KB a month's ledger folds into one line holding
its total, and the host keeps each account's month in memory once read, so a grant reads no
ledger. It holds at most 64 KB of any one line a daemon writes on stdout or stderr.
Accepted: the room is read when a turn starts, so a turn may pass a limit by its last call, and
two daemons of one person (or many people under the ceiling) turning at once may each spend up
to a turn's budget past it. That is bounded by the per-turn budget (default $1). A daemon that
lies about what it spent only loosens its own owner's cap.
Isolation
A daemon runs a person's own pack and the Godot that checks it, so a bug fed by their files could run their code. It reaches nothing of the host's or anyone else's:
- Environment: an allowlist (host/daemons.ts
daemonEnv):PATH,LANG,LANGUAGE,TZ,LC_*,ANTHROPIC_BASE_URL, theGAMEWRIGHT_*that find and run Godot, and a software renderer'sVK_ICD_FILENAMES,__EGL_VENDOR_LIBRARY_FILENAMES,LIBGL_ALWAYS_SOFTWARE,GALLIUM_DRIVER,MESA_*;HOME,TMPDIRand the XDG folders inside the person's folder;GAMEWRIGHT_HOSTED=1andGAMEWRIGHT_JUDGE=none. A secret added to the host later never reaches a daemon. - As root (the Fly.io container runs the host as root): each person's daemons run as a uid
and gid of their own (
users.uid, from 20000, given once and kept), and so does everything that writes in their folder: a game made (POST /api/gamesrunsgamewright newas them) and the folder emptied when their account is deleted (host clear-folder, then root removes the empty folder). Each is a child process of the core's with a daemon's environment, its home and working folder theirs (host/isolation.tspersonWriter), so what it makes is born theirs. Root makes their folder inusers/(0700, theirs) and changes the owner or mode of nothing in it, so no link they plant there (a hard link to a file of root's, a folder swapped for a link while a walk is under way) can turn root's hand on what is not theirs. A daemon also starts in its owner's folder, not the game's: root changes to the working folder before it becomes them, and a game folder could be swapped for a link. The data folder andusers/are only traversable (0711);host/(the database, the settings in it, andaccounts/) is root's alone (0700), so a daemon cannot even name a path to anything of the host's, and no hard link to it is possible whateverfs.protected_hardlinkssays. A daemon cannot list another's folder, read the database, the accounts or the host's/proc/<pid>/environ(whereKEY_ENCRYPTION_KEYandRESEND_API_KEYstill are). The image must keep the host as root and give daemons no shell or setuid helper beyond what Godot and the core need. - Files the host reads in a person's folder (
/api/file, a game'sgame.jsonandbrief.jsonfor its title): opened, then the descriptor checked, since another process of theirs can swap a link in between (host/files.tsopenContained): its real path first, then where/proc/self/fdsays it points (elsewhere, its device and inode against the path resolved again), never a link at the last step (O_NOFOLLOW), never a FIFO (O_NONBLOCK, so nothing waits on one), and, as root, a file of their own uid only (a hard link to another's is inside by every path). A game folder that is a link, or agames/that leads out, is no game. - Not root (a laptop, the tests): daemons run as the host's user, with the allowlisted environment; a game is made and a folder removed in the host itself; and the host says once that daemons are not isolated.
The self-check
gamewright host check-isolation --data <the host's data folder>, run as root on the host's own
machine (on Fly.io, over fly ssh console, with the image's core), proves the above where it
matters. It makes two throwaway people in the host's database and folders, runs a probe as the
first exactly as the host runs their work (host probe, a child process as their uid), and
checks that the probe ran as that uid; could write and read its own folder; got EACCES on
host/, the database and the other person's folder, and could not hard-link the database into
its own; got EACCES (or ENOENT under hidepid) on the check's own /proc/<pid>/environ (a
root process, as the host is) and PID 1's; and had exactly the allowlisted environment. Then, as
the host, it checks the file route in the probe's folder: their file served, a link to the
database swapped in between the check and the open refused, a file of root's refused. It
removes both people, folders and all, and prints one JSON line, {"ok", "root", "uid", "checks": {name: what it got}, "failed": [names]}: exit 0 when everything held, 1 when
anything did not (or the probe could not run, say the image's core is not readable by others),
2 when it is not root. It needs nothing but the core, so it runs from the production image.
Accepted: root still reads in a person's folder, never writes. It measures its size for the
storage quota (host/usage.ts walks it without following links; a folder swapped for a link mid-
walk can make it count, never read or change, what lies beyond), reads a game's title and
serves its files (the descriptor checks above), and lists games/.
Data
<data>/host/gamewright.db users (and the uid each one's daemons run as), codes (hashed),
wrong codes and locks by address, sessions (hashed), invites,
settings, quotas, the audit log, sealed Claude keys
<data>/host/accounts/<user id>/ cap.json and spend/<YYYY-MM>.jsonl, written by the host alone
<data>/host/accounts/ceiling.json everyone's monthly ceiling
<data>/users/<user id>/ the person's own: games/<game id>/ (their Game Packs), and the
daemon's HOME, .tmp (it makes that itself) and XDG folders
A host that finds gamewright.db (and its -wal and -shm) or accounts/ in the data folder
itself, where an older one kept them, moves them into host/ before it opens the database; with
both there it stops and says so.
Tables change through MIGRATIONS in host/store.ts, counted by PRAGMA user_version: a new
database is made whole at the latest version; an older one runs the steps it lacks, each once.
Running it on this machine
ADMIN_EMAILS=you@example.com HOST_INSECURE_COOKIES=1 BASE_PATH=/app \
KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
node core/src/cli/main.ts host --data /tmp/gw-host --port 8457 --listen 127.0.0.1 --dev-mail
--dev-mail writes every email to stderr (host (dev mail) to …: 123456 is your Gamewright code); without it the host needs RESEND_API_KEY and refuses to start. Then sign in with curl:
B=http://127.0.0.1:8457/app
curl -s -X POST -H 'content-type: application/json' -d '{"email":"you@example.com"}' $B/api/auth/request
curl -s -c jar -X POST -H 'content-type: application/json' \
-d '{"email":"you@example.com","code":"<the code from stderr>"}' $B/api/auth/verify
curl -s -b jar $B/api/me
curl -s -b jar -X POST -H 'content-type: application/json' \
-d '{"title":"Moon Bakery","start":"starter:adventure"}' $B/api/games
curl -s -b jar -X POST $B/api/games/moon-bakery/open
gamewright host users list|add|remove --data DIR and gamewright host session EMAIL --data DIR
manage accounts from the machine. The environment the host reads is in .env.example and at the
top of core/src/cli/host.ts. .claude/skills/verify/features/web-studio.md has the checks.
The Studio in a browser: the page and the export
tools/heavy.sh tools/web/build-studio.sh writes build/studio/web/ (the host's default
WEB_STUDIO_DIR): tools/export.sh studio-web exports the "Studio web" preset
(engine/export_presets.cfg), then each page, script, wasm and pck gets a .br and a .gz twin
for a server that sends them precompressed.
- Single-threaded, like Reelwright's: the page needs no SharedArrayBuffer. The host still sends
COOP
same-origin, COEPrequire-corpand CORPsame-origin, which a threaded build would need. - The Compatibility renderer (ADR-0007), WebGL 2.
- The Studio, not the web player: the preset carries the
gamewright_studiofeature, whoserun/main_sceneoverride (res://studio/studio.tscn) comes before the web player's inengine/project.godot. Godot takes the first override that matches, so the order matters: with the web player's first, the export booted the player. - The page is
engine/studio/web/shell.html: a loading screen in the Studio's colours with the download's progress, kept until the Studio says it has drawn itself (GwStudioWeb.say_readycallswindow.gamewrightStudioReady; after 30 s it goes anyway), a notice when the browser lacks WebGL 2 or WebAssembly, and a question before the tab closes on what the person typed to the Director and has not sent (window.gamewrightUnsaved).
Where the Studio finds the host
The page is <base>/studio/, so the host lives at the page's path with its last /studio
segment taken off: /app/studio/ gives /app (GwStudioWeb.base_of). Every call goes to the
page's origin under that base with the header X-Gamewright-Studio: 1: /api/me (who is signed
in), /api/games and /api/starts (the welcome and New game), POST /api/games and
/api/games/<id>/open, /api/session before every reconnect, /api/claude and
/api/claude/key (the key), /api/logout (the Routes above). A 401 sends the page to
<base>/login?next=…; a 503 maintenance loads the page again, which the host then answers with
its own notice.
What web mode changes
GwStudioWeb.is_web() is true in a browser (OS.has_feature("web"), the origin from
window.location); a test forces it at a stand-in. The Studio then makes a GwStudioHosted, and
the few places that differ ask it:
- The welcome lists the person's games on the host, says who is signed in with Sign out, and
has no Open a folder, no recent folders and no update check (the host serves the newest Studio).
Its sample cards are the host's (
/api/starts), and a card makes its copy there (POST /api/gameswithsample:<name>). - New game has no folder to choose; the host makes the game with
gamewright newand the Studio opens it. - Opening a game:
GwStudioSession.open_hostedasks the host to open it and connectsGwSessionClient.connect_to_urlto the socket it names:wss://gamewright-app.fly.dev/app/ws?ticket=…in production (Vercel carries no WebSocket), or/app/wswith the cookie where the host has noWS_ORIGIN. The hello is{token: "service", client, protocol}; the host puts the daemon's own token in its place. A ticket works once, for a minute, soGwSessionClient.renewasks/api/sessionagain before each reconnect (and opens the game again when the session names another, opened in another tab, or none). - Reading the game: there is no folder in the browser, so
GwStudioWebPackasks the core fordocs.list(the game's own documents, not its template's) into the sameGwStudioPackthe desktop reads from disk. The manifest's and the brief's files come with it (the overview's name, what the game is, whether it was played); any other document's file is read withdocs.getwhen it is shown (GwStudioHosted.show_text): adocs.gettakes a moment on a busy core, and a game has dozens of documents. A file that changes on the host (doc.event) reads the game again. - The Director's key is kept by the host, sealed, and offered as "your Gamewright account"
(
/api/claude/key, neverkey.set, which a hosted daemon answerskey.hosted); there is noclaude mcp addcommand on the key card or in the "Your key" menu, since the core is not on the person's computer. Keeping one closes the person's sockets (1012): the Studio comes back through a fresh ticket, and the hello then saysdirector. - Playing (2026-10-07): Play builds the game on the host, as on the desktop, and the web
Studio fetches that build (GwStudioWebBundle: its
bundle.json, then each file it lists, through/api/file, only inside the person's own folder) into the browser's storage, kept by the build's name so the same build is fetched once, and plays it in the middle with the same player the desktop hosts. F5 plays (the page keeps the key from reloading). The play origin (ADR-0009) cannot serve a person's private builds: its pages fetch only from themselves. - What only the desktop does (
GwStudioShortcuts.DESKTOP_ONLY): open a folder or a recent one, export a zip, quit, open a game in a window of its own, check for an update, connect the person's own Claude Code. The web Studio offers none of them: no menu item, palette entry, sheet row or key. - Exporting: nor does it export: the zip goes in a folder on the person's computer. No Export button or step, and Ctrl+E (or the palette) says to export in the desktop Studio.
Everything that starts a process (GwDaemonSupervisor, gamewright new and --list run by
GwStudioStart), the file dialogs, and use_this_engine stay on the desktop. Every desktop
Studio scenario passes unchanged (tools/studio/check.sh).
Proving the Studio in a browser
tools/web/studio-dev-server.ts is an account-less stand-in for the host: the export at
/app/studio/ with the isolation headers, the API above for one person, a real gamewright new
and gamewright serve --pack per game, one-time tickets to a relay at /app/ws that swaps the
hello's token (--cookie for /app/ws alone), and a key kept in memory, unchecked, that starts
the cores again.
engine/tests/studio/net/gw_studio_hosted_test.gd: the Studio forced into web mode on the desktop against the stand-in: the welcome's games, a game opened through a ticket and read through its core (the same documents the desktop reads from the folder, a document's file read when shown, the key card with no Claude Code), a sample's card copied on the host, a key kept bringing the core back through a fresh ticket, New game made on the host, and sign-in when nobody is signed in.tools/heavy.sh node tools/web/studio-check.mjs: the export in headless Chromium against the stand-in; with--real, againstgamewright hostitself on this machine (mail to its log, a cookie fromhost session,WS_ORIGINtickets, the game's core killed to force a reconnect). Shots to read inbuild/web-studio-proof/.
Deploying
Nothing deploys until the owner has a Fly.io account and token, and says so (ADR-0013).
node tools/host/build.tswritesbuild/host/context/: the core bundled by esbuild intoapp/gamewright.cjs(run by Node, its resources underGAMEWRIGHT_ROOT=/app: schemas, library, examples, the playbook, the kits and the engine project; the host starts its daemons and probes from the same script),BUILD.json(the console's Operations page shows its commit), the web export with its twins, and a Dockerfile on Ubuntu 24.04 with Node and Godot attools/toolchain.json's pins (checked by checksum) and Mesa and Xvfb for jobs that draw, the engine imported while the image builds (GAMEWRIGHT_ENGINE=godot,GAMEWRIGHT_ENGINE_ARGS=["--path","/app/engine"]). A job that draws (the Director's previews, L3's shots) runs throughgw-draw(GAMEWRIGHT_HEAVY): Godot under a private Xvfb with llvmpipe and the Compatibility renderer, astools/lib/render-env.sh's offscreen; the image fails to build unless a drawing job reports llvmpipe (without a display Godot draws nothing, silently, and every preview failed). The image's command isnode /app/gamewright.cjs host --data /data, onPORT8080.- Who runs what in the image. The host runs as root, so it can run each person's work under a
uid of their own (Isolation). The app (
/app: the core, schemas, library, examples, engine and web Studio), Node and Godot are root's, readable and runnable by every uid and writable by root alone (chmod -R u=rwX,go=rX, whatever modes the build context had), and no file in the image keeps a setuid or setgid bit. Daemons start Node, Godot and git themselves, and no shell butgw-draw(root's, run as the daemon's own uid;/bin/shstays for apt and for it). Root's HOME is/root, off the volume;/dataholds onlyhost/andusers/, which the host makes with their modes. Built here, the context's core and engine verify a sample to L1 with every file of the app read-only, and its host runs a person's daemon from it. tools/host/fly.toml: the appgamewright-appiniad,PUBLIC_URL=https://gamewright.diy/app,BASE_PATH=/app,WS_ORIGIN=wss://gamewright-app.fly.dev, a/healthzcheck, the volumegamewright_dataat/data, and a performance-2x with 4 GB, as Reelwright runs (about $65 a month): the web Studio's Playtest runs there, and on shared-cpu-2x, throttled under sustained load, Saltwell's ran out of L2's 600 s budget; it passes in 342 s here.tools/host/deploy.shbuilds the web Studio when there is none or an engine file is newer than it, the context, thenfly deploy --remote-only --ha=false.- The site:
web/next.config.tsrewrites/appand/app/<anything>tohttps://gamewright-app.fly.dev/app/…, cookies, query strings and POST bodies as they are (a build may point it elsewhere withGAMEWRIGHT_APP_ORIGIN). Next would redirect/app/studio/to/app/studio, where the page's relativeindex.jsis no longer the Studio's, soskipTrailingSlashRedirectis on and a redirect of its own strips trailing slashes everywhere but under/app. - The site's link to the web Studio ("Sign in" in the header and the menu, "The Studio in a
browser" in the footer, both to
/app, which signs a person in and then opens the Studio) is in a site built withGAMEWRIGHT_WEB_STUDIO=liveonly, so no page links to a host that is not there. Set it in the Vercel project's production environment once the checks below pass, then deploy the site (tools/site/deploy.sh); remove it and deploy again to take the link down.
Once, before the first deploy: fly apps create gamewright-app, fly volumes create gamewright_data --region iad --size 10, and fly secrets set RESEND_API_KEY=… ADMIN_EMAILS=… KEY_ENCRYPTION_KEY=… (with RESEND_FROM); a Resend domain for the sign-in mail. No DNS change:
gamewright.diy stays on Vercel, and the socket's address is the Fly domain.
Accepted, for now: behind the rewrite, the host's Fly-Client-IP is Vercel's address, not the
person's, so the per-IP limit on sign-in codes counts everyone Vercel sends from one address
together. The per-address limits and lockouts hold as they are.
After the first deploy
Before anyone is invited (all three with the owner's Fly token):
curl -s https://gamewright-app.fly.dev/healthz
fly ssh console -a gamewright-app -C "node /app/gamewright.cjs host check-isolation --data /data"
fly ssh console -a gamewright-app -C "node /app/gamewright.cjs host users list --data /data"
/healthz:{"ok":true,"version":"<VERSION>","daemons":0}.check-isolationmust print one line with"ok":true,"root":true, a"uid"of 20000 or more, and"failed":[], and exit 0. Itschecks:"uid"that same number;"own folder: write"and"own folder: read""ok";"host folder","database"and"another person's folder""EACCES";"database hard-linked"anything but"ok";"the host's environ"and"PID 1's environ""EACCES"or"ENOENT";"environment"the allowlisted names only (noRESEND_API_KEY,KEY_ENCRYPTION_KEYor other secret);"file route: own file""served";"file route: link swapped in"and"file route: not theirs""refused". Exit 1, or any name infailed, is a hole: let nobody in until it is fixed and the check passes. Amessagesaying the probe did not report means a person's uid could not run the core: the image's/appor Node is not readable or runnable by others.users listshows noisolation-check-…account: the check removes both of its own.
Then sign in at https://gamewright.diy/app with an address in ADMIN_EMAILS, make a game, open
it in the Studio, and only then set GAMEWRIGHT_WEB_STUDIO=live for the site.
Backups and the restore drill
Everything a person has on the host lives on one volume, gamewright_data (10 GB, iad, mounted
at /data): accounts, games, keys (encrypted). Fly snapshots it daily and keeps each snapshot 5
days (checked 2026-10-09: two snapshots, 286 MiB and 98 MiB stored).
fly volumes list -a gamewright-app
fly volumes snapshots list <volume id>
A longer retention (fly volumes update <volume id> --snapshot-retention 14) costs more: the
owner's call.
The drill, to run with the owner before anyone depends on the host (it touches the live app):
- Pick a snapshot:
fly volumes snapshots list <volume id>. - Make a volume from it, beside the live one:
fly volumes create gamewright_drill --snapshot-id <snapshot id> --region iad --size 10 -a gamewright-app. - Look inside without touching the live machine:
fly machine run <the app's image> --volume gamewright_drill:/data --region iad -a gamewright-app --rm --command "node /app/gamewright.cjs host users list --data /data". It must list the accounts the live host has (compare with the same command overfly ssh console). - Destroy the drill's volume:
fly volumes destroy <drill volume id> -a gamewright-app. - A real restore swaps the volume under the machine: stop it (
fly machine stop), make the volume from the snapshot as in 2 under the namegamewright_data, point the machine at it (fly machine update <machine> --mount source=<new volume id>,destination=/data), start it, check/healthzand a sign-in, and only then destroy the old volume.