Security
Adding an endpoint
The checklist for shipping a new API route without opening a hole. Four questions, then the gate decides.
Adding an endpoint
Four questions. Answer them before writing the handler, not after the audit fails.
1. Who is allowed to call this?
| Answer | What to write |
|---|---|
| One brand's people | if (!(await requireBrandAccess(slug, req))) return denied(); |
| Any studio operator | if (!(await requireOperator(req))) return denied(); |
| A payment processor | Verify the signature. A shared secret in a query string is not a signature. |
| Genuinely anyone | Go to question 2. |
2. If public — which of the four reasons is it?
Add it to PUBLIC_ROUTES in lib/security/policy.ts with self-capture, signed-webhook, public-content or stateless-compute.
If none of the four fits, it is not public. This is the point of the list: writing the line forces the reason to exist, and the reason is reviewable later by someone who was not in the room.
Watch this one.
NEVER_PUBLICpatterns — anything undercontacts/,admin/,brand/[slug],hq/brand— override the list. Adding one of those toPUBLIC_ROUTESfails the audit rather than taking effect.
3. What does it return?
Name every field. select("*") is how a live third-party API key ended up on the public internet: the column was added to the table months after the route was written, and the route had no opinion about it.
Wrap any response containing brand config in redactSecrets(). It is cheap, and it protects the secret somebody adds next year.
4. What does it write?
- Does it send anything to a human? Derive the recipient from the record, never from the request body.
- Does it move money or change access? It needs an operator, and it needs to be idempotent.
- Does it accept an id from the caller? Confirm that id belongs to the caller before acting on it.
Then run the gate
npm run security:gateExit 0 means every route is either guarded or declared. Exit 1 prints the ones that are neither, and the build stops.
The audit matches on real authorisation primitives — requireBrandAccess, requireOperator, isBrandOwner, a signature check, a bearer comparison. A comment saying the route is safe does not satisfy it, deliberately.
After a route changes shape
npm run security:postureRegenerates the numbers on the public page from the live system. Do this before a release that adds or removes endpoints, so the outward-facing document stays true.