The auth gate
Behind the proxy sits one Chatfuel account holding every customer's bot, so two fences decide what a request may name — and both fail closed.
Behind the proxy there is one Chatfuel account, and every bot the deployment serves lives in it. The token that reaches that account reaches all of them. So the question "may this request go through?" is not the same as "may this request name that bot?", and the proxy answers both — separately, and in that order.
Two fences, two questions
| Question | What answers it | What it asks |
|---|---|---|
| May this deployment touch that bot at all? | the workspace fence, always on | Chatfuel, with the master token |
| Does this signed-in person own that bot? | the auth gate, only with the auth module | your Supabase project, with the caller's own session |
They are not layers of the same check. The fence is a property of the deployment and applies with no
auth module installed at all; the gate is a property of the request. Where the gate applies, it
wins — its answer is narrower, because it is about one person rather than one account.
Both answer who may call. What a caller past them may then do — how much of the deployment they may spend, which operations exist, which ids inside a bot they may name — is a different set of questions, on ceilings and fences.
Without the auth module: the deployment fence
A Chatfuel account is a tree — workspaces, each holding bots — and the app lets a person move between the workspaces that account owns. So the fence is asked for rather than configured: the proxy queries the account's workspaces and their bots with the master token and caches the answer, defaulting to 60 seconds. A bot created in the Chatfuel dashboard five minutes ago belongs to the same account as the rest, and nobody has to redeploy for it to work.
The two failure modes are deliberately different. If a previous answer exists and a refresh fails,
that answer is served on for a short retry window — an upstream blip must not lock out an app whose
bots have not changed. If nothing is known yet and the query fails, the request is refused with
ProxyFenceUnavailable, because forwarding a bot id nobody has vouched for is the one thing worse
than an error.
With it: the session gate
Set both Supabase variables and every request must additionally carry the caller's session — a bearer
token on HTTP, an authToken in the WebSocket's connection_init — and every bot that request names
must belong to a workspace that session is a member of.
The proxy verifies nothing itself. It reads two unverified claims, which cost no network: exp, so
an expired token is refused without a round trip, and iss, which must be this project's own
{supabaseUrl}/auth/v1 — a token minted for somebody else's project is refused here for the same
answer PostgREST would give after a round trip. Otherwise it asks your own Supabase project which
bots this caller may open, through cf_my_bot_ids. PostgREST checks the signature; a bad or expired
token comes back as its 401.
That RPC is one rule, written once in the migration and repeated by the two functions the app reads:
a member of the workspace that holds the bot, who either administers the workspace — owner and
admin reach every bot in it — or was granted this one.
The answers map onto codes a module can branch on. No session, or one Supabase rejects, is
AuthSessionRequired. A signed-in caller with no workspace yet that names a bot is
AuthTenantForbidden — a session-level state the app fixes by provisioning, not a permission
failure. A bot that belongs to somebody else is BotNotAllowed. Supabase unreachable is
ProxyAuthUnavailable, never a silent pass. On a WebSocket each of those is a close code instead;
errors is the table.
Both fail closed
Half a configuration is not a configuration. Set one of the two Supabase variables without the other
and every proxied request answers 500 ProxyAuthMisconfigured — the proxy refuses to guess which
half you meant, because both guesses are wrong in a way you would only find out about later. The
same instinct runs through the fence: unknown means refuse.
That is the behaviour to expect from a deployment that is misconfigured rather than broken. It looks like total failure, on purpose. Picking a host covers reading the gate's state off the startup line and the health route before anything else.
How long an answer is trusted
The gate caches by sha256 of the token, never the token itself, and every entry is bounded by the
token's own exp — so a session that has expired is re-asked about rather than answered from a
memory of when it was still valid. A grant is held for 30 seconds by default. A 401 is held for the
same window and a 503 for a second, because an uncached refusal is a free round trip to your
Supabase project for whoever sends the junk token, and a stream of them would make this proxy a way
to point traffic at somebody else's project. Concurrent asks about one session share a single
flight, and misses that reach Supabase are metered per minute, with part of that budget held for
sessions the gate has already admitted.
A just-invited colleague is let in on their next request past the window rather than after a redeploy, and the provisioning route drops that session's entry the moment it attaches a bot — otherwise the app would spend the rest of the window being told its own brand-new bot is not its own.
The window is a real one, and worth knowing rather than hiding: a member removed from a workspace can
keep making HTTP calls for up to that long. WebSockets are stricter in one direction and looser in
another — the socket is gated before it is opened, so an unauthenticated socket never reaches
Chatfuel at all, but the fence is read once, at connect. Somebody removed mid-session keeps their
open socket until it drops. A grant the admin panel takes away is the exception: revoking one
closes the live sockets holding that bot with 4401, which is the client's instruction not to retry
with this session.
One tenant, many bots
The live model is one workspace per account with many Chatfuel bots inside it. Somebody signs up, the server creates their first bot with the deployment's master token, and they can add, rename and delete more. Colleagues arrive by invite into the same workspace, and which bots each of them may open is granted per person — owners and admins reach every bot in the workspace by role, and carry no grant rows at all.
The core of the module's migration is seven tables: profiles, workspaces, memberships, bots, bot
grants, invites and its own migration ledger. Row-level security is on and grants are revoked, with
no policies but two self-only ones on the profile mirror, so the cf_* functions are the entire
read and write surface.
Two constraints carry more weight than they look:
cf_tenants.created_byis unique. That single index is what makes sign-up safe — two tabs or a retried request cannot open two workspaces, and therefore cannot mint two accounts' worth of bots.- Adding a bot is two steps on purpose. The database decides whether the caller may, and reserves a row; then the server creates the bot in Chatfuel and attaches it with the service key. If either half fails the other is undone, so neither side is left holding what the other forgot.
Bot creation is bounded twice, by two SQL functions the browser is never granted, checked before
Chatfuel is ever asked: one caps a single workspace, the other the whole deployment. Underneath
both, the ceiling is Chatfuel's own — every bot of every account is created inside one Chatfuel
workspace, and a full plan answers WorkspaceFull to sign-ups and to "new bot" alike. The numbers
and the variables that move them are on accounts.
What the gate does not answer
Be precise about what this buys, because the gap is not obvious.
The gate is exact for anything that names a bot — the variable, the root field, on HTTP, REST and every WebSocket subscribe frame. Naming somebody else's bot never reaches Chatfuel.
It says nothing about an operation that names a flow, a contact or a conversation instead. Those ids are issued per bot, and the upstream API will not resolve one to a bot for the proxy: behind the master token every bot belongs to the same account. That is the resource fence's question, and it is answered from what the proxy has watched being handed out — see ceilings and fences.
A request that names no bot at all passes every fence. That is why, with the gate on, the fields
that would describe the whole Chatfuel account rather than one workspace are refused outright with
AccountScopeBlocked — listing the deployment's own bots would hand one customer the list of all the
others — and so are the fields that reach the account through a bot the caller does hold: its
workspace's other bots, its Chatfuel team, its API token.
And /admin is outside all of it, with the module or without it. The panel never consults the gate
and never applies the fence, because what it exists to show is the account-wide view they are built
to withhold. Its own door is a password and a signed cookie — the admin
panel.
For the variables that turn any of this on, see environment variables.
State
A pure reducer, a useReducer binding and a throwing context accessor — the whole state convention, with no state library in the app.
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.