Docs menu
User management (journal-backed)
A user store that resolves bearer tokens to a Principal, storing the token HASHED in the journal; add/remove from Studio's 'Users' view, with the token shown only once at creation.
What it's for / when to use it#
The open-core roleAuth works with a handful of tokens defined in hardcoded config — adding a new user requires a deploy. createJournalUserStore instead keeps users in the journal (data-driven, like policy/budget): an operator creates a new user from Studio, the returned token is handed to that person, and the user makes requests with that token via Authorization: Bearer <token> — no deploy, no code change.
Concrete scenario: in a SaaS deployment, the operator (an organization-less admin) creates a member from Studio's "Users" view for each new customer organization (with an organization assignment), and hands the token to the customer; the customer uses the API scoped to their own organization with that token. An organization-admin, in turn, can only manage members of their own organization.
Setup / import#
import { createEnterpriseAuth, createJournalUserStore, createJournalAuditSink } from '@gnldev/auth-ee';
import { toJournal } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';@gnldev/auth-ee is a paid/licensed package. createJournalUserStore requires a journal that supports listKeys to store users — toJournal(storage.runs) (Sqlite/Postgres/InMemory all provide listKeys) fits that structurally.
Step-by-step usage#
1. Set up the store on the journal and pass it to createEnterpriseAuth as userStore — it kicks in after SSO, before the open-core fallback:
const userStore = createJournalUserStore(toJournal(storage.runs));
const auth = createEnterpriseAuth({
licenseKey,
failClosed: true,
userStore,
audit: createJournalAuditSink(toJournal(storage.runs)),
fallback: roleAuth({
admin: { token: ADMIN_TOKEN, user: 'ops' }, // bootstrap operator
}),
});2. Pass that same userStore to Studio via the users option — this opens the "Users" view in Studio and the /users endpoints:
app.route('/studio', createStudioApp({
reader: toJournal(storage.runs),
gnl,
auth,
users: userStore, // the Studio "Users" view — add/remove; the token is shown once
}));3. When a new user is created from Studio (or programmatically via userStore.create()), the returned token is shown only at that moment — the server never stores the plain token anywhere, only its SHA-256 hash is written to the journal:
const { user, token } = await userStore.create({
email: '[email protected]',
roles: ['viewer'],
orgId: 'acme',
ttlMs: 30 * 24 * 60 * 60 * 1000, // expires automatically after 30 days
});
// token: 'eeu_...' — returned HERE ONLY; if it is lost, userStore.revoke() then create() again4. The user sends that token in an Authorization: Bearer eeu_... header; authenticate() looks up the token's SHA-256 in the journal's reverse index, and if it isn't expired (expiresAt) or revoked (revoked), it returns a Principal and updates lastUsedAt on a best-effort basis.
To cut off a user's access without deleting them, use userStore.revoke(id) — the record stays (for audit/history), only the token's reverse index is removed and it's marked revoked: true.
API reference#
createJournalUserStore(journal: JournalLike) => JournalUserStore. The journal must support listKeys (Sqlite/Postgres/InMemory provide it); throws otherwise.
JournalUserStoreThe authenticate(token, now?), list(), create(input), remove(id), revoke(id) contract.
EeUserRecordThe full record stored in the journal: id, roles, orgId?, tokenHash, createdAt, expiresAt?, lastUsedAt?, revoked?.
EeUserPublicEeUserRecord minus tokenHash — the safe view exposed by list() and to Studio.
CreateUserInputcreate()'s input: email?, name?, roles?, orgId?, ttlMs? (takes priority), expiresAt?.
StudioAppOptions.usersThe StudioUserStore passed to createStudioApp — if provided, it opens Studio's 'Users' view + the /users endpoints.
@gnldev/auth-ee is paid/licensed — without a valid license (licenseKey), createEnterpriseAuth falls back to fallback and userStore never kicks in; if failClosed: true is set, boot throws an error on an invalid license.'admin' (write) or 'viewer' (read), scoped to an organization. Two more exist and are easy to miss — 'member' sits between them (read plus agents:run, so someone can run an agent without becoming an admin), and the reserved 'platform-admin' is the explicit cross-organization grant. Without that last one an org-less user is fail-closed, so nobody becomes a super-admin by forgetting an orgId. A separate permissions[] overrides the role's grants entirely when you set it — that is how the six named read permissions are assigned per person.