Skip to content

@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.

npm install @stratum-hq/lib pg
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
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);
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.

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.

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 to getTenant. Throws TenantNotFoundError when no row matches, and (unless includeArchived is set) TenantArchivedError / TenantSuspendedError for 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; pass includeArchived for 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-tenant createTenant calls yourself if you need partial success.

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 with resumeTenant.
  • archiveTenant: a reversible soft delete. Accepts an active or suspended tenant; rejects if already archived or it has active children. Reverse with resumeTenant. This is the canonical name for what deleteTenant does.
  • 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 a pending tenant, 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/tenants activates a tenant itself once it has provisioned the storage.)
  • deleteTenant(id, audit?) is retained as a deprecated alias of archiveTenant; prefer archiveTenant.

See the tenant lifecycle guide for the full state machine and descendant rules.

Thin aliases for simple, single-level SaaS that never needs the hierarchy.

stratum.createOrganization(input, audit?): Promise<TenantNode> // createTenant with parent_id: null
stratum.listOrganizations(pagination): Promise<PaginatedResult<TenantNode>> // root-level only
stratum.getOrganization(id): Promise<TenantNode> // alias for getTenant
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, with source_tenant_id naming 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, getTenantContext and diffConfig take the same options.
  • setConfig(tenantId, key, input, audit?): an override of a key that an ancestor marked sensitive is stored sensitive whatever input.sensitive says. When input.sensitive is omitted, the key keeps its current flag; an explicit false clears a flag that the tenant set itself. batchSetConfig applies 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; with audit, a config.sensitive_applied entry 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 has rolled_back: true and succeeded: 0. Every result then has status error: 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 as ok / override / missing / conflict (a conflict is a child value that diverges from a locked parent key), with per-key detail and a rolled-up worst status.
  • batchComputeDrift(parentId, childIds): computeDrift fanned out over many children, with a per-status summary count.
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>
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.

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, ...): tenantId is null for a global key, which is not limited to one tenant.
  • validateApiKey(key): on success, sets the key’s last_used_at before the promise resolves, so listDormantKeys called next does not report the key. It skips that update when last_used_at is less than 60 seconds old, so last_used_at can 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; returns null when 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.
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 to tenantId (a caller can never page another tenant’s events), optionally narrowed by event type and a created_at window, paginated with limit (1-100, default 50) and offset.
  • listDeliveriesByEvent(eventId): every delivery attempt recorded for a single webhook event, newest first.
stratum.queryAuditLogs(query): Promise<AuditEntry[]>
stratum.getAuditEntry(id): Promise<AuditEntry | null>
stratum.recordAuditEvent(input): Promise<AuditEntry>
  • recordAuditEvent(input): append a custom event to Stratum’s audit_logs through 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 via queryAuditLogs. The row is stamped for input.tenantId and no other tenant. The required fields are tenantId (a UUID), actorId, action, resourceType and resourceId (a string, or null for an event about no single resource). actorType defaults to 'system' and metadata to {}; before, after, sourceIp and occurredAt are optional. Pass an optional occurredAt (ISO 8601 string or Date) to set the row’s created_at when seeding historical or backdated events; omit it and the row is stamped now(). If the input does not match RecordAuditEventInputSchema, recordAuditEvent throws a ValidationError and writes no row. The error’s details.issues lists 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
});
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>
stratum.exportTenantData(tenantId): Promise<Record<string, unknown>>
stratum.purgeTenant(tenantId, audit?): Promise<void>
stratum.purgeExpiredData(retentionDays?): Promise<{ deleted_count: number }>
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): throws RegionInUseError (code REGION_IN_USE, HTTP 409) when active tenants are still assigned to the region.
  • migrateRegion(tenantId, newRegionId): throws RegionNotActiveError (code REGION_NOT_ACTIVE, HTTP 409) when the status of the target region is not active.
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). Pass tenantId to 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). With tenantId, a role owned by another tenant is ignored while global roles still resolve.
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>[]>

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.

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:

  1. Set STRATUM_ENCRYPTION_KEY to new random key material.
  2. Set STRATUM_HKDF_SALT to a new random salt in hex, for example the output of openssl rand -hex 32. An HKDF salt is not secret.
  3. Set STRATUM_ENCRYPTION_KEY_PREVIOUS to stratum-dev-key.
  4. Set STRATUM_HKDF_SALT_PREVIOUS to the hex form of the built-in salt, 7374726174756d2d6e6f6e2d70726f64756374696f6e2d686b64662d73616c742d7631. Steps 3 and 4 keep existing values readable until the rotation finishes.
  5. Restart the deployment, so that it reads the new variables.
  6. 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,
});
  1. Examine anything the result lists in unreadable.
  2. Unset STRATUM_ENCRYPTION_KEY_PREVIOUS and STRATUM_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.

stratum.recordUsage(tenantId, input): Promise<UsageEvent>
stratum.aggregateUsage(query): Promise<UsageAggregate[]>
  • recordUsage(tenantId, input): record a countable usage event for a tenant. Pass idempotency_key to 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) on occurred_at.

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 | undefined
Stratum.currentTenantContext(): ResolvedTenantContext | undefined
Stratum.runWithTenant<T>(ctx, fn): T

currentTenantId / currentTenantContext return undefined when called outside an active context. runWithTenant(ctx, fn) executes fn with ctx as the active context.

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) runs fn bound 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 that stratum db roles prints 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 to SELECT on APP_READ_TABLES.
  • STRATUM_TABLES lists the public tables that the Stratum migrations create, plus _migrations. Use it to tell Stratum’s tables from your application tables. The CLI’s scan and migrate --all skip these tables.
  • verifyWebhookSignature({ secret, payload, signature, timestamp }) validates an incoming delivery’s HMAC signature and timestamp freshness (default window DEFAULT_WEBHOOK_TOLERANCE_SECONDS). See the webhooks guide.

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 ...");
});

@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 initializes
const stratum = new Stratum({ adminPool, pool, autoMigrate: true });
await stratum.initialize();
// Option 2: run them as a deploy step, on the admin login
await 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.

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");
}
}