diff --git a/.claude/launch.json b/.claude/launch.json index ef61bea..0c6c98e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,8 +5,8 @@ "name": "dev", "runtimeExecutable": "bun", "runtimeArgs": ["run", "dev"], - "port": 5175, - "autoPort": true + "port": 5176, + "autoPort": false } ] } diff --git a/.env.example b/.env.example index 5679deb..e1f0b22 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -PORT=5175 +PORT=5176 DATABASE_PATH=./data/airglow.db -PUBLIC_URL=http://127.0.0.1:5175 +PUBLIC_URL=http://127.0.0.1:5176 # Trusted proxy hops, only used as a fallback. In production the rate limiter # prefers Cloudflare's CF-Connecting-IP (the real client IP, not client- # forgeable). This setting only governs the X-Forwarded-For fallback used when diff --git a/CLAUDE.md b/CLAUDE.md index 13821a7..7903efc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,22 +28,40 @@ Directly call `vp`. **NO NEED** to invoke it with `bun`. ## Test and preview -You can start the server with `bun run dev`. -It's possibly already running in an other session or terminal, if that's the case, use the already running one on port 5175. -Always use `http://127.0.0.1:5175/` - -You have two users to test and preview the app - -- alice.pds.dev - - password: localdev - - did: did:plc:mjqzheqksuetwha7s33zmxjp - - is_partner_app: true - - admin: true -- bob.pds.dev - - password: localdev - - did: did:plc:zbxj6esdt3kwmjen5smgzrvq - - is_partner_app: false - - admin: false +### Dev server and port + +- **Your port is 5176.** Always use `http://127.0.0.1:5176/` (use `127.0.0.1`, not `localhost` — the OAuth flow is configured for the loopback IP). The human often runs their own preview on 5175, so leave 5175 alone. +- `PORT` is the single source of truth: it drives Vite's listen port, the PDS-proxy `APP_ORIGIN`, and `config.publicUrl`. Default is 5176 (`.env` + config defaults). Don't hardcode a port anywhere — auth breaks when they disagree. `strictPort` is on, so the server fails loudly rather than drifting to a port that breaks OAuth. +- Start with `bun run dev`, or use Claude Preview (`launch.json` → `dev`, port 5176). +- **If 5176 is taken, it's almost always a stale Claude session. Kill it and restart — never get stuck on this:** + ```sh + lsof -ti:5176 | xargs kill + ``` + +### Cold start (be patient, ~20-30s) + +The port opens within ~1s, but the server is **not actually ready** until the first request finishes compiling the SSR module graph (~15s; the `@atproto` tree is heavy and Vite transforms it on demand). The trap is thinking it's up because the port is open. + +- **Don't trust port-open. Wait for `GET /` to return 200 before testing:** + ```sh + until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5176/)" = "200" ]; do sleep 1; done + ``` +- Don't be surprised if you wait a few seconds after a restart. Just wait — don't start debugging a server that's still warming up. + +### Sign in / out (you can and must do this yourself) + +You have two test users. Switch between them to test authenticated/unauthenticated and admin/non-admin views. + +- alice.pds.dev — admin, is_partner_app: true — did: `did:plc:mjqzheqksuetwha7s33zmxjp` +- bob.pds.dev — non-admin, is_partner_app: false — did: `did:plc:zbxj6esdt3kwmjen5smgzrvq` + +**Sign in:** go to `http://127.0.0.1:5176/auth/login` → fill the handle field with `alice.pds.dev` or `bob.pds.dev` → submit → on the OAuth page click the **"Authorize"** button. It's always the same page and same button — click it blindly, no screenshot needed each time. It may or may not ask for the OAuth password, which is `localdev` for both accounts. Success redirects to `/u/`. + +**Sign out:** go to `http://127.0.0.1:5176/settings/account` → click **"Sign out"** (POSTs to `/auth/signout`). Redirects to `/`. + +**Switch users:** sign out, then sign in as the other. + +**If OAuth doesn't work at all** (the `/auth/login` submit fails to reach an authorize page), the local PDS server (Docker, on `localhost:3000`) is probably not running. Don't get into a debugging loop — just ask the human to start the PDS server. ## Conventions diff --git a/CONCEPTS.md b/CONCEPTS.md index 0933dfa..52d260d 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -22,7 +22,7 @@ The state where a Viewer holds a valid Airglow Session Cookie but has no OAuth S ### Audit event -A row in the auth-events log capturing security-relevant state changes (sign_in, sign_out, oauth_session_created, oauth_session_refreshed, oauth_session_revoked, session_loss_detected, session_reactivated). Each event is a _state claim_: recording `oauth_session_revoked` asserts that the Grant is no longer usable from this DID via Airglow. Audit events are surfaced by `/settings/security`. +A row in the auth-events log capturing security-relevant state changes (sign*in, sign_out, oauth_session_created, oauth_session_refreshed, oauth_session_revoked, session_loss_detected, session_reactivated). Each event is a \_state claim*: recording `oauth_session_revoked` asserts that the Grant is no longer usable from this DID via Airglow. Audit events are surfaced by `/settings/security`. ## Roles diff --git a/lib/config.ts b/lib/config.ts index 887ba62..8649577 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -129,11 +129,15 @@ if (!Number.isInteger(jetstreamMaxConnectionAgeMs) || jetstreamMaxConnectionAgeM ); } +const port = Number(env("PORT", "5176")); + export const config = { - port: Number(env("PORT", "5175")), + port, trustProxyHops, databasePath: env("DATABASE_PATH", "./data/airglow.db"), - publicUrl: env("PUBLIC_URL", "http://127.0.0.1:5175"), + // Default derives from PORT so the OAuth client metadata can never disagree + // with the dev server's listen port. PUBLIC_URL overrides it in production. + publicUrl: env("PUBLIC_URL", `http://127.0.0.1:${port}`), pdsUrl: process.env.PDS_URL?.replace(/\/$/, "") || "", jetstreamUrl: env("JETSTREAM_URL", "wss://jetstream2.us-east.bsky.network/subscribe"), jetstreamCursorFlushIntervalMs: Number(env("JETSTREAM_CURSOR_FLUSH_INTERVAL_MS", "5000")), diff --git a/vite.config.ts b/vite.config.ts index 0ae0b8a..7cdb5d2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -66,7 +66,11 @@ function devCss(): Plugin { } const PDS_TARGET = "http://localhost:3000"; -const APP_ORIGIN = "http://127.0.0.1:5175"; +// PORT is the single source of truth for the dev server (vp/vite honors it), +// defaulting to 5176. APP_ORIGIN must match the actual listen port: the PDS +// OAuth proxy rewrites the issuer to this origin, so a mismatch breaks sign-in. +const DEV_PORT = Number(process.env.PORT ?? 5176); +const APP_ORIGIN = `http://127.0.0.1:${DEV_PORT}`; function getPdsIssuer(): string | null { try { @@ -224,10 +228,13 @@ export default defineConfig(({ mode }) => ({ NSID_BLOCKLIST: "app.bsky.*", }, }, - fmt: { ignorePatterns: ["**/lib/db/migrations"] }, + fmt: { ignorePatterns: ["**/lib/db/migrations", "**/docs/**/*.md"] }, lint: { ignorePatterns: ["**/lib/db/migrations"], options: { typeAware: true, typeCheck: true } }, server: { - port: 5175, + port: DEV_PORT, + // Fail loudly instead of silently drifting to another port — a drifted port + // no longer matches APP_ORIGIN / publicUrl, which silently breaks OAuth. + strictPort: true, host: true, }, plugins: [