Admin
The operator's panel over the whole Chatfuel account behind the token — reached by a password in the server environment, never by an identity, and never from the rail.
What it is
The panel is opened by a password sitting in the server environment, and by nothing else. Not a Chatfuel login: the deployment holds one master token for one account, and asking the operator to sign in to Chatfuel from inside their own app would only prove they hold an account the token already speaks for. Not a Supabase role either: the accounts module is opt-in, a deployment without it has none at all, and an operator with one would be sitting in somebody's workspace.
What it shows is the whole Chatfuel account behind that token — every workspace and bot, one
bot's details and channels, who in this app reaches which bot, and whether the deployment itself
is healthy. The module makes no GraphQL call of its own: every question it asks needs the master
token, so all of them are asked on the server, through routes the vendored proxy adds under
/chatfuel/admin.
Installed as
| Wizard id | admin |
| Install it | npx @chatfuel/wizard --modules admin |
| Selected by | Opt-in — --yes never installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | auth |
| Skill | chatfuel-admin |
| Migration | 0001_admin.sql, written into your app as supabase/migrations/0020_chatfuel_admin.sql — only alongside auth |
Opt-in for the same reason auth is: it needs a credential the wizard has to invent, and one
that a run should not write silently. It is not hidden — it has a real /admin route — but it
is railHidden, which is the module contract's other flag: routed like any other module, and
in the rail like no other module, which is to say never. A test pins that rather than trusting
the comment: railHidden true, hidden falsy, absent from the rail list and from the built nav
groups.
auth is recommended because the Access tab needs the accounts it creates. The admin migration
adds no tables of its own — it reads the ones auth created, and applying it without them
fails, which is why the whole supabase/ directory is written only when auth is selected too.
Every function in it is granted to service_role and to nobody else, because the panel has no
Supabase session at all. The one change it makes to an existing table is dropping the NOT NULL
on cf_bots.tenant_id, so the panel can reserve a bot no workspace has claimed yet.
Routes and views
The view is a path segment; the workspace and the open bot are query parameters. bots is the
default, so it has no segment of its own.
| Path | What it shows |
|---|---|
/admin | Bots: the workspaces on a rail with their bot counts against the plan, one workspace's bots beside it, and a drawer over one bot. |
/admin/access | Who in this app reaches which bot, granted and revoked per person. Mounted only where the deployment has a database. |
/admin/health | Whether the token is still accepted, which fence is in force, whether the database answers, what is missing — and the button that turns scheduled posts on. |
| Parameter | What it does |
|---|---|
w | The workspace selected on the rail. Absent means the first one worth opening. |
b | The bot whose drawer is open. |
Whether the Access tab exists at all is decided on the server — authMode: 'on' plus a
service-role key — rather than guessed from env in the browser, and the header simply does not
draw a tab the deployment cannot back. Health is asked when it is opened, because checking the
token is a real round trip.
The bot drawer carries name (editable in place), bot id, workspace, created, time zone, country, industry, the connected channels, the Chatfuel team on that bot, and what this token may do to it. The health grid is six tiles — Chatfuel token, Account, Bot fence, Sign-in, Database, Publish queue — over four plain lines: Chatfuel API, Token variable, Home workspace, Outbound.
A bot can be created here without naming a workspace. The row is reserved with no tenant, which
means nobody can reach it — every rule joins cf_bots to cf_members through tenant_id — and
the first grant on the Access page settles which workspace it belongs to.
The model
One password, one cookie, two hours. POST /chatfuel/admin/session compares
timingSafeEqual over the SHA-256 of both sides — hashed first so the buffers are always the
same length, because a length check in front of a constant-time compare gives away how long the
real password is. On success it sets exactly one cookie, cf_admin, Path=/, HttpOnly,
SameSite=Strict, and Secure everywhere but plain HTTP to loopback. Every /chatfuel/admin/*
call must carry that cookie and an x-cf-admin header: a form posted from another origin can
send a cookie but cannot set a header.
The cookie's signing key is scrypt over the password, not the password and not an HMAC of it.
The payload is entirely predictable, so anyone who has seen one cookie holds a known plaintext
and its MAC and can guess offline with nothing to throttle; scrypt is what makes each guess
expensive. The salt is per deployment — ADMIN_COOKIE_SALT when it is set, otherwise a digest of
this deployment's own token and Supabase URL — because one salt fixed in the source is one
precomputed table that answers every deployment running this code. The key is derived once per
process, since a KDF per request is a denial of service pointed at ourselves.
Revocation has three levers, and only two hold everywhere. Signing out revokes the sessions
the instance that served it issued, through a watermark in that process's memory — on a host that
answers each request from a fresh instance, no other instance has heard of it. Rotating
ADMIN_PASSWORD invalidates every live session at once, because the key that signed them is
gone. Setting ADMIN_COOKIE_SALT to a new value does the same without changing the password,
which is also what to do when two deployments share a password and must not share cookies.
ADMIN_PASSWORD has a sixteen-character floor, and it exists because the throttle cannot be
relied on everywhere. Wrong-password counters live in one process's memory, and a host that
answers each request from a fresh instance has nothing to share them in — unless the auth
module is installed, in which case the count is also kept in your database (cf_admin_attempts)
and every instance shares one. Installing auth is not enough on its own: that shared counter is
written with SUPABASE_SERVICE_ROLE_KEY, which the proxy treats as optional, so with the module
on and the key unset a stateless host is back to a best-effort count in one process's memory.
Where there is no shared count the length of the secret is what actually stands between the
panel and a guess. Below the floor every route answers 500 AdminMisconfigured and the module
names the reason; unset, the routes are not claimed at all and the host answers its own 404.
Protecting /admin is yours to do, and nothing in the template does it for you. This
password is the only thing in front of the whole Chatfuel account behind CHATFUEL_TOKEN: there
is no second factor, no IP allowlist, no lockout that outlives a process, and no owner to appeal
to if it is guessed. Running the panel on a host the internet can reach is a decision, and the
material above is what you have to make it with.
The name is unprefixed on purpose. A VITE_ one would land in the browser bundle.
The routes deliberately bypass the fences. They never call the proxy's admission sequence,
never consult the auth gate, and never apply the workspace fence. Those exist to keep a request
away from bots that are not the caller's — and this panel's entire purpose is the account-wide
view they are built to withhold. It is the only place in the proxy where a fence is skipped, and
the route module says so at the top. Everything under /chatfuel/admin goes through one entry
point so the session check is impossible to add a route without, and /session is the only route
reached before it — GET and DELETE on it ask for the session like everything else, because a
sign-out moves the watermark and an unauthenticated caller must not be able to sign every admin
out. The panel also answers its own origin and no other, whatever ALLOWED_ORIGINS says.
Chatfuel deletes a workspace when its last bot goes, and nothing warns you. For the
workspace this deployment names that is unrecoverable from inside the app: the id in the
environment would point at something that no longer exists, and on a deployment with sign-up
nobody could create an account again, so it is refused outright and no flag gets past it. Any
other workspace is refused once, with a sentence saying what it costs, and allowed when the
same delete arrives again with force=1. And when Chatfuel cannot be asked which workspace a
bot is in, the delete is refused: a delete that says "try again in a moment" is recoverable and
a workspace that is gone is not.
Two more things about deletes are worth knowing before you write one. Asked about a bot it has
already deleted, Chatfuel answers NotEnoughPermissions — not "no such bot" — so a client that
treats only BotDoesNotExist as "already gone" leaves a half-finished delete that can never
complete. And the order is Chatfuel first, the database second (the opposite of a rename): a row
without its bot is a dead entry in everybody's switcher, while a bot without its row is merely
out of reach and the next attempt finishes the job.
After a create or a delete the panel clears both caches whole: the workspace fence holds its bot set for sixty seconds and the auth gate holds each session's for thirty, so without that a bot the panel just made is refused by the proxy for up to a minute after the screen says it exists. A delete, a grant and a revoke also close the live sockets of the bot they name, because a WebSocket reads its fence once at connect and a cache it will never consult again does nothing for it.
Turning scheduled posts on
The Health tab carries one action: Enable scheduled posts. It writes down where this deployment answers, so the timer beside the publish queue's database knows what to call — and that address cannot be set while the app is being scaffolded, because the app has no name yet.
There is no field to type an address into, and that is the point rather than an omission:
registering records where a credential gets posted every minute from then on, so the address is
PUBLIC_URL on the server and never something a request carries. The button is here rather than
in the publishing module because the row it writes belongs to the whole deployment rather than to
any workspace in it, and a workspace role is the wrong credential for that — every sign-up is the
owner of the workspace it opens. Holding ADMIN_PASSWORD is the question that matches the scope
of the write. The procedure, and everything the database side needs, is
schedule Instagram posts.
Configuration
| Variable | Notes |
|---|---|
CHATFUEL_TOKEN | Secret. Server-side only. |
VITE_CHATFUEL_WORKSPACE_ID | The workspace you picked during the wizard run. |
CHATFUEL_API_BASE | Defaults to https://panel.chatfuel.com. |
ADMIN_PASSWORD | Secret, unprefixed, at least 16 characters. The only thing that opens /admin. Unset means the routes are not mounted; changing it signs every open session out. |
ADMIN_COOKIE_SALT | Optional. The salt the session cookie is signed under. Set it to sign every admin out without changing the password, or to keep two deployments that share a password from sharing cookies. |
TRUST_FORWARDED_FOR | Optional, off by default. Lets the wrong-password counter key on the last x-forwarded-for hop. Only behind an edge that writes that header itself. |
The wizard has three ways to fill ADMIN_PASSWORD — a flag, the environment, or one it generates
— and generated is the default, because the thing being chosen is a secret nobody has to
remember. It is 24 random bytes as base64url, so 32 characters, and deliberately not hex: the
wizard's own log scrubber masks any 64-hex string, and a hex secret would be invisible in exactly
the output somebody needs to read it out of. A flag or an environment value is held to the same
floor as a typed one, before anybody is asked anything. It goes into .env and no other file —
not the handoff, not the agent instructions, not a README, because those are checked into
repositories — and it is printed once at the end, only when this run invented it.
Every variable the app reads, and which side reads it, is on environment variables.
Permissions
| Object | Action | Required for |
|---|---|---|
Bot | View | Listing the account's workspaces and bots, and reading one bot's details. |
Bot | Edit | Creating, renaming and deleting bots. |
These are the token's own permissions, not a person's. There is no identity here to attach them to — whoever can read the environment file already holds the token, so the panel grants nothing the credential did not.
Limits
/admin is the whole way in, before unlocking and after. There is no Admin item in the rail
and there never will be: the rail is the list of places a product's users go, and an item that
appeared once the password was accepted would still be an item anybody could see the moment they
borrowed the machine. Bookmark the URL.
Nothing here ever shows a secret. The health page reports CHATFUEL_TOKEN and the
service-role key as present or absent; a value would make one browser screen as sensitive as the
environment file. The variable's name is on screen, because an operator fixing a deployment
needs to know which name to set. A bot's apiToken is never selected in any query — it opens
that bot's public API.
Two things cannot be done from the panel by design. The admin password cannot be rotated here: a deployment that could rewrite its own environment from a browser would be a worse door than the one it replaced. And there is no session revocation beyond signing out on one instance, because the cookie is stateless — which is what makes it work on per-request functions.
Workspace members are not in the overview query. Asked for Workspace.members, the
accounting subgraph answers InternalServerError for every workspace on the account, and
because the field is non-nullable that one field turns the whole answer into data: null and
the panel loses the workspaces it was drawing. The people on a bot come back fine, and that is
what the drawer shows. A workspace's subscription is asked for in a query of its own whose
failure costs nothing, because the schema the app vendors carries no such field to vouch for.
A bot created outside this app has no row in the database. It can be renamed and deleted here, but the Access page will not list it.
The panel's own numbers:
| Password floor | 16 characters |
| Generated password | 32 characters (24 random bytes, base64url) |
| Session | 2 hours |
| Flat delay on every attempt | 250 ms, right or wrong |
| Wrong answers before the wait starts | 3 |
| The wait | Doubles from 1 second, up to 5 minutes — or 30 seconds where the bucket is shared by everybody behind an edge |
| Throttle entries kept in memory | 1,000; a cf_admin_attempts row means nothing an hour after the last wrong answer |
| Request body | 64 KiB, and 4 KiB on /session; past either, 413 AdminBodyTooLarge |
| Bot id accepted | ^[A-Za-z0-9_-]{1,64}$ |
| Bot name | 120 characters |
| Health token ping | 10-second timeout |
Admin, as a product
The three tabs, the door, and what an operator can and cannot do from inside their own deployment.
Accounts
Recommended alongside this one — the app's own accounts, which the Access tab grants bots to, and the migration that carries this panel's shared attempt counter.
The auth gate
The fence these routes deliberately skip, and what it is doing for every other request.
Your agent's notes
The wizard installs .claude/skills/chatfuel-admin/ (Codex: .agents/skills/chatfuel-admin/) — references/access.md for the door, references/bots.md for the last-bot rule and the cache clears, references/health.md for what the health page may report.
Accounts
Sign-in for your own customers on your own Supabase project — one workspace an account, as many bots as the caps allow, and the proxy turned into a per-bot gate.
Core
The module with no interface — the skill every other module is built on, and the transport, error and pagination rules the whole app inherits.