Ceilings and fences
What a caller past the gate may actually do — the per-process ceilings, the tenant bucket, the bot and resource fences, the operation allowlist and the document registry.
The gate answers one question: which bots may this session touch. It says nothing about how much of the deployment that session may spend, which operations exist at all, or which ids inside a bot it may name — and behind the proxy there is still one master token reaching one Chatfuel account. Most of the API does not name a bot at all, so most requests pass every fence the gate feeds.
So there is a second set of answers, and none of them is about identity.
| What it bounds | What it refuses | Code |
|---|---|---|
| One process's in-flight work | more uploads, GraphQL requests or sockets than this instance holds | ProxyBusy, a refused upgrade |
| One tenant's share | more requests a minute, or more live sockets, than one workspace gets | TenantBusy, close 4429 |
| The bot fence | a bot that is not the caller's | BotNotAllowed |
| The account denylists | reading or reshaping the Chatfuel account behind the deployment | AccountOperationBlocked, AccountScopeBlocked, AccountStructureBlocked |
| The resource fence | an id inside a bot that was handed out under a different one | ResourceNotAllowed |
| The operation allowlist | a root field no shipped module sends | OperationNotAllowed |
| The document registry | a document this app does not ship | OperationNotInRegistry |
Every default, variable and status behind that table is on environment variables, the proxy and errors.
What one process will spend
A ceiling on one request is not a ceiling on the process: a 25 MiB upload times N costs N times that, and N is whatever the caller opened. So the number in flight is bounded too — separately for uploads and for GraphQL, which the whole app talks through and gets the larger number. A batch is counted again on its own, because upstream it is one POST and here it is one slot and one token from the tenant's minute.
Sockets are the same shape, plus a much smaller ceiling on the sockets waiting to be admitted: one that has shown nothing still holds a place for as long as the init timeout allows, and a small budget there keeps the admitted sockets running while the unadmitted contend for it.
The tenant's share
Those ceilings are the deployment's, which is the whole of the protection while it serves one customer. With the gate on it serves many, and a global ceiling is one a single tenant can spend on everybody else's behalf: 256 sockets is 256 sockets whether they come from 256 customers or from one script. So the same shape again, keyed by tenant — and neither the bucket nor the socket count is a security boundary, because the fences are that.
The key is the fence itself rather than the session: a tenant with four tabs is still one tenant, and the bots they may touch is the only stable name the proxy has for them — which is also why the bucket is felt only where there is a fence to key by. Accounts that have signed up but hold no bot yet all hash to the same nothing, so they share one bucket at a fraction of a tenant's, sized for what they can legitimately do: finish signing up. All of it lives in one process, so what it bounds is one instance's share of a noisy neighbour; the caps that must hold everywhere live in SQL.
The bot fence, and the resource fence behind it
The bot fence is exact for anything that names a bot, and that is where it stops. An operation that names only the account passes every fence there is, so those are refused by name instead — the auth gate has that half.
The other half is ids inside a bot. Upstream authorizes the token, not the bot an id belongs to, so nothing there will resolve one for the proxy. The proxy keeps its own memory instead: every such id reaches a browser exactly once, inside an answer to a request the bot fence had already checked, and the fence writes down which bot it was handed out under. A later request naming an id known to be another bot's is refused before the master token sees it.
bound, the default with the gate on, refuses only what it knows to be foreign, so it cannot turn a
legitimate request away. strict refuses the unknown too. off is the default without the gate,
where there is one tenant and nobody to be foreign to. Two rules make the memory hard to poison: an
id the request itself carried is never learned from that request's answer, and an id seen under a
second bot is marked shared rather than re-owned. A shared id refuses nobody, so the attack costs
a refusal at worst and never a read.
That memory is one process's. A shared store on the deployment's own Supabase gives it a floor,
which is what makes bound survive a restart and strict safe on more than one instance — and the
table is service-role only, because a caller who could read it could ask which bot an id belongs to.
The operations this app sends
The fences above each name something dangerous. The allowlist asks the opposite question: of the thousands of fields in Chatfuel's schema, which does this app actually send? 353 — and every one was already written down, because the modules' own operation documents are what codegen reads. The list is generated from them and the repository's validator re-derives it, so a module that gains an operation cannot ship a feature the proxy refuses.
It is on with the gate and off without it, where the caller is the deployer and refusing them a field of their own schema protects nobody. Turning it off behind the gate is one variable away from the widest opening the proxy has, so it asks to be said twice: the value, and an acknowledgement beside it. Said once, it is ignored and the startup line says so.
The documents this app ships
The narrowest check, and the first one asked. A name check cannot tell one shape of bot { … } from
another: CurrentUser, and a CurrentUser with apiToken added to it, have the same root field, so
the allowlist sees one operation where the registry sees two.
So the app hands the proxy its own generated namespaces — a barrel the wizard writes, one
import * as per module and never export *, which would silently drop a name two namespaces
share. A document is admitted by its exact text, and failing that by a hash of that text with
whitespace, commas and comments stripped, so a bundler that moved a newline is not a production
refusal; a field added, an alias or a different operation name is a different document. What goes
upstream on a match is the app's own text and operation name, read off the document rather than off
the request.
A host that passes none keeps serving with the check off, so an app scaffolded before the barrel existed still boots — and says so on the startup line, because silence there would read exactly like a registry that is holding.
Where it is all announced
Every host prints its resolved configuration once, on one line: the gate, both fences and whether the resource store is shared, the size of the allowlist, the number of documents in the registry, whether the admin panel is mounted, and whether any origin may call. A deployment carrying something left over from a demo says so at every boot.