Skip to content

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

npm install @stratum-hq/sdk jsonwebtoken

jsonwebtoken is an optional peer dependency. The middleware needs it to verify tokens with jwtSecret. If you give jwtVerify instead, you do not need it.

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 token
app.use(s.middleware({ jwtSecret }));
// Fastify
app.register(s.plugin({ jwtSecret }));

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.

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

archiveTenant and purgeTenant do different things. Choose the correct one before you call it.

  • archiveTenant(id) sends DELETE /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) sends POST /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 admin scope.
  • 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);
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,
});
});
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.

The middleware resolves the tenant ID in this order:

  1. JWT: extracts tenant ID from a Bearer token claim at jwtClaimPath
  2. Header: reads X-Tenant-ID header (or custom headerName). When JWT verification is configured, this step runs only with trustTenantHeader: true.
  3. Custom resolvers: your async functions, tried in order

If no tenant ID is found, returns 400 MISSING_TENANT.

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,
},
],
}));

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 context
await runWithTenantContext(tenantContext, async () => {
const ctx = getTenantContext(); // available here
});

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 cache
const client = new StratumClient({
controlPlaneUrl: "http://localhost:3001",
apiKey: "sk_live_your_key",
cache: { enabled: false },
});

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.

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.

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.