Web frontend and supporting services for lance.blue
Rust 38%
TypeScript 29%
JavaScript 15%
Module Management System 8%
CSS 7%
Python 1%
Shell 1%
Astro <1%
Dockerfile <1%
<1%

README.md

headquarters #

The control plane for 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/ 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 explains why, and what breaks when it is not.

Pre-commit hooks #

Hooks are managed by prek 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/, 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/ 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; proxy-split adds the proxy, a second binary sharing a library crate. Each deploys as a 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=<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/<branch>-<sha> in infra's site bucket. The bucket name is deterministic, so the script carries it as a default; BUCKET=<name> 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, 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 — 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 — running it, signing in with a real account, and testing the production OAuth shape through a tunnel.
  • 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/<version>/<the path inside the install>, 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/.