GNL
Docs menu
Core · Free@gnldev/auth

Open-core auth (roleAuth)

A free, opt-in identity layer: four credential classes (superAdmin / admin / client / viewer) over bearer or basic tokens. Pick by WHO HOLDS the token, not by how much it needs to do.

What it's for / when to use it#

GNL's REST API and Studio are open by default — with no auth configured, anyone can read and write. roleAuth closes that in one line. The four classes exist because one “admin” was doing two unrelated jobs: an application that only needs to run an agent was carrying the same token that cancels runs, edits budgets and reads the whole organization's history.

This layer is open-core, and the line is deliberate: isolation is free — the organization boundary, the four classes and the resourceId rules that keep one end user's conversations apart from another's are all here, and none of them switch on when you pay. What is paid is identity management: SSO, per-user accounts, an audit trail, and permissions per person rather than four fixed classes. @gnldev/auth-ee implements the same AuthProvider contract, so moving up does not change your call sites.

Setup / import#

package
import { roleAuth, type Cred } from '@gnldev/auth';
import { createRestApi } from '@gnldev/server';
import { createStudioApp } from '@gnldev/studio';

@gnldev/auth is a standalone, independent package; both @gnldev/server (REST API) and @gnldev/studio accept the auth option as AuthProvider | undefined — the same provider is passed to REST and Studio at the same time.

Step-by-step usage#

1. Build a Cred per class from the environment. The classes are not tiers of the same thing — client is what your BACKEND carries, admin is what a PERSON carries:

src/index.ts
const cred = (name: string): Cred | undefined =>
  process.env.GNL_NO_AUTH === '1'
    ? undefined
    : { token: process.env[`GNL_${name}_TOKEN`], orgId: 'acme' };

// The credential your APPLICATION carries runs agents, manages nothing.
const clientAuth = roleAuth({ client: cred('CLIENT') });

// The credential a PERSON carries, for Studio.
const operatorAuth = roleAuth({ admin: cred('ADMIN'), viewer: cred('VIEWER') });

2. Give each host the credential that belongs to it. This used to be one shared auth object for both; it no longer should be, because Studio is an operator console and refuses an application credential outright (403) rather than serving it one end user at a time:

src/index.ts
// Your backend calls this one.
app.route('/api', createRestApi(config, { title: 'My API', auth: clientAuth }));

// A person opens this one. Studio refuses an application credential outright.
app.route('/studio', createStudioApp({
  reader: toJournal(storage.runs),
  apiBase: '/studio',
  gnl: createStudioRunner(gnl, config, { toJsonSchema: aiToolSchema }),
  auth: operatorAuth,
}));

3. Your backend calls the REST API with the client token, and names the end user each request acts for. A client credential serves many users under one token, so resourceId is required — without it the request is refused rather than served unscoped:

# resourceId names the end user this request acts for. A client credential
# serves many of them, so it is required — without it the call is a 400.
curl -s -X POST http://localhost:3001/api/agents/support/run \
  -H 'content-type: application/json' \
  -H "authorization: Bearer $GNL_CLIENT_TOKEN" \
  -d '{"runId":"r-882","prompt":"where is my order?","threadId":"t-ayse-1","resourceId":"u-ayse"}'

# Ask for one end user's data, and have ownership checked:
curl -s "http://localhost:3001/api/runs?resourceId=u-ayse" -H "authorization: Bearer $GNL_CLIENT_TOKEN"

Studio's SSE connections cannot send an Authorization header, so the same bearer token is also accepted via ?token= (bearer only, GET only). That value can land in proxy logs and browser history — prefer Studio's short-lived ?ticket= flow where you can.

Scope, role, and the ceiling between them#

Authorization here is two axes kept deliberately apart. Scope is where an identity may act — one organization, or the whole platform. Role is what it may do — read, run, manage. Conflating them is how an identity that was only ever meant to manage one tenant ends up managing all of them.

Platform scope is an explicit grant: the reserved platform-admin role. It is never inferred from an identity happening to carry no orgId. That inference is the classic fail-open footgun — forgetting to set an organization would mint a super-admin — so the strict model treats an unbound identity without the grant as { kind: 'none' } and denies it.

principalScope — the resolved "where"
import { principalScope, isPlatformAdmin } from '@gnldev/auth';

principalScope({ id: 'u1', roles: ['platform-admin'] });  // { kind: 'platform' }
principalScope({ id: 'u2', roles: ['admin'], orgId: 'acme' }); // { kind: 'org', orgId: 'acme' }
principalScope({ id: 'u3', roles: ['admin'] });           // { kind: 'none' } -> strict model denies

isPlatformAdmin({ id: 'u3', roles: ['admin'] });          // false — no orgId is NOT a platform grant

Precedence is deliberate: an explicit platform grant wins over an org binding, because a platform-admin is meant to be cross-organization. Everything else with an orgId is org-scoped, and everything else is nothing.

The ceiling. A user-management surface that validates only the target's organization leaves a hole: an org-bound admin could hand itself the platform-admin role and walk out of its own organization. assertAssignablePrivileges closes it — nobody can grant a privilege they do not themselves hold.

assertAssignablePrivileges — nobody grants above their own ceiling
import { assertAssignablePrivileges } from '@gnldev/auth';

const acmeAdmin = { id: 'u2', roles: ['admin'], orgId: 'acme' };

assertAssignablePrivileges(acmeAdmin, { roles: ['member'] });
// { ok: true } — an ordinary org role, the target keeps orgId 'acme'

assertAssignablePrivileges(acmeAdmin, { roles: ['platform-admin'] });
// { ok: false, reason: "only a platform-admin can grant the 'platform-admin' role" }

assertAssignablePrivileges(acmeAdmin, { permissions: ['*'] });
// { ok: false, reason: "only a platform-admin can grant the '*' (all-permissions) grant" }

The rule is minimal on purpose. A platform-admin may assign anything. Anyone else may not grant the platform-admin role, nor the '*' all-permissions grant — which is the same escalation expressed on the permission axis rather than the role axis. Ordinary organization roles stay assignable, because the target keeps its own orgId and so nothing crosses an organization boundary.

API reference#

fnroleAuth

{ superAdmin?, admin?, client?, viewer? } → AuthProvider | undefined. Returns undefined when no class is given (opt-in). { admin, viewer } alone behaves exactly as it did before the two new classes existed.

fnprincipalScope

Derives the scope: { kind: 'platform' } | { kind: 'org', orgId } | { kind: 'none' }. An explicit platform grant outranks an org binding.

fnisPlatformAdmin

True only when the principal carries the explicit platform-admin role. Being unbound (no orgId) is deliberately not enough.

fnassertAssignablePrivileges

Privilege ceiling for user creation and update: returns { ok: false, reason } when a non-platform-admin tries to grant the platform-admin role or the '*' permission.

typePrincipalScope

The resolved 'where' of an identity, independent of its role.

typeAssignabilityResult

{ ok: true } | { ok: false, reason } — the ceiling check outcome.

typeCLIENT_WRITES

The exact set of writes a client credential may perform (agents:run, workflow:run, run:cancel). It is a WHITELIST — a write this set does not name is refused, including on routes added in later versions.

typePLATFORM_ADMIN_ROLE

The reserved cross-organization grant that superAdmin carries. Never inferred from a missing orgId — that inference would turn a forgotten config line into a cross-org super-admin.

fnmakeGate

Converts an AuthProvider into a shared Hono gate (allow/deny) — REST and Studio don't reimplement this logic.

fnprincipalOf

Reads the Principal authenticated during allow() from the context (within the same request) — org/audit derive from this.

fnnormalizeAuth

AuthProvider | ReadWriteAuth | undefined → AuthProvider | undefined. Hosts collapse this to a single type.

fnfromReadWrite

Wraps the legacy { read, write } predicate pair (StudioAuth) into an AuthProvider — a backward-compatibility bridge.

typeAuthProvider

Stable contract: authenticate(c), authorize(principal, c, ctx), optional capabilities().

typePrincipal

The authenticated subject: { id?, roles, orgId?, permissions? }. The free tier uses roles; 'permissions' is what the paid RBAC fills in.

typeDecision

{ allow: true } | { allow: false, status?: 401 | 403, reason? } — the authorization decision.

typeCred

{ token?, user?, pass?, orgId?, platformAdmin? } — a class's bearer token and/or basic credentials. 'orgId' binds the identity to one organization; a request naming a different one is refused. 'platformAdmin: true' injects the reserved platform-admin role on any class — the static-config door to the cross-organization grant that the 'superAdmin' class carries by name. Prefer the class: it says which credential this IS, rather than adding a privilege to one that reads as an ordinary admin.

typeReadWriteAuth

A { read?, write? } predicate pair — converted into an AuthProvider via fromReadWrite.

Note
The gate is opt-in: roleAuth({}) with no credentials returns undefined and GNL keeps its open behavior — nothing breaks backward-compatibly. In production, though, an absent provider is an error rather than a silent open door.
Tip
The paid @gnldev/auth-ee implements the same AuthProvider interface for deployments that need SSO/RBAC/multi-org support — the migration requires no code changes on the REST/Studio side, just swapping the auth value.