ElementsGate

Overview

Permission to act — policy and rate gates on the trigger, left to right, first denial wins.

Gate is how your backend decides whether a caller may run a Flow. Policies and rate limits attach to the trigger with .gate(...). Identity comes from oke({ gate: { auth } }) into fx.auth; Gate policies read that principal — they do not invent a separate middleware layer.

For developers protecting APIs on okengine — declare the check, chain it on the trigger, keep do behind the first denial.

The one rule

First denial wins. Gates on a trigger evaluate left to right. The first reject stops the chain — later gates are skipped, and do never runs.

Chain — first denial wins

.gate(member, canBook, fair)
principalverified + scope
  1. 1memberpolicyauth.verified
  2. 2canBookscopebooking:create
  3. 3fairratemax:60 · per:"1m" · keyBy:"ip"
  4. Evaluating chain…

Smallest Example

Declare a policy and attach it

src/core/gate.ts
import { gate } from "okengine";

export const member = gate.policy("member", {
  description: "Signed-in workspace member",
  check: ({ auth }) => !!auth.verified,
});
src/flows/profile/get.ts
import { on, flow, http } from "okengine";
import { member } from "@/core/gate";

export const get = on(
  http.get().gate(member),
  flow({
    do: async (_, fx) => ({ userId: fx.auth.userId }),
  }),
);

Call with and without a session

# Anonymous → 401 Unauthorized (policy denied, no userId)
curl -X GET http://localhost:6530/profile -H "accept: application/json"

# Signed-in → 200 with principal
curl -X GET http://localhost:6530/profile \
  -H "accept: application/json" \
  -H "authorization: Bearer …"

Anonymous denial envelope:

{
  "data": null,
  "error": { "code": "Unauthorized", "message": "Authentication required.", "data": {} }
}

Auth posture is required

Every HTTP (and MCP tool) trigger must declare a gate chain or .public(). Omitting both fails boot with GateBootError. See Boot Posture.

Progressive Patterns

From a single policy to scopes, rates, and reusable chains:

Named ABAC check over auth / operator / optional meta:

src/core/gate.ts
import { gate } from "okengine";

export const member = gate.policy("member", ({ auth }) => !!auth.verified);

export const adminOnly = gate.policy("adminOnly", {
  description: "Admin scope required",
  check: ({ auth }) => auth.scopes.has("admin"),
});

Both forms are valid: a bare predicate, or { check, description? }.

Declaration Reference

DeclarationSignaturePurpose
gate.policygate.policy(name, check | { check, description? })Named ABAC / auth predicate
gate.scopegate.scope(name)Require auth.scopes.has(name)
gate.publicgate.public (handle)Intentionally unauthenticated sentinel
gate.rategate.rate({ max, per, keyBy?, … })KV-backed throttle
gate.allgate.all(...members)Reusable left-to-right chain
oke({ gate }) optionTypeDefaultMeaning
authbag / omittedoffSessions, keys, optional tenancy — see Authentication
policiesdecls / all handlesauto from registryExplicit bag; usually unnecessary
rateLimit.enabledbooleantrue when auth onStricter presets on auth Flows
unguardedHttp"deny" | "allow""deny""allow" only when env === "test"

Attaching Gates

Gates chain on the trigger, not on a gates: [...] field inside flow():

http.post().gate(member, gate.scope("editor"), gate.rate({ max: 100, per: "1m", keyBy: "user" }));

Public — open the route without authentication:

http.get().public();
// equivalent: http.get().gate(gate.public)

Resource mounts accept .gate(...) / .public() once — every CRUD verb (and live, when present) gets the same chain. See HTTP · Resources.

Evaluation Order

Detailed section

If you only need “attach and go”, the Smallest Example is enough. This section is the walk the runtime takes before do.

On each request the pipeline:

  1. Resolves identity into fx.auth (Bearer / cookie / API key when gate.auth is on).
  2. Resolves fx.tenant.id when gate.auth.tenant is enabled.
  3. Evaluates the trigger’s gate names in declaration order.
  4. Stops on the first denial — maps to a typed envelope; later gates never run.
  5. Invokes do only when every gate allowed.
src/flows/notes/create.ts
import { on, flow, http, gate } from "okengine";

const member = gate.policy("member", ({ auth }) => !!auth.verified);
const write = gate.scope("notes:write");
const throttle = gate.rate({ max: 60, per: "1m", keyBy: "user" });

// Order matters: identity → capability → quota
export const create = on(
  http.post().gate(member, write, throttle),
  flow({
    do: async (input, fx) => fx.json.create({ id: fx.id(), ...input }),
  }),
);
CallerFirst denialLater gates
AnonymousmemberUnauthorizedwrite / throttle skipped
Signed-in, no notes:writewriteForbiddenthrottle skipped
Signed-in + scope, quota burnedthrottleRateLimited
All passdo runs

Consequence: put identity policies before rates so anonymous traffic 401s instead of burning a shared "anon" quota under keyBy: "user".

Denial Mapping

Denials are typed error values — never thrown stacks mid-pipeline:

SituationCodeStatusTypical error.data
Policy denied, no auth.userIdUnauthorized401{}
Policy denied, principal presentForbidden403{ gate, reason }
Rate gate burned / no KVRateLimited429{ retryAfterMs }
Tenant required / not a memberForbidden403{ gate: "auth:tenants", reason }

Authenticated policy denial:

{
  "data": null,
  "error": {
    "code": "Forbidden",
    "message": "You are not allowed to perform this action.",
    "data": { "gate": "notes:write", "reason": "policy denied" }
  }
}

Rate burn:

{
  "data": null,
  "error": {
    "code": "RateLimited",
    "message": "Too many requests. Try again later.",
    "data": { "retryAfterMs": 42000 }
  }
}

Boot Posture

Every HTTP and MCP-tool trigger must carry at least one gate (including gate.public) or .public(). Missing posture throws GateBootError listing every gap:

gate boot failed — 2 trigger(s) missing auth posture (attach a gate or .public()):
  - notes.list GET /notes
  - notes.create POST /notes

unguardedHttp: "allow" skips the audit only when env === "test". Outside test it has no effect — migrate real apps with per-trigger .public().

MCP tools follow the same rule: a tool trigger with an empty gate list fails the same boot audit (listed as mcp + tool name).

The Capabilities of Gate

Troubleshooting

Learn more

  • Authenticationgate.auth, fx.auth, sessions and keys
  • Authorization — policies, scopes, gate.all
  • RLS — Gate identity stamped into SQL row policies
  • Rate Limits — strategies, keyBy, KV
  • Tenancyfx.tenant.id and isolation
  • HTTP.gate(...) / .public() on triggers
  • ErrorsUnauthorized · Forbidden · RateLimited

Next

On this page