@marlinjai/tenant-db
@marlinjai/tenant-db
The shared, crown-jewel multi-tenant isolation core for the Lumitra suite. This is THE single point of total-isolation-failure: the only place a tenant_group id becomes a Postgres schema name, and the only place withSchema may be called. Built once, adversarially verified, then replicated per app by the orchestrator.
It implements the schema-per-tenant_group pattern (public for global / control data, tg_<hex32> per org) on Kysely over the postgres.js driver, with every Section 11 red-team correction baked in.
See the blueprint: docs/superpowers/plans/2026-06-07-schema-per-tenant-pattern.md (especially section 11).
What it gives you
| Export | Role |
|---|---|
tenantSchema(id) |
The injection chokepoint. Strict UUID allowlist, derives tg_<hex32>. The ONLY id-to-schema mapping. |
assertTenantGroupId / isTenantGroupId / TenantGroupId |
Brand a raw string into a validated tenant_group id. |
globalDb(base) / tenantDb(base, id) |
The db factories. tenantDb = base.withSchema(tenantSchema(id)), a new immutable handle per request. |
tenantSchemaRef(id) |
Schema-qualify a raw sql fragment (sql\... FROM ${ref}.outbox_events``). Required for any raw SQL naming a tenant table. |
createNodeDb (/node) |
One postgres.js pool wrapped by Kysely, the base singleton for a Node service. |
createWorkersDb (/workers) |
Per-invocation client from a Hyperdrive connection string. Asserts prepare:false / fetch_types:false. |
migratePublic, provisionTenant, migrateAllTenants |
The owned provisioning + fleet-migration runner. |
PUBLIC_MIGRATIONS / TENANT_MIGRATIONS |
The ext setup + registry, and an example per-tenant set apps replace. |
The discipline (why it is leak-proof)
- A tenant id can only become a schema name via
tenantSchema, validated against a strict UUID allowlist and rebuilt from hex only. tenantDbis the only blessed caller ofwithSchema; CI guard G1 banswithSchemaeverywhere else.withSchemadoes NOT rewrite rawsqlfragments. Any raw fragment naming a tenant table MUST usetenantSchemaRef(guard G3).- Global tables are registered in the
Databaseinterface aspublic.<name>; referencing one bare inside a tenant query is a COMPILE error. - The locked
search_pathis a ROLE DEFAULT (ALTER ROLE app SET search_path = ext), NEVER a per-connectionSET(guard G2). - Provisioning is advisory-locked, atomic, idempotent; grants are per-schema only (guard G4); the Workers client asserts
prepare === false(guard G5).
CI guards
pnpm --filter @marlinjai/tenant-db lint:guards runs scripts/check-guards.mjs, a standalone scanner that fails on G1..G5. Downstream apps point it at their own src. A deliberate hazard fixture can opt out with a tenant-db-guard-disable-next-line comment (reserved for tests that prove the leak, never for app code).
Tests
tests/isolation.spec.ts: the test that matters. Seedstg_a,tg_b, and same-named DECOY tables inpublic; runs the actual repo functions scoped totg_aacross every read path; asserts zerotg_band zero public-decoy rows even when handed atg_bworkspace_id. Also asserts the documented hazard (a bare raw fragment escapes the wall).src/schema.spec.ts: resolver fuzz battery (injection, unicode,public, empty, non-string).tests/runner.spec.ts: provisioning + migrate-all idempotency, partial-failure resume, UNIQUE schema_name, per-schema grants.tests/type-discipline.test-d.ts: compile-time proof that forgettingpublic.is a type error.