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

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

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

<Callout type="warn">
  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.
</Callout>

## Configuration [#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. &#x2A;*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](/docs/deploy/environments).

## Permissions [#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 [#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`                                |

<Cards>
  <Card title="Accounts, as a product" href="/modules/accounts" description="What signing up gets somebody, the three roles, the invite flow, and the questions a self-serve deployment raises." />

  <Card title="The auth gate" href="/docs/concepts/auth-gate" description="How the proxy turns a Supabase session into a per-bot fence, and what it does and does not cover." />

  <Card title="Admin" href="/docs/modules/admin" description="The operator's side of the same account: every workspace and bot, and who in this app reaches which one." />

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