diff --git a/CLAUDE.md b/CLAUDE.md index b8b9ec6..da5e192 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ An AI assistant chat app where each conversation is an atproto permissioned spac - `plugins/` holds the shipped plugins. - `docs/architecture.md` is the human-facing architecture overview, and `docs/plugins.md` the plugin author guide. Update them when a change affects what they describe. - `GLOSSARY.md` holds the project's terms, and `docs/adr/` the decisions behind its less obvious choices. Read the relevant ADR before changing what it decided. -- `pnpm dev` runs the server, which serves the web app through Vite, as one process, and `pnpm dev --scan` also loads React Scan in the browser. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. +- `pnpm dev` runs the server, which serves the web app through Vite, as one process, and `pnpm dev --scan` also loads React Scan in the browser. `pnpm preview` builds the web app and runs the production build locally. `pnpm start` runs the production build for a deployment. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. - Local setup: copy `.env.example` to `.env`, fill in the secrets from `pnpm keys`, and put your DID in `ADMIN_DIDS`. Plugins, models, roles, and every other runtime setting are configured in the admin area at `/admin`. - Server tests run on both SQLite and PGlite through `tests/helpers/db.ts`. `tests/helpers/fake-pds.ts` is a fake spaces PDS with real error codes, policies, and signed commits. diff --git a/README.md b/README.md index 3cbc2da..863e343 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ Phase 1: 1. Run `pnpm dev`, or `pnpm dev --scan` to also load React Scan, which outlines components as they render. 2. Open http://127.0.0.1:3000 and sign in with your ATProto handle. + + To try the production build on this machine, run `pnpm preview` instead. It builds the web app, then starts the server in production mode at the same address. + 3. Open Settings > Admin to add your plugins and models, and choose who else can sign in. ## Deploying diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 48b90ff..cc25853 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -1,5 +1,6 @@ import { isValidDid } from '@atproto/syntax' import { z } from 'zod' +import { isLoopbackUrl } from './auth/oauth-client.ts' export class ConfigError extends Error { /** The environment variables at fault. */ @@ -102,9 +103,12 @@ export function loadConfig(env: Record = process.env const values = result.data if (values.NODE_ENV === 'production') { const problems: [string, string][] = [] - if (!values.PUBLIC_URL.startsWith('https://')) - problems.push(['PUBLIC_URL', 'must be HTTPS in production']) - if (values.OAUTH_PRIVATE_KEYS.length === 0) + // A loopback URL runs the production build on this machine, with the loopback client that + // needs no keys. Anything else is a deployment. + const local = isLoopbackUrl(values.PUBLIC_URL) + if (!local && !values.PUBLIC_URL.startsWith('https://')) + problems.push(['PUBLIC_URL', 'must be HTTPS or loopback in production']) + if (!local && values.OAUTH_PRIVATE_KEYS.length === 0) problems.push(['OAUTH_PRIVATE_KEYS', 'is required in production']) if (values.OAUTH_SCOPE_MODE === 'raw') problems.push(['OAUTH_SCOPE_MODE', 'raw is for local development only']) diff --git a/apps/server/tests/config.test.ts b/apps/server/tests/config.test.ts index 3c55f3b..736c5e6 100644 --- a/apps/server/tests/config.test.ts +++ b/apps/server/tests/config.test.ts @@ -77,4 +77,14 @@ describe('loadConfig', () => { }), ).toEqual(['OAUTH_PRIVATE_KEYS', 'OAUTH_SCOPE_MODE', 'PUBLIC_URL']) }) + + it('runs the production build on a loopback URL without keys', () => { + const config = loadConfig({ + ...env, + NODE_ENV: 'production', + PUBLIC_URL: 'http://127.0.0.1:3000', + }) + expect(config.publicUrl).toBe('http://127.0.0.1:3000') + expect(config.oauthPrivateKeys).toEqual([]) + }) }) diff --git a/package.json b/package.json index 5a5209d..c8a69fc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "pnpm --filter @scn-chat/server dev", "build": "pnpm --filter @scn-chat/web build", "start": "pnpm --filter @scn-chat/server start", + "preview": "pnpm build && pnpm start", "test": "vitest run", "typecheck": "pnpm -r --parallel typecheck", "lint": "biome check .", diff --git a/specs/admin-settings.md b/specs/admin-settings.md index 828c9a9..5d39bc6 100644 --- a/specs/admin-settings.md +++ b/specs/admin-settings.md @@ -62,9 +62,9 @@ After this spec, `.env` holds everything outside the database: | `NODE_ENV` | `development` | | | `DATABASE_URL` | `sqlite:./data/scn-chat.sqlite` | | | `SECRET_KEY`, `SECRET_KEYS_OLD` | | Encryption, as today | -| `OAUTH_PRIVATE_KEYS` | | Required in production, as today | +| `OAUTH_PRIVATE_KEYS` | | Required in production, unless `PUBLIC_URL` is loopback | | `ADMIN_DIDS` | | Required, from [[admin]] | -| `PUBLIC_URL` | `http://127.0.0.1:3000` | Must be HTTPS in production | +| `PUBLIC_URL` | `http://127.0.0.1:3000` | Must be HTTPS or loopback in production | | `PORT` | `3000` | | | `DATA_DIR` | `./data` | | | `LOG_LEVEL` | `info` | | diff --git a/specs/auth.md b/specs/auth.md index c28cbec..50079b6 100644 --- a/specs/auth.md +++ b/specs/auth.md @@ -15,7 +15,7 @@ An atproto account is the only identity SCN Chat uses, for spaces and fallback u `apps/server/src/auth/client.ts` creates one `NodeOAuthClient`: - **Production.** A confidential client with `token_endpoint_auth_method: "private_key_jwt"`, `dpop_bound_access_tokens: true`, and a keyset of ES256 keys from `@atproto/jwk-jose`. The client ID is `${PUBLIC_URL}/oauth-client-metadata.json`. The server publishes the metadata at that path and the public keys at `/oauth/jwks.json`. `PUBLIC_URL` must be HTTPS. -- **Development.** When `PUBLIC_URL` is a loopback address, the server uses `buildAtprotoLoopbackClientMetadata` instead. Loopback clients are always public, so development sessions last two weeks at most. +- **Loopback.** When `PUBLIC_URL` is a loopback address, in development or a local production build, the server uses `buildAtprotoLoopbackClientMetadata` instead. Loopback clients are always public, so their sessions last two weeks at most. The OAuth state and session stores are `oauth_state` and `oauth_session` tables, each with a key, a JSON value, and an updated timestamp. Sessions are keyed by DID. diff --git a/specs/foundation.md b/specs/foundation.md index bc23055..fb2753b 100644 --- a/specs/foundation.md +++ b/specs/foundation.md @@ -114,6 +114,7 @@ Vitest runs every package from the root config. Tests live in a `tests/` directo | `pnpm dev` | Runs the server with `node --watch`, serving the web app through Vite in the same process | | `pnpm build` | Builds the web app | | `pnpm start` | Runs the server in production mode, serving the built web app | +| `pnpm preview` | Builds, then runs `pnpm start` on this machine | | `pnpm test` | Runs all tests once | | `pnpm typecheck` | Type checks every package | | `pnpm lint` | Lints and checks formatting with Biome |