@stratum-hq/sdk
@stratum-hq/sdk provides an HTTP client for the Stratum control plane with built-in LRU caching, Express middleware, and a Fastify plugin. It resolves tenant context from incoming requests and attaches it to the request object.
Installation
Section titled “Installation”npm install @stratum-hq/sdk jsonwebtokenjsonwebtoken is an optional peer dependency. The middleware needs it to verify tokens with jwtSecret. If you give jwtVerify instead, you do not need it.
Quick start
Section titled “Quick start”import { stratum } from "@stratum-hq/sdk";
const s = stratum({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key",});
// The tenant binding comes from a signed token, so the server does not start// without the key that verifies it. Without jwtSecret, the middleware reads// the tenant from the X-Tenant-ID header instead.const jwtSecret = process.env.JWT_SECRET;if (!jwtSecret) { throw new Error("JWT_SECRET is required to verify bearer tokens.");}
// Express// Reads the tenant from the verified `tenant_id` claim of the bearer tokenapp.use(s.middleware({ jwtSecret }));
// Fastifyapp.register(s.plugin({ jwtSecret }));API key scope
Section titled “API key scope”The middleware resolves each tenant with GET /api/v1/tenants/:id/context, which needs only the read scope. Use a read key scoped to the tenant the server serves: it resolves that tenant and its descendants. A global read key resolves any tenant. See API Keys.
In resolved_config, a sensitive value inherited from an ancestor arrives masked (value: null, masked: true) unless the key belongs to the tenant that set it. To use such a secret on the server, read it with @stratum-hq/lib and resolveConfig(id, { revealSensitive: true }), or with a key of the tenant that set it.
StratumClient
Section titled “StratumClient”The HTTP client for the control plane API with built-in LRU caching:
import { StratumClient } from "@stratum-hq/sdk";
const client = new StratumClient({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key", cache: { enabled: true, // default: true ttlMs: 60000, // default: 60 seconds maxSize: 100, // default: 100 entries }, timeoutMs: 10000, // default: 10 seconds per control plane request; Infinity turns it off});Client methods
Section titled “Client methods”| Method | Returns | Description |
|---|---|---|
resolveTenant(id) |
TenantContext |
Resolve full context (cached, read scope) |
getTenantTree(rootId?) |
TenantNode[] |
List tenants or subtree |
createTenant(input) |
TenantNode |
Create tenant |
getTenant(id) |
TenantNode |
Get single tenant |
updateTenant(id, input) |
TenantNode |
Update tenant |
moveTenant(id, input) |
TenantNode |
Move in hierarchy |
archiveTenant(id) |
void |
Archive a tenant (soft delete, reversible) |
deleteTenant(id) |
void |
Deprecated. Same request as archiveTenant |
purgeTenant(id) |
void |
Permanently delete a tenant and its data (admin scope) |
rotateApiKey(keyId, name?) |
CreatedApiKey |
Rotate an API key |
listApiKeys(tenantId?) |
ApiKeyRecord[] |
List API keys |
listDormantKeys(days?) |
ApiKeyRecord[] |
Find unused keys |
createWebhook(input) |
Webhook |
Create webhook |
listWebhooks(tenantId?) |
Webhook[] |
List webhooks |
listRegions() |
Region[] |
List all regions |
createRegion(input) |
Region |
Create a region |
invalidateCache(id) |
void |
Invalidate single entry |
clearCache() |
void |
Clear all cached data |
Archive or purge a tenant
Section titled “Archive or purge a tenant”archiveTenant and purgeTenant do different things. Choose the correct one before you call it.
archiveTenant(id)sendsDELETE /api/v1/tenants/:id. The control plane marks the tenant as archived. The tenant row and its data stay in the database, and the archive is reversible.purgeTenant(id)sendsPOST /api/v1/tenants/:id/purge. The control plane permanently deletes the tenant (GDPR Article 17). You cannot undo a purge.
deleteTenant(id) is deprecated. It sends the same request as archiveTenant, so it does not remove data.
A purge has these conditions and effects:
- The API key must have the
adminscope. - The tenant must have no children, of any status.
- The control plane deletes the tenant row and the tenant’s records in the Stratum tables: config, permissions, API keys, roles, webhooks, consent records and audit logs.
- For a tenant with its own schema or database, the control plane also drops that schema or database.
- For a pending tenant, the purge removes the records and never drops storage. Use a purge to remove a tenant whose storage provisioning failed. A tenant-scoped key can purge a pending tenant in its own subtree.
- Rows in your own tables that share a database with other tenants stay. Delete them yourself.
- If the request times out, the purge can still complete. See Request timeout.
// Soft delete: reversible.await client.archiveTenant(tenantId);
// Hard delete: irreversible.await client.purgeTenant(tenantId);Framework integration
Section titled “Framework integration”import express from "express";import { StratumClient, expressMiddleware } from "@stratum-hq/sdk";
const app = express();const client = new StratumClient({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key",});
const jwtSecret = process.env.JWT_SECRET;if (!jwtSecret) { throw new Error("JWT_SECRET is required to verify bearer tokens.");}
app.use(expressMiddleware(client, { jwtClaimPath: "tenant_id", jwtSecret, onError: (err, req) => console.error(err),}));
app.get("/data", (req, res) => { const ctx = req.tenant; res.json({ tenantId: ctx.tenant_id, config: ctx.resolved_config, });});import Fastify from "fastify";import { StratumClient, fastifyPlugin } from "@stratum-hq/sdk";
const app = Fastify();const client = new StratumClient({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key",});
const jwtSecret = process.env.JWT_SECRET;if (!jwtSecret) { throw new Error("JWT_SECRET is required to verify bearer tokens.");}
app.register(fastifyPlugin, { client, jwtClaimPath: "tenant_id", jwtSecret,});
app.get("/data", (request, reply) => { const ctx = request.tenant; reply.send({ tenantId: ctx.tenant_id });});import { stratum } from "@stratum-hq/sdk";
const s = stratum({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key",});
const jwtSecret = process.env.JWT_SECRET;if (!jwtSecret) { throw new Error("JWT_SECRET is required to verify bearer tokens.");}
// Expressapp.use(s.middleware({ jwtClaimPath: "tenant_id", jwtSecret }));
// Fastifyapp.register(s.plugin({ jwtClaimPath: "tenant_id", jwtSecret }));
// Direct client accessconst ctx = await s.client.resolveTenant("tenant-uuid");Middleware options
Section titled “Middleware options”interface MiddlewareOptions { jwtClaimPath?: string; // JWT claim path for tenant ID jwtSecret?: string; // JWT verification secret (HS256) jwtVerify?: (token: string) => Record<string, unknown> | null; jwtAudience?: string; // Require this `aud` claim (with jwtSecret or jwtVerify) jwtIssuer?: string; // Require this `iss` claim (with jwtSecret or jwtVerify) headerName?: string; // Default: "X-Tenant-ID" trustTenantHeader?: boolean; // Read the header when JWT is configured. Default: false resolvers?: TenantResolver[]; // Custom resolution functions onError?: (err: Error, req: unknown) => void;}A verified token whose aud or iss claim does not match jwtAudience or jwtIssuer is rejected with 401 INVALID_TOKEN.
Tenant resolution order
Section titled “Tenant resolution order”The middleware resolves the tenant ID in this order:
- JWT: extracts tenant ID from a Bearer token claim at
jwtClaimPath - Header: reads
X-Tenant-IDheader (or customheaderName). When JWT verification is configured, this step runs only withtrustTenantHeader: true. - Custom resolvers: your async functions, tried in order
If no tenant ID is found, returns 400 MISSING_TENANT.
Custom resolvers
Section titled “Custom resolvers”app.use(s.middleware({ resolvers: [ { // Resolve from subdomain resolve: async (req) => { const subdomain = req.hostname.split(".")[0]; return subdomain !== "www" ? subdomain : null; }, }, { // Resolve from query parameter resolve: async (req) => req.query.tenant_id ?? null, }, ],}));AsyncLocalStorage context
Section titled “AsyncLocalStorage context”For code that does not have access to the request object (services, repositories):
import { getTenantContext, runWithTenantContext } from "@stratum-hq/sdk";
function myService() { const ctx = getTenantContext(); // throws if no context console.log(ctx.tenant_id);}
// Or run code with explicit contextawait runWithTenantContext(tenantContext, async () => { const ctx = getTenantContext(); // available here});LRU cache
Section titled “LRU cache”The built-in cache reduces control plane round-trips:
- Default max size: 100 entries
- Default TTL: 60 seconds
- Auto-invalidated on mutations (create, update, delete)
- Configurable or disableable via client options
// Disable cacheconst client = new StratumClient({ controlPlaneUrl: "http://localhost:3001", apiKey: "sk_live_your_key", cache: { enabled: false },});Error handling
Section titled “Error handling”The SDK throws typed errors from @stratum-hq/core:
import { ForbiddenError, RegionInUseError, RegionNotActiveError, RegionNotFoundError, TenantArchivedError, TenantNotFoundError, TenantSuspendedError, UnauthorizedError, ValidationError, WebhookNotFoundError,} from "@stratum-hq/core";
try { await client.resolveTenant("bad-id");} catch (err) { if (err instanceof TenantNotFoundError) { // The tenant does not exist. } if (err instanceof TenantSuspendedError || err instanceof TenantArchivedError) { // The tenant exists but is not active. } if (err instanceof ForbiddenError) { // The API key cannot access this tenant, or it does not have the scope for the operation. } if (err instanceof UnauthorizedError) { // The control plane rejected the API key. } if (err instanceof ValidationError) { // The control plane rejected the request body. details.issues lists each failed field. console.log(err.details?.issues); }}| Control plane response | Error |
|---|---|
400 VALIDATION_ERROR |
ValidationError, with the issues in details.issues |
| 400, any other code | Error, with the control plane’s message |
| 401 | UnauthorizedError |
403 TENANT_SUSPENDED |
TenantSuspendedError |
| 403, any other code | ForbiddenError |
404 TENANT_NOT_FOUND |
TenantNotFoundError |
404 WEBHOOK_NOT_FOUND |
WebhookNotFoundError |
404 REGION_NOT_FOUND |
RegionNotFoundError |
| 404, any other code | Error, with the control plane’s message |
409 REGION_IN_USE |
RegionInUseError, with the region ID in details.region_id |
409 REGION_NOT_ACTIVE |
RegionNotActiveError, with the region ID in details.region_id. Only POST /api/v1/tenants/{id}/migrate-region sends this code, and the SDK has no method for that route yet. |
410 TENANT_ARCHIVED |
TenantArchivedError |
Each item in details.issues has a path (the field, as an array of keys), a message, and a zod issue code. A 400 VALIDATION_ERROR that is not about one field, for example a batch that is too large, has no details.issues. If the control plane sends other details, the ValidationError keeps them in details.
An API key that is scoped to a tenant gets 403 FORBIDDEN for a descendant that is suspended or archived. The scope check stops the request before the control plane reads the tenant status. Only a key that is not scoped to a tenant gets TenantSuspendedError or TenantArchivedError.
An API key without the admin scope gets 403 FORBIDDEN from an admin operation, for example purgeTenant. The client throws ForbiddenError.
Middleware responses
Section titled “Middleware responses”The Express and Fastify middleware answer these errors from tenant resolution. The same rules apply to an impersonation target.
| Error | Response | onError |
|---|---|---|
TenantNotFoundError |
404 TENANT_NOT_FOUND |
Not called |
TenantSuspendedError |
403 TENANT_SUSPENDED |
Not called |
TenantArchivedError |
410 TENANT_ARCHIVED |
Not called |
ForbiddenError |
403 FORBIDDEN |
Not called |
TimeoutError |
504 CONTROL_PLANE_TIMEOUT |
Called |
UnauthorizedError |
500 CONTROL_PLANE_AUTH_FAILED |
Called |
A tenant error is a normal answer for the request, so it does not go to onError. An UnauthorizedError means that the control plane rejected the SDK’s own API key. The middleware answers 500, because the fault is in the server configuration. It also writes the cause to console.error. Other errors go to your framework’s error handler.
The StratumGuard in @stratum-hq/nestjs throws an exception with the same status for each of these errors.
To use the same mapping in another adapter, import tenantErrorResponse and controlPlaneErrorResponse from @stratum-hq/sdk. Each function returns { status, body }, or null for an error that it does not map.
Request timeout
Section titled “Request timeout”Each control plane request has a time limit of timeoutMs milliseconds (default 10000). A request that takes longer rejects with a TimeoutError DOMException.
The value must be an integer from 1 to 4294967295. To turn the time limit off, set timeoutMs: Infinity. Any other value makes the constructor throw a RangeError.
A timeout does not mean that the control plane stopped the operation. If purgeTenant rejects with a TimeoutError, the purge can still complete. Call getTenant(id) to find out whether the tenant still exists. The client removes the tenant from its cache in both cases.