AUTH4D-164 Flows condition step

AUTH4D-164 adds a condition step to the AUTH4D-159 flow definition. It evaluates an expression over a restricted event and then continues, skips the next steps or denies. The schemas and the type checker are in packages/contracts (FlowConditionExpressionSchema, FlowConditionBranchSchema, FLOW_CONDITION_FIELDS, FLOW_CONDITION_LIMITS, flowConditionTypeProblems, flowDefinitionProblems). The evaluator is apps/api/src/modules/flows/condition.ts. There is no new route and no migration.

Language choice: a JSONLogic subset

We chose JSONLogic over CEL and implemented the subset in the repository, with no dependency.

  • Size. The JSONLogic subset is a few hundred lines shared by the API and the contracts package. It adds no package to the Worker bundle and no change to the lockfile. A CEL implementation brings a text parser, macros and a type environment as a new dependency.
  • Safety. JSONLogic expressions are already JSON, so the request body is the syntax tree: there is no parser to harden. Each operator is an explicit case in one evaluator, field names resolve through a Map, and nothing reads object properties by name, so __proto__ and constructor resolve to nothing. We do not use a JSONLogic library, so its var path lookup over plain objects is not part of the design.
  • Termination. The subset leaves out JSONLogic’s map, filter, reduce, all, some, none, merge, cat, substr, method and arithmetic. With no loops and no functions, evaluation visits each node at most once.
  • Type checking. A small type checker over a fixed field table is enough for this language. CEL’s checker would need a declared environment that mirrors the same table.

CEL reads better when written by hand. A console editor can show expressions in a friendlier form later without changing what is stored.

Step shape

{
  "id": "geo",
  "type": "condition",
  "onError": "fail_closed",
  "expression": { "in": [{ "var": "country" }, ["KP", "IR"]] },
  "then": { "action": "deny", "reasonCode": "region_blocked", "message": "region_unavailable" },
  "else": { "action": "continue" }
}
Field Notes
expression Must evaluate to true or false.
then Branch taken when the expression is true. Defaults to { "action": "continue" }.
else Branch taken when it is false. Defaults to { "action": "continue" }.
onError As for every step: fail_closed (default) denies with flow_step_failed; fail_open skips.
when As for every step. When it does not match, the condition does not run and skips nothing.

At least one branch must do something other than continue.

Branch action Fields Effect
continue none Runs the next step.
skip steps (1 to 15) Passes over the next steps steps. Their trace outcome is skipped.
deny reasonCode, optional fixed message key Denies like a deny step. message is one of the AUTH4D-160 keys.

The trace outcome of the condition step is continued, branched (it skipped steps) or denied. branched is a new value of FlowStepOutcomeSchema.

Event fields

Expressions read fields with { "var": "<name>" }. The name must be a literal string. There are no computed names and no default values.

Field Type Notes
trigger string The flow trigger.
method string How the user signed in (FlowMethodSchema).
country string or null The edge’s ISO 3166-1 alpha-2 guess.
provider string or null The context provider, when the trigger has one.
application.id string The application (client id).
user.id string Needs a user.
user.is_guest boolean Needs a user. True when every linked identity is a guest identity.
user.email_verified boolean Needs a user. True when the user has a verified email identity.
user.email_domains list of strings Needs a user. Lowercased domains of verified addresses only.
identities.providers list of strings Needs a user. Distinct providers of the linked identities.
identities.count number Needs a user. Number of linked identities (at most 50 are read).
provider_ids.steam list of strings Needs a user. Linked Steam IDs.
provider_ids.discord list of strings Needs a user. Linked Discord user IDs.
roles list of strings Needs a user. The user’s AUTH4D-154 roles in this application.

Fields that need a user are not available to pre_registration flows. The event never holds email addresses, IP addresses, tokens or metadata values. The step reads D1 only for the field groups an expression uses: one identities query and one roles query, both filtered by the trigger’s tenant, and by the application for roles.

Operators

An operation is an object with exactly one key, the operator. A single argument may be given without a list ({ "!": { "var": "user.is_guest" } }). A list in argument position is a literal list of strings (["KP", "IR"]).

Operator Arguments Result
var field name The field’s value.
==, === two values (not lists) Strict equality. == does not convert types.
!=, !== two values (not lists) Strict inequality.
!, !! one boolean Negation, or the boolean itself.
and, or 1 to 64 booleans Short-circuit logic.
if condition, value, …, else value The value of the first true condition. Conditions are boolean.
<, <= two or three numbers Comparison; three arguments test a range.
>, >= two numbers Comparison.
in string and list, or string and string List membership, or substring. A null needle or haystack is false.

There is no truthiness. Operators that take booleans refuse other types, so a condition cannot pass because a string happens to be non-empty.

Limits

Every parse of a definition (saving a draft, publishing, and each run) checks the structure:

Limit Value
Nesting 16 levels of operators and lists
Nodes 600 operators, field references and values
String literal 256 characters
List literal 500 strings
Number literal finite, within ±9007199254740991
Evaluation work 10 000 units

The structure check stops at the first size or nesting violation, so a hostile body costs at most the node limit. The definition as a whole stays within the AUTH4D-159 limit of 32 KiB.

Evaluation is capped by work, not by a clock, because Workers clocks do not advance during synchronous code. Each node costs one unit, and in also costs one unit per list item or string character it scans. An expression that runs out of units fails the step. The step also runs inside the flow’s time budget, which bounds the D1 reads.

Publish-time type checking

POST /management/tenants/{tenantId}/flows/{flowId}/publish now type-checks every condition against the event of the flow’s trigger before it publishes. Drafts are only structure-checked, so work in progress can be saved. The checks are:

  • every field exists and is available to the trigger;
  • operators get the types they need, and the whole expression gives a boolean;
  • strings compared with trigger, method, provider or identities.providers are values the field can hold, so "dicsord" is reported;
  • no skip branch passes the last step.

A draft with problems is not published. The response is 400, no-store, in the validation_failed detail shape so an editor can mark each field:

{
  "error": {
    "code": "flow_invalid",
    "message": "The draft cannot be published until its problems are fixed.",
    "request_id": "req_...",
    "details": [
      {
        "field": "definition.steps.0.expression.in.1.var",
        "issue": "\"roles\" is not available before registration."
      },
      {
        "field": "definition.steps.1.expression.==.1",
        "issue": "\"dicsord\" is never a value of this field."
      }
    ]
  }
}

The field path follows the stored JSON: step index, then operator keys and argument indexes. An editor can run flowDefinitionProblems(definition, trigger) from @auth4/contracts as the user types and get the same problems. The publish statement also requires the draft to be unchanged since it was checked, so a concurrent save cannot publish an unchecked definition (409 flow_changed).

Errors at run time

A published condition cannot fail type checking. It can still fail at run time: the work budget runs out, the database read fails, or a direct engine call runs an unchecked definition. The step then follows onError, like any other step: fail_closed denies with flow_step_failed, and fail_open is traced failed_open and the flow continues.

Tests

  • tests/integration/auth4d-164.condition-language.test.ts: structure limits, type checking and the evaluator, including deep nesting, huge literals and lists, wide trees, prototype-like keys and field names, and the work budget.
  • tests/integration/auth4d-164.condition-step.test.ts: branches and traces, event fields from D1 rows, tenant and application isolation, onError on evaluation errors, publish-time problems, a bound flow through the engine hooks, and hostile draft bodies.

Source: docs/api-spec/auth4d-164.md