@stratum-hq/lib
@stratum-hq/lib is the direct library for embedding Stratum in your Node.js application. It talks directly to PostgreSQL with no HTTP server in between, giving you maximum performance for tenant operations.
Installation
Section titled “Installation”npm install @stratum-hq/lib @stratum-hq/core pgWhen to Use
Section titled “When to Use”| Use Case | Package |
|---|---|
| Node.js app, maximum performance | @stratum-hq/lib |
| Serverless functions | @stratum-hq/lib |
| Testing and scripting | @stratum-hq/lib |
| Polyglot stack, service separation | @stratum-hq/sdk + control plane |
| React admin UI | @stratum-hq/sdk + @stratum-hq/react |
Quick Start
Section titled “Quick Start”import { Pool } from "pg";import { Stratum } from "@stratum-hq/lib";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });const stratum = new Stratum({ pool });
const tenant = await stratum.createTenant({ name: "Acme Corp", slug: "acme_corp", isolation_strategy: "SHARED_RLS",});
const config = await stratum.resolveConfig(tenant.id);const permissions = await stratum.resolvePermissions(tenant.id);Constructor
Section titled “Constructor”const stratum = new Stratum({ pool: pgPool, // Required: pg.Pool instance keyPrefix: "sk_live_", // Optional: API key prefix (default: "sk_live_") logger: myLogger, // Optional: StratumLogger (default: defaultLogger) autoMigrate: false, // Optional: run migrations on initialize() (default: false) enforceRls: false, // Optional: hard-fail migration if the PG role has BYPASSRLS (default: false)});The pool is borrowed, not owned – Stratum never creates or closes the pool. You manage the pool lifecycle.
stratum.initialize(): Promise<void>Call initialize() once before using any other method. When autoMigrate is enabled it runs migrations (with an advisory lock so concurrent calls are safe); with autoMigrate: false it is a cheap no-op that still marks the instance ready. It is safe to call concurrently – every caller awaits the same promise. Set enforceRls: true in production so migrations refuse to run under a BYPASSRLS role.
API Reference
Section titled “API Reference”Tenants
Section titled “Tenants”stratum.createTenant(input, audit?): Promise<TenantNode>stratum.getTenant(id, includeArchived?): Promise<TenantNode>stratum.getTenantBySlug(slug, includeArchived?): Promise<TenantNode>stratum.listTenants(pagination): Promise<PaginatedResult<TenantNode>>stratum.updateTenant(id, patch, audit?): Promise<TenantNode>stratum.deleteTenant(id, audit?): Promise<void>stratum.moveTenant(id, newParentId, audit?): Promise<TenantNode>stratum.getAncestors(id): Promise<TenantNode[]>stratum.getRoot(id): Promise<TenantNode>stratum.getDescendants(id, includeArchived?): Promise<TenantNode[]>stratum.getChildren(id): Promise<TenantNode[]>stratum.reorderTenant(id, position, audit?): Promise<TenantNode>stratum.batchCreateTenants(inputs, audit?): Promise<BatchCreateResult>stratum.getTenantContext(tenantId): Promise<TenantContext>getTenantBySlug(slug, includeArchived?)– resolve a tenant by its globally unique slug in one indexed lookup, the slug-keyed counterpart togetTenant. ThrowsTenantNotFoundErrorwhen no row matches, and (unlessincludeArchivedis set)TenantArchivedError/TenantSuspendedErrorfor a non-active row.getRoot(id)– the root ancestor of any tenant (the tenant itself when it is already a root), resolved with single-row lookups rather than by walking the full ancestry chain.getDescendants(id, includeArchived?)– the descendant subtree, shallowest first. Excludes archived and soft-deleted tenants by default; passincludeArchivedfor the full historical subtree.batchCreateTenants(inputs, audit?)– creates every tenant in a single transaction (all-or-nothing). On any failure nothing persists and the result is{ created: [], errors: [<first failure>] }; wrap per-tenantcreateTenantcalls yourself if you need partial success.
Tenant lifecycle
Section titled “Tenant lifecycle”Tenants move through an explicit state machine – active to suspended or archived, and either back to active or on to a purge.
stratum.suspendTenant(id, audit?): Promise<TenantNode>stratum.resumeTenant(id, audit?): Promise<TenantNode>stratum.archiveTenant(id, audit?): Promise<TenantNode>suspendTenant– a reversible block on access. Rejects if the tenant is not active or has active children (suspend leaf-first). Reverse withresumeTenant.archiveTenant– a reversible soft delete. Accepts an active or suspended tenant; rejects if already archived or it has active children. Reverse withresumeTenant. This is the canonical name for whatdeleteTenantdoes.resumeTenant– returns a suspended or archived tenant to active. Rejects if the tenant is already active or its parent is not active (resume top-down).deleteTenant(id, audit?)is retained as a deprecated alias ofarchiveTenant; preferarchiveTenant.
See the tenant lifecycle guide for the full state machine and descendant rules.
Flat-tenancy convenience
Section titled “Flat-tenancy convenience”Thin aliases for simple, single-level SaaS that never needs the hierarchy.
stratum.createOrganization(input, audit?): Promise<TenantNode> // createTenant with parent_id: nullstratum.listOrganizations(pagination): Promise<PaginatedResult<TenantNode>> // root-level onlystratum.getOrganization(id): Promise<TenantNode> // alias for getTenantConfig
Section titled “Config”stratum.resolveConfig(tenantId): Promise<ResolvedConfig>stratum.setConfig(tenantId, key, input, audit?): Promise<ConfigEntry>stratum.deleteConfig(tenantId, key, audit?): Promise<void>stratum.getConfigWithInheritance(tenantId): Promise<ResolvedConfig>stratum.batchSetConfig(tenantId, entries, audit?): Promise<BatchSetConfigResult>stratum.diffConfig(tenantIdA, tenantIdB): Promise<ConfigDiff>stratum.computeDrift(parentId, childId): Promise<DriftResult>stratum.batchComputeDrift(parentId, childIds): Promise<BatchDriftResult>computeDrift(parentId, childId)– classify each resolved config key on the child against the parent asok/override/missing/conflict(aconflictis a child value that diverges from a locked parent key), with per-key detail and a rolled-up worst status.batchComputeDrift(parentId, childIds)–computeDriftfanned out over many children, with a per-status summary count.
Permissions
Section titled “Permissions”stratum.resolvePermissions(tenantId): Promise<Record<string, ResolvedPermission>>stratum.createPermission(tenantId, input, audit?): Promise<PermissionPolicy>stratum.updatePermission(tenantId, policyId, input, audit?): Promise<PermissionPolicy>stratum.deletePermission(tenantId, policyId, audit?): Promise<void>ABAC (Attribute-Based Access Control)
Section titled “ABAC (Attribute-Based Access Control)”stratum.createAbacPolicy(tenantId, input): Promise<AbacPolicy>stratum.getAbacPolicies(tenantId): Promise<AbacPolicy[]>stratum.resolveAbacPolicies(tenantId): Promise<ResolvedAbacPolicy[]>stratum.evaluateAbac(tenantId, request): Promise<AbacEvaluationResult>stratum.deleteAbacPolicy(tenantId, policyId): Promise<void>ABAC policies inherit through the tenant hierarchy using the same LOCKED/INHERITED/DELEGATED modes as permissions. See the ABAC guide for details.
API Keys
Section titled “API Keys”stratum.createApiKey(tenantId, nameOrOptions?, expiresAt?): Promise<CreatedApiKey>stratum.validateApiKey(key): Promise<ValidatedApiKey | null>stratum.revokeApiKey(keyId): Promise<boolean>stratum.rotateApiKey(keyId, newName?): Promise<CreatedApiKey>stratum.listApiKeys(tenantId?): Promise<ApiKeyRecord[]>stratum.getApiKey(id): Promise<ApiKeyRecord | null>stratum.listDormantKeys(dormantDays?): Promise<ApiKeyRecord[]>getApiKey(id)– look up a single API key by id, including its owning tenant; returnsnullwhen no key has that id. The primitive for authorizing an operation that targets a key by id whose owning tenant is not otherwise in the request.
Webhooks
Section titled “Webhooks”stratum.createWebhook(input, audit?): Promise<Webhook>stratum.getWebhook(id): Promise<Webhook>stratum.listWebhooks(tenantId?): Promise<Webhook[]>stratum.updateWebhook(id, input, audit?): Promise<Webhook>stratum.deleteWebhook(id, audit?): Promise<void>stratum.testWebhook(id): Promise<TestResult>stratum.listWebhookEvents(query): Promise<WebhookEvent[]>stratum.listDeliveriesByEvent(eventId): Promise<WebhookDelivery[]>listWebhookEvents({ tenantId, type?, from?, to?, limit?, offset? })– page a tenant’s webhook event stream, newest first. Always scoped totenantId(a caller can never page another tenant’s events), optionally narrowed by eventtypeand acreated_atwindow, paginated withlimit(1-100, default 50) andoffset.listDeliveriesByEvent(eventId)– every delivery attempt recorded for a single webhook event, newest first.
Audit Logs
Section titled “Audit Logs”stratum.queryAuditLogs(query): Promise<AuditEntry[]>stratum.getAuditEntry(id): Promise<AuditEntry | null>stratum.recordAuditEvent(input): Promise<AuditEntry>recordAuditEvent(input)– append a custom event to Stratum’saudit_logsthrough the public surface (Stratum owns the table and otherwise exposes only reads). The input is validated and written on the same path the internal services use, so the entry is indistinguishable from one Stratum writes itself and is immediately queryable viaqueryAuditLogs. The row is stamped forinput.tenantIdand no other tenant. Pass an optionaloccurredAt(ISO 8601 string orDate) to set the row’screated_atwhen seeding historical or backdated events; omit it and the row is stampednow().
const entry = await stratum.recordAuditEvent({ tenantId, actorId, actorType: "api_key", // 'api_key' | 'jwt' | 'system'; defaults to 'system' action: "invoice.sent", resourceType: "invoice", resourceId, before, after, metadata, sourceIp, // stored in the INET column occurredAt, // optional: backdate created_at});Consent
Section titled “Consent”stratum.grantConsent(tenantId, input, audit?): Promise<ConsentRecord>stratum.revokeConsent(tenantId, subjectId, purpose, audit?): Promise<boolean>stratum.listConsent(tenantId, subjectId?): Promise<ConsentRecord[]>stratum.getActiveConsent(tenantId, subjectId, purpose): Promise<ConsentRecord | null>GDPR & Data Retention
Section titled “GDPR & Data Retention”stratum.exportTenantData(tenantId): Promise<Record<string, unknown>>stratum.purgeTenant(tenantId, audit?): Promise<void>stratum.purgeExpiredData(retentionDays?): Promise<{ deleted_count: number }>Regions
Section titled “Regions”stratum.createRegion(input, audit?): Promise<Region>stratum.getRegion(id): Promise<Region>stratum.listRegions(): Promise<Region[]>stratum.updateRegion(id, input, audit?): Promise<Region>stratum.deleteRegion(id, audit?): Promise<void>stratum.migrateRegion(tenantId, newRegionId, audit?): Promise<void>Roles (RBAC)
Section titled “Roles (RBAC)”stratum.createRole(input, audit?): Promise<Role>stratum.getRole(id): Promise<Role | null>stratum.listRoles(tenantId?): Promise<Role[]>stratum.updateRole(id, input, audit?): Promise<Role | null>stratum.deleteRole(id, audit?): Promise<boolean>stratum.assignRoleToKey(keyId, roleId): Promise<boolean>stratum.removeRoleFromKey(keyId): Promise<boolean>stratum.resolveKeyScopes(keyId): Promise<string[]>stratum.assignRole(principalType, principalId, roleId, tenantId?): Promise<boolean>stratum.removeRole(principalType, principalId): Promise<boolean>stratum.resolvePrincipalScopes(principalType, principalId, tenantId?): Promise<string[]>The *Key methods bind a role to an API key. The principal-agnostic trio binds a role to any principal – an application user, a service account – not only an API key:
assignRole(principalType, principalId, roleId, tenantId?)– assign a role to a principal (one role per principal). PasstenantIdto scope the assignment to a tenant; a role owned by a different tenant is then refused, while global roles are always allowed.removeRole(principalType, principalId)– clear a principal’s role assignment.resolvePrincipalScopes(principalType, principalId, tenantId?)– the principal’s effective scopes via its assigned role, or[]when unassigned (fails closed). WithtenantId, a role owned by another tenant is ignored while global roles still resolve.
Webhook Deliveries (DLQ)
Section titled “Webhook Deliveries (DLQ)”stratum.getDeliveryStats(tenantId?): Promise<DeliveryStats>stratum.listFailedDeliveries(limit?, tenantId?): Promise<FailedDelivery[]>stratum.retryDelivery(deliveryId): Promise<boolean>stratum.retryFailedDeliveries(tenantId?): Promise<number>stratum.listWebhookDeliveries(webhookId): Promise<Record<string, unknown>[]>Encryption
Section titled “Encryption”import { encrypt, decrypt, reEncrypt } from "@stratum-hq/lib";
encrypt(plaintext: string): string // "v1:iv:tag:ciphertext"decrypt(ciphertext: string): stringreEncrypt(ciphertext: string, oldKey: string, newKey: string): string
stratum.rotateEncryptionKey(oldKey, newKey, audit?): Promise<KeyRotationResult>Usage metering
Section titled “Usage metering”stratum.recordUsage(tenantId, input): Promise<UsageEvent>stratum.aggregateUsage(query): Promise<UsageAggregate[]>recordUsage(tenantId, input)– record a countable usage event for a tenant. Passidempotency_keyto make the write safe to retry; a duplicate key is a no-op that returns the original event.aggregateUsage(query)– aggregate one tenant’s usage per metric over an optional half-open window[from, to)onoccurred_at.
Tenant context (AsyncLocalStorage)
Section titled “Tenant context (AsyncLocalStorage)”Static helpers that read and run within the request-scoped tenant context. They wrap the same AsyncLocalStorage the SDK and adapters use, so Stratum.currentTenantId() sees a tenant set by any middleware in the chain.
Stratum.currentTenantId(): string | undefinedStratum.currentTenantContext(): ResolvedTenantContext | undefinedStratum.runWithTenant<T>(ctx, fn): TcurrentTenantId / currentTenantContext return undefined when called outside an active context. runWithTenant(ctx, fn) executes fn with ctx as the active context.
Top-level exports
Section titled “Top-level exports”Beyond the Stratum class, @stratum-hq/lib exports helpers you can use directly:
import { migrate, // migrate({ pool, enforceRls? }): run the Stratum migrations migrateAllSchemas, // migrateAllSchemas(...): multi-schema migration runner runScopedJob, // runScopedJob(pool, tenantId, fn): tenant-scoped background job verifyWebhookSignature, signWebhookPayload, DEFAULT_WEBHOOK_TOLERANCE_SECONDS, RateLimiter, // standalone per-tenant fixed-window limiter MemoryRateLimitStore,} from "@stratum-hq/lib";runScopedJob(pool, tenantId, fn)runsfnbound to a single tenant, establishing both the AsyncLocalStorage tenant context and the Postgres RLS context (SET LOCAL app.current_tenant_id) for the job’s duration and tearing both down afterward, so a job cannot touch another tenant’s rows and the context never leaks onto the next job on a pooled connection.verifyWebhookSignature({ secret, payload, signature, timestamp })validates an incoming delivery’s HMAC signature and timestamp freshness (default windowDEFAULT_WEBHOOK_TOLERANCE_SECONDS). See the webhooks guide.
Pool Helpers
Section titled “Pool Helpers”Low-level helpers for advanced use:
import { withClient, withTransaction } from "@stratum-hq/lib";
const result = await withClient(pool, async (client) => { return client.query("SELECT * FROM tenants WHERE id = $1", [id]);});
await withTransaction(pool, async (client) => { await client.query("INSERT INTO ..."); await client.query("UPDATE ...");});Prerequisites
Section titled “Prerequisites”@stratum-hq/lib assumes the Stratum database schema exists. Run the control plane migrations first, or apply the migration SQL manually:
# Option 1: Start the control plane (runs migrations automatically)node packages/control-plane/dist/index.js
# Option 2: Apply SQL directlypsql -d stratum -f packages/control-plane/src/db/migrations/001_init.sqlError Handling
Section titled “Error Handling”All errors come from @stratum-hq/core:
import { TenantNotFoundError, TenantArchivedError, ConfigLockedError, PermissionLockedError, PermissionRevocationDeniedError,} from "@stratum-hq/core";
try { await stratum.setConfig(childId, "locked_key", { value: 500 });} catch (err) { if (err instanceof ConfigLockedError) { console.log("Cannot override locked key"); }}