# 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](../plan/complete/sign-in.md)). ## The one rule **Use `127.0.0.1` everywhere. Never `localhost`.** Two separate mechanisms punish mixing them: 1. An ATProto development client's `redirect_uri` must be a loopback **IP address**. `http://localhost:3000/oauth/callback` is rejected by the authorization server. (Confusingly, the `client_id` for the same client is literally `http://localhost` — that part is spec'd the other way round.) 2. Cookies are scoped by host, and `localhost` and `127.0.0.1` are 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 1. Open http://127.0.0.1:5173. 2. Type a real handle — your own Bluesky handle works. 3. You are sent to your own PDS to approve. lance.blue never sees a password. 4. 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](../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 |