Different Hunger

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:

FileWhat it does
lib/security/guard.tsThe only place that answers "may this caller see this?"
lib/security/policy.tsThe list of routes allowed to be public, each with a typed reason
lib/security/portal-session.tsSigned, expiring, scoped session tokens
scripts/security-audit.mjsWalks 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.

  1. 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.
  2. Portal session — someone who passed a brand's password gate. Scoped to that one brand, expires in 30 days.
  3. Service tokenINTERNAL_API_TOKEN for 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/sweep read if (secret && ...) and therefore ran unauthenticated for as long as CRON_SECRET was 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

ClassExamplesRule
SecretAPI keys, signing secrets, bank detailsServer-only. Never in a response, even to an authorised reader — redactSecrets() strips them by shape.
PersonalContacts, emails, phones, call recordsOwner or portal session only. Never on a public route, never as an identifier in a public payload.
Client-confidentialBrand config, blueprints, scans, plansScoped to that brand's owner.
PublicMarketing pages, published results, offer pagesAnything 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 session
  • public-content — already rendered on a public page
  • stateless-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.