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)verified + scope- 1
memberpolicyauth.verified - 2
canBookscopebooking:create - 3
fairratemax:60 · per:"1m" · keyBy:"ip" - →
Evaluating chain…
Smallest Example
Declare a policy and attach it
import { gate } from "okengine";
export const member = gate.policy("member", {
description: "Signed-in workspace member",
check: ({ auth }) => !!auth.verified,
});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:
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
| Declaration | Signature | Purpose |
|---|---|---|
gate.policy | gate.policy(name, check | { check, description? }) | Named ABAC / auth predicate |
gate.scope | gate.scope(name) | Require auth.scopes.has(name) |
gate.public | gate.public (handle) | Intentionally unauthenticated sentinel |
gate.rate | gate.rate({ max, per, keyBy?, … }) | KV-backed throttle |
gate.all | gate.all(...members) | Reusable left-to-right chain |
oke({ gate }) option | Type | Default | Meaning |
|---|---|---|---|
auth | bag / omitted | off | Sessions, keys, optional tenancy — see Authentication |
policies | decls / all handles | auto from registry | Explicit bag; usually unnecessary |
rateLimit.enabled | boolean | true when auth on | Stricter 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:
- Resolves identity into
fx.auth(Bearer / cookie / API key whengate.authis on). - Resolves
fx.tenant.idwhengate.auth.tenantis enabled. - Evaluates the trigger’s gate names in declaration order.
- Stops on the first denial — maps to a typed envelope; later gates never run.
- Invokes
doonly when every gate allowed.
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 }),
}),
);| Caller | First denial | Later gates |
|---|---|---|
| Anonymous | member → Unauthorized | write / throttle skipped |
Signed-in, no notes:write | write → Forbidden | throttle skipped |
| Signed-in + scope, quota burned | throttle → RateLimited | — |
| All pass | — | do runs |
Consequence: put identity policies before rates so anonymous traffic 401s instead of
burning a shared "anon" quota under keyBy: "user".
Predicates receive GatePolicyContext only — auth, operator, optional meta (ip, userId,
…). They must not touch Store, Vault, or the network. World access stays in do via fx. See
Authorization.
A rate take records remaining and retryAfterMs on the evaluation. A burn maps to
RateLimited with { retryAfterMs } in error.data.
Missing KV still uses kind rate → typed RateLimited (often retryAfterMs: 0);
telemetry reason is "rate gate requires kv".
A name that is not registered denies with reason unknown gate: …. Prefer attaching the declared
handle (member) rather than a raw string so the registry always knows the predicate.
Denial Mapping
Denials are typed error values — never thrown stacks mid-pipeline:
| Situation | Code | Status | Typical error.data |
|---|---|---|---|
Policy denied, no auth.userId | Unauthorized | 401 | {} |
| Policy denied, principal present | Forbidden | 403 | { gate, reason } |
| Rate gate burned / no KV | RateLimited | 429 | { retryAfterMs } |
| Tenant required / not a member | Forbidden | 403 | { 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 /notesunguardedHttp: "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
Authentication
Enable gate.auth, populate fx.auth, then protect Flows with policies.
Authorization Policies
gate.policy and gate.scope for RBAC / ABAC; compose with gate.all.
RLS
Stamp Gate identity into SQL — store.schema.policy helpers filter rows.
Rate Limits
gate.rate with five KV strategies — sliding-window-counter by default.
Tenancy
Opt into gate.auth.tenant and read fx.tenant.id for isolation.
Troubleshooting
Cause: gate boot failed — N trigger(s) missing auth posture (attach a gate or .public()):. Every
listed HTTP/MCP trigger needs .gate(...) or .public(). unguardedHttp: "allow" only works
when env === "test".
A policy denied and auth.userId is null — or Bearer forge / expiry mapped to Unauthorized
before gates. Send a valid Bearer (or cookie when enabled), or mark the route .public() if it
should be open.
The principal is authenticated but failed a later policy (error.data.gate names it). Check
scopes on the session / API key, or the predicate in that policy.
A gate.rate burned its quota (or ran without KV). Wait retryAfterMs, raise max / widen
per, or configure a KV driver when reason telemetry shows "rate gate requires kv".
Use gate.public or .public() for open surfaces. Do not declare gate.policy("public", …) or
gate.scope("public").
gate.all() with zero members is invalid. Pass one or more policy / rate / nested all handles.
Learn more
- Authentication —
gate.auth,fx.auth, sessions and keys - Authorization — policies, scopes,
gate.all - RLS — Gate identity stamped into SQL row policies
- Rate Limits — strategies,
keyBy, KV - Tenancy —
fx.tenant.idand isolation - HTTP —
.gate(...)/.public()on triggers - Errors —
Unauthorized·Forbidden·RateLimited