Security
Security
The operating rules for access, secrets and data in the 0HR platform. Internal SOP — the outward-facing summary lives at 0hr.app/security.
Security
This is the SOP. It tells an engineer or an AI session how to build without opening a hole. It is not the document you send a prospect — that is 0hr.app/security, which states the same controls in the language a procurement reviewer reads.
Split locked by mk on 2026-09-02: one public page, one internal doc, two audiences.
The rule
Public is an exception that must be argued for.
Everything else follows from that sentence. An endpoint is unreachable unless somebody decided otherwise, in writing, with a reason. There is no "I forgot to add the check" failure mode, because forgetting produces a build failure rather than an open door.
Why it is code, not a policy document
We wrote the same leak three times. /api/hq/brands, /api/brand/[slug] and /api/hq/brand/[slug] each returned a brand's whole config to the public internet, and fixing the first left the other two live. A written rule is obeyed by whoever read it and silently ignored by the next route somebody adds.
So the policy is executable:
| File | What it does |
|---|---|
lib/security/guard.ts | The only place that answers "may this caller see this?" |
lib/security/policy.ts | The list of routes allowed to be public, each with a typed reason |
lib/security/portal-session.ts | Signed, expiring, scoped session tokens |
scripts/security-audit.mjs | Walks every route and fails the build on anything undecided |
The three doors
There are exactly three ways to be authorised. Nothing else is accepted, anywhere.
- Owner session — a studio owner who signed in with Supabase. Remembered for 90 days by a signed cookie so the operator is not re-prompted daily.
- Portal session — someone who passed a brand's password gate. Scoped to that one brand, expires in 30 days.
- Service token —
INTERNAL_API_TOKENfor server-to-server calls that have no cookie to send.
Non-negotiables
- Fail closed. If a key is missing, refuse. Never fall through to allow.
/api/brand-os/sweepreadif (secret && ...)and therefore ran unauthenticated for as long asCRON_SECRETwas unset. - Never trust a caller-supplied recipient. If an endpoint sends an email or releases a secret, derive the destination from the record, not the request body.
- Never return a whole row.
select("*")on a table with a config column is how a live API key reached the public internet. Name the fields. - Never store a credential as a session. The portal cookie used to be the password. A session token is signed and expiring; a password is neither.
- Constant-time comparison for anything secret.
===on a token leaks it through timing.
Data classification
| Class | Examples | Rule |
|---|---|---|
| Secret | API keys, signing secrets, bank details | Server-only. Never in a response, even to an authorised reader — redactSecrets() strips them by shape. |
| Personal | Contacts, emails, phones, call records | Owner or portal session only. Never on a public route, never as an identifier in a public payload. |
| Client-confidential | Brand config, blueprints, scans, plans | Scoped to that brand's owner. |
| Public | Marketing pages, published results, offer pages | Anything here is on the internet forever. Assume a competitor reads it. |
Watch this one. An identifier counts as personal data if it is personal data. The public results board used member email addresses as React keys — technically an id, actually 23 real addresses on a public endpoint.
What is deliberately public
41 endpoints are reachable without a session, each carrying one of four reasons in policy.ts:
self-capture— an anonymous visitor submitting their own details (checkout, intake, lead capture)signed-webhook— verified by a third-party cryptographic signature, not a sessionpublic-content— already rendered on a public pagestateless-compute— maths with no stored data behind it, like a price quote
If a new route does not fit one of those four, it is not public. Guard it.