# 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.

Page: https://sdk.chatfuel.com/docs/modules/admin
Markdown: https://sdk.chatfuel.com/docs/modules/admin.md

## What it is [#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 [#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`                       |

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. Every function in it is granted to `service_role` and to nobody else, because the panel
has no Supabase session at all.

## Routes and views [#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, and what is missing.                  |

| 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.

## The model [#the-model]

**One password, one cookie, twelve 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` whenever the request arrived over TLS. The HMAC key is derived
from the password itself, so nothing is stored and a deployment answering each request from a
fresh instance works unchanged. 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.

**There is no other revocation.** A stolen cookie is good until it expires or the password
changes. Rotating `ADMIN_PASSWORD` invalidates every open session at once, and that is the whole
revocation story — stated rather than hidden, because it is the cost of a stateless session.

**`ADMIN_PASSWORD` has a sixteen-character floor**, and it exists because the throttle cannot be
relied on. 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. On the platform this is most likely
to run on, rate limiting is best-effort and 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.

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. `requireAdmin` runs before every route but `/session`.

<Callout type="warn">
  **Chatfuel deletes a workspace when its last bot goes.** Verified live, twice — 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.
</Callout>

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.

## Configuration [#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. |

The wizard has three ways to fill the last one — 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. 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](/docs/deploy/environments).

## Permissions [#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 [#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, 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 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                              | 12 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     |
| Throttle entries kept                | 1,000                                      |
| Bot id accepted                      | `^[A-Za-z0-9_-]{1,64}$`                    |
| Bot name                             | 120 characters                             |
| Health token ping                    | 10-second timeout                          |

<Cards>
  <Card title="Admin, as a product" href="/modules/admin" description="The three tabs, the door, and what an operator can and cannot do from inside their own deployment." />

  <Card title="Accounts" href="/docs/modules/accounts" description="Recommended alongside this one — the app's own accounts, which the Access tab grants bots to." />

  <Card title="The auth gate" href="/docs/concepts/auth-gate" description="The fence these routes deliberately skip, and what it is doing for every other request." />

  <Card title="Your agent's notes" href="/docs/concepts/agent-skills" description="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." />
</Cards>
