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__andconstructorresolve to nothing. We do not use a JSONLogic library, so itsvarpath 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,methodand 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,provideroridentities.providersare values the field can hold, so"dicsord"is reported; - no
skipbranch 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,onErroron evaluation errors, publish-time problems, a bound flow through the engine hooks, and hostile draft bodies.
Source: docs/api-spec/auth4d-164.md