diff --git a/.changeset/tenant-chokepoint-read-doors.md b/.changeset/tenant-chokepoint-read-doors.md new file mode 100644 index 0000000000..ab0165e97f --- /dev/null +++ b/.changeset/tenant-chokepoint-read-doors.md @@ -0,0 +1,55 @@ +--- +'@objectstack/driver-sql': patch +'@objectstack/driver-sqlite-wasm': patch +'@objectstack/driver-turso': patch +--- + +drivers: every SQL read door routes through the tenant chokepoint (#6792) + +`SqlDriver.applyTenantScope()` owns read-side tenant isolation for the whole SQL family — +the `tenantId` early-out, the "object has no tenant field" early-out, the NULL-org +platform-row rule (#2734) and the ADR-0105 D2 union posture (#3623). Its own docstring +said "every CRUD method routes through it". Nothing ever checked that, and it was false +for as long as it had existed. **Three** read doors built their query through +`getBuilder()` and never arrived: + +- **`findWithWindowFunctions()`** — the documented #4286 window door. It returns **rows**, + so on a deployment where the scope would have applied (`options.tenantId` set, object + has a tenant field) it returned rows belonging to **every** tenant. Measured with two + tenants seeded plus one NULL-org platform row: `tenantId: 'org_a'` returned + `[a1, a2, b1, b2, p1]` here against `find()`'s `[a1, a2, p1]` — another tenant's rows, + handed over at the driver layer. +- **`analyzeQuery()` / `explain()`** — returns a **plan**, not rows, so this is a smaller + fix and it is made on its own merits rather than folded into the one above. It is the + same defect #6577 fixed on these two methods one builder line lower: a plan is only + worth reading if it explains the statement `find()` would actually run, and a missing + tenant predicate changes selectivity and therefore which index the planner picks. + Compiled `select * from account` where `find()` sent the `organization_id` clause. +- **`distinct()`** — returns one column's **values** for every tenant. This one was in no + card. #6792 states the opposite, listing `distinct` among the scoped call sites; the + 13th read site is `aggregate()`. It was found by measuring the invariant rather than + re-reading it. + +All three now call `applyTenantScope()` beside their `getBuilder()` line, the position +`findRows()` uses. They route through the chokepoint rather than re-deriving a predicate: +a local equality would silently drop NULL-org platform rows (#2734) and collapse group +reads to active-org reach (#3623). Both of the chokepoint's early-outs are inherited +unchanged, so an unscoped admin/seed read (no `tenantId`) and any object without a tenant +field behave exactly as before. + +**The durable half is a gate, not the three lines.** `pnpm check:tenant-chokepoint` +(`scripts/check-tenant-chokepoint.mjs`, wired into `.github/workflows/lint.yml`) re-derives +the invariant from the AST across the `SqlDriver` family on every run: a method that builds +through `getBuilder(object, options)` must call `applyTenantScope()` on that builder, or +carry a written exemption. Insert builders are exempt structurally — write-side tenancy is +`injectTenantOnInsert` — rather than by a name list. It is keyed on the **builder** and not +on the method signature, because the signature criterion the card sketches ("takes +`(object, …, options)` and returns rows") misses `distinct` (no `query` parameter) and +`analyzeQuery` (returns a plan). Verified red against the pre-fix tree, red against a +newly-added unscoped door, and silent once that door is scoped. + +The chokepoint docstring no longer asserts the invariant; it names the gate that proves it. + +If you call these doors directly on a multi-tenant deployment, pass `options.tenantId` as +you would to `find()` — that is what now takes effect. Callers that never passed it are +unaffected; that remains the documented unscoped/admin path. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 12dd33a4f6..6ed50e30c5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -575,6 +575,44 @@ jobs: - name: Spec type-alias convention gate (ADR-0122) run: pnpm check:spec-parsed-alias + # Read-side tenant chokepoint gate (#6792, from #3724 / #6577). + # `SqlDriver.applyTenantScope()` owns read-side tenant isolation for the + # whole SQL family — the tenantId early-out, the no-tenant-field early-out, + # the NULL-org platform-row rule (#2734) and the ADR-0105 D2 union posture + # (#3623). Its own docstring claimed "every CRUD method routes through it". + # Nothing checked that, and it was FALSE for as long as it had existed: + # three doors built through `getBuilder()` and never arrived — + # `findWithWindowFunctions` (ROWS: a caller passing `tenantId` got every + # tenant's rows, measured `[a1,a2,b1,b2,p1]` against `find()`'s + # `[a1,a2,p1]`), `analyzeQuery`/`explain` (a PLAN for a statement `find()` + # would not run — the same defect #6577 fixed on these methods one builder + # line lower), and `distinct` (every tenant's values for one column). + # + # The third is the argument for gating rather than fixing. It was in NO + # card: #6792 asserts the opposite — that `distinct` is among the 13 scoped + # sites — and the triage comment and two rounds of measurement all + # inherited that sentence without re-deriving it. The 13th read site is + # `aggregate()`. Two of the three doors were found by a human reading the + # file for another reason; the third was found only by measuring, which is + # the thing a prose invariant can never do for itself. + # + # Keyed on the BUILDER, not the method signature. #6792 sketches "every + # method taking `(object, …, options)` and returning rows"; that criterion + # is measurably too narrow — `distinct(object, field, filters, options)` + # takes no query and `analyzeQuery` returns a plan, so it misses two of the + # three. `getBuilder()` is the single constructor of every statement this + # driver sends, so every builder is classified and one that cannot be + # classified is an error, never a default (#4690's family). Insert builders + # are exempt structurally, not by name: write-side tenancy is + # `injectTenantOnInsert`. + # + # Static AST over three files, no build needed, so it belongs in this job. + # Runs its own --self-test first, in both directions — the detector can be + # broken while every door is fine, and a scan that stops matching would + # report OK while reading nothing. + - name: Read-side tenant chokepoint gate + run: pnpm check:tenant-chokepoint + typecheck: name: TypeScript Type Check runs-on: ubuntu-latest diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index f6980a387d..1b7c034d99 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -590,6 +590,17 @@ const ranked = await sqlDriver.findWithWindowFunctions('employee', { }); ``` + +**Pass `options.tenantId` on a multi-tenant deployment.** Like `find()`, this door is +tenant-scoped only when the caller supplies it — the example above omits it, so it reads +across every tenant. That is the driver layer's documented contract (seed scripts and +cross-org tooling depend on the unscoped path), but it is a decision to make deliberately. + +Until #6792 the door ignored `options.tenantId` even when you *did* pass it and returned +every tenant's rows regardless. It now routes through the driver's `applyTenantScope` +chokepoint like every other read. + + For request-level analytics, use `aggregations` + `groupBy`, or model rankings in report/dashboard metadata. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index a0d35c3894..638ecfb9eb 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -826,6 +826,12 @@ from presentation; Postgres and MySQL hand back their own native temporal value) then re-deduplicates the presented values, because SQL `DISTINCT` compares the *stored* form. +It is tenant-scoped on the same terms as `find()` — the `organization_id` predicate is +applied when the call carries `options.tenantId` (the fourth argument), and the example +above omits it, so it returns the column's values across every tenant. Until #6792 the +predicate was dropped even when `tenantId` *was* supplied, which made a scoped call +disclose every other tenant's values for that column. + ### Full-Text Search The `search` parameter does **not** reach a full-text index. The engine expands it into @@ -959,6 +965,13 @@ projection, and niladic rendering means argument-taking functions (`LAG(field)`) emit without their argument. For request-level analytics use `aggregations` + `groupBy` (§5). +Tenancy works exactly as it does on `find()`: the driver applies its +`organization_id` predicate only when the call carries `options.tenantId`. The example +above omits it and therefore reads across every tenant — the intended unscoped/admin +path, but a deliberate choice rather than a default to inherit. (Until #6792 this door +dropped `options.tenantId` even when it was supplied, so a scoped call still returned +every tenant's rows.) + --- ## 7. Pagination diff --git a/package.json b/package.json index 31fbed3777..aa6b571cb4 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "check:engine-double-contract": "node scripts/check-engine-double-contract.mjs --self-test && node scripts/check-engine-double-contract.mjs", "check:resume-authority-declared": "node scripts/check-resume-authority-declared.mjs --self-test && node scripts/check-resume-authority-declared.mjs", "check:spec-parsed-alias": "node scripts/check-spec-parsed-alias.mjs --self-test && node scripts/check-spec-parsed-alias.mjs", + "check:tenant-chokepoint": "node scripts/check-tenant-chokepoint.mjs --self-test && node scripts/check-tenant-chokepoint.mjs", "check:stall-guard": "node scripts/run-with-stall-guard.mjs --self-test" }, "keywords": [ diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts new file mode 100644 index 0000000000..cad767cd5a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every read door routes through the tenant chokepoint — objectstack#6792. + * + * # What was open + * + * `applyTenantScope()` says of itself that it is "the single chokepoint for + * read-side tenant isolation in the SQL driver; every CRUD method routes + * through it". THREE methods did not: + * + * - `findWithWindowFunctions()` — the live window-function read door (#4286). + * It returns ROWS. With `options.tenantId` set on an object that has a + * tenant field, it returned rows belonging to EVERY tenant: cross-tenant + * read exposure at the driver layer, the #3724 class. + * - `analyzeQuery()` / `explain()` — returns a PLAN. Lower severity and a + * different defect: a plan is only worth reading if it explains the + * statement `find()` would actually run, and a statement missing the tenant + * predicate has different selectivity and picks different indexes. This is + * the same failure #6577 fixed on these two methods for `limit`, one + * builder line higher. + * - `distinct()` — returns one column's VALUES for every tenant. Named in no + * card; #6792 says the OPPOSITE, listing `distinct` among the 13 scoped + * sites (the 13th read site is `aggregate()`). Found by running the gate + * this change ships, which is the argument for having one. + * + * All three built through `this.getBuilder(object, options)` and none followed + * it with `applyTenantScope`, which is what every other read site does. + * + * Measured on `main` at `6595262`, before any line was added, against the + * fixture below (`org_a` owns a1/a2, `org_b` owns b1/b2, p1 is a NULL-org + * platform row): + * + * ``` + * find {} tenantId=org_a -> 3 rows (a1, a2, p1) + * window {} tenantId=org_a -> 5 rows (a1, a2, b1, b2, p1) <- org_b's rows + * distinct 'name' tenantId=org_a -> [A1, A2, B1, B2, P1] <- org_b's values + * analyze {} tenantId=org_a -> select * from `os6792_account` <- no predicate + * find {} tenantId=org_a -> select * from `os6792_account` + * where (`organization_id` = ? or `organization_id` + * is null) order by `id` asc + * ``` + * + * # Why the plan half asserts against a captured statement + * + * It cannot assert rows. So it asserts the tenant predicate against the + * statement a real `find()` **actually sent**, read off knex's `query` event, + * rather than against a predicate this file spells out itself. A test that + * rebuilds the expectation asserts only that knex works, and stays green on the + * day the two doors diverge again — which is the day this file exists for. + * + * # The negative cases are the fix's real boundary + * + * `applyTenantScope` is a no-op in two situations that must stay no-ops: no + * `tenantId` (the admin / seed / cross-org tooling path, kept working + * deliberately) and an object with no tenant field. Adding a scope call to a + * door is only correct if it inherits BOTH early-outs — a door that starts + * answering `[]` to an unscoped admin read is a different defect, not a fix. So + * each exposure case is stated beside the read it must not become. + * + * The NULL-org row is here for the same reason. `applyTenantScope` emits + * `(field = ? OR field IS NULL)` on purpose (#2734: strict equality hid every + * platform-seeded RBAC row from every tenant admin). A door "fixed" with a bare + * equality would pass a naive cross-tenant assertion and silently lose p1, so + * p1 is what distinguishes routing through the chokepoint from reimplementing + * a worse copy of it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqlDriver } from './index.js'; + +const TABLE = 'os6792_account'; +const UNSCOPED_TABLE = 'os6792_ledger'; + +/** Records the SQL this driver really sent, so the plan door can be held to it. */ +class InspectableSqlDriver extends SqlDriver { + readonly statements: Array<{ sql: string; bindings: readonly unknown[] }> = []; + + captureStatements(): void { + this.knex.on('query', (data: { sql?: string; bindings?: readonly unknown[] }) => { + if (typeof data?.sql === 'string') { + this.statements.push({ sql: data.sql, bindings: data.bindings ?? [] }); + } + }); + } + + /** The one statement issued against `table` while `run` executed. */ + async statementFor( + table: string, + run: () => Promise, + ): Promise<{ sql: string; bindings: readonly unknown[] }> { + const before = this.statements.length; + await run(); + const issued = this.statements.slice(before).filter((s) => s.sql.includes(table)); + expect(issued).toHaveLength(1); + return issued[0]!; + } +} + +/** The window spec this file reads with — argument-less by the #4286 contract. */ +const WINDOW = { + windowFunctions: [ + { function: 'ROW_NUMBER', alias: 'rn', orderBy: [{ field: 'balance', order: 'desc' }] }, + ], +} as const; + +const ids = (rows: any[]): string[] => rows.map((r) => r.id).sort(); + +describe('driver-sql — every read door routes through applyTenantScope (#6792)', () => { + let driver: InspectableSqlDriver; + + beforeAll(async () => { + driver = new InspectableSqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + } as any); + await driver.initObjects([ + { + name: TABLE, + fields: { + organization_id: { type: 'string' }, + name: { type: 'string' }, + balance: { type: 'number' }, + }, + }, + // No `organization_id`: `resolveTenantField` returns null, so the + // chokepoint is a no-op here by design. + { + name: UNSCOPED_TABLE, + fields: { name: { type: 'string' }, balance: { type: 'number' } }, + }, + ] as any); + + await driver.create(TABLE, { id: 'a1', organization_id: 'org_a', name: 'A1', balance: 10 } as any); + await driver.create(TABLE, { id: 'a2', organization_id: 'org_a', name: 'A2', balance: 20 } as any); + await driver.create(TABLE, { id: 'b1', organization_id: 'org_b', name: 'B1', balance: 30 } as any); + await driver.create(TABLE, { id: 'b2', organization_id: 'org_b', name: 'B2', balance: 40 } as any); + // Platform row: belongs to no tenant, so it belongs to no OTHER tenant + // either and must stay visible (#2734). + await driver.create(TABLE, { id: 'p1', name: 'P1', balance: 50 } as any); + + await driver.create(UNSCOPED_TABLE, { id: 'l1', name: 'L1', balance: 1 } as any); + await driver.create(UNSCOPED_TABLE, { id: 'l2', name: 'L2', balance: 2 } as any); + + driver.captureStatements(); + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + describe('findWithWindowFunctions — the ROW-returning door', () => { + it('does not return another tenant\'s rows', async () => { + const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { + tenantId: 'org_a', + } as any); + expect(ids(rows)).toEqual(['a1', 'a2', 'p1']); + expect(ids(rows)).not.toContain('b1'); + expect(ids(rows)).not.toContain('b2'); + }); + + it('gives each tenant its own rows, and only its own', async () => { + const a = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any); + const b = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_b' } as any); + expect(ids(a)).toEqual(['a1', 'a2', 'p1']); + expect(ids(b)).toEqual(['b1', 'b2', 'p1']); + }); + + it('agrees with `find()` on the same read — one driver, one answer', async () => { + const viaWindow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any); + const viaFind = await driver.find(TABLE, {}, { tenantId: 'org_a' }); + expect(ids(viaWindow)).toEqual(ids(viaFind)); + }); + + it('keeps the NULL-org platform row visible — the chokepoint, not a bare equality (#2734)', async () => { + const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any); + expect(ids(rows)).toContain('p1'); + }); + + it('honours the ADR-0105 D2 union posture the same way the chokepoint does (#3623)', async () => { + const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { + tenantId: 'org_a', + tenantIds: ['org_a', 'org_b'], + } as any); + expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']); + + const narrow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { + tenantId: 'org_a', + tenantIds: ['org_a'], + } as any); + expect(ids(narrow)).toEqual(['a1', 'a2', 'p1']); + }); + + it('still applies the caller\'s own `where` beside the tenant predicate', async () => { + const rows = await driver.findWithWindowFunctions( + TABLE, + { ...WINDOW, where: { balance: { $gte: 20 } } } as any, + { tenantId: 'org_a' } as any, + ); + // `p1` (balance 50) is the NULL-org platform row and is legitimately + // visible to org_a — it passes the filter AND the chokepoint. b1/b2 + // (30/40) pass the filter and must not survive the tenant predicate. + expect(ids(rows)).toEqual(['a2', 'p1']); + }); + + it('still computes the window function itself', async () => { + const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any); + expect(rows.every((r) => r.rn !== undefined)).toBe(true); + }); + + // ── the two early-outs the fix must inherit, not override ──────────────── + + it('is UNSCOPED with no tenantId — the admin / seed / cross-org path stays open', async () => { + const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any); + expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']); + }); + + it('is unaffected on an object with no tenant field, tenantId or not', async () => { + const scoped = await driver.findWithWindowFunctions(UNSCOPED_TABLE, { ...WINDOW } as any, { + tenantId: 'org_a', + } as any); + expect(ids(scoped)).toEqual(['l1', 'l2']); + }); + }); + + /** + * NOT one of the two doors #6792 names. Found by the gate this card ships, + * while checking what it would be red on — and the card's own enumeration + * says the opposite ("It is called at 13 sites — `findRows` …, `count`, + * `distinct`, the write paths"). `distinct` is not among them; the 13th read + * site is `aggregate`. Triage repeated the count without re-deriving which + * methods it covered, and both PM measurements inherited that. + * + * It returns column VALUES rather than whole rows, which lowers the volume + * and not the class: one call hands back every OTHER tenant's values for the + * named column. The door is documented with a runnable example + * (`content/docs/protocol/objectql/query-syntax.mdx:819`, + * `const industries = await driver.distinct('account', 'industry')`), so it + * is exposed the same way `findWithWindowFunctions` is. + * + * (Unrelated to the v17 note that "`aggregate()` / `distinct()` leaked" — + * that one is raw epoch storage reaching presentation, #3839/#3849.) + */ + describe('distinct — the third read door, found by the gate rather than by the card', () => { + it('does not return another tenant\'s values', async () => { + const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_a' } as any); + expect(values.sort()).toEqual(['A1', 'A2', 'P1']); + expect(values).not.toContain('B1'); + expect(values).not.toContain('B2'); + }); + + it('agrees with `find()` about which rows it may see', async () => { + const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_b' } as any); + const rows = await driver.find(TABLE, {}, { tenantId: 'org_b' }); + expect(values.sort()).toEqual(rows.map((r: any) => r.name).sort()); + }); + + it('still narrows by the caller\'s own filter beside the tenant predicate', async () => { + const values = await driver.distinct(TABLE, 'name', { balance: { $gte: 20 } } as any, { + tenantId: 'org_a', + } as any); + // `P1` is the NULL-org platform row (balance 50) — see the window-door + // case above. `B1`/`B2` also pass the filter and must not survive. + expect(values.sort()).toEqual(['A2', 'P1']); + }); + + it('is UNSCOPED with no tenantId — the admin / seed path stays open', async () => { + const values = await driver.distinct(TABLE, 'name'); + expect(values.sort()).toEqual(['A1', 'A2', 'B1', 'B2', 'P1']); + }); + + it('is unaffected on an object with no tenant field', async () => { + const values = await driver.distinct(UNSCOPED_TABLE, 'name', undefined, { + tenantId: 'org_a', + } as any); + expect(values.sort()).toEqual(['L1', 'L2']); + }); + }); + + describe('analyzeQuery / explain — the PLAN door', () => { + it('carries the tenant predicate `find()` actually emitted', async () => { + const emitted = await driver.statementFor(TABLE, () => + driver.find(TABLE, {}, { tenantId: 'org_a' }), + ); + const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any); + + // Read the predicate off the statement `find()` really sent rather than + // spelling it here, so a change to the chokepoint's SQL moves both sides. + expect(emitted.sql).toContain('organization_id'); + expect(emitted.bindings).toContain('org_a'); + + expect(analyzed.sql).toContain('organization_id'); + expect(analyzed.bindings).toContain('org_a'); + }); + + it('emitted a statement with NO tenant predicate before this fix — the regression sentinel', async () => { + const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any); + expect(analyzed.sql.toLowerCase()).toContain('organization_id'); + }); + + it('reaches the same statement through `explain()`, which forwards here', async () => { + const explained = await driver.explain(TABLE, {} as any, { tenantId: 'org_a' } as any); + expect(explained.sql).toContain('organization_id'); + expect(explained.bindings).toContain('org_a'); + }); + + it('carries the union predicate under the group posture', async () => { + const analyzed = await driver.analyzeQuery(TABLE, {} as any, { + tenantId: 'org_a', + tenantIds: ['org_a', 'org_b'], + } as any); + expect(analyzed.sql).toContain('organization_id'); + expect(analyzed.bindings).toContain('org_a'); + expect(analyzed.bindings).toContain('org_b'); + }); + + it('still returns a plan alongside the statement, unchanged by any of this', async () => { + const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any); + expect(analyzed.plan).toBeDefined(); + expect(analyzed.error).toBeUndefined(); + }); + + it('emits no tenant predicate when the caller gave no tenantId', async () => { + const analyzed = await driver.analyzeQuery(TABLE, {} as any); + expect(analyzed.sql).not.toContain('organization_id'); + }); + + it('emits no tenant predicate for an object with no tenant field', async () => { + const analyzed = await driver.analyzeQuery(UNSCOPED_TABLE, {} as any, { tenantId: 'org_a' } as any); + expect(analyzed.sql).not.toContain('organization_id'); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index ab194a973c..4d5c6e7371 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -4252,6 +4252,18 @@ export class SqlDriver implements IDataDriver { */ async distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); + // The third read door that skipped the chokepoint, and the one #6792 does + // NOT name — the card asserts the opposite ("called at 13 sites — …, + // `count`, `distinct`, …"). It is not among them; the 13th read site is + // `aggregate()`. Found by the gate this change ships, not by the card. + // + // VALUES rather than rows, which lowers the volume and not the class: + // measured on `main` at `6595262`, `distinct(account, 'name', undefined, + // { tenantId: 'org_a' })` returned `[A1, A2, B1, B2, P1]` — every other + // tenant's values for the named column. Documented with a runnable example + // (`content/docs/protocol/objectql/query-syntax.mdx`), so it is exposed the + // same way the window door is. + this.applyTenantScope(builder, object, options); if (filters) { this.applyFilters(builder, filters); @@ -4286,6 +4298,20 @@ export class SqlDriver implements IDataDriver { */ async findWithWindowFunctions(object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); + // ROWS, so this is the read-side wall itself — not a consistency tidy-up + // (#6792). This door returned every tenant's rows to a caller that passed + // `options.tenantId`, because it built through `getBuilder` and then simply + // never reached the chokepoint all thirteen other doors route through. + // Measured on `main` at `6595262` with two tenants seeded: `tenantId: + // 'org_a'` returned `[a1, a2, b1, b2, p1]` here and `[a1, a2, p1]` through + // `find()` — org_b's rows, handed to org_a, at the driver layer. + // + // Placed BESIDE `getBuilder` and above the caller's `where`, which is the + // position `findRows()` uses: the predicate has to be on the builder before + // anything reads it, and `applyTenantScope` is what owns the NULL-org + // platform-row and ADR-0105 D2 union semantics. Re-deriving either here + // would be a second, worse copy of the wall. + this.applyTenantScope(builder, object, options); builder.select('*'); @@ -4335,6 +4361,19 @@ export class SqlDriver implements IDataDriver { */ async analyzeQuery(object: string, query: DriverQuery, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); + // A PLAN, not rows — so this is a smaller fix than the one above, and it is + // made on its own merits rather than riding in on that one (#6792). It is + // the SAME defect #6577 fixed on this method one builder line lower: a plan + // is only worth reading if it explains the statement `find()` would + // actually run, and a missing tenant predicate is not a cosmetic + // difference — it changes selectivity and therefore which index the planner + // picks, so the EXPLAIN answers for a query nobody will execute. + // Measured on `main` at `6595262`, `tenantId: 'org_a'`: + // analyze -> select * from `os6792_account` + // find -> select * from `os6792_account` + // where (`organization_id` = ? or `organization_id` is null) + // order by `id` asc + this.applyTenantScope(builder, object, options); if (query.fields) { builder.select(query.fields); @@ -6677,8 +6716,32 @@ export class SqlDriver implements IDataDriver { * * Without a tenantId the call is treated as an unscoped/admin path — * keeps legacy callers, seed scripts, and cross-org tooling working. - * This is the single chokepoint for read-side tenant isolation in the - * SQL driver; every CRUD method routes through it. + * + * This is the single chokepoint for read-side tenant isolation in the SQL + * driver, and every read door routes through it — `findRows()` (what + * `find()`/`findOne()` use), `count()`, `aggregate()`, `distinct()`, + * `findWithWindowFunctions()`, `analyzeQuery()`/`explain()`, and the + * update/delete predicates and their readbacks. Write-side tenancy is a + * different mechanism and deliberately not this one: inserts stamp the + * column via {@link injectTenantOnInsert}, so the three `insert` builders + * (create / upsert / bulkCreate) reach `getBuilder` without coming here. + * + * ⚠️ That sentence used to be written as a claim — "every CRUD method routes + * through it" — and it was FALSE for as long as it had existed (#6792). + * Three doors built through `getBuilder` and never arrived: + * `findWithWindowFunctions` (ROWS — a caller passing `tenantId` got every + * tenant's rows), `analyzeQuery`/`explain` (a PLAN for a statement `find()` + * would not run), and `distinct` (every tenant's values for one column). The + * first two were filed; the third was found only because the invariant was + * finally MEASURED. Nothing had ever checked it, which is exactly how a + * docstring becomes the last place a wrong fact survives. + * + * So it is no longer a claim. `scripts/check-tenant-chokepoint.mjs` + * (`pnpm check:tenant-chokepoint`, wired into `.github/workflows/lint.yml`) + * re-derives it from the AST on every run: a method that builds through + * `getBuilder(object, options)` and lets that builder escape as a read must + * call this, or carry a written exemption. Edit this list and the gate will + * disagree with you — that is the point. */ protected applyTenantScope( builder: Knex.QueryBuilder, diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs new file mode 100644 index 0000000000..03aafa1b32 --- /dev/null +++ b/scripts/check-tenant-chokepoint.mjs @@ -0,0 +1,561 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Read-side tenant chokepoint guard (#6792, from #3724 / #6577). + * + * ## What it guards + * + * `SqlDriver.applyTenantScope()` is the single chokepoint for read-side tenant + * isolation in the SQL driver: it owns the `tenantId` early-out, the + * "object has no tenant field" early-out, the NULL-org platform-row rule + * (#2734) and the ADR-0105 D2 union posture (#3623). A read door that builds + * its own query and never arrives there is not "inconsistent" — it returns + * other tenants' data to a caller that asked to be scoped. + * + * So: **a method that builds through `this.getBuilder(object, options)` and + * lets that builder escape as a READ must call `this.applyTenantScope()` on + * it.** Write builders are exempt and the exemption is structural, not a + * name list — insert-side tenancy is a different mechanism + * (`injectTenantOnInsert` stamps the column), so a builder whose only escape + * is `.insert(...)` is correct without this call. + * + * ## Why the gate exists, and what it already caught + * + * The invariant was written down and never measured. `applyTenantScope`'s own + * docstring claimed "every CRUD method routes through it" — false, for as long + * as it had existed. Three doors built through `getBuilder` and never arrived: + * + * - `findWithWindowFunctions()` — the documented #4286 window door. Returns + * ROWS. Measured on `main` at `6595262` with two tenants seeded, + * `tenantId: 'org_a'` returned `[a1, a2, b1, b2, p1]` here against + * `[a1, a2, p1]` through `find()`. Cross-tenant read exposure. + * - `analyzeQuery()` / `explain()` — returns a PLAN. Compiled + * `select * from account` where `find()` sent the `organization_id` + * predicate: an EXPLAIN for a statement nobody will run. + * - `distinct()` — returns one column's VALUES for every tenant. **This one + * was in no card.** #6792 asserts the opposite — that the 13 call sites + * include `distinct` — and both the triage comment and two rounds of PM + * measurement inherited that sentence without re-deriving it. The 13th + * read site is `aggregate()`. It was found by running this gate. + * + * That is the case for the gate in one line: two of the three doors were found + * by a human reading the file, and the third was found by measuring. Nothing + * obliged the next one to route through the chokepoint, which is exactly how + * all three got out. + * + * ## Why the criterion is the BUILDER, not the signature + * + * #6792 sketches "every method taking `(object, …, options)` and returning + * rows". That criterion is the intuitive one and it is measurably too narrow — + * it misses two of the three real doors: + * + * - `distinct(object, field, filters?, options?)` takes no `query` at all. + * - `analyzeQuery(): Promise` does not return rows; it returns a plan, + * and its defect is that the plan describes an unscoped statement. + * + * Deciding "returns rows" from a return type needs dataflow the AST does not + * carry, and `Promise` is not a signal. The structural fact that actually + * defines a door is that it BUILDS one — `getBuilder` is the single constructor + * of every query this driver sends. So that is what is keyed on, and the check + * is sound rather than heuristic: every builder is classified, and one that + * cannot be classified is an error, never a default (#4690's family). + * + * ## Scope + * + * The `SqlDriver` family only — `driver-sql` plus the two subclasses that + * inherit this chokepoint (`driver-sqlite-wasm`, `driver-turso`). Scanning the + * subclasses is what stops a new door being added one layer down. + * + * `driver-memory` and `driver-mongodb` are outside it, and NOT because #5499 + * froze them: they do not use this mechanism at all. Neither file contains + * `getBuilder` or `applyTenantScope` (mongodb enforces its own wall in + * `mongodb-tenancy-guard.ts`, a different mechanism with a different shape). + * There is therefore no verdict for this gate to force on a frozen driver and + * no DEBT row to record — a real absence, stated rather than left to inference. + * + * ## What is NOT covered, named rather than implied + * + * - A builder passed to a HELPER that applies the scope on the caller's + * behalf. The gate looks for `applyTenantScope(, …)` within the + * method; a helper indirection would read as unscoped and go red. That is + * the safe direction (it demands the call be visible where the door is), + * and if a helper is ever wanted the answer is an EXEMPT entry with a + * reason, not a looser rule. + * - Raw `this.knex(...)` construction that bypasses `getBuilder` entirely. + * Out of reach of this criterion by construction. Nothing in the family + * does it today on a read path; if that changes, this gate cannot see it, + * and the honest place to record that limit is here. + * - **WHERE the call sits.** This is the important one, and it is measured + * rather than assumed. The gate asserts the call EXISTS on that binding, + * never its position — so a call moved below `builder.toSQL()`, or below + * `await builder`, satisfies this gate while isolating nothing. Measured + * during #6792's reverse verification: with the scope call relocated after + * the statement was snapshotted and after the rows were fetched, this gate + * reported clean (19/19 bindings) while nine assertions in + * `sql-driver-tenant-scope-read-doors.test.ts` went red. + * + * That is the division of labour, not a defect to fix here: making the + * gate position-aware would have it re-implement knex's evaluation order + * from the AST, and be wrong in a new way. **The gate proves the call is + * there; only the fixture proves it works.** Neither is redundant, and a + * change that keeps this green must still keep that file green. + * + * node scripts/check-tenant-chokepoint.mjs [--self-test] + * + * Exit codes: 0 clean · 1 an unscoped read door · 2 the scan could not run + * (no class found, a builder it cannot classify, or a DISCOVERED floor missed). + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** The `SqlDriver` family — every class that inherits this chokepoint. */ +const SCAN_FILES = [ + join('packages', 'drivers', 'driver-sql', 'src', 'sql-driver.ts'), + join('packages', 'drivers', 'driver-sqlite-wasm', 'src', 'sqlite-wasm-driver.ts'), + join('packages', 'drivers', 'driver-turso', 'src', 'turso-driver.ts'), +]; + +/** + * The floor a real scan must clear. A scan that silently stops matching reports + * "clean" while reading nothing (#4690), and this gate's whole value is that it + * is the only thing measuring the invariant. Set below today's count so + * ordinary edits do not trip it, and high enough that a broken matcher does. + */ +const DISCOVERED_FLOOR = 15; + +/** + * `'::::'` -> reason a reader can check. + * + * Empty by design. #6792 closed the last three unscoped read doors, so this + * starts from zero rather than from a ratchet. A new entry needs a reason, + * not a name — and "the layer above catches it" is not one: the chokepoint's + * own contract is what this asserts. + */ +const EXEMPT = Object.create(null); + +const BUILDER_FACTORY = 'getBuilder'; +const SCOPE_CALL = 'applyTenantScope'; + +/** `this.(...)` — the only receiver these two ever have. */ +function isThisCall(node, name) { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + node.expression.name.text === name + ); +} + +/** + * Does this initializer build through `this.getBuilder(...)`? Unwraps the + * chained forms the driver really uses — `this.getBuilder(o, opts).where(...)`, + * `.whereIn(...)`, `.withSchema(...)` — so a chain is still recognised as one + * builder rather than passed over. + */ +function buildsThroughGetBuilder(node) { + let cur = node; + while (cur) { + if (isThisCall(cur, BUILDER_FACTORY)) return true; + if (ts.isCallExpression(cur)) { cur = cur.expression; continue; } + if (ts.isPropertyAccessExpression(cur)) { cur = cur.expression; continue; } + return false; + } + return false; +} + +/** Every identifier-rooted `.` use inside `body`. */ +function memberUses(body, name) { + const members = new Set(); + const walk = (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === name + ) { + members.add(node.name.text); + } + ts.forEachChild(node, walk); + }; + walk(body); + return members; +} + +/** Is `this.applyTenantScope(, …)` called anywhere in `body`? */ +function isScoped(body, name) { + let found = false; + const walk = (node) => { + if (found) return; + if (isThisCall(node, SCOPE_CALL)) { + const first = node.arguments[0]; + if (first && ts.isIdentifier(first) && first.text === name) { + found = true; + return; + } + } + ts.forEachChild(node, walk); + }; + walk(body); + return found; +} + +/** + * Classify every `getBuilder` binding in one source file. + * + * Exported so `--self-test` can drive it over synthetic sources in BOTH + * directions without needing the repo in any particular state — the half a + * green run over a clean tree cannot exercise at all. + */ +export function analyzeSource(fileName, text) { + const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true); + const builders = []; + const unclassifiable = []; + + const visitMethod = (methodName, body) => { + if (!body) return; + + const walk = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer && buildsThroughGetBuilder(node.initializer)) { + if (!ts.isIdentifier(node.name)) { + // A destructured builder. Nothing does this today and the binding + // cannot be tracked by name — refuse rather than skip. + unclassifiable.push({ + method: methodName, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + why: 'a getBuilder() result bound by destructuring — cannot track the binding', + }); + return; + } + const name = node.name.text; + const members = memberUses(body, name); + builders.push({ + method: methodName, + binding: name, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + scoped: isScoped(body, name), + // Insert-side tenancy is injectTenantOnInsert's job, structurally. + write: members.has('insert'), + }); + return; + } + + // A getBuilder() call that is never bound to a name — an inline read this + // criterion cannot follow. Refuse; do not pass over. + if (isThisCall(node, BUILDER_FACTORY)) { + let p = node.parent; + while (p && (ts.isCallExpression(p) || ts.isPropertyAccessExpression(p))) p = p.parent; + if (p && !ts.isVariableDeclaration(p)) { + unclassifiable.push({ + method: methodName, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + why: 'a getBuilder() result used inline, never bound — cannot tell a read from a write', + }); + } + } + + ts.forEachChild(node, walk); + }; + + walk(body); + }; + + const walkTop = (node) => { + if (ts.isClassDeclaration(node)) { + for (const member of node.members) { + if ( + (ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member)) && + member.name && + ts.isIdentifier(member.name) + ) { + const body = ts.isMethodDeclaration(member) ? member.body : member.initializer; + visitMethod(member.name.text, body); + } + } + } + ts.forEachChild(node, walkTop); + }; + walkTop(sf); + + return { builders, unclassifiable }; +} + +/** The gate's verdict as a pure function of a classification. Self-testable. */ +export function violationsOf(file, { builders, unclassifiable }, exempt = EXEMPT) { + const problems = []; + for (const u of unclassifiable) { + problems.push({ + fatal: true, + text: `${file}:${u.line} ${u.method}() — ${u.why}`, + }); + } + for (const b of builders) { + if (b.scoped || b.write) continue; + if (exempt[`${file}::${b.method}::${b.binding}`]) continue; + problems.push({ + fatal: false, + text: + `${file}:${b.line} ${b.method}() builds \`${b.binding}\` through ${BUILDER_FACTORY}() ` + + `and never calls this.${SCOPE_CALL}(${b.binding}, …)`, + }); + } + return problems; +} + +// --------------------------------------------------------------------------- +// --self-test +// +// Every shape is proved to REPORT and its canonical counterpart proved to stay +// SILENT. A gate that has only ever been green cannot be told apart from a gate +// that matches nothing. + +const wrap = (body) => `class SqlDriver {\n${body}\n}`; + +function selfTest() { + const failures = []; + const assert = (cond, msg) => { if (!cond) failures.push(msg); }; + const run = (src) => { + const a = analyzeSource('t.ts', wrap(src)); + return { ...a, problems: violationsOf('t.ts', a) }; + }; + + const reports = [ + [ + 'a new row-returning door — the #6792 shape itself', + `async findWithWindowFunctions(object, query, options) { + const builder = this.getBuilder(object, options); + builder.select('*'); + return await builder; + }`, + ], + [ + 'a plan door — returns no rows, still a read', + `async analyzeQuery(object, query, options) { + const builder = this.getBuilder(object, options); + const sql = builder.toSQL(); + return { sql: sql.sql }; + }`, + ], + [ + 'a values door with no `query` parameter at all — what the signature criterion misses', + `async distinct(object, field, filters, options) { + const builder = this.getBuilder(object, options); + builder.distinct(field); + return await builder; + }`, + ], + [ + 'a chained builder', + `async readOne(object, id, options) { + const builder = this.getBuilder(object, options).where('id', id); + return await builder; + }`, + ], + [ + 'scoping the WRONG binding does not count as scoping this one', + `async two(object, options) { + const a = this.getBuilder(object, options); + const b = this.getBuilder(object, options); + this.applyTenantScope(a, object, options); + return [await a, await b]; + }`, + ], + [ + 'an update builder is a read predicate too — it must be scoped', + `async update(object, id, patch, options) { + const builder = this.getBuilder(object, options).where('id', id); + return await builder.update(patch); + }`, + ], + ]; + for (const [name, src] of reports) { + const { problems } = run(src); + assert(problems.length >= 1, `expected a report for: ${name}`); + } + + const silent = [ + [ + 'the canonical door — scoped beside getBuilder', + `async findRows(object, query, options) { + const b = this.getBuilder(object, options); + this.applyTenantScope(b, object, options); + return await b; + }`, + ], + [ + 'scoped inside a nested closure, which is how findRows really spells it', + `async findRows(object, query, options) { + const buildBase = () => { + const b = this.getBuilder(object, options); + this.applyTenantScope(b, object, options); + return b; + }; + return await buildBase(); + }`, + ], + [ + 'an INSERT builder — write-side tenancy is injectTenantOnInsert', + `async create(object, row, options) { + this.injectTenantOnInsert(object, row, options); + const builder = this.getBuilder(object, options); + return await builder.insert(row).returning('*'); + }`, + ], + [ + 'an upsert insert builder, conflict-merged', + `async upsert(object, row, options) { + const builder = this.getBuilder(object, options); + const insertion = builder.insert(row).onConflict(['id']); + await insertion.merge(); + }`, + ], + [ + 'a method that never builds at all', + `async disconnect() { await this.knex.destroy(); }`, + ], + [ + 'scope applied after other builder calls — position is not the assertion', + `async count(object, query, options) { + const builder = this.getBuilder(object, options); + builder.count('* as c'); + this.applyTenantScope(builder, object, options); + return await builder; + }`, + ], + ]; + for (const [name, src] of silent) { + const { problems } = run(src); + assert(problems.length === 0, `expected NO report for: ${name} (got ${problems.map((p) => p.text).join('; ')})`); + } + + // A builder the criterion cannot classify must ABORT, never pass. + { + const { problems } = run( + `async sneaky(object, options) { return await this.getBuilder(object, options).select('*'); }`, + ); + assert(problems.some((p) => p.fatal), 'an unbound inline getBuilder() must be reported as fatal'); + } + + // The exemption channel is real, and it is keyed to one binding. + { + const src = `async door(object, options) { + const builder = this.getBuilder(object, options); + return await builder; + }`; + const a = analyzeSource('t.ts', wrap(src)); + assert(violationsOf('t.ts', a).length === 1, 'the exemption fixture must report without an entry'); + assert( + violationsOf('t.ts', a, { 't.ts::door::builder': 'because' }).length === 0, + 'an EXEMPT entry must silence exactly its own binding', + ); + assert( + violationsOf('t.ts', a, { 't.ts::door::other': 'because' }).length === 1, + 'an EXEMPT entry for a different binding must NOT silence this one', + ); + } + + // The DISCOVERED floor must be able to fail. A matcher that stops matching + // is the failure mode this whole gate is protecting against. + { + const { builders } = run(`async nothing() { return 1; }`); + assert(builders.length === 0, 'the empty fixture must discover no builders'); + } + + if (failures.length > 0) { + console.error(`✗ check:tenant-chokepoint self-test (${failures.length} failure(s)):\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + console.log( + `✓ check:tenant-chokepoint self-test: ${reports.length} reporting shape(s), ` + + `${silent.length} silent counterpart(s), fatal/exemption/floor channels proved in both directions.`, + ); +} + +// --------------------------------------------------------------------------- +// main + +function main() { + const problems = []; + let discovered = 0; + let scannedFiles = 0; + + for (const rel of SCAN_FILES) { + const abs = join(ROOT, rel); + if (!existsSync(abs)) { + console.error( + `check:tenant-chokepoint: ${rel} does not exist.\n` + + 'The scan set is stale — a driver was renamed or moved. Refusing to report clean\n' + + 'over a file set that no longer describes the SqlDriver family.', + ); + process.exit(2); + } + scannedFiles += 1; + const analysis = analyzeSource(rel, readFileSync(abs, 'utf8')); + discovered += analysis.builders.length; + problems.push(...violationsOf(relative(ROOT, abs).replace(/\\/g, '/'), analysis)); + } + + if (discovered < DISCOVERED_FLOOR) { + console.error( + `check:tenant-chokepoint: discovered only ${discovered} ${BUILDER_FACTORY}() binding(s) across ` + + `${scannedFiles} file(s), below the floor of ${DISCOVERED_FLOOR}.\n` + + 'A scan that stops matching reports "clean" while reading nothing (#4690). Either the\n' + + 'driver was refactored away from getBuilder() — in which case this gate needs rewriting\n' + + 'against the new constructor — or the matcher is broken. Both are errors, not passes.', + ); + process.exit(2); + } + + const fatal = problems.filter((p) => p.fatal); + if (fatal.length > 0) { + console.error(`✗ check:tenant-chokepoint: ${fatal.length} builder(s) this gate cannot classify\n`); + for (const p of fatal) console.error(` • ${p.text}`); + console.error( + '\nA builder it cannot classify is an error, never a default. Bind the builder to a\n' + + 'local and apply the scope on it, or widen the criterion in\n' + + 'scripts/check-tenant-chokepoint.mjs deliberately.', + ); + process.exit(2); + } + + if (problems.length > 0) { + console.error(`✗ check:tenant-chokepoint: ${problems.length} unscoped read door(s)\n`); + for (const p of problems) console.error(` • ${p.text}`); + console.error(` +\`applyTenantScope\` is the single chokepoint for read-side tenant isolation: it owns +the tenantId early-out, the no-tenant-field early-out, the NULL-org platform-row rule +(#2734) and the ADR-0105 D2 union posture (#3623). A read door that skips it returns +OTHER TENANTS' data to a caller that asked to be scoped — measured, not theoretical +(#6792: three doors, one of them documented with a runnable example). + +Add the call beside the getBuilder() line, as every other door does: + + const builder = this.getBuilder(object, options); + this.applyTenantScope(builder, object, options); + +Do not re-derive the predicate locally — a bare equality silently drops NULL-org +platform rows (#2734) and collapses the group posture to active-org reach (#3623). + +If a door genuinely must build unscoped, add it to EXEMPT in +scripts/check-tenant-chokepoint.mjs with a reason a reader can check. "The layer +above catches it" is not one: this asserts the chokepoint's own contract.`); + process.exit(1); + } + + console.log( + `✓ check:tenant-chokepoint: ${discovered} ${BUILDER_FACTORY}() binding(s) across ${scannedFiles} ` + + 'file(s); every read builder routes through applyTenantScope(), every unscoped one is an insert.', + ); +} + +if (process.argv.includes('--self-test')) { + selfTest(); + process.exit(0); +} + +main();