GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/server

Multi-org isolation

Free in the open core: scopes every request to an isolated per-organization journal (via withOrg); runs, memory, and exactly-once guarantees are separated per organization, and an identity-bound org always wins over the header (403 on mismatch).

What it's for / when to use it#

When you want to serve multiple customers (organizations) from a single GNL deployment — e.g. in a SaaS product, each customer's run history, memory, and usage/budget counters must be completely separate from the others. When the org option is turned on, every request lands on a separate journal scoped via withOrg; organizations cannot see each other's keys, and the same runId is independent across organizations.

The organization a token belongs to is part of the token: Principal.orgId, set from Cred.orgId in roleAuth. It always overrides the x-gnl-org header — that identity gets a 403 if it asks for a different organization, so a token can never reach outside its own organization.

Once org is set, an identity bound to no organization is refused (403) rather than treated as a cross-organization operator. Reaching every organization is an explicit grant: the superAdmin class. A forgotten orgId therefore closes a door instead of opening all of them.

Setup / import#

org and OrgOptions are options of @gnldev/server's createRestApi; withOrg comes from @gnldev/durable underneath; the organization binding (Cred.orgId) comes from the free roleAuth in @gnldev/auth. No license is involved.

npm install
npm install @gnldev/server @gnldev/durable @gnldev/auth
config + auth
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { createRestApi } from '@gnldev/server';
import { roleAuth } from '@gnldev/auth';

const storage = new SqliteStorage('runs.db');
const config = { storage, agents: { starwars: { model, system: SYSTEM, tools } } };

// `superAdmin`, not a bare admin: with the `org` option configured, an identity bound to no
// organization is refused (403). The cross-organization scope is an EXPLICIT grant, never
// inferred from a missing orgId. The 'acme' viewer only ever sees acme's data.
const auth = roleAuth({
  superAdmin: { token: ADMIN_TOKEN },
  viewer: { token: ACME_TOKEN, orgId: 'acme' },
});

Step-by-step usage#

Passing org: {} to createRestApi is enough — by default the organization is resolved from the x-gnl-org header (customizable via OrgOptions.resolve). The registry is lazily set up per organization and cached.

turn org scoping on
app.mount('/api', createRestApi(config, {
  title: 'SWAPI Pro',
  auth,
  org: {},       // opt-in: every request lands in its organization's journal (withOrg)
  budgets: {},   // optional — per-organization budget/quota (separate doc: budget-quota)
}));

The platform operator (superAdmin, no organization by design) can act in whichever organization it names in the header:

superAdmin: pick an organization via x-gnl-org
# superAdmin: acts in any organization via the x-gnl-org header
curl -s -X POST http://localhost:3002/api/agents/starwars/run \
  -H 'content-type: application/json' -H 'authorization: Bearer ${ADMIN_TOKEN}' -H 'x-gnl-org: acme' \
  -d '{"runId":"demo-1","prompt":"Who is Darth Vader?"}'

An identity bound to an organization (viewer) lands in its own organization even without the header; if it tries to request a different organization, it gets a 403:

org-bound viewer + mismatch 403
# organization-BOUND viewer: scoped to acme even without the header
curl -s http://localhost:3002/api/usage -H 'authorization: Bearer ${ACME_TOKEN}'

# the same viewer asking for another organization (x-gnl-org: globex) → 403 org mismatch
curl -s http://localhost:3002/api/runs -H 'authorization: Bearer ${ACME_TOKEN}' -H 'x-gnl-org: globex'
# -> 403 "organization mismatch: identity is bound to organization 'acme'"

The underlying isolation mechanism can also be used directly — manually scope a journal to an organization:

withOrg — direct usage
import { withOrg } from '@gnldev/durable';

// createRestApi does this AUTOMATICALLY with opt-in org, but you can also use it directly:
// EVERY key in the journal is written/read under an `org:<orgId>:` prefix — runs, memory,
// queue and cache included, so EVERYTHING is isolated (same Journal interface; exactly-once
// is inherited for free).
const scoped = withOrg(baseJournal, 'acme');
// scoped.listRuns() returns ONLY acme's runs (with the prefix stripped).

API reference#

fnwithOrg

Scopes a journal to an organization (@gnldev/durable): the returned journal implements the same interface (get/put/putIfAbsent/listKeys/deletePrefix/readRun/listRuns are bridged if present), all keys are carried with an `org:<orgId>:` prefix and the prefix is stripped on read. Scoping an already-scoped journal throws.

typeRestApiOptions.org

A createRestApi option (@gnldev/server): opt-in multi-org support — when given, every request sees a journal scoped via withOrg, and the resolved organization is injected into requestContext. With an auth provider, an identity bound to no organization and without the platform-admin grant gets 403.

typeOrgOptions

{ resolve?, required?, requireRegistration?, maxInstances? } — resolve takes the web Request (default: the x-gnl-org header); required: true → a request without an organization gets 400 (default false: it runs in the shared scope); requireRegistration: true → only registered organizations may run (e.g. created via Studio's POST /organizations); maxInstances caps the per-organization registries kept in memory (default 512).

fnprincipalOf

@gnldev/auth: reads the authenticated Principal from the web Request; Principal.orgId comes from here and determines the bound organization inside scope().

typeCred.orgId

An optional field on roleAuth's (@gnldev/auth) credential (Cred): when given, that class's identity is bound to an organization — it's always scoped to that organization regardless of the request header, and a 403 is returned if a different organization is requested.

Free in the open core
Organization isolation needs no license: the org option, Principal.orgId and the fail-closed rule all ship in @gnldev/server, @gnldev/auth and @gnldev/durable. The paid @gnldev/auth-ee sells management — SSO, a user directory, RBAC, FGA and audit — never who can see what.
Tip
The per-organization registry (including model-fallback, memory) is set up lazily and cached — each request doesn't make a new createGnl call. Budget/quota is also split per organization; see the Budget & Quota doc for details.