diff --git a/.changeset/verify-org-scoped-context.md b/.changeset/verify-org-scoped-context.md new file mode 100644 index 0000000000..6137bb5ea9 --- /dev/null +++ b/.changeset/verify-org-scoped-context.md @@ -0,0 +1,52 @@ +--- +"@objectstack/verify": minor +--- + +feat(verify): `bootStack({ orgContext: true })` — a harness admin whose execution context carries an organization (#7762) + +`bootStack` could not mint an admin whose resolved execution context carried an +`organizationId`. Every `organization_id`-filtered read in the platform was +therefore structurally **untestable at the HTTP layer** in the open core: the +filter never engaged, so a fixture asserting on the difference between a +filtered and an unfiltered read saw no difference and passed for the wrong +reason. + +That is not theoretical. #7676 — package-seeded (`organization_id = null`) +sharing rules invisible to an org-scoped admin — escaped **both** suites that +should have caught it and needed a manual QA run to find. The HTTP-layer +regression test written for its fix measured green against the *unfixed* code +and was correctly deleted rather than shipped as phantom coverage. Two earlier +defects of the same class (`sys_business_unit` approver expansion, +`sys_metadata` pending-draft listing) were found the same way. + +`orgContext: true` flips `AuthPlugin`'s ADR-0081 D1 default-organization +bootstrap back on — the same one `objectstack dev` / `serve` give a real +single-tenant deployment. The admin is bound to a real `sys_organization` as +owner, their session carries `activeOrganizationId`, and org-scoped reads +resolve it off the execution context. The boot **asserts** the bind and refuses +to return a stack without it, because a best-effort bootstrap that quietly +no-ops is the vacuum this option exists to close. + +⛔ **It performs no tenant isolation.** It stamps the caller's organization; it +stands up no organization wall. With no `org-scoping` service present, +`SecurityPlugin` strips the wildcard `organization_id` RLS policies, so a +fixture asserting "tenant B cannot read tenant A's rows" and booting this way +would assert nothing and pass. Cross-tenant isolation still has exactly one +honest proof: `multiTenant: true` with the real `@objectstack/organizations`. + +The tenancy **posture** is untouched, and that is a property of the seam rather +than of this implementation: `TenancyService.probeIsolation` is +`() => !!ctx.getService('org-scoping')`, so the effective posture derives from +service registration alone and reads nothing about what any context carries. +The posture and the `degraded` flag are pinned identical with the flag off vs +on. + +`orgContext` does **not** compose with either spelling of `multiTenant` and the +boot refuses the combination rather than booting something weaker than it +reads: under `multiTenant: true` the enterprise package already owns the org +bootstrap, and under `multiTenant: 'posture-only'` the open bootstrap +deliberately abstains (walled posture), which would leave the admin org-less +inside a fixture that reads as org-bound. + +Default boots are unchanged — the option is opt-in and the existing dogfood +suite is unaffected. diff --git a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts new file mode 100644 index 0000000000..66af2541a4 --- /dev/null +++ b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7676 / #7762] The HTTP-layer regression test for #7676 — restored. +// +// ## Why this file has a history before it has a first commit +// +// #7676: `sys_sharing_rule` rows seeded by the package/app seeder carry +// `organization_id = null` (`bootstrapDeclaredSharingRules` defines under +// `SYSTEM_CTX`, before any org id exists). The admin read path scoped with a +// strict `organization_id = ` equality, so on a stock boot +// `GET /api/v1/sharing/rules` answered `{data: []}` over four active seeded +// rules, by-name GET and evaluate 404'd, and only the org-unfiltered by-id +// branch still worked. Rules that grant access but cannot be listed, inspected +// or deactivated. +// +// PR #7760 fixed it and its dev wrote exactly this test — then measured it +// GREEN against the UNFIXED code and correctly DELETED it rather than ship +// phantom coverage. The reason is #7762: `bootStack` could not mint an admin +// whose resolved execution context carried an organization, so `orgId` was +// `null` on every request, `adminOrgScope` returned the `where` untouched, and +// the strict-equality bug was unreachable from the harness. The assertion +// passed because the filter never engaged — the #4700 constant-false shape. +// +// `bootStack({ orgContext: true })` (#7762) is what makes the test mean +// something: the admin is bound to a real organization, their session carries +// `activeOrganizationId`, and the org-scoped branch of `listRules` / `getRule` +// is the one that actually runs. THAT is why this file boots with the flag and +// asserts the flag took effect FIRST — an org-less admin here would silently +// restore the vacuum this file exists to escape. +// +// ⛔ Nothing here is a tenant-isolation proof. `orgContext` stands up no +// organization wall (SecurityPlugin strips the wildcard `organization_id` RLS +// policies with no `org-scoping` service present) — see the option's doc block +// in `packages/verify/src/harness.ts`. This file proves that an org-scoped READ +// FILTER engages and admits the platform-global rows it must; it proves nothing +// about tenant B's rows, and must never be extended to claim that. +// +// @proof: org-scoped-sharing-rule-listing + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const RULES = '/sharing/rules'; +const SYS = { isSystem: true } as const; + +/** The four rules `examples/app-showcase` declares, all seeded org-less. */ +const SEEDED_RULE_NAMES = [ + 'share_red_projects_with_execs', + 'share_high_value_red_projects_with_managers', + 'share_new_inquiries_with_field_ops', + 'share_open_tasks_with_manager', +]; + +interface RuleRow { + id: string; + name: string; + organization_id: string | null; +} + +describe('#7676 — package-seeded (org-less) sharing rules stay visible to an ORG-BOUND admin', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let admin: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { orgContext: true }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: the admin actually carries an organization (else everything below is vacuous)', async () => { + // The measurement from #7762, inverted. The card recorded `POST + // /api/v1/sharing/rules` answering 201 with `organization_id: null`, and + // named that a direct proof the caller's context had no organization — + // `defineRule` stamps the row from the resolved context. So a NON-NULL + // stamp here is the same measurement reporting the opposite, taken through + // the same route. + const res = await stack.apiAs(admin, 'POST', RULES, { + name: 'org_bound_probe_7762', + label: 'Org-bound probe', + object: 'showcase_project', + recipientType: 'position', + recipientId: 'exec', + criteria: { health: 'red' }, + accessLevel: 'read', + }); + expect(res.status).toBe(201); + const row = (await res.json()) as RuleRow; + expect( + row.organization_id, + 'a row created by the org-bound admin is stamped with their organization', + ).toBeTruthy(); + + // And the org id is a real `sys_organization`, not a stray string. + const org = await ql.findOne('sys_organization', { + where: { id: row.organization_id }, + context: SYS, + }); + expect(org?.id).toBe(row.organization_id); + }); + + it('THE REPORTED CASE: GET /sharing/rules lists the seeded org-less rules', async () => { + // Pre-#7676 this answered `{data: []}`. It is also the assertion that was + // green on the unfixed code before `orgContext` existed — see the header. + const res = await stack.apiAs(admin, 'GET', RULES); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: RuleRow[] }; + const names = body.data.map((r) => r.name); + + for (const name of SEEDED_RULE_NAMES) { + expect(names, `seeded rule ${name} must be listed`).toContain(name); + } + // They really are the platform-global rows, not org-stamped copies — which + // is what makes them invisible to a strict `organization_id = orgId`. + for (const name of SEEDED_RULE_NAMES) { + const row = body.data.find((r) => r.name === name); + expect(row?.organization_id, `${name} is seeded org-less`).toBeNull(); + } + }); + + it('by-NAME GET resolves a seeded org-less rule (404 RULE_NOT_FOUND before the fix)', async () => { + const res = await stack.apiAs(admin, 'GET', `${RULES}/${SEEDED_RULE_NAMES[0]}`); + expect(res.status).toBe(200); + const row = (await res.json()) as RuleRow; + expect(row.name).toBe(SEEDED_RULE_NAMES[0]); + expect(row.organization_id).toBeNull(); + }); + + it('a seeded org-less rule can be EVALUATED — the half that granted access all along', async () => { + // Enforcement always read these rows (under SYSTEM_CTX); only the admin + // surface could not. "Rules that grant access but cannot be inspected or + // deactivated are the worst half of both properties." + const rule = await ql.findOne('sys_sharing_rule', { + where: { name: SEEDED_RULE_NAMES[0] }, + context: SYS, + }); + expect(rule?.id, 'the seeded rule exists in storage').toBeTruthy(); + const res = await stack.apiAs(admin, 'POST', `${RULES}/${rule.id}/evaluate`, {}); + expect(res.status).toBeLessThan(300); + }); + + it('the scope still SCOPES — another organization\'s rule is not listed', async () => { + // The counterweight, and the reason this is a scope rather than a hole: + // widening the read to `this org ∪ platform-global` must not also admit a + // THIRD org's row. Written at the storage seam because the harness mints + // exactly one organization — this is a read-filter assertion, NOT a + // tenant-isolation proof (see the header). + const foreignId = 'srule_7762_foreign'; + await ql.insert( + 'sys_sharing_rule', + { + id: foreignId, + organization_id: 'org_someone_else_7762', + name: 'foreign_org_rule_7762', + label: 'Foreign org rule', + object_name: 'showcase_project', + criteria_json: JSON.stringify({ health: 'red' }), + recipient_type: 'position', + recipient_id: 'exec', + access_level: 'read', + active: true, + }, + { context: SYS }, + ); + + const res = await stack.apiAs(admin, 'GET', RULES); + const body = (await res.json()) as { data: RuleRow[] }; + expect(body.data.map((r) => r.name)).not.toContain('foreign_org_rule_7762'); + + // [#7761] The by-id branch carries the same scope — an opaque id is not a + // tenant boundary. + const byId = await stack.apiAs(admin, 'GET', `${RULES}/${foreignId}`); + expect(byId.status).toBe(404); + }); +}); diff --git a/packages/verify/src/harness.org-context.test.ts b/packages/verify/src/harness.org-context.test.ts new file mode 100644 index 0000000000..1f124c5424 --- /dev/null +++ b/packages/verify/src/harness.org-context.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7762] `bootStack({ orgContext: true })` — the harness's only way to mint an +// admin whose RESOLVED execution context carries an `organizationId`. +// +// Why it exists: before this option, `bootStack`'s admin resolved org-less, so +// every `organization_id`-filtered read in the platform was structurally +// untestable at the HTTP layer in the open core. A fixture asserting on the +// difference between a filtered and an unfiltered read saw no difference, +// because the filter never engaged — which is how #7676 escaped both the +// plugin-sharing unit suite and the 579-test dogfood suite and needed a manual +// QA run to find. +// +// What this file pins is the RULING behind the option (issue #7762's claim +// comment): stamping an org id on the caller cannot move the deployment's +// tenancy posture, because `TenancyService.probeIsolation` is +// `() => !!ctx.getService('org-scoping')` — service registration only, reading +// nothing about what any context carries. That is asserted here rather than +// inherited: if the posture ever moves, this file goes red and the option's +// whole doc block is wrong. +// +// The HTTP-layer proof that the org id actually reaches an org-scoped read +// lives in the dogfood suite, against a real app: +// `packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts`. + +import { describe, it, expect, afterEach } from 'vitest'; +// `.js` extension deliberately: this package resolves NodeNext, so an +// extensionless relative import is a TS2835 the type-check-debt ratchet counts +// (the sibling test files predate the gate and carry theirs in the ledger — a +// NEW one would raise it, which is not something a test file gets to do). +import { bootStack, type VerifyStack } from './harness.js'; + +const app = { + manifest: { + id: 'com.example.org-context', + namespace: 'orgcontext', + version: '0.0.1', + type: 'app', + name: 'Org-Context Fixture', + }, + objects: [], +}; + +/** The `tenancy` service's answer — the deployment's effective posture. */ +interface TenancyShape { + posture: string; + requestedPosture: string; + isolationActive: boolean; + degraded: boolean; +} + +const SYS = { isSystem: true } as const; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +afterEach(() => { + delete process.env.OS_TENANCY_POSTURE; +}); + +/** Read the tenancy posture as every consumer sees it. */ +async function posture(stack: VerifyStack): Promise { + const t = await stack.kernel.getServiceAsync('tenancy'); + return { + posture: t.posture, + requestedPosture: t.requestedPosture, + isolationActive: t.isolationActive, + degraded: t.degraded, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function rowsOf(r: any): any[] { + return Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []; +} + +/** The org id the admin's SESSION carries — the one wire field a real login sets. */ +async function sessionOrgId(stack: VerifyStack): Promise { + await stack.signIn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ql = await stack.kernel.getServiceAsync('objectql'); + const users = rowsOf( + await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, limit: 1, context: SYS }), + ); + const userId = users[0]?.id; + if (!userId) return null; + const sessions = rowsOf( + await ql.find('sys_session', { where: { user_id: userId }, limit: 10, context: SYS }), + ); + for (const s of sessions) { + const org = s.active_organization_id ?? s.activeOrganizationId; + if (typeof org === 'string' && org) return org; + } + return null; +} + +describe('bootStack orgContext (#7762)', () => { + it( + 'THE RULING: the tenancy posture and `degraded` are IDENTICAL with the flag off vs on', + async () => { + // The load-bearing proof of issue #7762's ruling. `probeIsolation` is + // `() => !!ctx.getService('org-scoping')` — nothing about the effective + // posture reads whether a resolved context carries an `organizationId`, + // so binding the admin to an organization must not move it. If this ever + // fails, `orgContext` is making the open core claim a wall it does not + // have and the option must not ship in that shape. + const off = await bootStack(app, {}); + let baseline: TenancyShape; + try { + baseline = await posture(off); + } finally { + await off.stop(); + } + + const on = await bootStack(app, { orgContext: true }); + let withOrg: TenancyShape; + try { + withOrg = await posture(on); + } finally { + await on.stop(); + } + + expect(withOrg).toEqual(baseline); + // Spelled out too, so a future refactor of `posture()` cannot make the + // equality above pass by comparing two empty objects. + expect(baseline.posture).toBe('single'); + expect(baseline.degraded).toBe(false); + expect(withOrg.posture).toBe('single'); + expect(withOrg.isolationActive).toBe(false); + expect(withOrg.degraded).toBe(false); + }, + BOOT_TIMEOUT, + ); + + it( + 'binds the admin to a real organization, and their session carries it', + async () => { + const stack = await bootStack(app, { orgContext: true }); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ql = await stack.kernel.getServiceAsync('objectql'); + const orgs = rowsOf(await ql.find('sys_organization', { limit: 10, context: SYS })); + expect(orgs.length, 'the default-org bootstrap minted an organization').toBeGreaterThan(0); + + // `session.activeOrganizationId` is the ONE field `resolveAuthzContext` + // reads into `tenantId` → `ExecutionContext`. Without it on the session + // row, every org-scoped read downstream is org-less no matter how many + // organizations exist in the table. + const orgId = await sessionOrgId(stack); + expect(orgId, "the admin's session carries an active organization").toBeTruthy(); + expect(orgs.map((o) => o.id)).toContain(orgId); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); + + it( + 'the DEFAULT boot is unchanged — no organization, no active org on the session', + async () => { + // The other half of the contract, and the reason the existing 579-test + // dogfood suite is unaffected: `orgContext` is opt-in, and the org-less + // admin every current fixture asserts against stays org-less. + const stack = await bootStack(app, {}); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ql = await stack.kernel.getServiceAsync('objectql'); + expect(rowsOf(await ql.find('sys_organization', { limit: 10, context: SYS }))).toHaveLength(0); + expect(await sessionOrgId(stack)).toBeNull(); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); + + it( + "REFUSES to compose with multiTenant: 'posture-only' rather than no-op silently", + async () => { + // The dangerous half. `'posture-only'` requests the `isolated` posture, + // and the open default-org bootstrap abstains under every walled posture + // (ADR-0081 D1), so the combination would hand back an org-LESS admin + // from a call that reads as org-bound — vacuity wearing the mask of + // coverage, which is the entire defect class #7762 exists to close. + await expect(bootStack(app, { orgContext: true, multiTenant: 'posture-only' })).rejects.toThrow( + /orgContext:true does not compose with multiTenant/, + ); + // And it says WHY, so the caller does not have to read the harness. + await expect(bootStack(app, { orgContext: true, multiTenant: 'posture-only' })).rejects.toThrow( + /walled posture/, + ); + // Refused BEFORE any env mutation, so a rejected boot cannot leak a + // posture into the next boot in this worker. + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); + + it( + 'REFUSES to compose with multiTenant: true — the enterprise package owns that bootstrap', + async () => { + await expect(bootStack(app, { orgContext: true, multiTenant: true })).rejects.toThrow( + /does not compose with multiTenant/, + ); + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 98bd61341e..7a185ea3bb 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -174,6 +174,78 @@ export interface BootOptions { * simulate the deployment that legitimately has it open. */ multiTenant?: boolean | 'posture-only'; + /** + * Bind the harness admin to a real organization, so the execution context + * every request of theirs resolves CARRIES an `organizationId`. Default + * `false`. + * + * Mechanically it is one flip: `AuthPlugin`'s ADR-0081 D1 default-org + * bootstrap (`autoDefaultOrganization`), which the harness otherwise pins + * OFF (see the AuthPlugin registration below). The bootstrap mints a + * `sys_organization` and binds the platform admin to it as `owner`; the + * `session.create.before` hook then stamps that org onto the session as + * `activeOrganizationId`, which is the ONE wire field + * `resolveAuthzContext` reads into `tenantId` → `ExecutionContext`. Same + * path `objectstack dev` / `serve` give a real single-tenant deployment — + * nothing here is simulated. + * + * ## What it buys + * + * Application-level `organization_id`-scoped READS engage their filter, + * because they read the org id off the resolved context directly. Before + * this flag, no fixture in the open core could reach that branch over HTTP: + * `bootStack`'s admin resolved org-less, so a filtered read returned + * whatever the UNfiltered one did and a test asserting on the difference + * asserted nothing (#7762). `sys_sharing_rule` listing (#7676), + * `sys_business_unit` approver expansion (#3807) and `sys_metadata` + * pending-draft listing are the same shape. + * + * ## ⛔ It performs NO tenant isolation whatsoever + * + * It stamps the CALLER's org; it does not stand up an organization wall. + * With no `org-scoping` service registered — and this flag registers none — + * `SecurityPlugin` STRIPS the wildcard `organization_id` RLS policies that + * ship in the default permission sets (`collectRLSPolicies`; see the + * `multiTenant` doc block above). So a fixture asserting "tenant B cannot + * read tenant A's rows" and booting this way would assert nothing and pass: + * the #4700 constant-false capability probe wearing the opposite mask, and + * the precise trap this option's NAME is chosen to stay clear of. + * + * Cross-tenant isolation has exactly one honest proof in this repo: + * `multiTenant: true` with the real `@objectstack/organizations` installed + * (which is why those gates SKIP here instead of pretending — see + * `test/enterprise-organizations.ts`). + * + * The tenancy POSTURE is likewise untouched, and that is not an accident of + * this implementation but a property of the seam: `TenancyService`'s + * `probeIsolation` is `() => !!ctx.getService('org-scoping')` + * (`plugin-auth/src/auth-plugin.ts`), so the effective posture derives from + * SERVICE REGISTRATION only and reads nothing about what any context + * carries. `harness.org-context.test.ts` pins `posture` and `degraded` + * identical with the flag off vs on. + * + * ## Composition with `multiTenant`: it does NOT compose — the boot REFUSES + * + * `bootStack(app, { orgContext: true, multiTenant: … })` throws, for both + * spellings, rather than booting something that silently means less than it + * reads: + * + * - with `multiTenant: true`, the enterprise `@objectstack/organizations` + * package OWNS the org bootstrap and already binds the admin — this flag + * would be a second, competing owner of one invariant; + * - with `multiTenant: 'posture-only'`, it would be a NO-OP that looks like + * a feature. That mode requests the `isolated` posture, and the open + * default-org bootstrap deliberately abstains under every WALLED posture + * (`postureEnforcesWall`, ADR-0081 D1) — the open package never + * bootstraps an organization for a deployment whose multi-organization + * runtime it does not provide. The admin would resolve org-less while the + * fixture read as org-bound: vacuity, which is the whole defect class + * #7762 exists to close. + * + * A posture-gated seam that ALSO needs an org-bound caller therefore has no + * harness answer today; it needs the real enterprise package. + */ + orgContext?: boolean; /** * Root directory of the **host app** being verified — the one whose * `node_modules` carries the optional packages it declares (currently the @@ -254,6 +326,24 @@ export async function bootStack( ): Promise { process.env.NODE_ENV = 'development'; + // [#7762] `orgContext` and `multiTenant` are two owners of one invariant — + // refuse the combination rather than boot the weaker of them silently. See + // BootOptions.orgContext ("Composition with `multiTenant`") for why each + // spelling is refused; the `'posture-only'` half is the load-bearing one, + // because there the flag would be a pure no-op that still reads as coverage. + if (opts.orgContext && opts.multiTenant) { + throw new Error( + `verify: orgContext:true does not compose with multiTenant:${JSON.stringify(opts.multiTenant)}. ` + + (opts.multiTenant === 'posture-only' + ? "'posture-only' requests the `isolated` posture, and the open default-org bootstrap abstains " + + 'under every walled posture (ADR-0081 D1) — the admin would resolve org-less while the fixture ' + + 'read as org-bound. Drop one of the two options; a posture-gated seam that also needs an ' + + 'org-bound caller needs the real @objectstack/organizations package.' + : 'the enterprise @objectstack/organizations package owns the org bootstrap under multiTenant:true ' + + 'and already binds the admin. Drop orgContext.'), + ); + } + // [ADR-0105 D1] `multiTenant: true` REQUESTS the hard organization wall — // posture `isolated`, what `OS_MULTI_ORG_ENABLED=true` historically meant. // Since #3559 a walled posture is an explicit operator request resolved from @@ -323,9 +413,15 @@ export async function bootStack( // closed, and a stack's declared `position` / `permission` names are // positions, not org roles. `membership-role-vocabulary.dogfood.test.ts` // boots through this harness and asserts exactly that. + // + // [#7762] `opts.orgContext` is the one thing that turns that bootstrap back + // on — the harness's ONLY way to mint an admin whose resolved execution + // context carries an `organizationId`. It is the SAME bootstrap `objectstack + // dev`/`serve` run, not a harness-local imitation, and it lights up no + // organization wall (see BootOptions.orgContext). Default stays `false`. await kernel.use(new AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET, - autoDefaultOrganization: false, + autoDefaultOrganization: !!opts.orgContext, })); // ADR-0062 — datasource connection service (registers 'datasource-connection'), @@ -488,6 +584,41 @@ export async function bootStack( const admin = opts.admin ?? { email: DEFAULT_ADMIN_EMAIL, password: DEFAULT_ADMIN_PASSWORD }; + // [#7762] The vacuity guard for `orgContext`. `ensureDefaultOrganization` is + // deliberately best-effort — it swallows every failure so a login can never + // break on org bookkeeping — which means a fixture that asked for an + // org-bound admin and silently got an org-LESS one is exactly the shape this + // option exists to abolish. So the boot asserts the bind rather than + // assuming it: no `sys_member` row for the harness admin, no stack. + if (opts.orgContext) { + const sys = { isSystem: true } as const; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const engine = await kernel.getServiceAsync('objectql'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + const users = rowsOf( + await engine?.find('sys_user', { where: { email: admin.email }, limit: 1, context: sys }), + ); + const adminUserId: string | undefined = users[0]?.id; + const members = adminUserId + ? rowsOf(await engine.find('sys_member', { where: { user_id: adminUserId }, limit: 1, context: sys })) + : []; + if (!members[0]?.organization_id) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (kernel as any).shutdown?.().catch?.(() => {}); + throw new Error( + `verify: orgContext:true did not bind the harness admin (${admin.email}) to an organization. ` + + (adminUserId + ? 'The user exists but holds no sys_member row, so their sessions would carry no ' + + 'activeOrganizationId and every org-scoped assertion in this fixture would be vacuous.' + : 'No sys_user row resolved for that address — check `opts.admin` against the app the ' + + "harness actually seeded, since the default-org bootstrap targets the platform admin.") + + ' (ADR-0081 D1 `ensureDefaultOrganization` is best-effort by design; this is the harness ' + + 'refusing to hand back a stack that quietly means less than it reads.)', + ); + } + } + const signIn = async ( email: string = admin.email, password: string = admin.password,