ElementsGate

Authorization

gate.policy and gate.scope — reusable permission checks composed with gate.all and attached via .gate.

Authorization answers “may this principal do this?” after identity is known. Declare reusable gate.policy / gate.scope handles, compose them with gate.all, and attach the chain on the trigger.

For developers enforcing RBAC / ABAC on okengine — name the check once, reuse it on every route.

The one rule

Policies receive GatePolicyContext (auth, operator, optional meta) — never invent ctx.user or ctx.store. World access stays inside do via fx.

Smallest Example

Declare policies and scopes

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

/** Signed-in member. */
export const member = gate.policy("member", {
  check: ({ auth }) => !!auth.verified,
});

/** Holds the notes:write scope (name is the scope string). */
export const notesWrite = gate.scope("notes:write");

/** Admin-only ABAC example. */
export const adminOnly = gate.policy("adminOnly", ({ auth }) => auth.scopes.has("admin"));

gate.scope(name) is shorthand for gate.policy(name, ({ auth }) => auth.scopes.has(name)) with scopes: [name] recorded.

Compose and attach

src/flows/notes/create.ts
import { on, flow, http, gate } from "okengine";
import { member, notesWrite } from "@/core/gate";

export const notesMutate = gate.all(member, notesWrite);

export const create = on(
  http.post().gate(notesMutate),
  // or: .gate(member, notesWrite)
  flow({
    do: async ({ title }, fx) => fx.json.create({ id: fx.id(), title }),
  }),
);

See the denial

Missing notes:write on an authenticated caller:

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

Anonymous callers denied earlier in the chain get Unauthorized instead.

Progressive Patterns

From a single scope to Module:Action names, composition, and operator plane:

Prefer gate.scope when the check is exactly “has this scope string”:

export const bookingCreate = gate.scope("booking:create");
export const notesRead = gate.scope("notes:read");

Names containing : are Module:Action pairs — extracted into Manifest permissions for Console.

Policy Context

FieldTypeMeaning
auth.userIdstring | nullUser-plane principal
auth.scopesReadonlySet<string>Granted scopes (may include tenant-role union)
auth.verifiedboolean | undefinedCredential verified
auth.apiKeyIdstring | null | undefinedAPI-key principal when present
operator.idstring | nullOperator-plane principal
meta.ip / userIdoptionalSubject dims for rate keyBy

Policies must not touch the store, vault, or network — those belong in do via fx.

Declaration Forms

FormExampleNotes
Predicategate.policy("member", ({ auth }) => !!auth.verified)Shortest
Optionsgate.policy("member", { check, description? })Console / docs label
Scope shorthandgate.scope("notes:write")Records scopes: ["notes:write"]
Public sentinelgate.publicAlways allows; reserved name
Chaingate.all(a, b, c)Flattened at attach time

Reserved: gate.policy("public", …) and gate.scope("public") throw — use gate.public.

Module:Action Permissions

Detailed section

Scopes with a colon (notes:write) are Module:Action pairs. Manifest + Console derive the permission catalog from Flows, effects, and gate scopes — you do not hand-maintain a second list.

SourceExample pair
Flow id notes.createnotes:create
gate.scope("booking:create")booking:create
Effect reads: ["sql:notes"]store.sql:read
Operator-plane Flowalso console:… when applicable

Consequence: prefer gate.scope("notes:write") over a one-off policy with the same string — the scope is recorded on the declaration for Manifest / Console.

Tenant roles may grant application scopes only (notes:write), not console:*. See Tenancy.

Attaching on Resources & Live

Chain once on the mount — every verb (and live, when present) inherits the same gates:

src/flows/notes/index.ts
import { on, http } from "okengine";
import { member, notesWrite } from "@/core/gate";
import { notesResource } from "./resource";

export const notes = on(http.resource("/notes", notesResource.all()).gate(member, notesWrite));

Live firehoses use the same .gate(...) fluent:

on(http.live(orderStatus).gate(member));

See HTTP · Resources and HTTP · Live Streams.

Denial Mapping

SituationCodeStatuserror.data
Policy denied, no auth.userIdUnauthorized401{}
Policy denied, principal presentForbidden403{ gate, reason }
Unknown gate name at runtimedenyreason: "unknown gate: …"

reason is "policy denied" for failed predicates (unless a rate gate burned earlier).

Troubleshooting

Learn more

  • Authentication — how fx.auth is filled
  • RLS — row policies from the same Gate identity
  • Rate Limits — throttle on the same chain
  • Tenancy — tenant-role scope union
  • HTTP.gate(...) on triggers

Next

On this page