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.
What it is
This is the module that turns the app into a product other people sign up to. Every account that signs up gets a Chatfuel bot of its own, and can create more — all of them made by this app's server with your master token, inside one workspace of their own that they never learn is one of many. Colleagues arrive by invite and join the inviter's workspace instead of getting one of their own.
Sign-in is Supabase Auth — email and password — on your Supabase project, whose data and keys are yours. The Chatfuel token never leaves the server; what reaches it is the proxy, which has become a gate.
Installed as
| Wizard id | auth |
| Install it | npx @chatfuel/wizard --modules auth |
| Selected by | Opt-in — --yes never installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | — |
| Skill | chatfuel-auth |
| Migration | 0001_auth.sql, written into your app as supabase/migrations/0001_chatfuel_auth.sql |
Opt-in because it needs credentials the wizard cannot invent: a Supabase project. --yes
installs every other ready module and skips this one with a line saying so; name it explicitly
to get it.
It is also hidden, which in the manifest means exactly one thing: no rail item and no
/auth route. It contributes a host integration instead — the shell wraps itself in the
module's Gate, puts the module's item in the user menu, and hands it the routes below. What it
changes is who may reach everything else.
The migration is one file and it is the whole schema — every table and every cf_* RPC,
idempotent and re-runnable. The supabase/ directory exists in your app only when this module is
selected, and every other module's SQL lands in it after this one in name order. There is nothing
to seed: a workspace appears when somebody signs up.
Routes and views
Eight routes, and none of them is /auth. The shell knows these first path segments are the
module's and never treats them as module ids.
| Path | Rendered |
|---|---|
/sign-in | Instead of the shell |
/sign-up | Instead of the shell |
/invite/<token> | Instead of the shell |
/forgot-password | Instead of the shell |
/reset-password | Instead of the shell |
/auth | Instead of the shell — the callback a mailed link lands on |
/no-access | Instead of the shell |
/team | Inside the shell chrome |
team is the only one in IN_SHELL_ROUTES, and it is opened from the avatar menu rather than
from the rail — and only for an owner or an admin. It is one page: members and invites in a
single table with a Bots column that opens each person's access and a Reset password link
action in the row menu, a Bots table (new, rename, delete), Invite people, and a danger zone
holding leave and transfer ownership.
Everything here is written app-relative, because only the shell knows where the app is mounted.
The two links that have to be absolute are the two that get mailed — an invite and a recovery
link — and the runtime hands those the mount point. A returnTo is app-relative too, is
rejected if it names another origin, and never bounces back into an auth screen.
The model
One workspace per account is a database constraint, not a convention: cf_tenants.created_by
is unique, which is also what makes sign-up safe from two tabs. At most one owner is a
partial unique index on (tenant_id) where role = 'owner'. A bot is a row in cf_bots whose
bot_id is unique and null for the seconds between the row existing and Chatfuel answering
with a bot; the gate never counts such a row.
| Role | What it is |
|---|---|
| Owner | One per workspace. Cannot be removed, demoted, or leave without transferring first. |
| Admin | Manages members, invites and bots, and reaches every bot without a grant. Acts only on people below them: cannot touch the owner row or a fellow admin. |
| Member | Uses the bots they were granted, on the Team page or by the invite that brought them in. |
Reaching every bot and administering the team are the same pair of roles today and are kept as
two separate rules, in the SQL and in lib/roles.ts, because they are different questions.
Owners and admins hold no grant rows at all — their access comes from the role, and listing
it would read as something revocable. Every RPC that acts on a person — change role, remove,
issue a recovery link — refuses a target who is not strictly below the caller, because an admin
who could demote a peer could then reset that peer's password.
Every customer's bot comes out of one Chatfuel workspace. CHATFUEL_WORKSPACE_ID names it,
the wizard asked which one, and its plan pays for all of them. Adding a bot is two steps: the
caller reserves a row with cf_new_bot, as themselves, so the database decides whether they may;
then the server creates the bot in Chatfuel with the master token and names it to the row with
cf_bot_created, a function granted to service_role and nobody else. If either half fails the
other is undone. Plain createBot is not a fallback: it puts each bot in a throwaway workspace
with a limit of one and nobody's plan behind it. A deployment without CHATFUEL_WORKSPACE_ID
refuses to provision at all.
Two ceilings sit inside cf_new_bot, before Chatfuel is ever asked, and this page is where
their numbers live:
| Function | Default | Set at install by | What it bounds |
|---|---|---|---|
cf_bot_cap() | 20 | CHATFUEL_BOT_CAP | Bots in one workspace — the ceiling a person meets. |
cf_bot_total_cap() | 200 | CHATFUEL_BOT_TOTAL_CAP | Bots across the whole deployment — the ceiling that actually bounds your bill, because a per-workspace ceiling says nothing about how many workspaces there are. |
Both are one-line SQL functions in your own database. The two variables are read once, by the
wizard run that installs the module, and substituted into the migration; they are never written
into .env and the app never reads them. Afterwards the only thing that changes a cap is
re-running its create or replace with another number in the SQL editor. Past either one the
database answers PT429, which the proxy hands on as 429 BotLimitReached. Neither is sized
against the Chatfuel plan: when the workspace's own botsLimit runs out, sign-ups and New bot
alike answer 409 WorkspaceFull, and raising the caps past the plan only moves the refusal to
Chatfuel.
A delete is fenced twice, and both fences answer 409 LastBotInWorkspace with one sentence. A
workspace may not delete the bot it can still open — nothing openable is the state provisioning
exists to end, and the app answers it by asking for another bot on your plan — and nobody may
delete the last bot in CHATFUEL_WORKSPACE_ID, because Chatfuel deletes a workspace when its
last bot goes and that would take sign-up away from everybody.
Two trust boundaries do the work, and they answer different questions:
| Question | Mechanism | |
|---|---|---|
PostgREST + the cf_* RPCs | What may this user do inside the workspace? | security definer functions; the tables have RLS on with no policies, so the RPCs are the entire read/write surface. A missing capability is a missing RPC, never a new table policy. |
| The proxy gate | May this request reach Chatfuel at all, for this bot? | The proxy calls cf_my_bot_ids with the caller's JWT and fences every request against the answer. |
The second one is the isolation boundary, because behind it there is a single Chatfuel account
holding every customer's bot. The fence is exact for anything that names a bot — botID in the
variables, the root bot(id:) field, every WebSocket subscribe frame — and answers 403 BotNotAllowed without the request ever reaching Chatfuel. Reads that reach for the account
(currentUser beyond the fields that name the bot or resource they answer about, and botsV2)
are refused with 403 AccountScopeBlocked, precisely because they would hand one customer the
list of all the others. An operation that names a flow, a contact or a conversation instead of a
bot is fenced by what the proxy has watched being handed out (403 ResourceNotAllowed) — that
fence and its modes are on the auth gate. An empty set is still
ok: true: signed in, no workspace yet — the state the provisioning route exists to leave.
The gate caches its answer for 30 seconds per sha256(jwt), and it runs on a WebSocket at
connect time rather than per frame. So a member you just removed keeps working over HTTP for
up to half a minute, and keeps an already-open subscription until that socket drops. Every
socket carries a deadline of its own — the session JWT's exp or an hour, whichever comes
first — after which it is closed with 1012 and the client reconnects to be gated again; the
admin panel closes the live sockets of a bot it deletes or moves.
Sign-up, and who may
Sign-up is the form every SaaS has, and what makes this one different is what follows it: the account gets a workspace, and the server creates a Chatfuel bot for it under your master token, on your plan. Who may open an account is the Supabase project's own Allow new users to sign up toggle, and the wizard asks about it once, on a project it creates:
--signup | What the wizard asserts on the new project |
|---|---|
open | Sign-ups on, addresses taken on trust (mailer_autoconfirm). The default, because a fresh project has no SMTP and a confirmation mail nobody receives is a sign-up that never finishes. |
confirm-email | Sign-ups on, and a new account waits for a link. Needs mail that arrives. |
closed | The sign-up form is off; you create accounts in the Supabase dashboard, and an invite link joins an existing account to a workspace. |
On a project you brought, the wizard writes none of the three, and a later run on any project
leaves them alone — a closed sign-up stays closed. Under open nothing proves an address exists,
let alone that the person typing it owns it, and the first account through the door owns the
workspace; the server says so on its startup line when sign-ups are open, and again when
confirmation is off. To restrict which accounts get a workspace rather than whether anyone
may sign up, put a check inside cf_claim_workspace in a migration of your own.
Password recovery
Forgot password cannot deliver mail until you configure SMTP on the project. Until then an
owner or admin issues a link from the Team row menu, and the link never appears on their screen:
POST /chatfuel/auth/recovery-link answers { "delivered": "server-log" } and writes the URL to
the server log as one greppable RECOVERY-LINK line. That link is the account it names until
it is used, and a log is read by more people than the route admits, so the write is opt-in:
without AUTH_RECOVERY_LINK_LOG=true the route mints nothing and answers 501 RecoveryLinkNotEnabled, which the Team page shows as "Password reset links are not enabled on
this deployment". The link is built from PUBLIC_URL and nothing in the request; without it the
route answers 409 ProxyPublicUrlMissing.
Who may be named is the database's decision, not the route's. cf_recovery_authorize runs as
the caller and requires three things: the target is a member of the caller's workspace, ranks
strictly below them, and belongs to no other workspace on this deployment — a link resets an
account, not a membership, so a target who also stands elsewhere would carry this workspace's
admin into one they were never admitted to. Every issue leaves a row in cf_recovery_events,
readable by the workspace's admins and by the person it names, because a log is not a record of
who asked.
Configuration
| Variable | Notes |
|---|---|
VITE_SUPABASE_URL | Your project's URL. Both sides. |
VITE_SUPABASE_ANON_KEY | The publishable key. Both sides. |
SUPABASE_SERVICE_ROLE_KEY | Secret, and not optional under auth: it registers each bot the server creates and mounts the password-recovery route. |
CHATFUEL_WORKSPACE_ID | The one Chatfuel workspace every customer's bot is created in. Unrelated to VITE_CHATFUEL_WORKSPACE_ID. |
AUTH_RECOVERY_LINK_LOG | Optional. true lets the recovery-link route write to the server log; unset, it answers 501. |
SUPABASE_PROJECT_REF | Bookkeeping: the project's ref, written on the access-token path so it is on hand for the CLI or the dashboard. Nothing reads it back. |
The wizard fills all but the workspace pick from a Supabase personal access token, which it uses
once, keeps in memory, and writes nowhere. CHATFUEL_BOT_CAP and CHATFUEL_BOT_TOTAL_CAP are
read by that same run and go into the SQL, not into .env. What each variable is and which side
reads it is on environment variables.
The migration also creates two tables other parts of the deployment lean on: cf_admin_attempts,
the wrong-password counter the admin panel's door shares across instances,
and cf_resource_owner, the resource fence's shared memory.
Permissions
The manifest declares no Chatfuel permissions at all. This module asks nothing of the Chatfuel token — it decides who may use the one the deployment already holds.
What stands in their place is two lists. Inside a workspace, the cf_* RPCs are the whole
surface: cf_profiles is the one table with a direct grant (select and update, self only), and
tenants, members, bots, grants and invites are reachable by function or not at all; the one RPC
the anon key may call is cf_invite_preview, which answers a masked address and needs the token
itself. On the way out, the gate's answer to cf_my_bot_ids decides every request.
Limits
Saved views and preferences are lost under this module. currentUser.userStorageItem is
storage against the Chatfuel account, so allowing it would hand every customer the same list —
the proxy answers AccountScopeBlocked. Inbox views and canned responses, Deals' and Contacts'
saved views, Knowledge base preferences and Bookings' preferences all degrade to "nothing saved
yet". A per-account version would live in Supabase, not in Chatfuel.
Removal is a real lock-out. Somebody removed from a workspace loses it; signing up again gives them a new, empty bot rather than the one they were removed from. That is the difference the bot boundary makes, and it is worth saying out loud before an admin clicks Remove.
Sign-in is email and password only. OAuth and magic link are not wired; the adapter and a footer slot on the sign-in screen are where they would go. There is no second factor and no admin approval step.
The workspace has no settings. It is named after the account that created it and is not editable in the app: one less write path.
| Workspaces per account | 1 (created_by is unique) |
| Owners per workspace | 1 (a partial unique index) |
| Bots per workspace | 20 (cf_bot_cap()) |
| Bots per deployment | 200 (cf_bot_total_cap()) |
| Invite token | 24 random bytes; only the hash is stored, and the raw token is shown once |
| Invite expiry | 7 days by default; over 0 and at most 30; the dialog offers 1, 7 or 30 |
| Invite-link previews | 60 a minute per bucket, with the anon key |
| Password floor | 8 characters (Supabase's own) |
| Profile name | 60 characters |
| Bot name | 80 characters |
| An unfinished bot reservation | Swept after 10 minutes |
| Gate cache | 30 seconds, bounded by the JWT's own exp; a 503 is remembered for 1 second |
| Gate misses that reach Supabase | 600 a minute, a fifth of them held for sessions already admitted |
Accounts, as a product
What signing up gets somebody, the three roles, the invite flow, and the questions a self-serve deployment raises.
The auth gate
How the proxy turns a Supabase session into a per-bot fence, and what it does and does not cover.
Admin
The operator's side of the same account: every workspace and bot, and who in this app reaches which one.
Your agent's notes
The wizard installs .claude/skills/chatfuel-auth/ (Codex: .agents/skills/chatfuel-auth/) — the data model, the RPC catalog with its error codes, provisioning, invites, and references/security.md for what the fence does not cover.
Channels
Every channel the bot is connected to, on one route — the hand-off that connects one, the two keys the return leg writes, and the refusals that cannot be told apart.
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.