npm.io
0.1.0 • Published 2d ago

@marlinjai/tenant-db

Licence
MIT
Version
0.1.0
Deps
2
Size
203 kB
Vulns
0
Weekly
0

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

  1. A tenant id can only become a schema name via tenantSchema, validated against a strict UUID allowlist and rebuilt from hex only.
  2. tenantDb is the only blessed caller of withSchema; CI guard G1 bans withSchema everywhere else.
  3. withSchema does NOT rewrite raw sql fragments. Any raw fragment naming a tenant table MUST use tenantSchemaRef (guard G3).
  4. Global tables are registered in the Database interface as public.<name>; referencing one bare inside a tenant query is a COMPILE error.
  5. The locked search_path is a ROLE DEFAULT (ALTER ROLE app SET search_path = ext), NEVER a per-connection SET (guard G2).
  6. 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. Seeds tg_a, tg_b, and same-named DECOY tables in public; runs the actual repo functions scoped to tg_a across every read path; asserts zero tg_b and zero public-decoy rows even when handed a tg_b workspace_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 forgetting public. is a type error.

Keywords