@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 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 (the application login) adminPool: adminPgPool, // Optional in 1.x: the admin login, a member of the control role (see below) controlRole: "stratum_control", // Optional: the control role (default: the database's, else stratum_control) allowLegacyKeyHashes: true, // Optional: accept SHA-256 API key hashes while an HMAC secret is set (default: true in 1.x) 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 initialize() and migrations 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.
With adminPool, every library query and autoMigrate run on the admin login, as a member of the control role of migration 032, and the library sets no app.bypass_rls. initialize() checks both logins against the role model and warns about each problem (with enforceRls: true, a problem with the application login throws). Without adminPool, the library uses the legacy app.bypass_rls path and logs a one-time deprecation warning; adminPool becomes required in 2.0. See Hardening: separate admin and app roles.
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 every deployment other than development and test so initialize() and migrations refuse to run under a BYPASSRLS role. The role is checked on every initialize(), with or without autoMigrate, and on every migrate() call, not only when a migration is applied. initialize() logs a warning when autoMigrate is on and enforceRls is off there.
API reference
Section titled “API reference”The audit argument
Section titled “The audit argument”The methods that change tenants, config, permissions, keys, roles, webhooks, consent and regions take an optional audit argument, shown as audit? in the signatures below. It is an AuditContext from @stratum-hq/core:
import type { AuditContext } from "@stratum-hq/core";
const audit: AuditContext = { actor_id: "user-123", // required: who made the change actor_type: "jwt", // required: "api_key" | "jwt" | "system" source_ip: "203.0.113.7", // optional request_id: "req-42", // optional};await stratum.createTenant({ name: "Acme Corp", slug: "acme" }, audit);With audit, the method writes an entry to the audit log with the before and after state. Without it, the change is made and no audit entry is written. The control plane passes an audit context built from the request on its mutating routes.
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, options?): 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>stratum.activateTenant(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).activateTenant: moves apendingtenant, created with its own schema or database, to active once its storage exists.- The control plane has a route only for archiving (
DELETE /api/v1/tenants/:id). It has no route to suspend, resume or activate a tenant, so call these methods from the library. (POST /api/v1/tenantsactivates a tenant itself once it has provisioned the storage.) 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, options?): Promise<ResolvedConfig>stratum.setConfig(tenantId, key, input, audit?): Promise<ConfigEntry>stratum.deleteConfig(tenantId, key, audit?): Promise<void>stratum.getConfigWithInheritance(tenantId, options?): Promise<ResolvedConfig>stratum.batchSetConfig(tenantId, entries, audit?): Promise<BatchSetConfigResult>stratum.diffConfig(tenantIdA, tenantIdB, options?): Promise<ConfigDiff>stratum.computeDrift(parentId, childId): Promise<DriftResult>stratum.batchComputeDrift(parentId, childIds): Promise<BatchDriftResult>resolveConfig(tenantId, options?): a sensitive value inherited from an ancestor comes back masked:value: null,sensitive: true,masked: true, withsource_tenant_idnaming the tenant that set it. A tenant’s own sensitive values come back decrypted. Pass{ revealSensitive: true }in trusted server code that needs the inherited secret itself, and never send that result to a client. Pass{ viewerTenantId }to reveal only the values that tenant set.getConfigWithInheritance,getTenantContextanddiffConfigtake the same options.setConfig(tenantId, key, input, audit?): an override of a key that an ancestor marked sensitive is stored sensitive whateverinput.sensitivesays. Wheninput.sensitiveis omitted, the key keeps its current flag; an explicitfalseclears a flag that the tenant set itself.batchSetConfigapplies the same rules to each entry. When a key is stored as sensitive, the overrides of that key in the tenant’s descendants are stored sensitive and encrypted in the same transaction; withaudit, aconfig.sensitive_appliedentry lists their tenant IDs and keys, never their values.batchSetConfig(tenantId, entries, audit?): atomic. If any key is locked by an ancestor, invalid, or named more than once, nothing is written and the result hasrolled_back: trueandsucceeded: 0. Every result then has statuserror: the keys that caused the rollback carry their own reason, and the rest say they were not applied.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, audit?): Promise<AbacPolicy>stratum.getAbacPolicies(tenantId): Promise<AbacPolicy[]>stratum.resolveAbacPolicies(tenantId): Promise<ResolvedAbacPolicy[]>stratum.evaluateAbac(tenantId, request): Promise<AbacEvaluationResult>stratum.deleteAbacPolicy(tenantId, policyId, audit?): 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?, audit?): Promise<CreatedApiKey>stratum.validateApiKey(key): Promise<ValidatedApiKey | null>stratum.revokeApiKey(keyId, audit?): Promise<boolean>stratum.rotateApiKey(keyId, newName?, audit?): Promise<CreatedApiKey>stratum.listApiKeys(tenantId?): Promise<ApiKeyRecord[]>stratum.getApiKey(id): Promise<ApiKeyRecord | null>stratum.listDormantKeys(dormantDays?): Promise<ApiKeyRecord[]>createApiKey(tenantId, ...):tenantIdisnullfor a global key, which is not limited to one tenant.validateApiKey(key): on success, sets the key’slast_used_atbefore the promise resolves, solistDormantKeyscalled next does not report the key. It skips that update whenlast_used_atis less than 60 seconds old, solast_used_atcan lag the latest use by up to one minute. The update waits at most one second. A failure or timeout of that update does not fail the validation.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. The required fields aretenantId(a UUID),actorId,action,resourceTypeandresourceId(a string, ornullfor an event about no single resource).actorTypedefaults to'system'andmetadatato{};before,after,sourceIpandoccurredAtare optional. 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(). If the input does not matchRecordAuditEventInputSchema,recordAuditEventthrows aValidationErrorand writes no row. The error’sdetails.issueslists each failed field.
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, // an IPv4 or IPv6 address, with an optional /prefix; 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>getRegion, updateRegion, deleteRegion and migrateRegion throw RegionNotFoundError (code REGION_NOT_FOUND, HTTP 404) when the region does not exist. migrateRegion throws TenantNotFoundError when the tenant does not exist. The errors for two conflicts carry status code 409:
deleteRegion(id): throwsRegionInUseError(codeREGION_IN_USE, HTTP 409) when active tenants are still assigned to the region.migrateRegion(tenantId, newRegionId): throwsRegionNotActiveError(codeREGION_NOT_ACTIVE, HTTP 409) when the status of the target region is notactive.
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, audit?): Promise<boolean>stratum.removeRoleFromKey(keyId, audit?): Promise<boolean>stratum.resolveKeyScopes(keyId): Promise<string[]>stratum.assignRole(principalType, principalId, roleId, tenantId?, audit?): Promise<boolean>stratum.removeRole(principalType, principalId, audit?): 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”Stratum encrypts config values set with sensitive: true and webhook secrets with AES-256-GCM. The key comes from STRATUM_ENCRYPTION_KEY, with STRATUM_HKDF_SALT as the key-derivation salt. Both are required whenever NODE_ENV is not development or test (an unset NODE_ENV counts as development); only development and test fall back to a built-in key. Outside development and test the library refuses to load unless STRATUM_ENCRYPTION_KEY is at least 32 bytes and is not the built-in development key, and STRATUM_HKDF_SALT is not the built-in development salt. In every environment, a STRATUM_HKDF_SALT that is set must be a non-empty, even-length hex string. The legacy WEBHOOK_ENCRYPTION_KEY variable is read only in development and test; elsewhere, set STRATUM_ENCRYPTION_KEY to its value. The package does not export the encryption functions; use the Stratum method below to change the key.
stratum.rotateEncryptionKey(oldKey, newKey, audit?, salts?): Promise<KeyRotationResult>
interface KeyRotationSalts { oldSalt?: string; // hex; the salt the existing values use. Default: STRATUM_HKDF_SALT newSalt?: string; // hex; the salt the run encrypts under. Default: STRATUM_HKDF_SALT}rotateEncryptionKey re-encrypts sensitive config values and webhook secrets in batches of 100. It decrypts each value with the old key and the old salt, and encrypts it with the new key and the new salt. Each batch commits in its own transaction. If a run fails partway, run it again with the same keys and salts. The second run skips values that already decrypt with the new key and the new salt.
To change only the salt, give the same key as oldKey and newKey.
During a rotation, STRATUM_ENCRYPTION_KEY_PREVIOUS and STRATUM_HKDF_SALT_PREVIOUS keep values that are not yet rotated readable. If the current key and salt do not decrypt a value, Stratum tries the previous key with the previous salt. If only one of the two previous variables is set, the other one uses its current value.
Moving off the built-in development key
Section titled “Moving off the built-in development key”In development and test (and when NODE_ENV is unset), Stratum falls back to a built-in development key and a built-in HKDF salt when STRATUM_ENCRYPTION_KEY and STRATUM_HKDF_SALT are not set. Every other environment refuses to start without both. Values encrypted under the fallback must be re-encrypted before the deployment runs with real key material. Move them to a new random key and a new random salt in this order:
- Set
STRATUM_ENCRYPTION_KEYto new random key material. - Set
STRATUM_HKDF_SALTto a new random salt in hex, for example the output ofopenssl rand -hex 32. An HKDF salt is not secret. - Set
STRATUM_ENCRYPTION_KEY_PREVIOUStostratum-dev-key. - Set
STRATUM_HKDF_SALT_PREVIOUSto the hex form of the built-in salt,7374726174756d2d6e6f6e2d70726f64756374696f6e2d686b64662d73616c742d7631. Steps 3 and 4 keep existing values readable until the rotation finishes. - Restart the deployment, so that it reads the new variables.
- Rotate from the built-in pair to the new pair. If the run fails partway, run it again with the same arguments:
await stratum.rotateEncryptionKey("stratum-dev-key", process.env.STRATUM_ENCRYPTION_KEY!, undefined, { oldSalt: process.env.STRATUM_HKDF_SALT_PREVIOUS, newSalt: process.env.STRATUM_HKDF_SALT,});- Examine anything the result lists in
unreadable. - Unset
STRATUM_ENCRYPTION_KEY_PREVIOUSandSTRATUM_HKDF_SALT_PREVIOUS.
Keep the new salt afterwards. Data encrypted under a salt that is not configured cannot be read. To change the salt again later, rotate with that salt as oldSalt.
To rotate through the control plane, do steps 1 to 5 for the control plane deployment. For step 6, call POST /api/v1/maintenance/rotate-encryption-key with the built-in pair as the old pair and the new pair as the new pair:
{ "old_key": "stratum-dev-key", "new_key": "<the value of STRATUM_ENCRYPTION_KEY>", "old_salt": "7374726174756d2d6e6f6e2d70726f64756374696f6e2d686b64662d73616c742d7631", "new_salt": "<the value of STRATUM_HKDF_SALT>"}The route accepts the same key as old_key and new_key when old_salt and new_salt differ. A salt that the body does not give is the configured STRATUM_HKDF_SALT.
A salt must be a non-empty, even-length hex string. rotateEncryptionKey throws a ValidationError for any other salt and changes no row. The control plane route answers 400 VALIDATION_ERROR.
interface KeyRotationResult { config_entries_rotated: number; // values re-encrypted by this run webhooks_rotated: number; already_rotated: number; // values that already decrypt with the new key and salt unreadable: KeyRotationUnreadableRow[]; // values that decrypt with neither pair}
interface KeyRotationUnreadableRow { table: "config_entries" | "webhooks"; id: string;}A value in unreadable stays unchanged, and the run continues. Examine each one before you remove the old key. When some values are unreadable, the run logs the warning encryption key rotation left unreadable rows.
If encrypted values exist and none of them decrypts with the old key or the new key, rotateEncryptionKey throws a ValidationError and changes no row. This result almost always means that the old key or the old salt is wrong.
Applying the sensitive flag to existing overrides
Section titled “Applying the sensitive flag to existing overrides”stratum.applySensitiveConfigFlags(audit?): Promise<number>applySensitiveConfigFlags finds every config entry that is not sensitive while an ancestor of its tenant (archived ancestors included) marks the same key sensitive, and stores it sensitive and encrypted. It returns the number of entries it changed. It works in batches of 100, and each batch commits in its own transaction. A second run changes nothing, so it is safe to run again after a failure.
Writes made with this version already apply the flag to overrides, so run it once after upgrading, to cover overrides stored by an earlier version. With audit, it records one config.sensitive_flags_applied entry with the count.
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?, controlRole? }): run the Stratum migrations migrateAllSchemas, // migrateAllSchemas(...): multi-schema migration runner STRATUM_TABLES, // readonly string[]: the tables the Stratum migrations create STRATUM_CONTROL_ROLE, // "stratum_control": the default control role APP_READ_TABLES, // readonly string[]: the Stratum tables the application login may read bootstrapRolesSql, // bootstrapRolesSql({ adminRole?, appRole?, controlRole?, schema? }): role setup SQL inspectRoleModel, // inspectRoleModel({ appPool?, adminPool? }): the role-model checks as data 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.bootstrapRolesSql()returns the idempotent SQL thatstratum db rolesprints and applies: it checks the Stratum tables for objects the migrations did not create, creates the control role, makes the admin login a member, moves the Stratum objects the application login owns to the admin login, applies the control role, and limits the application login toSELECTonAPP_READ_TABLES.STRATUM_TABLESlists thepublictables that the Stratum migrations create, plus_migrations. Use it to tell Stratum’s tables from your application tables. The CLI’sscanandmigrate --allskip these tables.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 needs PostgreSQL with the uuid-ossp and ltree extensions, and the Stratum tables. Create the extensions as a superuser (or your provider’s administrative login). Then create the tables in one of these ways:
import { migrate } from "@stratum-hq/lib";
// Option 1: run the migrations when the instance initializesconst stratum = new Stratum({ adminPool, pool, autoMigrate: true });await stratum.initialize();
// Option 2: run them as a deploy step, on the admin loginawait migrate({ pool: adminPool });The control plane runs the same migrations when it starts. npx @stratum-hq/cli migrate does not create Stratum’s tables: it adds tenant_id and row-level security to your own tables.
Error 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"); }}