Local development #
Running the site and the control plane on a laptop.
You need either Docker (with the compose plugin) or a Rust toolchain plus Node 24. Nothing else, and no AWS account: development uses the loopback OAuth client, so nothing has to be reachable from the internet.
Status. The sign-in flow works — the first real login completed in production on 2026-08-03 (plan/sign-in.md).
The one rule #
Use 127.0.0.1 everywhere. Never localhost.
Two separate mechanisms punish mixing them:
- An ATProto development client's
redirect_urimust be a loopback IP address.http://localhost:3000/oauth/callbackis rejected by the authorization server. (Confusingly, theclient_idfor the same client is literallyhttp://localhost— that part is spec'd the other way round.) - Cookies are scoped by host, and
localhostand127.0.0.1are different hosts. Load the site on one and the API on the other and the session cookie is set on a host the site never talks to. Everything appears to work until the moment you are silently signed out.
astro.config.mjs binds to 127.0.0.1 for this reason.
With Docker #
cp .env.example .env # optional; the defaults are the dev defaults
docker compose up
The API runs under the stock rust:1 image and web under node:24,
both with the repo bind-mounted. The API compiles on start (its cargo cache
lives in a named volume, so later boots are incremental); there is no watcher
— after an edit, docker compose restart api. The published ports are bound
to 127.0.0.1 only.
| site | http://127.0.0.1:5173 |
| api | http://127.0.0.1:3000 |
| health | http://127.0.0.1:3000/healthz |
If your account is not uid 1000, set HQ_UID and HQ_GID in .env —
otherwise files land in the bind mount owned by someone else.
Without Docker #
Two terminals — a Rust toolchain for the API, Node 24 for web (nvm use
reads .nvmrc):
cargo run -p headquarters-api # repo root
cd web && npm install && npm run dev
Outside a container, the API's default bind of 127.0.0.1:3000 is already
correct.
Signing in #
- Open http://127.0.0.1:5173.
- Type a real handle — your own Bluesky handle works.
- You are sent to your own PDS to approve. lance.blue never sees a password.
- You land back on the site signed in, showing your handle and DID.
The loopback flow has been completed end to end; production logins run through the confidential client at api.lance.blue.
Testing the production client shape #
The loopback client skips the two things production does: serving client metadata, and authenticating to the token endpoint with a signing key. To exercise those you need a public HTTPS origin, which a tunnel provides:
cloudflared tunnel --url http://127.0.0.1:3000 # or: ngrok http 3000
Two things to expect. The tunnel hostname changes every restart on the free
tiers, and client_id must match the URL the metadata is served from, so a
new tunnel means a new public URL. And the site is still on plain HTTP at
127.0.0.1:5173 while the API is now HTTPS, which makes the two cross-site
rather than same-site: the session cookie needs Secure and the browser will
refuse to send it back to an HTTP page. Tunnel the site too, or accept that
this mode tests the client metadata and not the cookie.
Tests #
cargo test # repo root
There is no test that performs an OAuth exchange; that needs a fake authorization server, and it is in plan/session-security.md.
When it does not work #
| symptom | cause |
|---|---|
| "Cannot reach the control plane" | the API is not running, or VITE_API_ORIGIN disagrees with where it is |
| signed in, then immediately signed out on reload | the site and the API disagree about localhost vs 127.0.0.1 |
| CORS error in the console | the allowed web origin is not exactly the site's origin, scheme and port included |
invalid redirect_uri at the PDS |
the redirect is on localhost rather than 127.0.0.1 |
| login works, handle shows "unverified" | the handle in the DID document does not resolve back to that DID; expected for some hosting setups, and a real signal |