From 69916d1e12b58dc6e64977a68c0b63429efef482 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:04:54 +0000 Subject: [PATCH 1/7] test(plugin-auth): pin the default organization id against a delete and recreate A user created after the default organization is deleted and recreated in the same process must bind to the new id. Red at this commit: the cached id is returned without checking that its row still exists. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../plugin-auth/src/tenancy-service.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index f09c7434032..db3fb9bba5e 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createTenancyService, resolveDefaultOrgId } from './tenancy-service.js'; -import { backfillMemberships } from './reconcile-membership.js'; +import { backfillMemberships, reconcileMembership } from './reconcile-membership.js'; function makeEngine(orgs: Array<{ id: string; slug?: string }>) { return { @@ -544,3 +544,67 @@ describe('single-posture organization census (#17010)', () => { expect(logger.error.mock.calls[0]![0]).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS); }); }); + +// --------------------------------------------------------------------------- +// The default organization id is checked when a user is BOUND, not trusted from +// a process-long cache. Under the `auto` membership policy membership is decided +// once, at creation (ADR-0093 D7), so a user bound to an organization that no +// longer exists is never repaired. The default organization can be deleted and +// recreated under a new id inside one process: the single-org bootstrap +// recreates a missing `slug='default'` organization on the next `sys_user` +// write, which is the very sign-up that then binds. +// --------------------------------------------------------------------------- +describe('defaultOrgId is revalidated when a user is bound', () => { + /** + * `sys_organization` + `sys_member` over arrays. `find` honours every `where` + * key as an equality, so an existence read by `id` answers for that id and + * nothing else. + */ + function makeStore(orgs: Array<{ id: string; slug?: string }>) { + const members: Array<{ id: string; organization_id: string; user_id: string }> = []; + const tables: Record>> = { + sys_organization: orgs, + sys_member: members, + }; + const engine = { + find: vi.fn(async (object: string, query: any) => { + const where: Record = query?.where ?? {}; + const rows = (tables[object] ?? []).filter((row) => + Object.entries(where).every(([k, v]) => row[k] === v), + ); + return rows.slice(0, query?.limit ?? rows.length); + }), + insert: vi.fn(async (object: string, row: any) => { + (tables[object] ??= []).push(row); + return row; + }), + }; + return { engine, orgs, members }; + } + + const singleOrg = (engine: unknown) => + createTenancyService({ requested: 'single', probeIsolation: () => false, getEngine: () => engine }); + + it('a user created after the default organization is deleted and recreated binds to the NEW id', async () => { + const store = makeStore([{ id: 'org_old', slug: 'default' }]); + const tenancy = singleOrg(store.engine); + const bind = (userId: string) => + reconcileMembership(store.engine, userId, { + policy: 'auto', + resolveTargetOrg: () => tenancy.defaultOrgId(), + }); + + // The first user resolves (and memoizes) the default organization. + expect(await bind('usr_first')).toEqual({ outcome: 'bound', organizationId: 'org_old' }); + + // Same process: the default organization is deleted, then recreated by the + // bootstrap under a new id. + store.orgs.splice(0, store.orgs.length); + store.orgs.push({ id: 'org_new', slug: 'default' }); + + expect(await bind('usr_second')).toEqual({ outcome: 'bound', organizationId: 'org_new' }); + expect(store.members.find((m) => m.user_id === 'usr_second')?.organization_id).toBe('org_new'); + // The memo now names the organization that exists. + expect(await tenancy.defaultOrgId()).toBe('org_new'); + }); +}); From 3e2e917f27ecca0ff1eac2a0c5c5b3ecfba2c7f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:12:17 +0000 Subject: [PATCH 2/7] fix(plugin-auth): revalidate the memoized default organization id on every call TenancyService.defaultOrgId() returned its memoized id for the life of the process. A default organization deleted and recreated in that process left new users under the auto policy bound to the deleted id, and since membership is decided once at creation (ADR-0093 D7) nothing repaired them. The memo is now checked with one read of sys_organization by primary key. Only a definite absence drops it, and the id is then resolved again by the same rule that set it (slug default first, else the sole organization). An unanswered read keeps the memo rather than binding the user to nothing. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../plugin-auth/src/tenancy-service.test.ts | 72 ++++++++++++++++++- .../plugin-auth/src/tenancy-service.ts | 57 +++++++++++++-- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index db3fb9bba5e..819c9f7b9f9 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -202,8 +202,15 @@ describe('createTenancyService', () => { orgs.push({ id: 'org_default', slug: 'default' }); // bootstrap runs expect(await t.defaultOrgId()).toBe('org_default'); // re-resolved const callsAfterResolve = engine.find.mock.calls.length; - await t.defaultOrgId(); // memoized — no new query - expect(engine.find.mock.calls.length).toBe(callsAfterResolve); + // Memoized: the next call checks the memo with ONE read by primary key + // and does not re-run the resolution. + expect(await t.defaultOrgId()).toBe('org_default'); + expect(engine.find.mock.calls.length).toBe(callsAfterResolve + 1); + expect(engine.find.mock.calls.at(-1)).toEqual([ + 'sys_organization', + { where: { id: 'org_default' }, limit: 1 }, + { context: { isSystem: true } }, + ]); }); }); }); @@ -607,4 +614,65 @@ describe('defaultOrgId is revalidated when a user is bound', () => { // The memo now names the organization that exists. expect(await tenancy.defaultOrgId()).toBe('org_new'); }); + + it('COST: a memoized id that still exists costs ONE read per call and is not re-resolved', async () => { + const store = makeStore([{ id: 'org_default', slug: 'default' }]); + const tenancy = singleOrg(store.engine); + expect(await tenancy.defaultOrgId()).toBe('org_default'); // resolves and memoizes + + for (let i = 0; i < 3; i++) { + store.engine.find.mockClear(); + expect(await tenancy.defaultOrgId()).toBe('org_default'); + expect(store.engine.find).toHaveBeenCalledTimes(1); + expect(store.engine.find).toHaveBeenCalledWith( + 'sys_organization', + { where: { id: 'org_default' }, limit: 1 }, + { context: { isSystem: true } }, + ); + } + }); + + it('a deleted default organization not yet recreated resolves to null, then to its replacement', async () => { + const store = makeStore([{ id: 'org_old', slug: 'default' }]); + const tenancy = singleOrg(store.engine); + expect(await tenancy.defaultOrgId()).toBe('org_old'); + + store.orgs.splice(0, store.orgs.length); + // Nothing to bind to: the deleted id is not handed out. + expect(await tenancy.defaultOrgId()).toBeNull(); + + store.orgs.push({ id: 'org_new', slug: 'default' }); + expect(await tenancy.defaultOrgId()).toBe('org_new'); + }); + + it('the replacement is picked by the same rule as the first resolution (slug default first)', async () => { + const store = makeStore([{ id: 'org_old', slug: 'default' }]); + const tenancy = singleOrg(store.engine); + expect(await tenancy.defaultOrgId()).toBe('org_old'); + + // Deleted, then two organizations exist, one of them the recreated + // `slug='default'` bootstrap organization: that one wins, as it would at boot. + store.orgs.splice(0, store.orgs.length); + store.orgs.push({ id: 'org_other' }, { id: 'org_new', slug: 'default' }); + expect(await tenancy.defaultOrgId()).toBe('org_new'); + }); + + it('an existence read that the store cannot answer keeps the memoized id', async () => { + const store = makeStore([{ id: 'org_default', slug: 'default' }]); + const tenancy = singleOrg(store.engine); + expect(await tenancy.defaultOrgId()).toBe('org_default'); + + // A failed read is not evidence that the organization is gone; dropping + // the memo on it would bind the next user to no organization at all. + store.engine.find.mockRejectedValueOnce(new Error('store unreachable')); + expect(await tenancy.defaultOrgId()).toBe('org_default'); + + // A reply that is not a row list is not an answer either. + store.engine.find.mockResolvedValueOnce({ records: [] } as any); + expect(await tenancy.defaultOrgId()).toBe('org_default'); + + // The next ANSWERED read still revalidates. + store.orgs.splice(0, store.orgs.length); + expect(await tenancy.defaultOrgId()).toBeNull(); + }); }); diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts index a74fb7a84dd..f0012491afb 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -139,8 +139,10 @@ export interface TenancyService { * (ADR-0093 D3). Returns `null` whenever a walled posture was REQUESTED — the * framework never guesses a target org there; invite / add-member / SSO JIT * own membership. Also `null` before an org exists (e.g. before the - * default-org bootstrap runs). Positive resolutions are memoized (the id is - * stable). + * default-org bootstrap runs). A positive resolution is memoized and checked + * against the store on every later call, one read by primary key: an + * organization deleted since is resolved again by the same rule, so a user + * is never bound to an organization that no longer exists. * * [#17010] The first ANSWERED call under a non-walled posture also takes the * organization census and reports at `error` when this deployment holds more @@ -228,6 +230,31 @@ export async function resolveDefaultOrgId(engine: any): Promise { return null; } +/** + * Does the organization `id` still exist? One read of `sys_organization` by + * primary key. + * + * Answers `true` / `false` only when the store answered, and `null` when it + * did not: no engine, no `find`, a failed read, or a reply that is not a row + * list. Unlike {@link findRows}, a failed read is NOT folded into "no rows": + * the caller drops its memoized id on `false` alone, and a store that could not + * be asked has not said the organization is gone. + * + * A row counts only when its own `id` is the one asked about, so an engine + * that ignored the `where` cannot vouch for an organization it never returned. + */ +async function organizationExists(engine: any, id: string): Promise { + if (!engine || typeof engine.find !== 'function') return null; + let rows: unknown; + try { + rows = await engine.find('sys_organization', { where: { id }, limit: 1 }, { context: SYSTEM_CTX }); + } catch { + return null; + } + if (!Array.isArray(rows)) return null; + return rows.some((row: any) => row?.id != null && String(row.id) === id); +} + /** * The stable NAME of this report — the grep token an operator or a support * thread keys on, the same way `no_sign_in_account_at_boot` leads its line in @@ -482,11 +509,31 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService { // The census below is downstream of this line on purpose: a walled // deployment DECLARED its organizations and pays nothing here. if (postureEnforcesWall(requestedPosture)) return null; - if (cachedDefaultOrgId) return cachedDefaultOrgId; const engine = deps.getEngine?.(); + if (cachedDefaultOrgId) { + // The memo is checked against the store on every call, never trusted + // for the life of the process. The default organization can be deleted + // and recreated under a new id in this process (the single-org + // bootstrap recreates a missing `slug='default'` organization on the + // next `sys_user` write), and under the `auto` policy a user's + // membership is decided once, at creation (ADR-0093 D7): a user bound + // to the deleted id is never repaired. One read by primary key; an + // existing organization returns here with no re-resolution. + // + // Only a `false` drops the memo. An unanswered read (`null`) is not + // evidence that the organization is gone, and dropping the memo on it + // would turn a store hiccup into a user bound to no organization at + // all, which nothing repairs either; the memo is then the last answer + // the store gave. + const stillExists = await organizationExists(engine, cachedDefaultOrgId); + if (stillExists !== false) return cachedDefaultOrgId; + // Gone: resolve again by the same rule that set the memo, so the + // replacement is the organization a fresh boot would pick. + cachedDefaultOrgId = null; + } const resolved = await resolveDefaultOrgId(engine); - // Memoize only a positive resolution — a null (org not bootstrapped yet) - // must re-resolve on the next call. + // Memoize only a positive resolution — a null (org not bootstrapped yet, + // or deleted and not yet recreated) must re-resolve on the next call. if (resolved) cachedDefaultOrgId = resolved; // [#17010] The organization census — one `count(sys_organization)`, once // per process, on the seam that was already reading this object. From 324cf766224bac455c62322d2d4ba30b05540445 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:19:58 +0000 Subject: [PATCH 3/7] test(dogfood): door pin for the default organization recreated between two sign-ups Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...ion-recreated-binds-new-id.dogfood.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts diff --git a/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts b/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts new file mode 100644 index 00000000000..9e33197ee6c --- /dev/null +++ b/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * On a real boot: a user created after the default organization is deleted + * and recreated in the same process is bound to the organization that exists, + * not to the deleted one's id. + * + * The tenancy service memoizes the default organization id. Under the `auto` + * membership policy a user's membership is decided once, at creation + * (ADR-0093 D7), so a bind to a deleted id is never repaired. The sequence is + * driven through the real surface: an administrator deletes the default + * organization through better-auth's own endpoint, and the next sign-up's + * `sys_user` insert is what makes the single-org bootstrap recreate it, ahead + * of that same sign-up's membership bind. The first sign-up, before the + * delete, is asserted bound to the original organization on the same run, so + * the memo is warm and the second half measures the revalidation and not a + * deployment that binds nobody. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The creation-time membership may land after the sign-up response. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 60; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +async function signUpAndFindUser(stack: VerifyStack, ql: any, email: string, name: string): Promise { + await stack.signUp(email, 'Recreated!Pass123', name); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + expect(user, `sys_user row for ${email}`).toBeTruthy(); + return String(user.id); +} + +describe('the default organization id is revalidated when a user is bound', () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + let originalOrgId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // The single-org default organization — reuse the bootstrap's when the + // boot made one, otherwise stand it up the way the bootstrap would. + const [existing] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + originalOrgId = existing + ? String(existing.id) + : String((await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX })).id); + + // The admin must own the organization for better-auth to let it delete it. + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const adminUserId = String(adminUser.id); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId, organization_id: originalOrgId }, 5); + if (adminMembers.length > 0) { + await ql.update('sys_member', { id: adminMembers[0].id, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: adminUserId, organization_id: originalOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + }, 240_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('a user signed up after the default organization is deleted and recreated binds to the new id', async () => { + // ── before the delete: creation binds to the original organization ─── + const firstUserId = await signUpAndFindUser(stack, ql, 'before.default.recreate@example.com', 'Before Recreate'); + const firstMember = await waitForMembership(ql, firstUserId); + expect(String(firstMember.organization_id)).toBe(originalOrgId); + + // ── an administrator deletes the default organization ──────────────── + const deleted = await stack.apiAs(adminToken, 'POST', '/auth/organization/delete', { + organizationId: originalOrgId, + }); + expect(deleted.status, await deleted.clone().text()).toBe(200); + expect(await findRows(ql, 'sys_organization', { id: originalOrgId }, 1)).toHaveLength(0); + + // ── the next sign-up recreates it, and is bound to the recreated one ── + const secondUserId = await signUpAndFindUser(stack, ql, 'after.default.recreate@example.com', 'After Recreate'); + const [recreated] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(recreated, 'the bootstrap recreated the default organization').toBeTruthy(); + const recreatedOrgId = String(recreated.id); + expect(recreatedOrgId).not.toBe(originalOrgId); + + const secondMember = await waitForMembership(ql, secondUserId); + expect(String(secondMember.organization_id)).toBe(recreatedOrgId); + + // The creating request's first session carries the same organization. + const firstSessions = await findRows(ql, 'sys_session', { user_id: secondUserId }, 5); + expect(firstSessions.length).toBeGreaterThan(0); + expect(String(firstSessions[0].active_organization_id)).toBe(recreatedOrgId); + }, 180_000); +}); From fc8e1c8b8352ae9826587223bcb8f13f2c9e760a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:20:28 +0000 Subject: [PATCH 4/7] chore(changeset): plugin-auth patch for the revalidated default organization id Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21868-default-org-id-revalidated.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21868-default-org-id-revalidated.md diff --git a/.changeset/21868-default-org-id-revalidated.md b/.changeset/21868-default-org-id-revalidated.md new file mode 100644 index 00000000000..f9a74ea9ac1 --- /dev/null +++ b/.changeset/21868-default-org-id-revalidated.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-auth': patch +--- + +A user created after the default organization is deleted and recreated in the same process is now bound to the organization that exists, not to the deleted one's id. + +Clause-②: no + +- **What was wrong.** The `tenancy` service's `defaultOrgId()` memoized the default organization id for the life of the process and never checked it again. The single-org bootstrap recreates a missing `slug='default'` organization on the next `sys_user` write, under a new id. Users created under the `auto` membership policy after that were bound to the deleted id. Membership is decided once, at creation (ADR-0093 D7), so nothing repaired them later. +- **What changed.** Every call checks the memoized id against `sys_organization` with one read by primary key. If the organization still exists, it is returned and nothing is re-resolved. If it is gone, the id is resolved again by the same rule that set it (the `slug='default'` organization first, else the only organization), so the replacement is the one a fresh boot would pick. If none exists yet, the answer is `null`, and the next call resolves again. +- **A read the store cannot answer** (a failed read, or a reply that is not a row list) keeps the memoized id. It is not treated as proof that the organization is gone, because that would bind the next user to no organization at all. +- **Who sees it.** Every reader of `defaultOrgId()` gets the check: the membership bind at user creation and its first-session settle, the self-registration grant, the admin create-user path, the membership backfill, the anonymous public form doors and the check on organization-scoped form writes, and the email-template bootstrap. Each call with a memoized id costs one primary-key read of `sys_organization`. +- No public export, option or accepted input changes. Walled postures still answer `null` without reading anything. From 13d72b32f9536deea1103096dc221fd36af327b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:28:44 +0000 Subject: [PATCH 5/7] test(dogfood): boot the door pin with the real default-organization bootstrap The harness pins the single-org bootstrap off unless orgContext is set, so nothing recreated the deleted organization. Boot with orgContext and assert the bootstrap's organization and the admin's owner membership as premises. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...ion-recreated-binds-new-id.dogfood.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts b/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts index 9e33197ee6c..8c404ff5d39 100644 --- a/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts +++ b/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts @@ -15,6 +15,10 @@ * delete, is asserted bound to the original organization on the same run, so * the memo is warm and the second half measures the revalidation and not a * deployment that binds nobody. + * + * Booted with `orgContext`, the harness's switch for the real single-org + * default-organization bootstrap (it is off by default there): that bootstrap + * is what creates the original organization and what recreates it. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -52,26 +56,18 @@ describe('the default organization id is revalidated when a user is bound', () = let originalOrgId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootStack(showcaseStack, { orgContext: true }); adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - // The single-org default organization — reuse the bootstrap's when the - // boot made one, otherwise stand it up the way the bootstrap would. + // PREMISE: the bootstrap created the default organization and bound the + // admin as its owner, which is what lets better-auth delete it below. const [existing] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); - originalOrgId = existing - ? String(existing.id) - : String((await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX })).id); - - // The admin must own the organization for better-auth to let it delete it. + expect(existing, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); + originalOrgId = String(existing.id); const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); - const adminUserId = String(adminUser.id); - const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId, organization_id: originalOrgId }, 5); - if (adminMembers.length > 0) { - await ql.update('sys_member', { id: adminMembers[0].id, role: 'owner' }, { context: SYSTEM_CTX }); - } else { - await ql.insert('sys_member', { user_id: adminUserId, organization_id: originalOrgId, role: 'owner' }, { context: SYSTEM_CTX }); - } + const adminMembers = await findRows(ql, 'sys_member', { user_id: String(adminUser.id), organization_id: originalOrgId }, 5); + expect(adminMembers.map((m) => m.role), 'PREMISE: the admin owns the default organization').toEqual(['owner']); }, 240_000); afterAll(async () => { await stack?.stop?.(); }); From 8fe4041e75f06e068cd916a25f29088bfeaf2c74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:52:19 +0000 Subject: [PATCH 6/7] test(plugin-auth): the tenancy test double refuses a WHERE combinator check:where-matcher found the new double reading a combinator key as a field name. It now throws on any key it does not implement. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- packages/plugins/plugin-auth/src/tenancy-service.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index 819c9f7b9f9..0352774bf6b 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -565,7 +565,7 @@ describe('defaultOrgId is revalidated when a user is bound', () => { /** * `sys_organization` + `sys_member` over arrays. `find` honours every `where` * key as an equality, so an existence read by `id` answers for that id and - * nothing else. + * nothing else, and refuses a combinator it does not implement. */ function makeStore(orgs: Array<{ id: string; slug?: string }>) { const members: Array<{ id: string; organization_id: string; user_id: string }> = []; @@ -577,7 +577,11 @@ describe('defaultOrgId is revalidated when a user is bound', () => { find: vi.fn(async (object: string, query: any) => { const where: Record = query?.where ?? {}; const rows = (tables[object] ?? []).filter((row) => - Object.entries(where).every(([k, v]) => row[k] === v), + Object.entries(where).every(([k, v]) => { + // Plain equality only: a combinator is refused, never read as a field name. + if (k.startsWith('$')) throw new Error(`WHERE combinator ${k} is not implemented by this double`); + return row[k] === v; + }), ); return rows.slice(0, query?.limit ?? rows.length); }), From 4303d35abb8c637e9747efb415ab5ede4a3fd448 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:29:55 +0000 Subject: [PATCH 7/7] test(dogfood): move the default-organization recreate door scenario out of this change The booted scenario deletes the default organization through the organization delete door, and that door fails before the bind it measures whenever a federated object is provisioned on the runner: the engine's cascade scan probes the federated object's injected organization_id and the remote table has no such column. The scenario moves to that defect's finding as its acceptance pin. The memo fix stays covered by the unit pin in tenancy-service.test.ts and its ablation. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...ion-recreated-binds-new-id.dogfood.test.ts | 103 ------------------ 1 file changed, 103 deletions(-) delete mode 100644 packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts diff --git a/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts b/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts deleted file mode 100644 index 8c404ff5d39..00000000000 --- a/packages/qa/dogfood/test/default-organization-recreated-binds-new-id.dogfood.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * On a real boot: a user created after the default organization is deleted - * and recreated in the same process is bound to the organization that exists, - * not to the deleted one's id. - * - * The tenancy service memoizes the default organization id. Under the `auto` - * membership policy a user's membership is decided once, at creation - * (ADR-0093 D7), so a bind to a deleted id is never repaired. The sequence is - * driven through the real surface: an administrator deletes the default - * organization through better-auth's own endpoint, and the next sign-up's - * `sys_user` insert is what makes the single-org bootstrap recreate it, ahead - * of that same sign-up's membership bind. The first sign-up, before the - * delete, is asserted bound to the original organization on the same run, so - * the memo is warm and the second half measures the revalidation and not a - * deployment that binds nobody. - * - * Booted with `orgContext`, the harness's switch for the real single-org - * default-organization bootstrap (it is off by default there): that bootstrap - * is what creates the original organization and what recreates it. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; - -const SYSTEM_CTX = { isSystem: true }; - -async function findRows(ql: any, object: string, where: any, limit = 50): Promise { - const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : (rows?.records ?? []); -} - -/** The creation-time membership may land after the sign-up response. */ -async function waitForMembership(ql: any, userId: string): Promise { - for (let i = 0; i < 60; i++) { - const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); - if (rows.length > 0) return rows[0]; - await new Promise((r) => setTimeout(r, 250)); - } - throw new Error(`no sys_member row appeared for ${userId}`); -} - -async function signUpAndFindUser(stack: VerifyStack, ql: any, email: string, name: string): Promise { - await stack.signUp(email, 'Recreated!Pass123', name); - const [user] = await findRows(ql, 'sys_user', { email }, 1); - expect(user, `sys_user row for ${email}`).toBeTruthy(); - return String(user.id); -} - -describe('the default organization id is revalidated when a user is bound', () => { - let stack: VerifyStack; - let ql: any; - let adminToken: string; - let originalOrgId: string; - - beforeAll(async () => { - stack = await bootStack(showcaseStack, { orgContext: true }); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // PREMISE: the bootstrap created the default organization and bound the - // admin as its owner, which is what lets better-auth delete it below. - const [existing] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); - expect(existing, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); - originalOrgId = String(existing.id); - const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); - const adminMembers = await findRows(ql, 'sys_member', { user_id: String(adminUser.id), organization_id: originalOrgId }, 5); - expect(adminMembers.map((m) => m.role), 'PREMISE: the admin owns the default organization').toEqual(['owner']); - }, 240_000); - - afterAll(async () => { await stack?.stop?.(); }); - - it('a user signed up after the default organization is deleted and recreated binds to the new id', async () => { - // ── before the delete: creation binds to the original organization ─── - const firstUserId = await signUpAndFindUser(stack, ql, 'before.default.recreate@example.com', 'Before Recreate'); - const firstMember = await waitForMembership(ql, firstUserId); - expect(String(firstMember.organization_id)).toBe(originalOrgId); - - // ── an administrator deletes the default organization ──────────────── - const deleted = await stack.apiAs(adminToken, 'POST', '/auth/organization/delete', { - organizationId: originalOrgId, - }); - expect(deleted.status, await deleted.clone().text()).toBe(200); - expect(await findRows(ql, 'sys_organization', { id: originalOrgId }, 1)).toHaveLength(0); - - // ── the next sign-up recreates it, and is bound to the recreated one ── - const secondUserId = await signUpAndFindUser(stack, ql, 'after.default.recreate@example.com', 'After Recreate'); - const [recreated] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); - expect(recreated, 'the bootstrap recreated the default organization').toBeTruthy(); - const recreatedOrgId = String(recreated.id); - expect(recreatedOrgId).not.toBe(originalOrgId); - - const secondMember = await waitForMembership(ql, secondUserId); - expect(String(secondMember.organization_id)).toBe(recreatedOrgId); - - // The creating request's first session carries the same organization. - const firstSessions = await findRows(ql, 'sys_session', { user_id: secondUserId }, 5); - expect(firstSessions.length).toBeGreaterThan(0); - expect(String(firstSessions[0].active_organization_id)).toBe(recreatedOrgId); - }, 180_000); -});