# headquarters The control plane for [lance.blue](https://lance.blue), and the home for architecture documentation that spans more than one repo. Two pieces live here: the **static site** the public sees, and the **control plane** — a Cargo workspace of Rust services under `services/` — that holds ATProto OAuth sessions, launches matches and proxies them to the browser. > **Status.** Signing in with an ATProto account works in production, a > signed-in player can launch a match against Princess and play it through in > the browser, and two players can play each other. What is missing is the > durable half: camo records aside, nothing is written to anyone's PDS — no > match result. [plan/](plan/) is what is being built and roughly in what > order, and which parts of it are shipped. ## Quick start docker compose up # then http://127.0.0.1:5173 Or, with a Rust toolchain and Node 24 and no Docker, in two terminals: cargo run -p headquarters-api # repo root cd web && npm install && npm run dev Front-end work needs neither: cd web && npm run dev:local-auth That signs in for real — the actual OAuth flow, against the player's actual PDS, ending up with a real DID — but runs it from the browser and keeps the tokens in this tab instead of in a session on headquarters-api. So the site can be worked on with no API running at all. It is a development mode only, and not a preview of a design choice: the real client keeps tokens server-side because a match outlives the tab that started it, and the control plane has to publish a result after the player has closed the page. Loopback clients also get refresh tokens lasting about a day, and no silent sign-in. It cannot reach production — the guard is `import.meta.env.DEV`, which is a literal `false` there, so the branch and the OAuth client chunk are both eliminated. Development needs no AWS account and no public hostname — it uses ATProto's loopback OAuth client. The one thing to know is that everything must be `127.0.0.1` and never `localhost`; [docs/local-dev.md](docs/local-dev.md) explains why, and what breaks when it is not. ## Pre-commit hooks Hooks are managed by [prek](https://prek.j178.dev/) and configured in `prek.toml`. One-time setup: uv tool install prek prek install --prepare-hooks They then run on every commit; `prek run --all-files` runs them by hand. `prek install` writes two shims, `pre-commit` and `commit-msg`. Anyone who installed before the second one existed needs to run it again — a missing `commit-msg` shim fails open, so the scope check below silently does nothing. ### Commit scopes A commit's Conventional Commits scope is an **epic id** from [plan/](plan/), so that a commit can be found from the epic it belongs to: feat(camo): serve a saved camo into the launch manifest `scripts/check-commit-scope.sh` enforces it at commit-msg time, against both the filenames in `plan/` and the ids in `plan/README.md`'s register — an epic that is only a row so far is still a valid scope. A commit with no scope passes, and so do the few scopes that are deliberately not epics; the script lists them and says why. The component scopes this repo used before epics (`web`, `api`, `matches`) still pass, and should shrink to nothing. ### The plan `scripts/check-plan.py` checks the shape of [plan/](plan/) whenever a file in it changes. Frontmatter carries exactly `id`, `title`, `status`, `repos`, `dependsOn` and `exitCriterion`; `id` equals the filename; `status` is one of `shipped`, `open`, `blocked` or `continuous`; every `dependsOn` names a real epic; every epic is linked from `plan/README.md`; and every relative link resolves from the file it is in. It also holds the `## Done` invariant: one Done section, last in the file, with no `- [x]` above it and no `- [ ]` below — which is what makes "is this epic finished" answerable by looking. An epic in `plan/complete/` has nothing open at all. There is no `order` key, and reintroducing one fails. This is shape only, and it is checked at commit time because the frontmatter is read as an Astro content collection: without it a malformed file surfaces as a failed site build instead. ## Layout | | | |---|---| | `web/` | the static site. TypeScript and Astro. The app is still one page with a hash router and no framework in it; Astro prerenders the pages around it. Builds to `web/dist`, deployed to S3 + CloudFront (owned by `infra`). | | `services/` | the control plane's Rust services; the workspace `Cargo.toml` sits at the repo root. `services/api` (crate `headquarters-api`, axum) is the API binary; the open [proxy-split](plan/proxy-split.md) epic plans a second binary sharing a library crate, but today both live in one container. | | `compose.yaml` | local development for both, on loopback | | `plan/` | what is being built, one file per epic. The register is `plan/README.md` | | `docs/` | design documents, including contracts with other repos | | `lexicons/` | copies of the ATProto schemas this repo writes records against. Not edited here — see below | The site is static because it can be: it holds no secret and makes no privileged call. Everything privileged is an HTTP call to headquarters-api, which is where the OAuth sessions against players' own PDSes — and therefore the interesting part — live. The deployable image is `services/api/Dockerfile`. `compose.yaml` does not build it — development runs stock images against a bind mount, so the image stays a production artifact instead of drifting into a development environment that happens to ship. ## Lexicons `lexicons/` holds a copy of each schema this repo writes records against, one file per NSID in a directory per segment: `lexicons/blue/lance/camo.json` is `blue.lance.camo`. They are authored in the `lexicons` repo and never edited here. Copies rather than a submodule because that is what the tooling does. A schema is published as a record in its authority's repository, and `@atproto/lex` fetches one by NSID, resolving it through the `_lexicon` DNS record: npm --prefix web run lexicons:update # re-resolve and rewrite lexicons/ npm --prefix web run lexicons:check # installed copies against the manifest CIDs npm --prefix web run lexgen # regenerate the TypeScript `lexicons.json` is the manifest: for each NSID, the at-uri it came from and the CID of the record. That is the provenance — a copy either hashes to what the authority published or it does not — and `lexicons:check` is what says so. One caveat worth knowing: plain `lex install` trusts whatever is already in `lexicons/` and pins *its* CID rather than re-resolving. Always update with `--update`, which the script above does. `@atproto/lex` owns these files here. The `lexicons` repo writes them in goat's form instead, which differs — goat strips `$type` and sorts keys, so the two tools disagree on bytes and a manifest written from one will not verify the other's file. Neither is wrong; the authority repo uses the authoring tool and this one uses the consuming tool. `goat lex lint` reads both, which is why the commit hook still runs it. `web/src/lexicons/` is generated from these files and is not committed. Every build, dev server and typecheck regenerates it first. ## Deploying AWS_PROFILE= scripts/deploy.sh Deploys the current commit: builds and pushes both artifacts — the API image and the site — then writes the release id they were named after into `releases.api` and `releases.site` in infra's `envs/lance.blue/releases.auto.tfvars.json`. `INFRA_DIR` overrides where that checkout is; the default is the sibling directory. It refuses to run unless `AWS_PROFILE` is set and this repo is clean — a release id names a commit, and a dirty tree names one nobody can rebuild. Any branch can be deployed, because the branch name is part of the release id and what is serving stays legible. On `main` it also refuses unless HEAD is exactly `origin/main`, neither ahead nor behind: that is the one branch a build off it is expected to match. Whether the infra checkout is clean is not checked: arena writes its own key in the same file, so an uncommitted bump there is normal. What it leaves behind is one uncommitted change in infra, and applying that is the deploy — the apply flips CloudFront to the new prefix, invalidates the cache, and moves the API service to the new image tag. The script prints the plan and apply commands and runs neither. Either half also runs on its own, below. ## Deploying the site web/scripts/push.sh The script builds `web/` (`web/scripts/build.sh`, which also runs on its own) and pushes it to `releases/-` in infra's site bucket. The bucket name is deterministic, so the script carries it as a default; `BUCKET=` overrides it. Nothing serves the new build until infra's `site_origin_path` points at that path and CloudFront is invalidated; the script prints both steps. Pushing and serving are separate on purpose — a push is safe from any laptop, and flipping the path is an infra change with an infra review. ### Publishing the blog to ATProto ATP_APP_PASSWORD=... web/scripts/publish.sh The last step, once the release is confirmed serving — not part of the push. It writes the blog's posts to lance.blue's own repository as standard.site records with [sequoia](https://sequoia.pub), configured by `web/sequoia.json`. A document record carries the post's address, so the address has to answer first. The script refuses to publish a post the live site is not already serving, comparing the page's canonical link rather than its status — a missing page comes back as the app's own index.html with a 200. `--dry-run` runs that check and stops. Publishing writes an `atUri` into each post's frontmatter, and the link tag is rendered from it, so a new post's tag only appears in the *next* build: commit, deploy, publish, commit the `atUri`, deploy again. A post that already has a record needs one deploy, not two. The publication itself was made once, by hand: `sequoia init` created the `site.standard.publication` record, wrote its at:// URI into `sequoia.json`, and wrote the same URI to `web/public/.well-known/site.standard.publication`, which is what proves the domain owns the publication. Both are committed, and nothing rewrites them. ## Building the API image services/api/push.sh The script builds `services/api/Dockerfile` for arm64 — the API runs on Graviton — and pushes it to infra's ECR repository, tagged with the branch and commit sha. `services/api/build.sh` is the build alone, without the push. The Dockerfile cross-compiles: its build stage always runs on the building machine's own architecture and targets arm64 from there, so an x86 machine needs no QEMU setup and compiles at native speed. `PLATFORM=linux/amd64` builds an x86 image instead — useful for running the production image locally on an x86 machine. ## Documents - [docs/identity-and-sessions.md](docs/identity-and-sessions.md) — how login works, why the tokens live on the server rather than in the browser, and what the session cookie is and is not. - [docs/local-dev.md](docs/local-dev.md) — running it, signing in with a real account, and testing the production OAuth shape through a tunnel. - [docs/launch-manifest.md](docs/launch-manifest.md) — the contract between headquarters and a match container. Both sides implement it now; the manifest builder is golden-tested against this document. Design documents live here rather than in the repo that happens to implement them first, so a contract between two components has one home instead of two copies. ## The repos | repo | what it is | |---|---| | `headquarters` | control plane: launches matches, holds OAuth sessions against players' own PDSes, serves the AppView | | `arena` | the match container — MegaMek plus Suramadu, one game per image | | `infra` | AWS infrastructure, including the ECR repository arena publishes to | | `lexicons` | ATProto lexicon definitions | | `spike` | the original feasibility work, kept for reference | ## Licensing Non-commercial fan project. headquarters is the piece that must stay a separate process from the match container: Suramadu is AGPL-3.0 and injects into the application JVM, and keeping the control plane at arm's length is what keeps it outside that licence's reach. The full reasoning is in `LICENSING.md` in the arena repo. **Third-party assets in the site.** MegaMek's unit sprites are used under **CC BY-NC-SA 4.0**, the licence its whole `data` tree ships under. No unit art is copied into this repository at all: a release's `data/images/units/` is held verbatim in the assets bucket and served at `/assets/megamek//`, which `web/src/megamek.ts` builds and `web/src/scenario/unit-art.ts` names. The camo editor's preview targets, the scenario preview's art, and both halves of a match report - the page and the drawn card - are fetched that way. A machine with no art of its own is drawn with MegaMek's own `defaults/default_*.png` for its class, which comes down the same pipeline rather than from here. Still committed here, from the same tree and under the same licence: `web/src/camo/backgrounds/` (four terrain tiles), `web/public/megamek-camo/` (41 camo patterns, built by `web/scripts/megamek-camo.mjs`) and `web/public/eras/` (eight era icons, the last of them `civil-war.png`, redrawn white at 64px from `data/images/universe/era_civilwar.png` in the pinned arena image) come from the same tree under the same licence, credited in `web/src/content/about.json`. The faction palettes in `web/src/camo/palette.ts` are measured off those camo files by the same script, so they are derived from the art and covered by the same terms. `crates/mms/tests/corpus/` holds the 48 scenario files from the same release, copied verbatim, and is what says the `.mms` reader reads the real format rather than a subset of it. That obligates us to three things, all of which already hold: stay non-commercial, attribute MegaMek (done in the editor UI and here), and share alike. Note this is an asset licence and not a code copyleft — it covers those files and anything derived from them, not the rest of `web/`.