The proxy
Every route the proxy mounts, which of them exist only when a module or a variable does, the ceilings and fences with their values, 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. This page holds the values — paths, ceilings, defaults, codes. Why each fence exists is on ceilings and fences; the variables that feed the options are on environment variables; every code with its cause is on errors.
Before any route runs
Two checks stand in front of every route the proxy claims, and the WebSocket upgrade asks the same two, because it hangs off the bare server where no middleware runs.
- Host. The
Hostheader must name this deployment: a loopback name always does; otherwise an entry inallowedHosts, or the host of an entry inallowedOriginsor ofpublicUrl. An entry without a port matches any port. A deployment that named none of them and is not bound to loopback is not checked at all. A request that fails is403 ProxyHostForbidden, and it is asked first, so a request to the wrong name collects no CORS header on its way out. - Origin.
sec-fetch-site: same-originornonepasses on the browser's own word. Otherwise theOriginmust be this deployment's own host — host and port, never the scheme, because behind an edge that terminates TLS the socket is plain http — or an entry inallowedOrigins. A request with noOriginand nothing from Fetch Metadata is not a browser request and passes. A request that fails is403 ProxyOriginForbidden.
A listed cross-origin caller gets access-control-allow-origin: <origin>,
access-control-allow-credentials: true and vary: origin. An OPTIONS preflight on a claimed
path is answered 204 with GET, POST, PATCH, DELETE, OPTIONS, the request headers
authorization, content-type, x-cf-admin, and access-control-max-age: 600. A refused upgrade
is answered as plain HTTP 403 Forbidden before any socket exists.
Everything under /chatfuel/admin is served same-origin only, whatever allowedOrigins says.
The host check is not narrowed with it: it is a statement about this server, and the server is the
same one whichever route was asked for.
The routes
Paths are the defaults; every one of them is an option.
| Route | Mounted | What it does |
|---|---|---|
/chatfuel/healthz | Always, by the host, before the proxy | Answers {"ok":true} with 200, or {"ok":false} with 503 when the configuration has a problem — cache-control: no-store, the security headers, and nothing else in the body. The same path on all three hosts. It sits under the prefix because at the root it is not the proxy's to claim: on Vercel a bare /healthz matches the SPA rewrite and answers 200 with index.html. |
POST /chatfuel/graphql | Always | Requires content-type: application/json — a missing header is refused too, since a POST with no content-type is a request a page can send cross-site with no preflight. Reads the body once, runs the checks below, and forwards a body it serialised itself to {upstream}/graphql?op=<name> with authorization: Bearer <CHATFUEL_TOKEN>. 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, and no other subprotocol. Same path as the POST route; the Upgrade disambiguates. The first frame must be connection_init or the socket closes 4401; that payload is consumed by the gate and never relayed, the relay sends its own init upstream with the master token, and every later frame goes through the same checks a POST body does. An upgrade on any other path is a no-op, which is what lets Vite's own HMR socket share the server. |
POST /chatfuel/api/filestorage/upload/<bot|livechat|plugin|widget|useraccount> | Always | The REST passthrough is an allowlist of five upload paths, forwarded to {upstream}/api/… with the token injected and the method written as POST. The four bot-scoped paths require exactly one ?botID=, read under any spelling of the parameter and fenced the way a GraphQL variable is: none is 400 InvalidRequest, two are 400 InvalidRequest. useraccount writes to the account behind the token and is 403 AccountOperationBlocked with the gate on. Any other path under /chatfuel/api is 403 RestPathNotAllowed; any other method is a 405. |
POST /chatfuel/auth/provision | Gate on and a service-role key | The second half of signing up: the caller's workspace and its bots, adding the first bot when it has none that can be opened — a reserved row with no Chatfuel id is not a bot. 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. An empty <id>, or one with a slash in it, is 404 BotNotFound before anything runs. |
POST /chatfuel/auth/recovery-link | Gate on and a service-role key | Body {email} from an owner or admin. Answers {"delivered":"server-log"} and writes the reset link to the server log — never to the caller — and only on a deployment that opted that in; otherwise 501 RecoveryLinkNotEnabled. |
/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.
The order a GraphQL body is checked in
Every step answers before the next one runs, and the first refusal is the one you get. A
subscribe frame on the socket runs the same chain, with the registry first and the rest in this
order; there a refusal is an in-band error frame on that subscription's id, not a close.
| Step | Refusal |
|---|---|
The content type is application/json | 415 UnsupportedMediaType |
The gate, with the auth module | 401 AuthSessionRequired, 403 AuthTenantForbidden, 503 ProxyAuthUnavailable; with half a configuration 500 ProxyAuthMisconfigured |
| A token exists | 500 ProxyTokenMissing |
| The tenant's minute has a request left in it | 429 TenantBusy, retry-after: 5 |
Fewer than graphqlMaxConcurrent requests are in flight | 503 ProxyBusy, retry-after: 5 |
| The body is under 2 MiB | 413 RequestTooLarge |
The body is JSON with a string query in every entry | 400 ProxyMalformedQuery |
The batch has at most graphqlMaxBatch entries | 413 BatchTooLarge |
No entry carries extensions | 400 ProxyExtensionsUnsupported |
| Every document is one the app ships, when a registry exists | 403 OperationNotInRegistry; 400 ProxyMalformedQuery when the text does not parse |
| The document parses and its walks finish | 400 ProxyMalformedQuery |
No root field is __schema or __type | 403 IntrospectionBlocked |
| No root field is an account-level operation | 403 AccountOperationBlocked |
With the gate on: nothing under currentUser or reached through a bot reads the account | 403 AccountScopeBlocked |
| With the gate on: no root field changes the deployment's workspaces and bots | 403 AccountStructureBlocked |
| Every root field is on the allowlist, when it is on | 403 OperationNotAllowed |
| Every bot the document names is a string | 400 ProxyMalformedQuery |
| The fence can be resolved, when a bot or a resource is named | 503 ProxyFenceUnavailable |
| Every bot named is the caller's | 403 AuthTenantForbidden when the session's set is empty, 403 BotNotAllowed otherwise |
Every resource named is not known to be another bot's — or is known, in strict | 403 ResourceNotAllowed |
Chatfuel answers within timeoutMs, or slowTimeoutMs for instagramAccountPublish{Image,Reel,Story,Carousel} | 504 ProxyUpstreamUnavailable |
What comes back is the upstream status and body, with two exceptions: an error entry naming an
internal upstream service has that sentence replaced and, when it carried no code of its own, is
tagged UpstreamServiceError; and a request that named exactly one bot teaches the resource fence
which bot every id in the answer belongs to.
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, ProxyAdminPasswordWeak joins the configuration problems, 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 POST /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 under a per-deployment salt, lasts two hours, is sent with
Path=/; HttpOnly; SameSite=Strict and Secure everywhere but plain-http loopback, 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. A body over 64 KiB is 413 AdminBodyTooLarge; on /session, which answers
before any credential is checked, the ceiling is 4 KiB.
| Route | Methods | What it does |
|---|---|---|
/chatfuel/admin/session | GET, POST, DELETE | Read the session, unlock with the password, sign out. Only POST is reached before the session check. |
/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 — names and yes/no, never values. |
/chatfuel/admin/tenants | GET | The app's own accounts, where the auth module is installed; 500 ProxyAuthMisconfigured without it. |
/chatfuel/admin/grants | POST, DELETE | Hand one bot to one of the app's accounts, and take it back. A DELETE names the pair in the query string, not the body. |
/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; a bot id is held to the same shape. |
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. Behind the gate like every proxied route. |
/chatfuel/publishing/register | POST | Records where this app answers, so the scheduler knows where to knock. The caller is the admin panel's holder — the cf_admin cookie and header — not a workspace role, because the row belongs to the whole deployment and every sign-up is the owner of the workspace it opens. Needs PUBLISHING_SECRET and PUBLIC_URL. |
/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. ?botID= is required here too. |
/chatfuel/publishing/media | POST, DELETE | Durable storage for what a scheduled post is made of — the platform's own uploads are short-lived, and its API does not say for how long. ?botID= is required and fenced. |
/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.
A ceiling given as something that is not a positive number is not a ceiling: the default stands.
| 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. |
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. The admin panel's "home" workspace is this, or VITE_CHATFUEL_WORKSPACE_ID without it. |
allowedBotIds | Unset — the request-time fence | A list freezes the bot fence at startup; 'any' turns it off entirely. |
fence | ttlMs 60 000, retryMs 5 000, timeoutMs 10 000 | Tuning 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 (cacheTtlMs 30 000, rpcName cf_my_bot_ids, serviceRoleKey or SUPABASE_SERVICE_ROLE_KEY); false never gates. |
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. |
wsInitTimeoutMs | 5_000 | How long the relay waits for the browser's connection_init before closing 4408. |
allowedOrigins | ALLOWED_ORIGINS, comma- or space-separated | Origins besides the deployment's own that may call from a browser, lowercased, trailing slashes trimmed. '*' is any origin, credentials and all. |
allowedHosts | ALLOWED_HOSTS, plus the hosts of allowedOrigins and publicUrl | The names this deployment answers to. '*' turns the check off. Under the Vite plugin, Vite's own server.allowedHosts is added, and true there is '*' here. |
loopbackOnly | false | True when the socket is bound to loopback; the Node server and the Vite plugin pass it from their bind, a serverless function leaves it alone. |
restMaxConcurrent | REST_MAX_CONCURRENT, then 8 | REST uploads in flight at once. |
graphqlMaxConcurrent | GRAPHQL_MAX_CONCURRENT, then 32 | GraphQL requests in flight at once, counted from admission to the upstream answer. |
graphqlMaxBatch | GRAPHQL_MAX_BATCH, then 25 | Operations one GraphQL body may carry. The shell sends no batches. |
wsMaxSockets | WS_MAX_SOCKETS, then 256 | Browser WebSockets one instance holds. |
wsPreAuthSockets | WS_PRE_AUTH_SOCKETS, then 32 | Sockets that may be waiting to be admitted at once. |
tenantRequestsPerMinute | TENANT_REQUESTS_PER_MINUTE, then 600 | Requests one tenant may send per minute, as a token bucket that also holds a minute's worth as burst. Felt only with a fence to key a tenant by. |
tenantMaxSockets | TENANT_MAX_SOCKETS, then 8 | Browser WebSockets one tenant may hold at once. |
resourceFence | CHATFUEL_RESOURCE_FENCE, then bound with the gate on and off without | off, bound or strict. An unrecognised value is the default, never off. |
resourceStore | CHATFUEL_RESOURCE_STORE, then on wherever a fence that is not off has a service-role key to reach the table with | Only 'off' turns it off. |
operationAllowlist | CHATFUEL_OPERATION_ALLOWLIST, then on with the gate on and off without | 'on' or 'off'. Behind the gate, off is ignored unless CHATFUEL_OPERATION_ALLOWLIST_OFF=1 is set beside it, and the startup line says so. |
operationAllowlistExtra | CHATFUEL_OPERATION_ALLOWLIST_EXTRA, comma-separated | Root fields allowed beside the generated list. |
operations | None — there is no env var | The app's generated namespaces from src/operationDocs.ts. Omitted, no document check runs; [] refuses every document. |
adminPassword | ADMIN_PASSWORD | Unset unmounts the panel; shorter than 16 characters makes it answer 500. |
adminCookieSalt | ADMIN_COOKIE_SALT, then the first 32 hex characters of a sha256 over the token and the Supabase URL | The salt the admin cookie's signing key is derived with. Changing it invalidates every live admin session. |
trustForwardedFor | TRUST_FORWARDED_FOR is true or 1; otherwise false | Whether the last hop of x-forwarded-for keys the admin-password throttle. |
recoveryLinkLogging | AUTH_RECOVERY_LINK_LOG is true or 1; otherwise false | Whether the recovery-link route may write its link to the server log. Off, it answers 501. |
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. |
mediaBucket | PUBLISHING_MEDIA_BUCKET, then cf-pub-media | The storage bucket the media routes read and write. A name outside [A-Za-z0-9._-] is dropped and the default stands. |
mediaQuotaMb | PUBLISHING_MEDIA_QUOTA_MB, then 512 | Megabytes of media one bot may keep in that bucket. |
mediaTtlDays | PUBLISHING_MEDIA_TTL_DAYS, then 30 | Days an object no post refers to is kept. |
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. |
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.
The ceilings
| Ceiling | Where | Default | Past it |
|---|---|---|---|
| Body of a GraphQL POST | per request | 2 MiB | 413 RequestTooLarge |
| Body of a REST upload | per request | 25 MiB | 413 RequestTooLarge |
Body of a JSON route — provision, bots, recovery-link, posts, publish-due | per request | 2 MiB | 413 RequestTooLarge |
Body of an admin route; of /chatfuel/admin/session | per request | 64 KiB; 4 KiB | 413 AdminBodyTooLarge |
| A media file | per request | 25 MiB | 413 MediaTooLarge |
| Operations in one GraphQL body | per request | 25 | 413 BatchTooLarge |
| Resource ids one document names | per request | 500 | 400 ProxyMalformedQuery |
| REST uploads in flight | per process | 8 | 503 ProxyBusy, retry-after: 5 |
| GraphQL requests in flight | per process | 32 | 503 ProxyBusy, retry-after: 5 |
| Browser WebSockets | per process | 256 | the upgrade is refused 503 |
| Sockets waiting to be admitted | per process | 32 | the upgrade is refused 503 |
| Requests a minute | per tenant | 600, and a tenth of that for every signed-in account with no bot yet, which share one bucket | 429 TenantBusy, retry-after: 5 |
| Live sockets | per tenant | 8, and a tenth of that for the no-bots bucket | close 4429 |
| One browser frame; one upstream frame | per socket | 1 MiB; 16 MiB | the socket closes |
| Frames held before the upstream socket opened | per socket | 64 frames or 512 KiB | close 4400 |
| Frames held behind a shared-store lookup | per socket | 64 | close 4400 |
| Open subscriptions | per socket | 128 | an error frame, TooManySubscriptions |
| A socket's life | per socket | the session JWT's exp, or an hour, whichever is sooner | close 1012; the client reconnects and is gated again |
| Gate misses that reach Supabase | per process | 600 a minute, a fifth of them held for sessions already admitted | 503 ProxyAuthUnavailable, no network |
| Tenants tracked | per process | 10 000, idle entries dropped after five minutes | the oldest go |
A body past its ceiling is refused at the byte that crosses it: the bytes past it are dropped
rather than kept, the stream is paused, the 413 goes out with connection: close, the socket is
ended, and 250 ms later it is destroyed if the caller is still sending.
The document registry
The narrowest document check and the first one asked. Pass the app's generated namespaces as
operations — src/operationDocs.ts, the barrel the wizard writes with one import * as per
module and never export * — and the proxy forwards those documents and no others. All three
hosts pass the same object.
| Value | What it is |
|---|---|
| Indexed by | the exact text of every string-like export that parses and defines exactly one operation; and by sha256(stripIgnoredCharacters(text)) — whitespace, line breaks, commas and comments are the only differences the second lane absorbs |
| Built | eagerly, at startup; a document defining two operations, or two different operations sharing one key, throws there |
| A match forwards | the record's own text and the record's own operation name, read off the document and never off the request |
| A miss | 403 OperationNotInRegistry, or 400 ProxyMalformedQuery when the text does not parse; a batch is admitted whole or not at all; the socket answers per subscribe frame with an error frame |
operations omitted | no document check runs, and the startup line says operations: NO REGISTRY (this host passes no \operations`; document checking is off)` |
operations: [] | every document is refused |
| On the startup line | operations: <n> documents |
The operation allowlist
A name check on an operation's root fields, following a spread into its fragment and ignoring
aliases. The list is ALLOWED_ROOT_FIELDS in allowedOperations.ts — 353 fields, generated by
scripts/allowed-operations.ts from every module's skill/examples/operations.graphql, and
re-derived by the repository's validator, which fails when the two disagree.
| Value | What it is |
|---|---|
| Default | on with the gate on, off without it |
CHATFUEL_OPERATION_ALLOWLIST_EXTRA | root fields merged into the list by name |
CHATFUEL_OPERATION_ALLOWLIST=off behind the gate | ignored unless CHATFUEL_OPERATION_ALLOWLIST_OFF=1 is set beside it |
| A miss | 403 OperationNotAllowed, on HTTP and per subscribe frame alike; it runs after the account denylists, so an operation on both — workspaceCreateBot is one — is refused with the reason that explains it |
| On the startup line | operation allowlist: 353 fields, or operation allowlist: OFF; an ignored off appends (CHATFUEL_OPERATION_ALLOWLIST=off IGNORED: behind the gate it also needs CHATFUEL_OPERATION_ALLOWLIST_OFF=1) |
The resource fence
Which bot a flow, block, contact, conversation or task id belongs to, learned from the proxy's own answers and checked on the next request that names it.
| Value | What it is |
|---|---|
| Modes | off; bound — refuse an id known to belong to another bot, forward one nothing is known about; strict — refuse the unknown too |
| Default | bound with the gate on, off without; an unrecognised value is the default |
| An id | a quoted string of 24 hex characters, under an argument named id, ending in ID, Id, IDs or Ids, or one of attachment, before, documents, files, goodsServices, images, logo, setInheritFrom, wabaIDHint; never botID, assigneeID, targetWorkspaceID, userID or workspaceID |
| Learned from | an answer to a request that named exactly one bot; the bot's own id and every id the request itself carried are skipped; an id later seen under a second bot is marked shared and refuses nobody |
| Held | 50 000 bindings per process, each for 24 hours; at most 5 000 ids read out of one answer |
| A refusal | 403 ResourceNotAllowed — "The <argument> in this request belongs to another workspace", or in strict "This proxy cannot tell which bot that <argument> belongs to, and does not forward what it cannot check" |
| When the fence cannot be resolved | a request naming only a resource pays the same 503 ProxyFenceUnavailable a request naming a bot does |
| The shared store | cf_resource_owner on the deployment's own Supabase, service-role only; read through cf_resource_owner_lookup with a 2 s deadline, written through cf_resource_bind in 250 ms batches of up to 500 ids, only for the ids a caller names, rewritten after 6 hours; a row older than a day is invisible; a store that does not answer is left alone for 5 s and an unknown id is noted for a minute |
| On the startup line | resource fence: bound (shared), resource fence: strict (this instance only), or nothing when it is off |
The startup line
Every host prints the resolved configuration once, from describeAuthMode, and prints each
configuration problem before it by variable name — never by value.
| Gate | The line |
|---|---|
on | auth gate on (bots per account in workspace <id>, recovery-link route mounted (server-log delivery off), provisioning + bot routes mounted, publish queue routes mounted, …) — each segment appears only when the route is mounted, and (link written to the server log) replaces the delivery note when the log is opted in |
off | auth gate off, bot fence: this account's workspaces — or <n> bot(s) for a frozen list, or off |
misconfigured | auth gate MISCONFIGURED — every request will fail with ProxyAuthMisconfigured |
The tail is the same for on and off, in this order: the resource fence, operation allowlist: <n> fields or OFF, operations: <n> documents or NO REGISTRY, admin panel mounted or
admin panel MISCONFIGURED (ADMIN_PASSWORD too short), and ORIGINS: * (ANY origin may call this proxy) when '*' is in the origin list. A machine that reaches the internet through a proxy
appends — outbound via HTTPS_PROXY=… with the credentials masked.
Two shapes are refused before anything binds, with a line starting REFUSING TO SERVE: — open
mode on a host that is not loopback without CHATFUEL_OPEN_PROXY=1, and ALLOWED_ORIGINS='*'
without a gate. The Node server prints them and never binds; the Vite plugin throws before the
middleware mounts; the Vercel function logs them, answers the health route, and answers 503 ProxyRefusedToServe to everything else. An acknowledged open mode on a reachable host prints a
WARNING: line beside the listening line instead.
The three hosts
| Host | File | Serves | What differs |
|---|---|---|---|
| Vite dev server | vite.ts | The health route, then 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. It reads Vite's server.host as its bind and Vite's server.allowedHosts as its host list. A configuration problem is logged, never fatal: requests answer with a synthetic envelope so the problem is visible in the app. A refusal to serve is thrown before the middleware mounts. |
| Node server | server.ts | /chatfuel/healthz, then the proxy, then the built dist/ | server/entry.ts loads a .env beside the app on start, with real environment variables winning, and passes HOST, PORT, BASE_PATH and the operations barrel. Static serving is GET/HEAD, contained inside distDir lexically before any filesystem call and again by realpath after it, 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 | content/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; a parameter written twice, or one decoding to a leading /, a . or .. segment, a ? or a #, is 400 InvalidRequest. Env is read at module scope, once per cold start. An unclaimed path is a 404 NotFound envelope. The function's ceiling is 300 seconds. |
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.
Every response the proxy writes carries the same seven security headers on all three hosts —
every proxied answer, the health answer, every static file and 404 on the Node server, and every
path on Vercel through vercel.json:
| Header | Value |
|---|---|
content-security-policy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none' |
strict-transport-security | max-age=63072000; includeSubDomains — sent over plain http too, where the browser is required to ignore it |
x-content-type-options | nosniff |
x-frame-options | DENY |
referrer-policy | no-referrer |
cross-origin-opener-policy | same-origin |
permissions-policy | camera=(), geolocation=(), payment=(), usb=(), microphone=(self) |
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>, and a body the proxy serialised from what it read —query,operationName,variables— rather than the bytes that arrived, so the request that was fenced and the request that runs are one object. Of the caller's query string only?op=<name>travels. - 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. Every later frame goes upstream re-serialised from what the relay read, never as it arrived.
Coming back the other way, the upstream status passes through untouched, because a GraphQL refusal
arrives as an envelope under HTTP 200 and rewriting it would lose the answer. The body is piped
rather than collected, except on the GraphQL route, where an error entry naming an internal
upstream service has that sentence replaced before it reaches the browser. 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 three capabilities a module can reach beyond its props, what the shell does with what they publish, and the 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.