Access control
What the audit found, 20 Aug 2026
Alex asked for the code to be secure and not callable from anywhere. The audit found the portal had no authentication of any kind. Thirty-one API routes, publicly reachable. A probe from an unauthenticated machine read:
- the full client list with database ids
- all 51 WhatsApp group names, plus the office phone number
- the Meta ad-account-to-client mapping
and a PATCH /api/deals was accepted and processed — it returned 404 only because the id in the probe did not exist. A real id would have been written by an anonymous caller.
Nothing suggests this was exploited. It was open for about four days.
The shape of the fix
One gate, in src/proxy.ts (Next 16 proxy convention, formerly src/middleware.ts), in front of everything. A check per route is a check somebody forgets on the thirty-second route; this one protects a new endpoint by existing.
Two credentials, because there are two kinds of caller:
| Who | How | |
|---|---|---|
ATLAS_TEAM_PASSPHRASE | a person in a browser | exchanged once at /login for an HMAC-signed httpOnly cookie, 30 days |
ATLAS_API_TOKEN | scripts, cron, the smoke test | Authorization: Bearer … on every request |
Browsers are redirected to /login. API callers get a bare 401 — an error that explains what was missing is a hint.
Only /api/health stays public, for the container healthcheck; it returns a status word. /api/health/deep is *not* public, because naming which subsystem is failing is reconnaissance.
Details that are free now and awkward later
- Constant-time comparison, so a signature does not leak its prefix through response timing.
- A fixed 600ms delay on a failed login, so the endpoint measures nothing.
- Identical responses for a wrong passphrase and an unconfigured one.
- Fail closed: if
ATLAS_SESSION_SECRETis missing or under 32 characters, verification throws and the middleware denies. A misconfiguration locks the door rather than removing it.
This is deliberately not real auth
There is no user table, no per-person identity, no roles. It is the smallest thing that closes an open door, and it is written so that replacing it means deleting src/lib/auth.ts rather than unpicking it. Real accounts arrive with auth proper — whose trigger is the employee counterpart layer (see vision.md), because a counterpart without a verified person is a toy.
What that means in practice: the portal knows someone is on the team, not which teammate. The name pills on /today are a filter, not an identity. Do not build anything that assumes otherwise.
How a code change is verified
- The repo is private, one collaborator, zero forks.
- Both droplet deploy keys are read-only — the server can fetch, never push.
ci.ymlruns typecheck, lint, the 78 tests, and the build on every push and PR. It did not run the tests before today, which meant the eighteen that assert the auth gate had never once executed in CI. It also ran red on lint for weeks, unnoticed — a permanently red gate is not a gate, so the sixteen errors were cleared.- CI additionally asserts things a unit test cannot, because they are about what is *absent*: the middleware still exists and still refuses; no write verb has appeared in the WhatsApp transport; nothing credential-shaped is committed; no env file is tracked.
deploy.shasserts the gate as part of the deploy contract — an unauthenticated/api/clientsmust answer 401 or the deploy fails. A change that silently removed the middleware would otherwise look like a successful deploy.deploy.ymlis now manual only (workflow_dispatch). It never worked —DEPLOY_HOSTwas never set, so every run since the repo began failed in ten seconds, which is the sole reason its bug never caused damage: itcd'd to/srv/atlas-portalregardless of branch, so a push todevwould have reset the production directory to the dev branch. Fixed, and no longer triggered by a push, because auto-deploy-on-push is the opposite of a change gate.
What is still open, stated plainly
- Branch protection needs GitHub Pro on a private repo (~$4/month). Without it, CI can report but cannot *block* a push to
main. This is the one purchase that would make the gate binding. - The credentials sit in the droplet's environment. Anyone with shell access can read them and call any API directly. Every guard here binds this codebase, not the credential.
- One shared passphrase means no attribution and no individual revocation: removing one person's access means rotating for everyone.