Skip to content
ChatfuelSDK
Modules

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 idauth
Install itnpx @chatfuel/wizard --modules auth
Selected byOpt-in — --yes never installs it
RequiresNothing, beyond the implicit core
Recommends—
Skillchatfuel-auth
Migration0001_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.

PathRendered
/sign-inInstead of the shell
/sign-upInstead of the shell
/invite/<token>Instead of the shell
/forgot-passwordInstead of the shell
/reset-passwordInstead of the shell
/authInstead of the shell — the callback a mailed link lands on
/no-accessInstead of the shell
/teamInside 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.

RoleWhat it is
OwnerOne per workspace. Cannot be removed, demoted, or leave without transferring first.
AdminManages 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.
MemberUses 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:

FunctionDefaultSet at install byWhat it bounds
cf_bot_cap()20CHATFUEL_BOT_CAPBots in one workspace — the ceiling a person meets.
cf_bot_total_cap()200CHATFUEL_BOT_TOTAL_CAPBots 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:

QuestionMechanism
PostgREST + the cf_* RPCsWhat 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 gateMay 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:

--signupWhat the wizard asserts on the new project
openSign-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-emailSign-ups on, and a new account waits for a link. Needs mail that arrives.
closedThe 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

VariableNotes
VITE_SUPABASE_URLYour project's URL. Both sides.
VITE_SUPABASE_ANON_KEYThe publishable key. Both sides.
SUPABASE_SERVICE_ROLE_KEYSecret, and not optional under auth: it registers each bot the server creates and mounts the password-recovery route.
CHATFUEL_WORKSPACE_IDThe one Chatfuel workspace every customer's bot is created in. Unrelated to VITE_CHATFUEL_WORKSPACE_ID.
AUTH_RECOVERY_LINK_LOGOptional. true lets the recovery-link route write to the server log; unset, it answers 501.
SUPABASE_PROJECT_REFBookkeeping: 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 account1 (created_by is unique)
Owners per workspace1 (a partial unique index)
Bots per workspace20 (cf_bot_cap())
Bots per deployment200 (cf_bot_total_cap())
Invite token24 random bytes; only the hash is stored, and the raw token is shown once
Invite expiry7 days by default; over 0 and at most 30; the dialog offers 1, 7 or 30
Invite-link previews60 a minute per bucket, with the anon key
Password floor8 characters (Supabase's own)
Profile name60 characters
Bot name80 characters
An unfinished bot reservationSwept after 10 minutes
Gate cache30 seconds, bounded by the JWT's own exp; a 503 is remembered for 1 second
Gate misses that reach Supabase600 a minute, a fifth of them held for sessions already admitted

On this page