diff --git a/.env.example b/.env.example index 81904628..09178554 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ # FOUNDER OS — copy to .env.local and fill in. .env.local is gitignored. +# ── Access gate ────────────────────────────────────────────────────────── +# REQUIRED on any public deployment (Railway etc.). The app has no auth of its +# own; unset = every page and API route is open. Generate: openssl rand -hex 32 +# Leave blank for local dev. +FOUNDER_OS_ACCESS_TOKEN= + # ── Email (up to 4 IMAP inboxes) ───────────────────────────────────────── # Gmail: use an App Password (Google Account → Security → 2FA → App passwords) INBOX_1_HOST=imap.gmail.com diff --git a/README.md b/README.md index 769c0348..38ca24f5 100644 --- a/README.md +++ b/README.md @@ -216,10 +216,41 @@ so they never touch the seeded dev DB. 1. Create a Railway project and point it at this repo. 2. Set the build command to `npm run build` and the start command to `npm start` (the app serves on the `PORT` Railway provides). -3. Add a database service and any connector credentials as environment variables +3. **Set `FOUNDER_OS_ACCESS_TOKEN` before adding any credentials.** Railway + serves the app on a public `*.up.railway.app` URL, and with the token unset + every page and API route is open to anyone who finds it. See + [Access control](#access-control). +4. Add a database service and any connector credentials as environment variables in the Railway dashboard. -4. Deploy. The knowledge services (G-Brain and Optimal Engine) run as companion +5. Deploy. The knowledge services (G-Brain and Optimal Engine) run as companion services and are referenced by URL from the app's environment. +6. Open the deployed URL in a private window and confirm you get the token + prompt, not the dashboard. + +--- + +## Access control + +Founder OS is a single-operator app: it has no user accounts and **no +authentication of its own**. On your laptop that is fine. On a public URL it +means anyone who finds the domain can read connector status, send replies as +you through `/api/comms/reply`, and run any agent through +`/api/agents/[id]/run`. + +The access gate closes that. Set one environment variable on the host: + +```bash +FOUNDER_OS_ACCESS_TOKEN=$(openssl rand -hex 32) +``` + +With it set, `middleware.ts` challenges every page and API route. Enter the +token once (the prompt, or `https://your-app/?token=…`) and an `httpOnly` cookie +remembers that browser for 30 days. Rotating the token signs every browser out. + +The gate is opt-in. **Unset means open**, which keeps local dev and the +read-only public demo working with no setup, so a deployment that holds real +credentials must set it. The decision logic lives in `lib/access-gate.ts`, with +its contract in `tests/access-gate.test.ts`. --- diff --git a/tests/access-gate.test.ts b/tests/access-gate.test.ts index cf3c123b..fe210601 100644 --- a/tests/access-gate.test.ts +++ b/tests/access-gate.test.ts @@ -62,3 +62,31 @@ describe('middleware wiring', () => { expect(src).toMatch(/matcher/); }); }); + +/** + * The gate is opt-in, so the docs are the only thing standing between a + * first-time deployer and a public URL with live keys behind it (issue #2). + * If the deploy steps stop naming the token, the gate may as well not exist. + */ +describe('deploy docs name the gate', () => { + const readme = readFileSync(join(process.cwd(), 'README.md'), 'utf8'); + const envExample = readFileSync(join(process.cwd(), '.env.example'), 'utf8'); + + test('the Railway steps require FOUNDER_OS_ACCESS_TOKEN', () => { + const start = readme.indexOf('## Deploying to Railway'); + expect(start).toBeGreaterThan(-1); + const next = readme.indexOf('\n## ', start + 1); + const railway = readme.slice(start, next === -1 ? undefined : next); + expect(railway).toContain('FOUNDER_OS_ACCESS_TOKEN'); + }); + + test('README has an Access control section saying unset means open', () => { + expect(readme).toContain('## Access control'); + const section = readme.slice(readme.indexOf('## Access control')); + expect(section.toLowerCase()).toContain('unset means open'); + }); + + test('.env.example lists the token so it is discoverable', () => { + expect(envExample).toMatch(/^FOUNDER_OS_ACCESS_TOKEN=/m); + }); +});