Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
28 changes: 28 additions & 0 deletions tests/access-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading