Accounts
Sign-in for your own customers on your own Supabase project — one workspace an account, a bot each, 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.
The manifest still calls it "one tenant per deployed bot". That has been untrue since migration
0002_multi_bot.sql moved bots out of cf_tenants.bot_id and into cf_bots: the model is
one tenant, many bots.
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 |
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.
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, 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'.
| 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. Cannot touch the owner row. |
| 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 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. So its botsLimit is the ceiling
for the whole deployment rather than per customer, and when the allowance runs out both sign-ups
and New bot answer 409 WorkspaceFull. Nothing in the app caps it; a per-account cap belongs in
a check inside cf_new_bot. The alternative — 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, and that
workspace outlives the bot. A deployment without CHATFUEL_WORKSPACE_ID refuses to provision at
all.
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 id and botRole, and botsV2) are refused with 403 AccountScopeBlocked, precisely because they would hand one customer the list of all the others.
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.
Nothing errors in the meantime. Closing sockets on a role change is named as a follow-up, not
implemented.
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. |
SUPABASE_PROJECT_REF | Optional. |
CHATFUEL_WORKSPACE_ID | The one Chatfuel workspace every customer's bot is created in. Unrelated to VITE_CHATFUEL_WORKSPACE_ID. |
The wizard fills all but the workspace pick from a Supabase personal access token, which it uses once, keeps in memory, and writes nowhere. What each variable is and which side reads it is on environment variables.
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. 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. Deals' saved views and Bookings' preferences both
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.
Anyone who can open the app can create an account, and each one creates a bot. There is no
billing, no cap, and no switch for it — that is what a self-serve product is, and it is also the
cost model. A restriction is a check in cf_claim_workspace in a migration of your own.
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. And Forgot password cannot deliver mail until you configure SMTP on the project — until then an admin issues a reset link from the Team row menu.
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 account | Uncapped here — the Chatfuel workspace's plan is the ceiling |
| 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 |
| 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 |
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.
Ads Optimization
Conversion reporting for click-to-WhatsApp ads — event sets over one automation scope, the seven triggers, and the permission that decides whether any of it is delivered.
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.