Skip to content

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

npm install -g @stratum-hq/cli
# Or use directly without installing:
npx @stratum-hq/cli <command>

Interactive project setup wizard:

stratum init

What it does:

  1. Detects your framework and ORM from package.json (Express, Fastify, Next.js, Prisma, etc.)
  2. Asks: direct library (@stratum-hq/lib) or HTTP API + SDK (@stratum-hq/sdk)?
  3. Generates config file, middleware/plugin, database setup, .env template
  4. If React is detected, generates provider, tenant guard components, and custom hooks
  5. 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.

Validate your database is ready for Stratum:

stratum health
stratum health --database-url postgres://user:pass@host:5432/mydb

Checks:

  • Database connectivity
  • PostgreSQL version (16+ recommended, 14+ required)
  • Required extensions: uuid-ossp and ltree
  • 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-url is limited to the application’s share (see Hardening: separate admin and app roles). With --admin-database-url (or DATABASE_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.

Check a database that already runs Stratum for configuration problems and damaged data:

stratum doctor
stratum doctor --database-url postgres://user:pass@host:5432/mydb
stratum doctor --depth-warning 30
stratum doctor --admin-database-url postgres://stratum_admin:pass@host:5432/mydb

Checks:

  • Database connectivity
  • Stratum schema tables
  • RLS enabled and forced on every table with a tenant_id column
  • RLS policies that filter by tenant
  • Bypass policies: policies that admit app.bypass_rls directly
  • Control role: whether migration 032 applied the control role
  • App role: the login of --database-url is 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-url can act as the control plane
  • Legacy switch: whether app.bypass_rls still 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 --apply restores 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_KEY setting
  • 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.

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 t
SET ancestry_path = chain.ancestry_path, depth = chain.depth
FROM chain
WHERE 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.

Report which application tables need tenant isolation, and write the SQL that adds it:

stratum scan # report only
stratum scan --generate > migration.sql # SQL to a file, report on the terminal
stratum scan --exclude users,sessions --generate > migration.sql

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

Add tenant isolation to existing database tables:

# Scan all tables and show RLS status
stratum migrate --scan
# Migrate a specific table
stratum migrate orders
# Migrate all unmigrated tables interactively
stratum migrate --all
# Migrate a table that has rows, and assign every existing row to one tenant
stratum migrate orders --tenant <tenant-uuid>

For each table, migration:

  1. Adds tenant_id UUID NOT NULL column
  2. Enables ROW LEVEL SECURITY + FORCE ROW LEVEL SECURITY
  3. Creates tenant_isolation policy
  4. Creates index on tenant_id
  5. Adds foreign key to tenants table (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.

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.

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 psql
stratum 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/mydb

The 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 --admin-database-url postgres://stratum_admin:pass@host:5432/mydb
stratum db unlock --admin-database-url postgres://stratum_admin:pass@host:5432/mydb

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

Start the control plane and the demo app on your machine:

stratum playground
stratum playground --database-url postgres://user:pass@localhost:5432/stratum --cp-port 4000

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

Generate framework-specific integration boilerplate without the full init wizard:

stratum scaffold express # Express middleware + routes
stratum scaffold fastify # Fastify plugin
stratum scaffold nextjs # Next.js proxy (or middleware) + API helpers + layout
stratum scaffold react # React provider + guards + hooks
stratum scaffold prisma # Tenant-scoped Prisma client
stratum scaffold docker # Docker Compose for Stratum + PostgreSQL
stratum scaffold env # .env template with all variables

Generates:

  • stratum-middleware.ts: SDK client + expressMiddleware setup
  • tenant-routes.ts: example routes using req.tenant

Generates:

  • stratum-plugin.ts: SDK client + fastifyPlugin setup

Generates:

  • proxy.ts on Next.js 16 and later, middleware.ts on Next.js 15: written next to the app directory (src/proxy.ts when the project keeps its app in src/app), because Next.js runs the file only from there. The CLI reads the version from node_modules/next, else from the next range in package.json. When it cannot read the version, it writes middleware.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 the tenant_id claim of a bearer token verified with JWT_SECRET (using jose), and rejects a token that does not verify. The subdomain is forwarded as x-tenant-slug, never as the tenant ID. stratum init generates the same file for Next.js.
  • app/api/stratum/[...path]/route.ts: the Stratum API route, written next to the app directory like proxy.ts. StratumProvider sends its requests to /api/stratum, and this route adds STRATUM_API_KEY on the server and forwards them to the control plane. It denies every request until you implement authorize(). It is a different file from proxy.ts, which resolves the tenant.
  • lib/stratum.ts: server-side helpers for API routes and Server Components
  • components/tenant-layout.tsx: client component with StratumProvider

Generates:

  • stratum-provider.tsx: AppStratumProvider wrapper
  • tenant-guard.tsx: PermissionGuard and ConfigGuard components
  • use-tenant.ts: usePermission(), useConfig(), useIsRootTenant() hooks

Generates:

  • stratum-prisma.ts: tenant-scoped Prisma client using @stratum-hq/db-adapters

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 receives STRATUM_ENCRYPTION_KEY, STRATUM_HKDF_SALT and STRATUM_API_KEY_HMAC_SECRET from 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 as docker/init-db.sql in 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 of stratum_control, neither superuser nor BYPASSRLS. It runs the migrations and owns the Stratum tables.
    • stratum_app: the application login, without BYPASSRLS, 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/stratum

Replace the stratum_dev passwords outside development.

Generates:

  • .env.stratum: all Stratum environment variables with defaults and descriptions:
    • DATABASE_URL, the application login, and DATABASE_ADMIN_URL, the admin login of the role model
    • JWT_SECRET, STRATUM_ENCRYPTION_KEY, STRATUM_HKDF_SALT and STRATUM_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.

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

# Initialize a new Express + Prisma project
npx @stratum-hq/cli init
# Check database readiness
npx @stratum-hq/cli health -d postgres://user:pass@localhost/mydb
# Add RLS to your orders table
npx @stratum-hq/cli migrate orders
# Scaffold React components into src/components
npx @stratum-hq/cli scaffold react --out src/components
# Generate a tenant-scoped API key
npx @stratum-hq/cli generate api-key --name "web-app" --tenant abc123