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/.