apps/api
The platform API (TECHNICAL_SPECS §9.6): accounts, drafts (on a bundled level or a level spec), builds with their preview pictures (jobs of a durable queue kept in the store, outliving a restart), published games (searched, sorted, rated, reported, remixed), rooms on the game servers, runs and the lobbies that gather them (Quick play, the missions' index), each account's Respect, conduct and hidden skill (every settled run paid, endorsements, the missions' ratings, gates, the seasonal leaderboard), the catalogue, moderation (the texts of public games; the moderators' report queue, takedowns and audit log), the accounts' daily limits (counted in the store; the admins' overrides). Fastify, JSON validated by the same zod schemas the game uses. The routes and how a game goes from a draft to a room are in the Platform guide.
| File | What |
|---|---|
app.ts | buildApp: the routes of accounts, levels, the catalogue (?limit=) and the schemas (one template's, ?template=), drafts, builds and publishing, a creation job's session refused every route its job does not need before any runs (its draft alone, its builds ahead of the line and not the day's), built from their dependencies so the tests run them with fakes; CORS for every origin and /info (the release, the protocol, the cities' address); the per-account daily quotas, spent in the store against each account's limits; who is a moderator (FOI_MODERATORS) and an admin (FOI_ADMINS); /me with the account's limits and today's use; a waiting build's place in the queue. |
guides.ts | The guides a game is written from (GET /guides, GET /guides/:name?section=): the mission language, a region's tiles, the city spec and the tone, read from docs/guide once, whole or one ## section at a time; the MCP server's resources and the creator read them here. |
openapi.ts | The API in OpenAPI 3.1 (GET /openapi.json): every route the API registers described in ROUTE_DOCS (what it does, who calls it, its tag; the creation loop's bodies with the game's, the mission's and the level spec's JSON Schemas) or marked internal, else buildApp throws (The platform API). |
games.ts | The published games' routes: the list (q, sort new, top or featured), a game's page (its missions, the caller's stars), its versions, its preview (/games/:id/preview.png, /previews/:hash.png, with caching headers), its rooms, ratings, reports and remixes. Each mission with how it is played; a curated game (a level that ships) by its name; a mission's run (runs.ts). |
runs.ts | Runs (TECHNICAL_SPECS §9.5): a mission's run opened for the caller or their party (refused outside its players; a versus party put on its teams in turn, assignTeams) or for a lobby's explicit roster on its teams (openRoster, checked by teamsRefusal), every seat's join URL, whoever opened it told when it ends (onEnded), GET /runs/:id, a fresh join URL, the result its game server reports with the run's token (taken once; a versus run won with its winner, or a draw), the sweep of the lost. A seat is held until its run ends or its room says it is gone (seatOf; a seat filled under way only while the room lists it, RoomStatus.late); a run nobody will play is abandoned, its room closed (abandon). A seat filled under way (fill, one at a time per run): the gate and kicks asked again (admitLate), the store's row first, then the run's server, the row taken back on a definite refusal and kept on no answer; the result's seats from the start matched to the roster exactly, those filled under way by account (reconcileResult). |
takedowns.ts | The moderators' routes: the report queue, takedowns and restores, the featured flag, dismissals, the audit log. |
admin.ts | The admins' routes (/admin/…): the accounts the API has seen, searched by id or name; one account's limits, today's use and override; setting and resetting an override; every override; the audit of the changes (action: 'limits' in the moderators' audit log). |
creations.ts | Creation jobs (FOI_CREATOR): an owner's brief queued (POST /jobs), listed with its place in line and why it waits, its step and its result, cancelled; the allowance spent at enqueue and given back on a cancel before it starts or a failure no fault of the brief; the runners' private routes behind FOI_CREATOR_SECRET (/creator/claim, a long poll; heartbeats; finish), the job's own session (a grant scoped job:<id>, JOB_ROUTES, revoked at its end), a lost lease swept; the admins' queue and budget (/admin/creations, /admin/jobs/:id/cancel); a job's end in its owner's feed. A brief moderated before its creation is spent (422 refused, nothing spent), what the model wrote moderated at its end (until then hidden from all but the job's session; refused: the job fails, a draft it made withheld, one it revised put back, its builds withheld; draftHidden), its transcript kept 30 days for the moderators and admins (GET /moderation/creations/:id/transcript, purgeTranscriptsEvery), its counts and spend in the metrics. |
budget.ts | The model's budget: the month's cap (FOI_CREATOR_MONTHLY_USD) over the days left, today's spend and reservations from the store's ledger, whether briefs are taken (open), whether a queued creation may start (covers), when creation resumes. |
limits.ts | An account's limits a day: the defaults, an admin's override while it holds (until), what it used today; an override as an admin sends it, checked (parseLimits). |
telemetry.ts | Crash reports and anonymous usage events the players' games send with their consent (POST /telemetry/crash, /telemetry/events: checked, size-capped, a few a minute per install, no address kept), the moderators' view grouped by fingerprint (GET /telemetry/crashes), the counts for Prometheus, and the hourly purge past FOI_TELEMETRY_DAYS. |
social.ts | The social layer's state in the process (SocialHub): presence, parties (up to four; the leader's room sent to every member), the party's channel and direct messages, the feed each account long-polls; FriendBackend, where the friends and the mutes live. |
social-routes.ts | The social routes: friends with their presence and room, parties, messages, mutes, the feed, reports of players, and POST /moderation/chat (a public room's line, asked with the room secret). See Social. |
lobbies.ts | The lobbies (LobbyHub, in memory like the parties): a multiplayer mission's players gathered on their teams (a party kept on one), the ready check, the host's start, kicks and the lead passing on, the countdown that opens the run's room at its start (the city busy tried again, a member still in another run left out) and sends each member their join URL at its end, the results and the vote on the next mission (the same, the game's next, another level's, Replay), members gone dropped; a lobby of a playlist (playlist) with its offers from it and moving on along it when nobody votes; Quick play's player alone seated in a lobby's run under way (fillRun: the team with the fewest players, the seat filled by fillSeat, the members gone from the run out of the lobby); a playlist's lobby whose run cannot open where it moved on to back to vote on the mission it came from (voteAgain); Respect plugged in (paid before the results show, the gate admit asked after warm, each member's tierOf, the teams balanced by skillOf, the gate as it asks now gateOf, the pool, quickPlayers). Guards: a party with a member the host kicked refused whole, a party's members held in a run or another lobby's countdown invited instead, one opening at a time and a run opened too late abandoned, the game read again before every run, a 60 s opening timeout, the changes pushed together (250 ms), invitations deduped, limited and expiring, 500 lobbies at most, a stranger's view without accounts. |
lobby-routes.ts | The lobbies' routes: POST /lobbies (from a playlist, which must have its mission), GET /lobbies, /lobbies/mine, /lobbies/:id, its join, leave, ready, team, start, vote, kick and invite, POST /lobbies/quick; one open run per account. |
matchmaking.ts | Quick play by playlist (playlists.ts): a group placed in the fullest public lobby with room for it on one team, else in a seat of a lobby's run under way that takes players then (LobbyHub.fillRun), else in a new lobby on the playlist's next mission, which remembers it; the Respect gate, the conduct pool and the versus lobbies nearest the group's skill (QuickPlayGate). |
playlists.ts | The playlists (Playlists, GET /playlists): Co-op, Versus, Races and Any, each a rotation across levels (rotation, nextInRotation) of the curated games' multiplayer missions and the published ones whose version qualified for Respect (Respect.missionQualified, asked a few at a time); each playlist's missions kept a few seconds; its numbers in PLAYLIST_LIMITS. |
missions-index.ts | The catalogue of games the lobbies and Quick play read (gameCatalog: a game by its id with its document and how its run opens, every listed game's missions) and GET /missions?mode=&players=&level=, each mission with its public lobbies. |
standing.ts | The rules of standing, pure: a run's Respect step by step (awardSeat: the base pro rata by the seat's span, the difficulty, the outcome, presence, the bonus, the day's repeat, qualified, the conduct band; a span under MIN_SPAN counts for nothing), conduct's costs (conductOfSeat), endorsements' worth, the tiers, bands, seasons, gates (gateOf, gateRefusal) and the low-conduct pool; the constants RESPECT, CONDUCT, TIERS, POOL. |
skill.ts | The hidden skill: Glicko-2 (rate), a team's composite, a versus run's ratings by teams or, in a free-for-all, by places (rateVersus). |
respect.ts | Respect: every settled run paid once (its ledger lines), the run counted in its mission version's tally, endorsements, the missions' ratings, what the lobbies and Quick play ask (a gate, a tier, a skill, the pool) answered at once from the accounts it looked at (warm), the views of the routes, the admins' adjustments, upheld reports, the export and erasing; FOI_RESPECT on, shadow or off. |
respect-routes.ts | Respect's routes: /me/standing (and its export), /players/:id/standing, /leaderboard, /runs/:id/standing, /runs/:id/endorse, /runs/:id/rating; the admins' /admin/accounts/:id/standing and /conduct, the moderators' /moderation/reports/:id/uphold; the tests' PUT /debug/standing/:id with FOI_DEBUG=1. |
testing.ts | The tests' helpers for the lobbies and Respect: the API on fakes with a clock the test moves (Respect on, shadow or off; the debug routes), a fleet in memory that may be busy, sign-ins, a published game, a party, friends, a run's result reported. |
social-dev.ts, social-nakama.ts | The friend backends: the dev accounts' in memory (devFriends) and in a JSON file (fileFriends); Nakama's friends and storage, as the player, and the chat kept by the runtime module's RPC (nakamaFriends). |
oauth.ts | OAuth 2.1 for the MCP server, the API as the authorization server: the metadata, client registration (public clients, https or loopback redirect URIs), the sign-in page and its consent, codes with PKCE S256 only, rotating refresh tokens (one used twice revokes its grant), revocation, limits per address, e-mail and client; the hourly purge. |
steam.ts | Steam as the API asks it: webSteam (the Web API on partner.steam-api.com with the publisher key: a ticket checked, a copy its own or lent, a name; an OpenID assertion checked, its nonce taken once, then confirmed by Steam) and fakeSteam (tickets fake:<steamid>:<copy>, the copy own, shared or none; an OpenID page of its own); the tests give it recorded answers. |
steam-routes.ts | Signing in through Steam: the app's ticket (POST /auth/steam), a browser's OpenID round trip (/auth/steam/openid/start, back to the platform's own pages with a one-time code, POST /auth/steam/exchange), an e-mail account linking its Steam account (POST /auth/steam/link), the fake's page; an account without a copy gets no session where the platform is for owners. |
entitlement.ts | Who may do what: the levels (session, player, creator) open or for the game's owners, an account's entitlement asked of Steam again when older than a caller allows (entitlementOf), the daily sweep of the accounts played in the last 14 days. |
config.ts | loadConfig: the environment (below), with defaults that need no service. |
auth.ts | The account providers: devAuth (accounts made on sight, sessions this API signs) and nakamaAuth (Nakama's REST sign-in; its session tokens checked with the shared session key). Guests play; creators need an account. Both issue OAuth's access tokens (issue: an hour's session naming its grant). |
store.ts | Drafts, builds, published game versions (with what a remix copies), ratings, reports, the games' standing (featured, taken down), the audit log, the terms accepted, the telemetry, OAuth's clients, codes, grants and refresh tokens (by their hashes), the job queue's jobs (claimed in fair order, held by a lease, settled by the attempt that holds them), the daily quotas' uses and the admins' overrides, the Steam accounts' entitlements, the ledger of the platform model's spend and the creations' transcripts: memoryStore and fileStore (a JSON file). Runs and their rosters (a guest's seat marked). The standings, their ledger (a line applied once), the endorsements, the missions' tallies and ratings. |
postgres.ts | postgresStore: the store in Postgres, each document whole as JSONB beside its keys, the indexes the queries need, the migrations run at start under an advisory lock (the fifth: jobs, claimed with FOR UPDATE SKIP LOCKED; quota_usage, spent in one statement; account_limits; the tenth: the standings, their ledger, applied under a lock of the account's standing, the endorsements, the missions' tallies and ratings). |
artifacts.ts | Built games by the SHA-256 of their JSON, levels' previews (PNG) by the SHA-256 of their bytes: memoryArtifacts, fileArtifacts. |
s3.ts | s3Artifacts: built games and previews in an S3 bucket (artifacts/<hash>.json, previews/<hash>.png, each uploaded once), path-style for MinIO; ensureBucket makes the bucket at start. |
builds.ts | buildGame (resolve the level: a bundled one, or a spec draft's generated one with its places merged into the game's; validate the game; store the artifact and the level's preview, the generator's or the bundled level's committed one; keep the place ids) and buildJobs, builds as jobs of the durable queue (a snapshot of the game and the spec in each job; FOI_BUILD_CONCURRENCY at once; a build whose process went away queued again, failed after three tries). A generated level's audit (the generator's stats.audit) comes back as warnings (auditIssues): how many pieces the repair left out and why, each fault it could not clear, a road apart. A build that passes writes its manifest (currentManifest: the release, the formats, the generator) into its artifact and the build; a draft's game is migrated first (parseGame). |
queue.ts | The durable job queue over the store (createQueue): a process claims the next job of a kind in line (priority, then the owner who started fewest that day, then the oldest), holds it with a lease it renews while it runs, settles it; leases that ran out are taken up again or failed; lineOrder and linePositions give each waiting job its place. |
generator.ts | Where a spec draft's level is generated: httpGenerator (the worker, FOI_WORKER_URL), processGenerator (@foi/worker in this process, when Blender is found), noGenerator; cachedGenerator keeps levels by the hash of their spec. |
fleet.ts | httpFleet: opens a room on the game server with the most room to spare (players over capacity), the next when one refuses (503) or is down, a fleet server sent the API's private address; lists their rooms, signs join URLs (@foi/protocol's join tokens), says where a city is played (GET /play?level=: its fullest room with a place left). A game is placed only on servers of its release line or newer (minRelease, from its manifest); for a run, a server at its cap of runs (maxRuns, its heartbeat's FOI_MAX_RUNS) is passed by, and with none to take it the run is refused with the city busy (CITY_BUSY); an ordinary room is placed as ever. close closes a room on its server (DELETE /rooms/:id); addSeat adds a seat to a room's run under way (POST /rooms/:id/seats, 5 s at most: SEAT_TIMEOUT_MS), its refusal definite or not (no answer). |
fleet-registry.ts | FleetRegistry: the game servers that registered by heartbeat, forgotten after 30 s; their internal addresses held to the fleet's networks and their public ones to its domain (no SSRF, no players sent elsewhere); the ones rooms may go to (same protocol, not draining); Prometheus's targets. |
fleet-routes.ts | POST /fleet/heartbeat, GET /fleet (the fleet's secret) and GET /fleet/targets (Prometheus's http_sd), never through the proxy. |
metrics.ts | The API's Prometheus metrics (GET /metrics, never through the proxy): the requests by route and status, the fleet's servers, players, capacity and lost servers, the pieces the level audit left out of generated levels (foi_level_repairs_total, by rule and template), the platform model's spend, creations and refused briefs (foi_creator_cost_usd_total, foi_creator_jobs_total, foi_creator_refused_total), its budget and the creations under way (foi_creator_budget_usd, foi_creator_open_jobs), the process. |
moderation.ts | The pluggable moderator (off, wordlist, claude, chain, openrouter: the word list then a small model on OpenRouter, each call's cost reported to the ledger; createChatModerator: the public chat's, the word list alone under openrouter) and textsOf, the texts of a game strangers read. The word list's words are data (blocked-words.json). |
index.ts | The process: the configuration, the adapters, the routes. |
Run it with npm run dev:api, or the whole platform (game server with rooms, API, web client) with npm run dev:platform; the production set-up is the Compose stack (Deploying). fleet.integration.test.ts runs the fleet over real HTTP: two game servers (@foi/server's endpoints and heartbeat) registering, one draining, one refusing, one gone quiet. queue.test.ts runs the queue with a handler it holds: its slots, the line's order and turns, leases renewed and run out, a restart on the file store, a hold lost; builds.test.ts builds through it, across an API restart on the file store and over attempts that ran out; limits.test.ts and admin.test.ts take the limits and the admins' routes (who may, the search, an override raising a build past the default and running out, the audit, the quotas kept across a restart, a build's place in line); app.test.ts drives every route through Fastify's inject with in-memory adapters, a fake fleet and a fake generator (it answers with test-generated-town, its places and a picture), a ticking clock and one moderator: the loop from a draft to a room, ratings, search and sorting, reports, takedowns and the audit log, versions and previews, remixes; oauth.test.ts takes OAuth through inject: the metadata, registrations and their limits, the sign-in page (escaped, never framed) and its consent, every refusal of an authorization request (no PKCE, plain, an unregistered redirect URI), the code's exchange and its failures (a wrong verifier, another client, used twice, expired), rotating refresh tokens and a reused one revoking its grant, revocation by either token, access tokens every route takes until their grant ends; adapters.test.ts covers the configuration, both account providers (their OAuth access tokens), the fleet and the moderators; stores.test.ts runs one contract (drafts, builds, versions, ratings, reports, standings, the audit log, OAuth's records, each code and refresh token used once; jobs claimed in fair order, once each even all at once, leases renewed, settled and run out; quotas spent never past their limit, all at once too; the admins' overrides and the accounts found; artifacts and previews) against every store and artifact store (Postgres with FOI_TEST_DATABASE_URL, S3 with FOI_TEST_S3_ENDPOINT, FOI_TEST_S3_ACCESS_KEY_ID and FOI_TEST_S3_SECRET_ACCESS_KEY; skipped without them).
Environment
Where each is set: the root .env.example for the API run on its own (npm run dev:api), deploy/.env.example for the ones the Compose stack sets (the stack fixes the adapters: Nakama, Postgres, MinIO); each with its comment there, held to config.ts by npm run env:check.
| Variable | Default | What |
|---|---|---|
FOI_API_PORT | 8080 | The port. |
FOI_API_URL | http://localhost:<port> | Where the game servers fetch artifacts from (the stack's: http://api:8080). |
FOI_PUBLIC_URL | FOI_API_URL | The public address: the OAuth issuer, its sign-in page and endpoints at its root (/oauth/…); https off one's own machine. |
FOI_ROOM_SECRET | a development secret | Shared with the game servers: opens rooms, signs join tokens. |
FOI_GAME_SERVERS | http://localhost:7777 | The game servers, comma-separated, each internal-url or internal-url|public-ws-url. |
FOI_AUTH, FOI_AUTH_SECRET | dev, a development secret | dev (accounts made on sight, sessions signed with the secret) or nakama. |
NAKAMA_URL, NAKAMA_SERVER_KEY, NAKAMA_SESSION_KEY | The Nakama server, its server key and its session encryption key, for FOI_AUTH=nakama. | |
NAKAMA_HTTP_KEY | none | Nakama's runtime HTTP key: the party's and direct messages are kept in its channels (the module's RPC). |
FOI_STORE | file | memory, file (<FOI_DATA_DIR>/store.json) or postgres. |
DATABASE_URL | The Postgres connection string, for FOI_STORE=postgres. | |
FOI_ARTIFACTS | memory with the memory store, else file | memory, file (<FOI_DATA_DIR>/artifacts) or s3. |
S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | AWS, us-east-1 | The bucket, for FOI_ARTIFACTS=s3; with an endpoint (MinIO), addressed path-style. |
FOI_DATA_DIR | .foi-data | Where the file store and the file artifacts live. |
FOI_MODERATION | off | off, wordlist, claude, chain or openrouter (its spend counted in the platform model's month; public chat checked by the word list alone). |
FOI_BLOCKED_WORDS | none | More words the word list refuses, comma-separated. |
ANTHROPIC_API_KEY, FOI_MODERATION_MODEL | none, claude-haiku-4-5 (google/gemini-2.5-flash-lite on OpenRouter) | The Claude classifier's key, and the classifier's model. |
OPENROUTER_API_KEY | none | The platform's OpenRouter key, for FOI_MODERATION=openrouter. |
FOI_QUOTA_BUILDS, FOI_QUOTA_ROOMS, FOI_QUOTA_CREATIONS | 100, 50, 5 | Per account and day (UTC), counted in the store; an admin's override replaces them for one account. Creations are counted once the platform creates games itself. |
FOI_BUILD_CONCURRENCY | 1 | How many builds this API process runs at once; 0 runs none (they wait in the queue). |
FOI_MODERATORS | none | The moderators' account ids, comma-separated (a dev account's is user-<email>, a Nakama account's its user id). |
FOI_ADMINS | none | The admins' account ids, comma-separated, as the moderators': they set an account's limits (/admin/…, the ?admin page, npm run admin:limits). |
FOI_DEBUG_ACCOUNTS | none (off) | Account ids, comma-separated, exact and lower case, never a guest's: their tokens carry debug, so every room honours their debug commands; their runs pay nothing. |
FOI_ENTITLEMENT | open | open (anyone) or steam (the game's owners, signed in through Steam only; it needs FOI_STEAM). |
FOI_JOIN | open | What the game servers ask of a player, as /info tells the clients: open, or token (GET /play signs one). |
FOI_STEAM | off | off, web (Steam's Web API) or fake (the API's own Steam: development and the tests only). |
STEAM_PUBLISHER_KEY, FOI_STEAM_APP_ID | none | The Steamworks publisher key and the game's app id, for FOI_STEAM=web. |
FOI_STEAM_STORE_URL, FOI_WEB_ORIGINS | none | The game's store page ("Get it on Steam"); more origins a Steam sign-in may go back to. |
FOI_CREATOR | off | The platform's own model creating games from owners' briefs: off (/jobs answers 503), fake (the runners' scripted model), openrouter or claude. |
FOI_CREATOR_SECRET | none | Needed when FOI_CREATOR is on, 16 characters at least: the creator runners claim with it (/creator/…, never through the proxy). |
FOI_CREATOR_MONTHLY_USD | 25 | The model's hard cap a month in dollars; each day's budget is what is left of it over the days left. |
FOI_WORKER_URL | none | The generator worker (apps/worker) that builds spec drafts' levels; without it the API generates them itself when it finds Blender. |
FOI_FLEET_SECRET | none | Turns the fleet's registry on: game servers posting heartbeats with it (16 characters at least) take rooms too. |
FOI_FLEET_NETWORKS, FOI_FLEET_DOMAIN | the private networks, localhost | Where the fleet's servers may be: their internal addresses' networks (IPv4 CIDRs), their public addresses' domains. |
FOI_API_FLEET_URL | FOI_API_URL | Where the fleet's servers reach the API (artifacts, moderation): its address on the private network. |
FOI_BLENDER | the PATH, then the install folders | The Blender the API runs when it generates in its own process. |
FOI_LEGAL_OPERATOR, FOI_LEGAL_CONTACT, FOI_LEGAL_COUNTRY | none | Who runs the platform, an e-mail for privacy requests and the country whose law the terms follow: the privacy policy and the terms name them (/info). |
FOI_TELEMETRY_DAYS | 90 | How long the players' crash reports and usage events are kept (1 to 365 days). |
FOI_RESPECT | on | Respect, conduct and skill: on (shown, gating, pooling, balancing teams), shadow (computed and kept, shown to nobody, gating nobody) or off (nothing computed). |
Without FOI_WORKER_URL the API generates spec drafts' levels itself when it finds Blender (FOI_BLENDER, the PATH, the installers' folders), else their builds fail with an issue saying so.