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 }):

  • event is FlowCustomCodeEventSchema: { type: "flow.custom_code", version: 1, step, sent_at, input }, where input is the trigger input (trigger, tenant_id, application_id, method, context with the edge country and provider, and user_id except for pre_registration). Nothing else from the request reaches the step: no addresses, tokens, emails or headers. The object is deep-frozen.
  • secrets holds 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 generated index.js that imports each step’s module as step-N.js. Script names are a4f- 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 use PUT /accounts/{account}/workers/dispatch/namespaces/{namespace}/scripts/{name} with the FLOWS_SCRIPT_UPLOAD_TOKEN Worker secret, bindings: [], limits.cpu_ms and the Tail Worker as tail_consumers.
  • Calls. The engine calls the script through the FLOW_DISPATCHER dispatch namespace binding with limits: { cpuMs: 50, subRequests: 5 } and outbound: { 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, .local and similar), auth4.dev and its subdomains and the hosts in BLOCKED_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 through flow_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 /flows and PUT /flows/{flowId}/draft refuse custom code steps without the entitlement.
  • POST /flows/{flowId}/publish uploads the version’s script first. 400 custom_code_rejected means Cloudflare refused the module (for example a syntax error); 503 custom_code_unavailable means the runtime is missing or failed and the publish can be retried. The draft stays a draft. The management.flow.published audit event gains custom_steps.
  • POST /flows/{flowId}/rollback re-uploads a pruned script and needs the entitlement.
  • POST /flows/{flowId}/test runs custom code for real for owners and admins: a draft is uploaded as a test script. For viewers, and when the runtime is not configured, custom code steps are simulated as continuing and listed in simulatedSteps. 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:

  1. 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.
  2. Inputs. The step receives only FlowCustomCodeEventSchema (validated before sending) and its own secrets. The answer is parsed with FlowCustomCodeResponseSchema, bounded at 4,096 bytes; a claims patch goes through the AUTH4D-154 metadata limits at write time.
  3. 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.
  4. 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.
  5. Upload token. FLOWS_SCRIPT_UPLOAD_TOKEN is a Worker secret scoped to Workers Scripts Edit on one account. It is sent only in the Authorization header to api.cloudflare.com, never logged, stored or returned. Cloudflare error bodies are never surfaced.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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