# Local developer mode

> The server side of local developer mode - how the Moonspell server signs a developer's CLI in, gives it the game's repo, takes a published version and handles invites, under /api/dev.

In local developer mode a developer builds a Moonspell game on their own machine with their own coding agent, and the `crescent` CLI publishes it to the Moonspell app. Moonspell runs no sandbox and no model for it. This page is the server side: the routes, the sign-in, the rules. The developer's side is [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md). The CLI itself is handed out built; its source is private ([What is open and what is private](https://docs.moonspell.app/start/open-source.md)).

Everything here is behind `CRESCENT_DEV_MODE=on`. When it is off (the default), every `/api/dev/*` route answers 404 `{error: "dev_mode_off"}`. The tables come from `db/migrations/0015_local_dev_mode.sql`.

A local game is a game like any other: owned by the account that signed the CLI in, listed in that player's games on every app, and **public at once**. Its first version goes into the feed for every player, plays from its `/g/<buildId>` link and the App Clip, and is open to community builds, like a game made in the app. `games.source` and `game_builds.source` (`worker` or `local`) only record where a game started and where each version came from.

It works the other way round too: every game is its owner's dev game, wherever it was made. A game made in the app can be continued and published from the owner's machine, and from a collaborator's (`crescent continue <gameId>`). One rule between the two: a publish waits while a worker run is building the game (409).

## Code

| What | Where |
| --- | --- |
| Routes | `src/app/api/dev/**` |
| The error shape, the switch, body parsing | `src/lib/dev/http.ts` (`devRoute`, `DevError`, `devModeOn`) |
| Every limit | `src/lib/dev/limits.ts` |
| Tokens, user codes, invite tokens | `src/lib/dev/tokens.ts` |
| Who is calling, the device flow, CLI tokens | `src/lib/dev/auth.ts` (`devCallerFromRequest`, `requireDevCaller`) |
| Roles | `src/lib/dev/access.ts` (`roleFor`, `may`, `devGameFor`) |
| `DevGame`, `DevVersion` | `src/lib/dev/shapes.ts` |
| Local games | `src/lib/dev/games.ts` |
| The game's repo | `src/lib/dev/repo.ts` |
| Version rules, pure | `src/lib/dev/upload-rules.ts` |
| Begin and complete a version | `src/lib/dev/uploads.ts` |
| Invites and collaborators | `src/lib/dev/invites.ts` |
| The cover | `src/lib/dev/cover.ts` |
| Someone else's game: access, the fork, drafts (rules pure in `fork-rules.ts`) | `src/lib/dev/fork.ts`, `src/lib/dev/fork-rules.ts` |
| The starter and CLI downloads | `src/app/api/dev/starter/`, `src/app/api/dev/cli/` |

Unit tests: `src/lib/dev/*.test.ts` (`npm test`).

## Downloads

These need no sign-in.

| Route | Answer |
| --- | --- |
| `GET /api/dev/starter` | `{url, version, sha256, size}`: the current starter archive. Its files sit at the archive's root. |
| `GET /api/dev/cli` | `{url, version, sha256, size, blobUrl}`: the current CLI package. `url` is a versioned URL on moonspell.app that npm and npx take. |
| `GET /api/dev/cli/latest.tgz` | Redirects to the current CLI package (cached for a minute), for `npx https://moonspell.app/api/dev/cli/latest.tgz init my-game`. |
| `GET /api/dev/cli/crescent-cli-<version>.tgz` | Redirects to that version's package. A version's bytes never change. |

The starter is the template's tracked files with the overlay in `templates/crescent-game-local/` laid over them: `rewrites.md` (exact rewordings of the skills and docs that name the hosted builder's sandbox) and `files/` (the `moonspell-dev` skill, `.claude/settings.json`, the save hook). `scripts/pack-starter.mts` packs it, and fails when a rewording no longer matches the template or a file still names something only the hosted builder has. The template itself is never changed for this mode. Packing also builds the CLI, so it needs the CLI's private source.

## Who is calling

Every other route takes `Authorization: Bearer <token>` with either:

- a **dev token**, which `crescent login` gets through the device flow below. Only its SHA-256 is stored; revoking it marks it revoked.
- the **app session** (`src/lib/app-session.ts`), which the web app sends, so the developer pages on the web call the same routes.

Making or changing anything needs a registered account (signed in with Google, or Apple on iOS): an anonymous one gets 403 `sign_in_required`.

## Errors

Every answer is JSON with `Cache-Control: no-store`, and every success is 200. Errors are `{error: "<code>", message}`, where `message` is a sentence for the developer:

| Code | Status | When |
| --- | --- | --- |
| `unauthorized` | 401 | No credentials, a revoked token, or the wrong kind for the route |
| `sign_in_required` | 403 | An anonymous account |
| `forbidden` | 403 | A collaborator asking for an owner's action |
| `not_found` | 404 | No such thing, or not the caller's |
| `dev_mode_off` | 404 | `CRESCENT_DEV_MODE` is not on |
| `limit` | 429 | Too big, too many files, too many versions or games a day |
| `invalid` | 400 | A malformed body, a bad path, a build that will not play |
| `conflict` | 409 | The commit is not pushed, the upload expired, a worker run is building the game, a version number was taken |
| `authorization_pending`, `slow_down`, `access_denied`, `expired_token` | 400 | The device flow's poll |

An `invalid` answer from completing a version also carries `problems: string[]`.

## Sign-in: the device flow

It follows the shape of the OAuth device flow (RFC 8628):

1. `POST /api/dev/device` (no auth) `{clientName?}` returns `{deviceCode, userCode, verificationUri, verificationUriComplete, interval: 3, expiresIn: 600}`. `userCode` looks like `ABCD-EFGH` and avoids letters and digits that are easy to confuse. `verificationUri` is `<PUBLIC_BASE_URL>/cli`.
2. The person opens `/cli?code=...` signed in, sees which client is asking, and approves (`GET /api/dev/device/:userCode`, `POST /api/dev/device/approve`).
3. The CLI polls `POST /api/dev/device/token` (no auth) `{deviceCode}` every 3 seconds: `authorization_pending` until approved, `slow_down` when it polls too fast, `access_denied` once declined, `expired_token` for an unknown, expired or used code. Once approved it gets `{token, user: {id, name}}`. One code gives one token.

Then:

| Route | Auth | Answer |
| --- | --- | --- |
| `POST /api/dev/handoff` | dev token | `{code, expiresIn: 300}`: a one-time code that signs the CLI in as the same account (`crescent login --code`). The Moonspell MCP server hands this to the machine. |
| `POST /api/dev/handoff/token` | none | `{code}` returns `{token, user}` once. |
| `GET /api/dev/me` | dev token | `{user: {id, name}, tokenName, createdAt}` |
| `POST /api/dev/logout` | dev token | Revokes it. |
| `GET /api/dev/tokens` | app session | The account's live tokens, newest first (Settings) |
| `DELETE /api/dev/tokens/:id` | app session | Revokes one. |

## Games

| Route | Who | Body and answer |
| --- | --- | --- |
| `POST /api/dev/games` | registered | `{title}` returns `{game: DevGame}`: a `local` game owned by the caller. At most 20 a day per account. |
| `GET /api/dev/games` | signed in | `{games: DevGame[]}`: the caller's games, made in the app or on a machine, and the ones they collaborate on, most recently touched first |
| `GET /api/dev/games/:id` | owner, collaborator | `{game, versions, collaborators}`: versions newest first (at most 50) |
| `POST /api/dev/games/:id/repo-token` | owner, collaborator | `{remoteUrl, username, password, branch: "main", expiresAt}`; creates the repo on the first call |

```ts
DevGame    = { id, title, slug, repo, role: "owner" | "collaborator", coverUrl, devUrl, latest: DevVersion | null }
DevVersion = { buildId, version, createdAt, summary, commitSha, by: { id, name }, shareUrl,
               platforms: { ios: true, android: boolean, web: boolean } }
```

`devUrl` is `<PUBLIC_BASE_URL>/dev/<id>` and `shareUrl` is `<PUBLIC_BASE_URL>/g/<buildId>`. Anyone without a role on a game gets `not_found`.

The repo is the game's own private repo, the same one worker runs push to ([Game repos](https://docs.moonspell.app/moonspell/game-repos.md)). The answer is host-neutral (`remoteUrl`, `username`, `password`), so the CLI never needs to know where repos live. The password is a short-lived token for that one repo; the CLI hands it to git per command and does not write it to disk.

## Versions

A version's files go from the developer's machine straight to blob storage, one client token per file; the server never proxies them. The layout is the same as for worker runs: `games/<id>/v<n>/` for iOS, with `android/` and `web/` beside it ([Builds and versions](https://docs.moonspell.app/moonspell/builds.md)).

1. **Begin**: `POST /api/dev/games/:id/versions` (owner, collaborator) with `{commitSha, summary, platforms, files}`. The server checks the limits, that the repo has the commit (409 until it is pushed), and that no worker run is building the game (409). It then reserves the next version number and answers `{uploadId, version, expiresAt, uploads}`, one upload token per declared file.
2. **Upload**: the CLI puts each file with its token.
3. **Complete**: `POST /api/dev/games/:id/versions/:uploadId/complete` (whoever began it) with `{gdd?}`. The server checks that every declared file arrived at its declared size and that each platform's build is complete and refers only to files of this version. It then records the version, and from that moment it is public: in the owner's games, the feed, at `/g/<buildId>`, in the App Clip and the Android and web apps. It answers `{version, shareUrl, devUrl, warnings}`; completing again answers with the same version.

iOS must pass. Android and the web are best effort, as for worker runs: a platform that fails its checks is left out of the version, and `warnings` says why. If the game has no cover yet, the cover workflow starts once, from the version's notes.

### Limits

- `commitSha` is a full 40-character hash; `summary` at most 4,000 characters; `gdd` at most 30,000.
- `platforms` includes `ios`, and each file belongs to one listed platform.
- A path is relative, with forward slashes, no empty, `.` or `..` segment, at most 300 characters of letters, digits and `. _ - @ + ~`.
- At most 60 MB a file, 2,000 files and 200 MB a version, 30 versions a game in 24 hours, and an upload lasts 30 minutes.

An abandoned upload holds its version number until it expires; if a later version completes meanwhile, that number is skipped. Worker runs step over live reservations, so the two never take the same number.

## Invites and collaborators

| Route | Who | Body and answer |
| --- | --- | --- |
| `POST /api/dev/games/:id/invites` | `play`: owner, collaborator; `build`: owner | `{kind, maxUses?, expiresInDays?}` returns `{invite}` |
| `GET /api/dev/games/:id/invites` | owner (all), collaborator (their own) | `{invites}`, live ones, newest first |
| `DELETE /api/dev/invites/:id` | the game's owner or the invite's maker | Revokes it. |
| `GET /api/dev/invites/:token` | none | The invite's kind, the game, its latest version, and whether it still works |
| `POST /api/dev/invites/:token/accept` | registered | `build` only: the caller becomes a collaborator. Accepting again, or as the owner, changes nothing. |
| `DELETE /api/dev/games/:id/collaborators/:userId` | owner; a collaborator for themselves | Removes them. |

An invite's link is `<PUBLIC_BASE_URL>/i/<token>`. A `build` invite defaults to one use within 7 days; `maxUses` goes up to 1,000 and `expiresInDays` to 365. A play invite is not used up by being opened.

A collaborator may read the game, get a repo token, publish versions and make play invites, but not make build invites or remove other people.

Signing in from an anonymous account to an existing one (`moveEverything`, `src/lib/sign-in.ts`) carries tokens, games and collaborations over.

## Someone else's game

Any registered player may continue a game they don't own, as long as it takes other players' changes (`PublicGame.buildable`: a repo with a commit on its latest version, and community runs open). They work on their own copy, publish drafts only they play, and merge through the same merge run and judge as the app's merge card ([Community builds](https://docs.moonspell.app/moonspell/community-builds.md)).

**The rule that shapes it: a contributor's credentials never write the game's repo**, neither its public branch nor anyone's draft. A token for one shared repo can't promise that (GitHub's and Artifacts' tokens are per repo, not per branch), so:

- **The fork is a repo of its own**, one per game and player (the `game_forks` table): `<game repo name>-fork-<12 hex of the player's id>`, private, on the same repo host as the game, holding the game's public branch (or the player's unmerged draft, when they have one) on `main`, never the game's other branches. On GitHub it is a new repo in the same organization seeded by a copy (GitHub can't fork a repo into the org that owns it); on Artifacts it is the binding's own `fork(name, {defaultBranchOnly: true})`, then the same copy when the seed is a draft. The player's machine only ever gets a write credential for that repo.
- **The draft stays where every draft is**, `community/<userId>` on the game's repo, where Nova's draft runs, the merge run and the judge already read it. Only the server moves that branch: when a draft version completes, it copies the fork's commit over plain git smart HTTP (upload-pack from the fork with a read token, receive-pack to the game's repo with a write token, both minted server-side and used once: `copyCommit`, `src/lib/game-repo/smart-http.ts`). No git binary on Vercel, no change to the merge run.

Why not let the merge run fetch the fork instead: Nova's draft runs continue `community/<userId>` and decide whether a draft starts over from what is there, and cancelling and the judge read it too. One home for the draft means none of them changes.

### Who is who

`GET /api/dev/games/:id/access` (any signed-in caller, anonymous included) → `{role, buildable, reason?, game: {id, title, slug}, draft, forked}`:

| `role` | Who | Works on |
|---|---|---|
| `owner`, `collaborator` | the game's own people | its repo (`repo-token`), as before |
| `contributor` | any registered player, on a `buildable` game | their fork (`fork`) |
| `none` | an anonymous account, or a game that can't take changes (`reason` says which) | nothing |

`draft` is a contributor's unmerged draft `{buildId, draft, baseVersion, commitSha, summary, createdAt}` (made with Nova or on their machine), the one a merge would put in. `continueRoleFor` (`fork-rules.ts`) decides; the web's `continueRole` and the CLI's `continueAccess` follow it.

### Routes

| Route | Who | Body → answer |
|---|---|---|
| `POST /api/dev/games/:id/fork` | contributor | → `{remoteUrl, username, password, branch: "main", expiresAt, draft: {branch: "moonspell/draft", commitSha} \| null}`: an hour of write access to their fork, made on the first call (that call counts toward the day's community changes). `draft`: the player's draft as the game's repo has it, copied onto the fork's `moonspell/draft` on every call, so their machine can merge what Nova changed in the app. |
| `POST /api/dev/games/:id/versions` | contributor | the version body plus `draft: true` → `{uploadId, draft: true, expiresAt, uploads}`. The commit must be on their fork (409 until pushed); no Nova run on their draft and no merge of it may be running (409); the day's limit (429); nothing on their draft branch may be missing from the commit (409 `reason: "draft_changed"`, below). Files go to `games/<id>/drafts/community-<userId>/local-<uploadId>/`. |
| `POST /api/dev/games/:id/versions/:uploadId/complete` | contributor | `{gdd?, draft: true}` → `{draft: {buildId, draft, baseVersion, commitSha, summary, createdAt, platforms}, warnings, notice}`. Files and manifests are checked as for a version; then `community/<userId>` moves to the commit (compare-and-swap on its old value); then the draft build is recorded in `recordBuild`'s draft shape (`branch`, the next `draft`, `base_version`, `version` null, `source: 'local'`). From then on only that player plays it, in the feed and on Home in place of the public version, as with Nova's drafts. |
| `POST /api/dev/games/:id/merge` | contributor | `{confirmed: true}` (the app's tick, never implied: anything else is 400) → `{run, message}`. Calls `startMerge` and starts the merge run, as `POST /api/app/games/:id/merge` does. Refused while a draft from their machine is still uploading. |

An owner or collaborator sending `draft: true`, or asking to merge, gets 400 `invalid` (their publish makes the next public version). A `none` caller gets 403 `forbidden` with the reason.

### Drafts from two places

A player may change their draft with Nova in the app and on their machine. Neither loses the other's commits:

- **Machine, then Nova:** the draft branch has the machine's published draft, and Nova's next draft run continues it (it has an unmerged draft, so it doesn't start over) and pushes a fast-forward.
- **Nova, then machine:** a draft from the fork moves `community/<userId>` only when nothing on it would be lost (`draftMove`): the branch doesn't exist (`create`); it is already there (`none`); nothing on it is unmerged (`start-over`, forced, as a draft run started over does); or its head is in the commit's history (`forward`, checked on the fork: GitHub's compare API, or the edge walking every parent on Artifacts). Otherwise it refuses (`refuse`: 409 `draft_changed`, with `draftBranch` and `draftCommit`) and puts the draft on the fork's `moonspell/draft`. `crescent save` merges `moonspell/draft` before every push (a conflict stops it and says how to merge by hand), so the next publish goes forward.
- A publish refuses while a Nova run is changing the draft or a merge of it is running; a merge refuses while a draft from the machine is still uploading.

Saved-but-unpublished commits stay on the fork: a merge takes the last published draft (`crescent merge` says so when the folder has newer commits).

### Limits

The community rules hold: `buildable` games only, a registered account, and the day's limit of 5 community changes per player and game (`contributionBlock`, `contributionsToday`: Nova's draft runs and drafts from a machine together, counted when the fork is first made and at each draft publish). The author can cancel an accepted change as for any other.

### On the machine

`crescent continue <gameId>` asks `access` first. For a contributor it calls `fork`, clones it, lays the local overlay, and writes `.moonspell/game.json` with `fork: true`. The marker never reaches a commit: the file is committed without it first when the game has none, and git is told to leave the local change out (`update-index --skip-worktree`), so a merged draft never makes the game's own repo look like a fork (and `continue`/`link` for the game's own people drop a stray marker). `save` pushes to the fork, merging `moonspell/draft` first; `publish` makes a draft and says that only they play it until they merge it (no share link, no QR code); `crescent merge --confirm` (MCP `moonspell_merge {confirmed: true}`) asks for the merge, and says a judge decides whether it becomes the game's next version. The remote MCP server's `moonspell_start` hands out the same `continue` command for other people's buildable games.

## Not done yet

- No live "new version" event: a local version has no worker run, so open apps see it on their next fetch of the games list or the feed.
- A game backend deployed from the developer's machine, and generated sounds, sprites, models or a new cover on demand.
- A share link for a draft. A draft plays only for its player, signed in, in the app and on moonspell.app.
