Docs menu
Multi-org isolation
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 header always wins (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.
An identity bound to an organization (given via Cred.orgId, e.g. a viewer token) always overrides the x-gnl-org header: that identity gets a 403 even if it requests a different organization — so a token can never leak outside its own organization.
Setup / import#
org and OrgOptions are options of @gnldev/server's createRestApi; withOrg comes from @gnldev/durable underneath. For an identity bound to an organization (Cred.orgId), you use EE auth (@gnldev/auth-ee) or the free roleAuth's orgId field.
npm install @gnldev/server @gnldev/durable # @gnldev/auth-ee arrives separately, under licenseimport { SqliteStorage } from '@gnldev/durable/sqlite';
import { createRestApi } from '@gnldev/server';
import { createEnterpriseAuth } from '@gnldev/auth-ee';
import { roleAuth } from '@gnldev/auth';
const storage = new SqliteStorage('runs.db');
const config = { storage, agents: { starwars: { model, system: SYSTEM, tools } } };
// The 'acme-viewer' token carries an IDENTITY-BOUND organization (orgId) → it only ever sees
// acme's data; asking for another org returns 403. admin is global and can act in any
// organization via the x-gnl-org header.
const auth = createEnterpriseAuth({
licenseKey: process.env.GNL_LICENSE_KEY,
publicKey: process.env.GNL_EE_PUBLIC_KEY,
failClosed: true,
fallback: roleAuth({
admin: { token: ADMIN_TOKEN, user: 'ops' },
viewer: { token: ACME_TOKEN, orgId: 'acme' }, // demo viewer bound to an organization
}),
});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.
app.route('/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)
}));A global (bootstrap) identity can operate in whichever organization it requests via the header:
# admin: 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:
# 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:
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#
withOrgScopes 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.
RestApiOptions.orgA 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.
OrgOptions{ resolve?: (c) => orgId, required?: boolean } — if resolve isn't given, the x-gnl-org header is read; if required is true, a request without an organization gets a 400 (if false, it runs in the shared space).
principalOf@gnldev/auth: reads the authenticated Principal from the Hono Context; Principal.orgId comes from here and determines the bound organization inside scope().
Cred.orgIdAn optional field on roleAuth's (@gnldev/auth) credential (Cred): when given, that role'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.
@gnldev/auth-ee is required (see Signed license). EE auth isn't required for an organization bound to an identity; it can also be bound via the free roleAuth's Cred.orgId field — but the org option itself is free in @gnldev/server; it's the full identity/role/license chain that's paid.createGnl call. Budget/quota is also split per organization; see the Budget & Quota doc for details.