# `tenant-scoped/create-tenant-scoped-wrapper` — SQL rewriter for row-level tenancy _(no submodules)_ ## Purpose This module is the load-bearing safety boundary between a plugin route handler and the underlying admin database surface. Every SELECT/UPDATE/DELETE that the route handler issues is rewritten in flight to carry a `WHERE tenant_id = ?` predicate; every INSERT either carries the tenant column or is rejected (or auto-injected, configurable). The contract is enforced *at the wrapper*, so a route handler literally cannot read another tenant's rows even if the plugin author is malicious. The wrapper does not perform full SQL parsing; it performs a fast, case-insensitive lexical pass that handles the shapes a route handler realistically writes (simple FROM table, optional WHERE, ORDER BY, LIMIT, RETURNING). Anything beyond — multi-row INSERT, INSERT...SELECT, DDL, named params — is rejected with `TENANT_SCOPE_VIOLATION`, forcing the plugin author to either move the statement into a migration or declare the `db.unsafe-queries` capability. ## Features - Auto-injects `WHERE tenant_id = ?` (or `AND tenant_id = ?` when WHERE already exists) on SELECT/UPDATE/DELETE. Tail clauses (ORDER BY, LIMIT, RETURNING, GROUP BY) are preserved. - INSERT handling: detect tenant column; if absent and `autoInjectInserts: true`, rewrite to include it; if absent and `false`, reject; if present with a conflicting value (literal or bound), reject. - Transactions: every `tx.query` and `tx.execute` inside the transaction callback is rewritten identically to top-level calls. - Prepared statements: rewriting happens at execute time (so the bound params are visible), falling back to a non-prepared execute on the admin surface. - Pure-function `rewriters` export for direct testing. ## Usage: Basic ```ts import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped"; const scoped = createTenantScopedWrapper(admin, "acme" as TenantId); const rows = await scoped.query("SELECT * FROM users"); // executed SQL: SELECT * FROM users WHERE tenant_id = ? // bound params: ["acme"] ``` ## Usage: Advanced ```ts import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped"; const strict = createTenantScopedWrapper(admin, tenantId, { tenantColumn: "org_id", autoInjectInserts: false, }); // INSERT must explicitly carry org_id matching the resolved tenant await strict.execute( "INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)", ["inv_1", tenantId, 100] ); // rejected: literal doesn't match scope await strict .execute("INSERT INTO invoices (org_id) VALUES ('other')") .catch((e) => e.code === "DB_ADAPTER.TENANT_SCOPE_VIOLATION"); ``` ## API Reference - `createTenantScopedWrapper(admin, tenantId, options?): TenantScopedDatabaseLike`. - `TenantScopedWrapperOptions` — `{ tenantColumn?, autoInjectInserts? }`. - `rewriters.injectWhere(sql, tenantColumn, tenantId, params)` — appends a WHERE/AND predicate. - `rewriters.handleInsert(sql, tenantColumn, tenantId, params, autoInject)` — verifies or auto-injects. ## Implementation Notes The rewriter uses positional `?` parameters because they are accepted by SQLite, MySQL, and (with driver-side rebinding) Postgres. Engines requiring `$N` placeholders are expected to rebind at the driver layer. Record-shaped params and multi-row INSERTs are rejected explicitly rather than guessed; that pushes complex SQL into either a migration (which runs on the admin surface) or `db.unsafe.query` (capability-gated), keeping the rewriter narrow and auditable. Transaction callbacks receive a wrapped `DbTransaction` so the rewrite contract holds across the whole tx body.