AUTH4D-162 Flows custom code steps
AUTH4D-162 adds the custom_code step type (Flows option D). A project’s own JavaScript runs as a
user Worker in a Cloudflare Workers for Platforms dispatch namespace (auth4-flows-staging,
auth4-flows-production), never inside the API Worker. Custom code is the paid Custom code
steps add-on (AUTH4D-165 (repository file docs/api-spec/auth4d-165.md)) and is off by default: the step needs the
flows_custom_code entitlement to be saved, published and run.
The schemas are in packages/contracts (FlowStepSchema, FLOW_CUSTOM_CODE_LIMITS,
FlowCustomCodeEventSchema, FlowCustomCodeResponseSchema, FlowStepSecretListResponseSchema,
SetFlowStepSecretRequestSchema, FlowCustomCodeStatusResponseSchema,
FlowCodeLogListResponseSchema). The runtime is apps/api/src/modules/flows/custom-code.ts; the
routes are in apps/api/src/modules/flows/management.ts. The outbound and Tail Workers are in
infra/cloudflare/flows. Storage is migration 0028_flow_custom_code.sql. Setup is an owner action
in deployment.md (repository file docs/operations/deployment.md).
The step
{
"id": "gate",
"type": "custom_code",
"source": "export default async function (event, { secrets }) {\n return { action: \"continue\" };\n}\n",
"timeoutMs": 1000,
"fallback": "deny",
"onError": "fail_closed"
}
| Field | Rule |
|---|---|
source |
An ES module with a default export, at most 12,288 UTF-8 bytes. |
timeoutMs |
50 to 1,900, default 1,000. The flow’s time budget also limits it. |
fallback |
deny (default) or continue: what happens when the project no longer has the entitlement. |
onError |
As for every step: a throw, a bad answer or a timeout fails open or closed. |
A flow can hold at most 4 custom code steps. The default export is called as
run(event, { secrets }):
eventisFlowCustomCodeEventSchema:{ type: "flow.custom_code", version: 1, step, sent_at, input }, whereinputis the trigger input (trigger,tenant_id,application_id,method,contextwith the edge country and provider, anduser_idexcept forpre_registration). Nothing else from the request reaches the step: no addresses, tokens, emails or headers. The object is deep-frozen.secretsholds the step’s secrets by name (below).
It must return FlowCustomCodeResponseSchema, the same answer a call_backend endpoint gives:
{ "action": "continue" }
{ "action": "continue", "claims": { "tier": "gold" } }
{ "action": "deny", "reason_code": "banned_player", "message": "account_restricted" }
claims is a bounded patch (1 to 16 keys) of the user’s AUTH4D-154 auth4_meta metadata, applied
only when the whole flow continues; null removes a key. Anything else, or an answer above 4,096
bytes, fails the step.
Runtime
- One script per flow version. Publishing uploads one immutable user Worker per
(tenant, flow, version): a generatedindex.jsthat imports each step’s module asstep-N.js. Script names area4f-and 40 hex characters of a hash, and the script tags are hashes of the tenant and flow, so the namespace reveals no ids. Uploads usePUT /accounts/{account}/workers/dispatch/namespaces/{namespace}/scripts/{name}with theFLOWS_SCRIPT_UPLOAD_TOKENWorker secret,bindings: [],limits.cpu_msand the Tail Worker astail_consumers. - Calls. The engine calls the script through the
FLOW_DISPATCHERdispatch namespace binding withlimits: { cpuMs: 50, subRequests: 5 }andoutbound: { tenant_id, script_name }. The step timeout and the flow’s remaining budget abort the call. - Outbound. Every
fetch()from the step goes through the Flows outbound Worker. It allows only http(s) on the default ports to public destinations, refuses private, loopback, link-local, shared, reserved and multicast IPv4 and non-global IPv6 addresses, internal or reserved names (localhost,.internal,.localand similar),auth4.devand its subdomains and the hosts inBLOCKED_HOSTS, and does not follow redirects. Each fetch is logged with the tenant, script, method, host and status; paths and queries are not logged. - Pruning. After every publish, rollback and test upload, the API keeps the active version’s script, the 2 newest other published versions and the newest test script of the flow, and deletes the rest from the namespace. Rolling back to a pruned version uploads the same immutable script again.
- Logs. The Flows Tail Worker stores the scripts’ console lines and uncaught exceptions in
flow_code_logs, attributed to the tenant throughflow_code_scripts, for 7 days. The generated wrapper replaces secret values in console output with[redacted].
When the runtime is not configured (FLOWS_CUSTOM_CODE_PROVIDER, CLOUDFLARE_ACCOUNT_ID,
FLOWS_DISPATCH_NAMESPACE, FLOWS_SCRIPT_UPLOAD_TOKEN and the FLOW_DISPATCHER binding), publishing
custom code returns 503 custom_code_unavailable and bound custom code steps fail (their onError
applies).
Entitlement
The flows_custom_code entitlement (AUTH4D-165: a paid add-on line, its grace period, or an operator
override) is checked:
| When | Without the entitlement |
|---|---|
| Console editor | The Custom code step type is hidden and the add-on call to action is shown. |
| Create a flow or save a draft with code | 402 feature_not_entitled. A draft without custom code steps saves as usual. |
| Publish, or roll back to a code version | 402 feature_not_entitled; nothing is uploaded or switched. |
| Set a step secret | 402 feature_not_entitled. Deleting a secret is always allowed. |
| Every execution | The step does not run; its fallback applies and an audit event is written. |
At execution the entitlement is read once per run. With fallback: "continue" the step is traced as
not_entitled and the flow goes on; with fallback: "deny" the flow is denied with reason code
custom_code_not_entitled (hosted login shows not_allowed). Each skip writes
flows.custom_step.skipped to the project’s audit log with the version, step, fallback and reason.
Management routes
All routes are under /management/tenants/{tenantId} and follow the AUTH4D-159 rules: a
management token, the per-actor rate limit, project membership (403 otherwise), tenant:read and
any project role for reads, tenant:write and owner or admin for writes (viewers get 403,
audited as denied). The console reaches them only through its BFF.
| Route | Scope | Response |
|---|---|---|
GET /flow-custom-code |
tenant:read |
FlowCustomCodeStatusResponseSchema |
GET /flows/{flowId}/steps/{stepId}/secrets |
tenant:read |
FlowStepSecretListResponseSchema (names) |
PUT /flows/{flowId}/steps/{stepId}/secrets/{name} |
tenant:write |
FlowStepSecretListResponseSchema |
DELETE /flows/{flowId}/steps/{stepId}/secrets/{name} |
tenant:write |
204; the body must be {} |
GET /flows/{flowId}/logs |
tenant:read |
FlowCodeLogListResponseSchema (newest 100) |
GET /flow-custom-code returns { entitled, available, limits }, where available says whether
this environment has a configured runtime.
Changed AUTH4D-159/161 routes:
POST /flowsandPUT /flows/{flowId}/draftrefuse custom code steps without the entitlement.POST /flows/{flowId}/publishuploads the version’s script first.400 custom_code_rejectedmeans Cloudflare refused the module (for example a syntax error);503 custom_code_unavailablemeans the runtime is missing or failed and the publish can be retried. The draft stays a draft. Themanagement.flow.publishedaudit event gainscustom_steps.POST /flows/{flowId}/rollbackre-uploads a pruned script and needs the entitlement.POST /flows/{flowId}/testruns custom code for real for owners and admins: a draft is uploaded as atestscript. For viewers, and when the runtime is not configured, custom code steps are simulated as continuing and listed insimulatedSteps. Without the entitlement they follow their fallback, as in an execution, but no audit event is written.
Step secrets
{name} is ^[A-Z][A-Z0-9_]{0,63}$; {stepId} is a step id. The request body is
{ "value": "…" }, 1 to 1,024 bytes. A step holds at most 8 secrets (409 secret_limit_reached;
replacing an existing one is allowed). Values are encrypted with AUTH_ENCRYPTION_KEY_RING under a
purpose derived from the flow, step and name, and are never returned, logged or put in audit events
(503 not_ready when the key ring is missing). Secrets belong to the step id in every version of the
flow, so changing a value needs no new upload. They are passed to the step in the dispatch request
on each run. management.flow.step_secret_set and management.flow.step_secret_deleted record the
flow, step and name (variable).
Security review notes
A security review is required before production turns this on. The points to check:
- Isolation. Tenant code runs only in the dispatch namespace, never in the API Worker; the API never evaluates it. User Workers get no bindings, so they cannot reach D1, Durable Objects, queues or the API’s secrets.
- Inputs. The step receives only
FlowCustomCodeEventSchema(validated before sending) and its own secrets. The answer is parsed withFlowCustomCodeResponseSchema, bounded at 4,096 bytes; a claims patch goes through the AUTH4D-154 metadata limits at write time. - Limits. 50 ms CPU and 5 subrequests per call (Workers for Platforms custom limits), the step
timeout, and the flow’s time budget. Hung or failing code follows
onError. - Outbound. Covered by
infra/cloudflare/flows/flows.test.mjs. Workers cannot open raw sockets and Cloudflare does not route Worker fetches to private networks, so the outbound Worker is defence in depth; DNS names that resolve to private addresses are not checked, because a Worker cannot resolve them. Redirects are returned to the step, so a redirect target goes through the outbound Worker again. - Upload token.
FLOWS_SCRIPT_UPLOAD_TOKENis a Worker secret scoped to Workers Scripts Edit on one account. It is sent only in the Authorization header toapi.cloudflare.com, never logged, stored or returned. Cloudflare error bodies are never surfaced. - Immutability. A published version’s script name is derived from its immutable definition; re-uploading after a prune produces the same content. Test scripts have their own names and are never dispatched by executions.
- Entitlement. Re-checked on every execution from D1 (primary); a revoked project’s steps follow the tenant-chosen fallback and are audited. Billing state never makes the engine skip steps that do not need the add-on.
- Logs. The Tail Worker attributes logs only through
flow_code_scripts; unknown script names are dropped. The wrapper redacts secret values of 4 characters or more from console output and exceptions. Tenants can still exfiltrate their own secrets deliberately (for example by encoding them), which is their own data. Logs are visible to every project member, viewers included. - Script count. Pruning bounds scripts per flow to 4 (3 published, 1 test); flows are bounded per project (50), so a project has at most 200 scripts.
- Known limitations. Deleting a project leaves its scripts in the namespace until an operator deletes them by tag. Rate limits on test runs are the per-actor management budget.
Storage
Migration 0028_flow_custom_code.sql adds flow_code_scripts, flow_step_secrets and
flow_code_logs. See d1-schema.md (repository file docs/operations/d1-schema.md).
Tests
tests/integration/auth4d-162.custom-code.test.ts: contracts, script building, the Cloudflare client against a mock API and dispatcher, entitlement gating at save, publish, rollback, test and execution, fallback and audit, step secrets, pruning, logs and tenant isolation. Workers for Platforms is replaced by an in-process fake runtime; no test runs tenant code or reaches the network.tests/integration/auth4d-162.console-custom-code.test.ts: the console step catalog, the code editor, error wording and the BFF allowlist.infra/cloudflare/flows/flows.test.mjs: the outbound policy and the Tail Worker.
Source: docs/api-spec/auth4d-162.md