The proxy
Every route the proxy mounts, which of them exist only when a module or a variable does, the options that move them, and the three hosts that serve the same core.
One core is assembled from per-concern modules and imported from one place by all three hosts, so the route table is the same in dev, on a Node server and on Vercel.
The routes
Paths are the defaults; every one of them is an option.
| Route | Mounted | What it does |
|---|---|---|
POST /chatfuel/graphql | Always | Forwards the body to {upstream}/graphql with authorization: Bearer <CHATFUEL_TOKEN>. Every bot the body names — the botID variable, and the argument of a root bot(id: $x) field, read out of the query text rather than trusted to a convention — is checked against the fence first. Any other method is a 405 with an allow header and a bare body: a wrong method is a caller-side programming error, not an application answer. |
WS /chatfuel/graphql | Always | The graphql-transport-ws relay. Same path as the POST route; the Upgrade disambiguates. An upgrade on any other path is a no-op, which is what lets Vite's own HMR socket share the server. |
/chatfuel/api/* | Always | Passthrough to {upstream}/api/* — the REST file uploads — with the same token injection. Any method; a botID query parameter is fenced the same way a GraphQL variable is. |
POST /chatfuel/auth/provision | Gate on and a service-role key | The second half of signing up: the account's workspace and its bots, adding the first bot when it has none. Idempotent — somebody who already has a workspace gets it back. Without CHATFUEL_WORKSPACE_ID it stops rather than creating a bot that bills nowhere. |
POST /chatfuel/auth/bots | Same | Another bot for the caller's workspace. Body {name}. |
PATCH /chatfuel/auth/bots/<id> | Same | Renames it, in the database first and then in Chatfuel. Body {name}. |
DELETE /chatfuel/auth/bots/<id> | Same | Deletes it, in Chatfuel first and then in the database. |
POST /chatfuel/auth/recovery-link | Gate on and a service-role key | Body {email} from an owner or admin; answers { tokenHash, url } so an operator can reset a member's password before custom SMTP exists. |
/chatfuel/admin/* | ADMIN_PASSWORD is set | The operator panel — its own table below. |
/chatfuel/publishing/* | Gate on and a service-role key | The Instagram publish queue — its own table below. |
POST /chatfuel/auth/provision and the three bots routes are mounted on the gate and the
service-role key; CHATFUEL_WORKSPACE_ID is checked inside the handler, so a deployment missing it
gets an answer that names the variable rather than a 404.
What decides whether a route exists at all
| Resolved flag | True when | Routes it mounts |
|---|---|---|
provisionRoute | authMode === 'on' and a service-role key is available | /chatfuel/auth/provision, /chatfuel/auth/bots, /chatfuel/auth/bots/<id> |
recoveryLinkRoute | The same condition | /chatfuel/auth/recovery-link |
publishingQueueRoute | The same condition | Everything under /chatfuel/publishing |
adminRoute | adminMode is not off — that is, ADMIN_PASSWORD is set at all | Everything under /chatfuel/admin |
A route that is not mounted is not claimed: the dispatcher returns false, the host answers its own
404, and that 404 is the signal. The app reads a 404 on the publishing routes as "this deployment
keeps its queue in the browser" and offers no schedule control. A deployment that never asked for a
panel answers 404 on /chatfuel/admin and the app reads that as "no panel".
The one exception is a password that is set but shorter than 16 characters: adminMode becomes
misconfigured, the routes stay claimed, and they answer 500 — a panel that quietly is not there
teaches the operator nothing.
The publish queue has a second, quieter half. With the routes mounted but PUBLISHING_SECRET
unset, the queue still stores posts and never fires them, and the scheduler's callback route
refuses every request.
Under /chatfuel/admin
Every path except /session requires a signed cf_admin cookie and an x-cf-admin request
header — a form posted from another origin can send a cookie but cannot set a header. The cookie is
minted from ADMIN_PASSWORD, lasts 12 hours, and is the whole authorization: this module never calls
admitRequest, never consults the auth gate and never applies the workspace fence, because the
panel's purpose is the account-wide view those exist to withhold.
| Route | Methods | What it does |
|---|---|---|
/chatfuel/admin/session | GET, POST, DELETE | Read the session, unlock with the password, sign out. |
/chatfuel/admin/overview | GET | The account behind the master token: its workspaces and their bots. |
/chatfuel/admin/health | GET | Whether the token still works and what this deployment is missing. |
/chatfuel/admin/tenants | GET | The app's own accounts, where the auth module is installed. |
/chatfuel/admin/grants | POST, DELETE | Hand one bot to one of the app's accounts, and take it back. |
/chatfuel/admin/bots | POST | Create a bot. |
/chatfuel/admin/bots/<id> | GET, PATCH, DELETE | One bot's details, rename, delete. |
/chatfuel/admin/workspaces/<id> | GET | One workspace. An id that is not [A-Za-z0-9_-]{1,64} is a 404 before anything runs. |
Anything else under the prefix is 404 AdminRouteNotFound.
Under /chatfuel/publishing
| Route | Methods | What it does |
|---|---|---|
/chatfuel/publishing/config | GET | Whether this deployment can schedule at all. |
/chatfuel/publishing/register | POST | Records where this app answers, so the scheduler knows where to knock. Owner or admin only. |
/chatfuel/publishing/posts | GET, POST | The bot's queued posts, and a new one. ?botID= is required and fenced. |
/chatfuel/publishing/posts/<id> | PATCH, DELETE | Edit or drop one. The id must be a UUID, or it names no post and the answer is a 404. |
/chatfuel/publishing/media | POST, DELETE | Durable storage for what a scheduled post is made of — the platform's own uploads expire two hours after they arrive. |
/chatfuel/publishing/publish-due | POST | The scheduler's callback. Authorised by x-chatfuel-publish-key against the shared secret, never by a session. |
The options
createChatfuelProxy(options, env) takes these; each falls back to the env bag, then to a default.
| Option | Default | What it changes |
|---|---|---|
upstream | CHATFUEL_API_BASE, then https://panel.chatfuel.com | Where every forwarded call goes. Trailing slashes are trimmed. |
httpPath | /chatfuel/graphql | The GraphQL POST path. |
wsPath | Same as httpPath | The upgrade path. POST and Upgrade disambiguate, so they can share. |
apiPath | /chatfuel/api | Forwarded to {upstream}/api. |
authPath | /chatfuel/auth | Prefix for provision, bots and recovery-link. |
publishingPath | /chatfuel/publishing | Prefix for the publish queue. |
adminPath | /chatfuel/admin | Prefix for the operator panel. |
adminPassword | ADMIN_PASSWORD | Unset unmounts the panel; shorter than 16 characters makes it answer 500. |
publishingSecret | PUBLISHING_SECRET | The secret the scheduler and this server share. Without it nothing is scheduled. |
bypassSecret | VERCEL_AUTOMATION_BYPASS_SECRET | Recorded at registration so the scheduler's callback is not turned away at the edge before any of this code runs. |
publicUrl | PUBLIC_URL | Where this deployment answers from the outside. Anything that is not an absolute http(s) URL is dropped rather than patched up — this value decides where a credential is posted every minute, and deriving it from a request means the requester chooses it. |
tokenEnv | 'CHATFUEL_TOKEN' | Which variable holds the Chatfuel token. |
token | env[tokenEnv] | Explicit override, for tests. A value containing whitespace is treated as missing. |
workspaceId | CHATFUEL_WORKSPACE_ID | The Chatfuel workspace provisioned bots are created in — the billing container. |
timeoutMs | 30_000 | Upstream HTTP budget. |
slowTimeoutMs | 290_000 | The budget for the four instagramAccountPublish* fields, which block while Instagram transcodes. Separate rather than a raised default, so a dead upstream is still felt in thirty seconds everywhere else, and a little under five minutes because that is where a serverless function is killed. |
allowedBotIds | Unset — the request-time fence | A list freezes the fence at startup; 'any' turns it off entirely. |
fence | — | ttlMs (60 s), retryMs (5 s), timeoutMs (10 s) and test injection for the request-time fence. It exists only when a token does. |
auth | Resolved from env | An object forces the gate on with those values; false never gates. |
wsInitTimeoutMs | 5_000 | How long the relay waits for the browser's connection_init before closing. |
auth resolved from env has three outcomes: both VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
set is on, neither is off, and exactly one is misconfigured — which fails closed, so every
proxied request answers 500. See the auth gate for what the gate then
does, and the environment for where the variables come from.
The three hosts
| Host | File | Serves | What differs |
|---|---|---|---|
| Vite dev server | vite.ts | The proxy, mounted as middleware plus an upgrade listener | The only file in the package that imports vite. It builds the env bag as loadEnv(mode, envDir, '') — an empty prefix, so unprefixed secrets are read from .env without ever reaching import.meta.env — then lets process.env win. A configuration problem is logged, never fatal: requests answer with a synthetic envelope so the problem is visible in the app. |
| Node server | server.ts | /healthz, then the proxy, then the built dist/ | Reads process.env only. Static serving is GET/HEAD, contained inside distDir before any filesystem call, immutable under /assets/, no-cache for index.html, and an unknown path that is not asset-shaped falls back to index.html. basePath mounts all of it under a sub-path. An upgrade that is not the relay's path is destroyed. Not re-exported from the package entry — importing it by path is what keeps vite out of the server bundle. |
| Vercel function | apps/shell/api/chatfuel.ts | The proxy only — Vercel serves dist/ itself | The module's default export is an http.Server, which is what makes incoming WebSocket upgrades work. vercel.json rewrites /chatfuel/:cfpath* to /api/chatfuel, and restoreUrl turns ?cfpath=auth/provision back into /chatfuel/auth/provision before the core sees it, so the route table needs no per-host variant. Env is read at module scope, once per cold start. /chatfuel/healthz answers 200 or 503 and lists the configuration problems by name. An unclaimed path is a 404 NotFound envelope. |
A rewrite straight to panel.chatfuel.com would be cheaper and cannot work: a rewrite forwards the
browser's headers and has no way to add one, so the token would have to be sent by the client — the
one thing the proxy exists to prevent. The headers block in vercel.json sets response headers,
not request ones.
Nothing the client sent is forwarded
Outgoing headers are built from scratch rather than merged, so the browser's Authorization and its
cookies are stripped by construction:
- The GraphQL forward sends exactly
content-type: application/jsonandauthorization: Bearer <token>. - The REST passthrough sends
authorizationplus the request'scontent-typewhen there is one, because a multipart upload needs its boundary. Nothing else, cookies included, travels. - The WebSocket relay waits for the browser's
connection_init, gates it, and then sends its ownconnection_init {authToken: "Bearer …"}upstream. The browser's payload — the user's session JWT — is consumed by the gate and never leaves the server.
Coming back the other way, the upstream status and body pass through untouched, because a GraphQL
refusal arrives as an envelope under HTTP 200 and rewriting it would lose the answer. The proxy's own
refusals are no-store: a cached 403 would keep saying "not your bot" about a bot that has since
become the caller's. Which codes those are is on the errors page.
The shell API
The two hooks a module can reach beyond its props, what the shell does with what they publish, and the three files behind them that a module may not import.
Errors
Every error code the stack can produce — the proxy's HTTP refusals, the WebSocket close codes, the api-client's error classes — with what causes each one and what to change.