@happy-technologies/tenancy
v0.1.0
Published
Transaction-scoped Postgres GUC wrapper and header/query tenant resolver shared across Happy Technologies products. Wrapper + resolver only: no RLS policy SQL, no table schemas, no FORCE-vs-ENABLE decision. Products own their isolation model.
Readme
@happy-technologies/tenancy
Transaction-scoped Postgres GUC wrapper and header/query tenant resolver,
shared across Happy Technologies products. Source-shipped ESM (no build
step); exports live at ./src/*. Postgres-only: pg is a peerDependency,
the app owns its pool and pg version.
Extracted from happy-knowledge's withTenantScope
(packages/compiler/src/store/metadata-db.ts:37-46) and
withPlatformExemption (packages/compiler/src/store/tenants-db.ts:357-371),
generalized so any consumer can set arbitrary session GUCs
transaction-locally, plus its requestedTenant header/query resolver
(packages/compiler/src/api/auth.ts:40-48).
Install
bun add @happy-technologies/tenancy pgUsage
withGucScope
Run fn inside a transaction with every entry in gucs set via
set_config(name, value, true) (transaction-local, so it never leaks onto
the pooled connection after commit/rollback/release):
import { withGucScope } from "@happy-technologies/tenancy";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await withGucScope(
pool,
{ "app.tenant_id": tenantId, "app.platform_admin": "on" },
async (client) => {
return client.query("SELECT * FROM widgets"); // RLS-scoped
},
);withTenantScope
A thin specialization of withGucScope for the common single-tenant case,
with room for extra GUCs in the same transaction:
import { withTenantScope } from "@happy-technologies/tenancy";
await withTenantScope(pool, { tenantId }, async (client) => {
return client.query("SELECT * FROM widgets");
});
// with an extra GUC, e.g. a platform-admin exemption alongside the tenant scope
await withTenantScope(
pool,
{ tenantId, gucs: { "app.platform_admin": "on" } },
async (client) => client.query("SELECT * FROM widgets"),
);If fn throws, the transaction rolls back and the checked-out client is
released with no GUC set on it.
resolveTenant
Resolve the tenant id a request is scoped to, from a framework-agnostic
plain-object view of the request: the X-Organization-Id header takes
precedence (matched case-insensitively), then a configurable query param
(default tenantId), then an optional path param. Returns undefined,
never a default, when nothing resolves; the app decides whether that means
reject or auto-provision.
import { resolveTenant } from "@happy-technologies/tenancy";
// e.g. inside Hono/Express middleware, after extracting plain objects:
const tenantId = resolveTenant(
{ headers: req.headers, query: req.query, params: req.params },
{ queryParam: "tenantId", pathParam: "orgId" },
);Non-goals
This package is a wrapper and resolver only. It deliberately does NOT:
- Define RLS policy SQL or table schemas. Consumers own their own migrations and the shape of their tenant-scoped tables.
- Decide the isolation model. A direct
tenant_idcolumn (happy-knowledge) vs. a membership JOIN (happyhive) are both valid and stay app-local. - Decide
FORCEvsENABLE ROW LEVEL SECURITY.
Security note
FORCE ROW LEVEL SECURITY is the consumer's responsibility. This
wrapper only sets a transaction-local GUC; it does not create, alter, or
force any RLS policy. Without FORCE ROW LEVEL SECURITY on the scoped
tables, table owners and superuser-privileged connections bypass RLS
entirely, so a policy that relies on app.tenant_id provides no isolation
guarantee for those roles. Adopting this wrapper does not by itself change,
strengthen, or weaken any app's existing RLS enforcement; each app must
verify and configure its own policies and FORCE/ENABLE posture.
