@stratum-hq/cli
@stratum-hq/cli helps you integrate Stratum into existing projects. It detects your framework, generates boilerplate, manages database migrations, and scaffolds framework-specific code.
Installation
Section titled “Installation”npm install -g @stratum-hq/cli
# Or use directly without installing:npx @stratum-hq/cli <command>Commands
Section titled “Commands”stratum init
Section titled “stratum init”Interactive project setup wizard:
stratum initWhat it does:
- Detects your framework and ORM from
package.json(Express, Fastify, Next.js, Prisma, etc.) - Asks: direct library (
@stratum-hq/lib) or HTTP API + SDK (@stratum-hq/sdk)? - Generates config file, middleware/plugin, database setup,
.envtemplate - If React is detected, generates provider, tenant guard components, and custom hooks
- Prints install instructions for required packages
Every question has a default, shown in brackets: press Enter to accept it. When nothing is detected, the defaults are “Other / None” for the framework, the direct library, and pg. You can also pipe the answers in, one per line. If stdin closes before every question is answered, the command exits with code 1 and writes nothing.
The generated .env.stratum is the same file as scaffold env writes, plus JWT_SECRET.
stratum health
Section titled “stratum health”Validate your database is ready for Stratum:
stratum healthstratum health --database-url postgres://user:pass@host:5432/mydbChecks:
- Database connectivity
- PostgreSQL version (16+ recommended, 14+ required)
- Required extensions:
uuid-osspandltree - BYPASSRLS privilege (must be disabled for the application role)
- Stratum schema tables (tenants, config_entries, etc.)
- The role model of migration 032: whether the control role is applied, and whether the login of
--database-urlis limited to the application’s share (see Hardening: separate admin and app roles). With--admin-database-url(orDATABASE_ADMIN_URL), it also checks the admin login and the legacy switch. - RLS status on all user tables
If a check fails, health exits with code 1, as doctor does: for example a missing extension, a login with BYPASSRLS, or PostgreSQL older than 14. A warning, such as a missing Stratum schema or a table that needs migration, does not change the exit code. See Exit codes.
stratum doctor
Section titled “stratum doctor”Check a database that already runs Stratum for configuration problems and damaged data:
stratum doctorstratum doctor --database-url postgres://user:pass@host:5432/mydbstratum doctor --depth-warning 30stratum doctor --admin-database-url postgres://stratum_admin:pass@host:5432/mydbChecks:
- Database connectivity
- Stratum schema tables
- RLS enabled and forced on every table with a
tenant_idcolumn - RLS policies that filter by tenant
- Bypass policies: policies that admit
app.bypass_rlsdirectly - Control role: whether migration 032 applied the control role
- App role: the login of
--database-urlis not a member of the control role, owns no Stratum object or the schema, writes no Stratum table, and reads no credential table - Admin role: the login of
--admin-database-urlcan act as the control plane - Legacy switch: whether
app.bypass_rlsstill passes the tenant policies (needs--admin-database-url) - Stratum policies: how RLS and the policies of the Stratum tables differ from the canonical set of migration 032 (
stratum db roles --applyrestores it) - Indexes on
tenant_id - Orphaned tenants: a parent that is missing or archived
- Tenant parent cycles: tenants whose parent chain comes back to the same tenant
- Stale API keys: not used for 90 days or more
- Expired API keys that are not revoked
- The
STRATUM_ENCRYPTION_KEYsetting - Tree depth: the maximum depth of an active tenant (advisory)
The data checks read the Stratum tables across tenants. With --admin-database-url (or DATABASE_ADMIN_URL), a member of the control role, they run on that login and set nothing. Without it, they use the legacy app.bypass_rls setting and print a warning; once stratum db lock has closed that path, each data check reports that it could not run instead of a pass. The role-model checks are warnings in 1.x. If a check fails, the command exits with code 1. A warning does not change the exit code.
The tree depth check is advisory. Stratum accepts a tenant tree of any depth. Config resolution reads every ancestor of a tenant, so a deep tree makes it slower. If the maximum depth is more than the warning threshold, doctor warns and lists the deepest tenants. The check never fails doctor. The default threshold is 20. To change it, use the --depth-warning <n> option or the STRATUM_DOCTOR_DEPTH_WARNING environment variable. The option has priority over the variable. If the value is not a positive integer, doctor warns and uses 20.
Stratum refuses every write that makes a parent cycle (migration 029), but it does not repair a cycle that is already in the data. While a cycle is in the data, a write that changes the slug of a tenant in the cycle fails. For each cycle, doctor lists its tenants from child to parent.
Repair a tenant parent cycle
Section titled “Repair a tenant parent cycle”To repair a cycle, set parent_id of one tenant in the cycle to a tenant outside the cycle, or to NULL. Then recompute ancestry_path and depth for the whole tree. Triggers keep ancestry_ltree correct, but nothing in the database maintains ancestry_path and depth.
Do not use moveTenant or POST /api/v1/tenants/:id/move for this repair. They compute the new paths from the stored ancestry_path of the moved tenant, and in a cycle that value can be wrong. The result can then leave other tenants of the cycle with a wrong ancestry_path and depth.
Run this SQL as one transaction, as the admin login (a member of the control role). The SET LOCAL app.bypass_rls line serves a login that is not, and works only while the legacy switch is on. Replace the two placeholders first. The advisory lock is the lock that Stratum takes for every change to the tree, so no Stratum write runs at the same time.
BEGIN;SELECT pg_advisory_xact_lock(('x' || substr(md5('stratum.tenant_tree'), 1, 16))::bit(64)::bigint);SET LOCAL app.bypass_rls = 'on';
-- Break the cycle. To make the tenant a root, write NULL in place of the quoted new parent id.UPDATE tenants SET parent_id = '<new-parent-id>' WHERE id = '<cycle-member-id>';
-- Recompute ancestry_path and depth from parent_id, for every tenant below a root.WITH RECURSIVE chain AS ( SELECT id, '/'::text AS ancestry_path, 0 AS depth FROM tenants WHERE parent_id IS NULL UNION ALL SELECT c.id, CASE WHEN chain.ancestry_path = '/' THEN '/' || chain.id ELSE chain.ancestry_path || '/' || chain.id END, chain.depth + 1 FROM tenants c JOIN chain ON c.parent_id = chain.id)UPDATE tenants tSET ancestry_path = chain.ancestry_path, depth = chain.depthFROM chainWHERE t.id = chain.id AND (t.ancestry_path IS DISTINCT FROM chain.ancestry_path OR t.depth IS DISTINCT FROM chain.depth);
COMMIT;If there is more than one cycle, break each cycle with its own UPDATE before the recompute. Tenants that are still on a cycle have no root, so the recompute does not reach them. Run stratum doctor again after the transaction to confirm that the check passes.
stratum scan
Section titled “stratum scan”Report which application tables need tenant isolation, and write the SQL that adds it:
stratum scan # report onlystratum scan --generate > migration.sql # SQL to a file, report on the terminalstratum scan --exclude users,sessions --generate > migration.sqlThe report groups the tables of the public schema by what they lack: a tenant_id column, RLS, a policy that filters by tenant, or FORCE ROW LEVEL SECURITY. Stratum’s own tables are left out.
With --generate (or -g), the command writes only SQL to stdout and the report to stderr, so redirecting stdout gives a file that psql -f migration.sql runs. The SQL runs in one transaction. It adds a nullable tenant_id column (with a foreign key to tenants(id) when that table exists), enables and forces RLS, creates a tenant_isolation policy and an index on tenant_id. It does not touch a table whose existing policies do not filter by tenant; it lists those in a comment instead. New tenant_id columns start out NULL: backfill them, then set them NOT NULL, as the comment at the end of the file explains. Review the SQL before you run it.
--exclude <tables> takes a comma-separated list of table names to leave out of the report and the SQL. Unlike migrate, the generated SQL quotes every name, so it also covers tables whose names migrate does not take, such as names with capital letters or spaces.
stratum migrate
Section titled “stratum migrate”Add tenant isolation to existing database tables:
# Scan all tables and show RLS statusstratum migrate --scan
# Migrate a specific tablestratum migrate orders
# Migrate all unmigrated tables interactivelystratum migrate --all
# Migrate a table that has rows, and assign every existing row to one tenantstratum migrate orders --tenant <tenant-uuid>For each table, migration:
- Adds
tenant_id UUID NOT NULLcolumn - Enables
ROW LEVEL SECURITY+FORCE ROW LEVEL SECURITY - Creates
tenant_isolationpolicy - Creates index on
tenant_id - Adds foreign key to
tenantstable (if it exists)
The foreign key needs the REFERENCES privilege on tenants. The role model of migration 032 does not give it to the application login, so after stratum db roles the migration stops, changes nothing, and names the grant (GRANT REFERENCES (id) ON tenants TO <login>). Granting it is opt-in: run it yourself, or stratum db roles --apply --grant-references.
If the table already has rows, each row needs a tenant. Give that tenant with --tenant <uuid>, and the migration assigns every existing row to it. The tenant must exist in the tenants table; the command looks it up as the control role with --admin-database-url (or DATABASE_ADMIN_URL), else through the legacy app.bypass_rls path. Without --tenant, the migration stops and changes nothing. With --all, the same --tenant applies to every table that has rows.
The command migrates application tables only. It rejects the name of a table that Stratum’s own migrations create, such as tenants, and --all skips those tables. It takes table names of lowercase letters, digits and underscores; for other names, use stratum scan --generate.
migrate --scan suggests stratum migrate <table> only for the tables that command accepts. It lists tables with other names separately, and reports tables whose policies need fixing by hand without a suggestion.
migrate <table> and migrate --all ask for confirmation. If stdin closes before the answer, the command exits with code 1 and changes nothing.
A table counts as isolated only when RLS is enabled and forced and its policies filter rows by tenant: every permissive policy on the table must compare tenant_id with the current tenant setting, app.current_tenant_id, in USING and in any WITH CHECK. The policy’s name does not matter. PostgreSQL combines permissive policies with OR, so one policy that admits other rows opens the whole table. scan, migrate, health and doctor report such a table with the policy that fails the check, and migrate does not replace policies you wrote: correct or drop that policy, then run the command again. migrate --all exits non-zero while any such table remains.
stratum generate api-key
Section titled “stratum generate api-key”Generate an API key from the command line:
stratum generate api-key --name "my-service"stratum generate api-key --name "admin" --tenant <tenant-uuid>The plaintext key is displayed once and never stored. When STRATUM_API_KEY_HMAC_SECRET is set, the key is stored with an HMAC hash, as the library stores it; otherwise with SHA-256. The key is written as the control role with --admin-database-url (or DATABASE_ADMIN_URL), else through the legacy app.bypass_rls path.
stratum db roles
Section titled “stratum db roles”Print, or apply, the SQL that sets up the role model of migration 032:
# Print the SQL to review it, or to run it with psqlstratum db roles --admin-role stratum_admin --app-role stratum_app
# Apply it as a superuser (or a login with CREATEROLE that owns the Stratum tables)stratum db roles --apply --admin-role stratum_admin --app-role stratum_app \ --database-url postgres://postgres:pass@host:5432/mydbThe SQL is bootstrapRolesSql() from @stratum-hq/lib. It first checks that the Stratum tables carry only what the Stratum migrations created, and stops otherwise. Then it creates the NOLOGIN control role, makes the admin login a member, moves the Stratum tables and functions that the application login owns to the admin login (never your application’s tables), applies the control role (which drops every policy on the Stratum tables and re-creates the canonical ones), and limits the application login to SELECT on the read-list tables. --grant-references also grants the application login REFERENCES (id) on tenants, for foreign keys from its tables; it is opt-in because it also lets that login block the deletion of a tenant. With --apply, the command reports the resulting role model and exits 1 if the control role is not applied. --control-role names the control role (default: the connection’s stratum.control_role setting, else the role the database already uses, else stratum_control), and --schema the schema of the Stratum tables (default public).
stratum db lock / db unlock
Section titled “stratum db lock / db unlock”stratum db lock --admin-database-url postgres://stratum_admin:pass@host:5432/mydbstratum db unlock --admin-database-url postgres://stratum_admin:pass@host:5432/mydbdb lock turns the legacy switch of migration 032 off, so a session that sets app.bypass_rls no longer passes the tenant policies; db unlock turns it back on. Run them as a member of the control role: the command connects to --admin-database-url (or DATABASE_ADMIN_URL), else to --database-url, and fails when the login cannot change the switch. db lock refuses while the control role is not applied. The switch applies to every client of the database. See Hardening: separate admin and app roles.
stratum playground
Section titled “stratum playground”Start the control plane and the demo app on your machine:
stratum playgroundstratum playground --database-url postgres://user:pass@localhost:5432/stratum --cp-port 4000Run it from the root of a clone of the Stratum repository: it starts the control-plane and demo workspaces with npm run dev. It checks the database connection, starts the control plane (port 3001, or --cp-port), waits up to 10 seconds for /api/v1/health, then starts the demo (API on port 3200, web on port 3300). Ctrl+C stops both. If either process exits, the command stops the other and exits with code 1.
stratum scaffold
Section titled “stratum scaffold”Generate framework-specific integration boilerplate without the full init wizard:
stratum scaffold express # Express middleware + routesstratum scaffold fastify # Fastify pluginstratum scaffold nextjs # Next.js proxy (or middleware) + API helpers + layoutstratum scaffold react # React provider + guards + hooksstratum scaffold prisma # Tenant-scoped Prisma clientstratum scaffold docker # Docker Compose for Stratum + PostgreSQLstratum scaffold env # .env template with all variablesscaffold express
Section titled “scaffold express”Generates:
stratum-middleware.ts: SDK client +expressMiddlewaresetuptenant-routes.ts: example routes usingreq.tenant
scaffold fastify
Section titled “scaffold fastify”Generates:
stratum-plugin.ts: SDK client +fastifyPluginsetup
scaffold nextjs
Section titled “scaffold nextjs”Generates:
proxy.tson Next.js 16 and later,middleware.tson Next.js 15: written next to the app directory (src/proxy.tswhen the project keeps its app insrc/app), because Next.js runs the file only from there. The CLI reads the version fromnode_modules/next, else from thenextrange inpackage.json. When it cannot read the version, it writesmiddleware.ts, which Next.js 15 and 16 both run, and prints the codemod that renames it. It never writes one file next to the other, even with--force, because Next.js 16 refuses a project that has both. The file takes the tenant ID from thetenant_idclaim of a bearer token verified withJWT_SECRET(usingjose), and rejects a token that does not verify. The subdomain is forwarded asx-tenant-slug, never as the tenant ID.stratum initgenerates the same file for Next.js.app/api/stratum/[...path]/route.ts: the Stratum API route, written next to the app directory likeproxy.ts.StratumProvidersends its requests to/api/stratum, and this route addsSTRATUM_API_KEYon the server and forwards them to the control plane. It denies every request until you implementauthorize(). It is a different file fromproxy.ts, which resolves the tenant.lib/stratum.ts: server-side helpers for API routes and Server Componentscomponents/tenant-layout.tsx: client component withStratumProvider
scaffold react
Section titled “scaffold react”Generates:
stratum-provider.tsx:AppStratumProviderwrappertenant-guard.tsx:PermissionGuardandConfigGuardcomponentsuse-tenant.ts:usePermission(),useConfig(),useIsRootTenant()hooks
scaffold prisma
Section titled “scaffold prisma”Generates:
stratum-prisma.ts: tenant-scoped Prisma client using@stratum-hq/db-adapters
scaffold docker
Section titled “scaffold docker”Generates:
docker-compose.stratum.yml: PostgreSQL + control plane services. The control plane connects as the application login (DATABASE_URL) and the admin login (DATABASE_ADMIN_URL), and receivesSTRATUM_ENCRYPTION_KEY,STRATUM_HKDF_SALTandSTRATUM_API_KEY_HMAC_SECRETfrom your environment when they are set.stratum-init-db.sql: runs once, as the bootstrap superuser, when the database volume is created. It runs the same statements asdocker/init-db.sqlin the Stratum repository: it creates the extensions and the roles of the role model:stratum_control: the NOLOGIN control role.stratum_admin: the admin login, a member ofstratum_control, neither superuser norBYPASSRLS. It runs the migrations and owns the Stratum tables.stratum_app: the application login, withoutBYPASSRLS, with no privilege on the Stratum tables. It can create its own tables.
Once the control plane has migrated, give the application login read access to the read list:
stratum db roles --apply --admin-role stratum_admin --app-role stratum_app \ --database-url postgres://stratum:stratum_dev@localhost:5432/stratumReplace the stratum_dev passwords outside development.
scaffold env
Section titled “scaffold env”Generates:
.env.stratum: all Stratum environment variables with defaults and descriptions:DATABASE_URL, the application login, andDATABASE_ADMIN_URL, the admin login of the role modelJWT_SECRET,STRATUM_ENCRYPTION_KEY,STRATUM_HKDF_SALTandSTRATUM_API_KEY_HMAC_SECRET, each with a new random value- the control plane URL and API key for
@stratum-hq/sdk, and optional tuning
The generated secrets are for development. Outside development and test (any other NODE_ENV), Stratum refuses to start without STRATUM_ENCRYPTION_KEY (at least 32 bytes) and STRATUM_HKDF_SALT (an even-length hex string), and requires STRATUM_API_KEY_HMAC_SECRET to be at least 32 bytes when set. Generate new values for each environment, keep them in a secret manager, and keep them stable once data exists: a different key or salt cannot decrypt values encrypted before, and a different HMAC secret no longer matches the API keys hashed with it. The file holds secrets, so keep it out of version control.
Global options
Section titled “Global options”| Flag | Description |
|---|---|
--database-url, -d |
PostgreSQL connection string |
--admin-database-url |
Connection of the admin login, a member of the control role (default: DATABASE_ADMIN_URL). doctor, generate api-key, migrate --tenant and db lock use it for Stratum’s tables; without it they fall back to the legacy app.bypass_rls path with a warning |
--control-role |
Control role of migration 032 (default: the stratum.control_role setting of the connection, else stratum_control) |
--admin-role, --app-role |
Logins for db roles |
--schema |
Schema of the Stratum tables for the db commands (default public) |
--apply |
Run the db roles SQL instead of printing it |
--grant-references |
db roles: also grant the application login REFERENCES (id) on tenants (opt-in) |
--name |
Name for generated API key |
--tenant |
Tenant ID for generated API key, or for the existing rows of a migrated table |
--out |
Output directory for scaffolded files |
--force |
Overwrite existing files |
--generate, -g |
Write migration SQL to stdout (scan) |
--exclude |
Comma-separated tables to leave out (scan) |
--depth-warning |
Tree depth above which doctor warns (default: STRATUM_DOCTOR_DEPTH_WARNING, else 20) |
--cp-port |
Control plane port for playground (default 3001) |
--help, -h |
Show help |
--version, -v |
Show version |
Environment
Section titled “Environment”| Variable | Description |
|---|---|
DATABASE_URL |
Default for --database-url |
DATABASE_ADMIN_URL |
Default for --admin-database-url |
STRATUM_DOCTOR_DEPTH_WARNING |
Default for --depth-warning |
STRATUM_API_KEY_HMAC_SECRET |
When set, generate api-key stores an HMAC hash |
NO_COLOR |
Set to any non-empty value to turn off colors in the output |
Exit codes
Section titled “Exit codes”| Command | Exit code 0 | Exit code 1 |
|---|---|---|
health |
Every check passed, or only warnings | A check failed, or the database connection failed |
doctor |
Every check passed, or only warnings | A check failed, or the database connection failed |
scan |
The scan ran, whether or not tables need migration | The database connection or a query failed |
migrate |
The migration ran, or you declined it | An error, stdin closed at a prompt, or (--all) a table keeps a policy that does not filter by tenant |
db roles --apply |
The control role is applied | The SQL failed, or the control role is not applied |
Every command exits with code 1 on an error, an unknown command, or invalid options.
Examples
Section titled “Examples”# Initialize a new Express + Prisma projectnpx @stratum-hq/cli init
# Check database readinessnpx @stratum-hq/cli health -d postgres://user:pass@localhost/mydb
# Add RLS to your orders tablenpx @stratum-hq/cli migrate orders
# Scaffold React components into src/componentsnpx @stratum-hq/cli scaffold react --out src/components
# Generate a tenant-scoped API keynpx @stratum-hq/cli generate api-key --name "web-app" --tenant abc123