From 38f1eeed79e46ddb5dafdbc224bd630b88e62dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:21:40 +0000 Subject: [PATCH 01/12] wip: create-user joins a chosen organization --- .../src/identity/sys-user.object.ts | 17 +++ .../plugin-auth/src/admin-user-endpoints.ts | 120 ++++++++++++++++-- 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 1f522f9d8d0..ea0dccd7c10 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -304,6 +304,23 @@ export const SysUser = ObjectSchema.create({ required: false, helpText: 'Optional. Grants this permission set to the new user, so they can open their apps on first sign-in.', }, + // Optional: join the new user to one organization as a `member` in the + // same step — the organization's Add Member action folded into the + // creation. It matters in multi-org mode, where the server binds no + // organization by itself (ADR-0093 D3 — it never guesses one). INLINE + // with `reference`, so the dialog renders an organization picker + // instead of a raw id box. The route joins it through better-auth's + // `addMember` in the account's own transaction: a refused join creates + // no user. + { + name: 'organizationId', + type: 'lookup', + reference: 'sys_organization', + label: 'Organization', + required: false, + helpText: 'Optional. Adds the new user to this organization as a member, so you do not have to add them on its Members list afterwards.', + requiresFeature: 'organization', + }, ], resultDialog: { title: 'User Created', diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index f5905a17468..6e747eea7e8 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -127,6 +127,10 @@ export interface AdminUserEndpointDeps { * [#25338] What create-user's optional `permissionSet` needs. Absent ⇒ a * request that names a set is refused 501 and no user is created; a request * that names none never reads it. See {@link AdminCreateGrantSeam}. + * + * Its unit of work also carries create-user's optional `organizationId`, so + * a refused join rolls the account back as a refused grant does. Absent ⇒ a + * request that names an organization is refused 501 the same way. */ createGrant?: AdminCreateGrantSeam; logger?: { warn(msg: string): void }; @@ -208,6 +212,7 @@ import { generatePlaceholderEmail } from './placeholder-email.js'; import { revokeSessionsAndApiKeys, type ForcedResetRevocationOutcome } from './forced-reset-revocation.js'; import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js'; import { resolveDefaultOrgId } from './tenancy-service.js'; +import type { AddMemberCapableApi } from './organization-add-member.js'; import { readJsonRequestBody } from './request-json-body.js'; import { cloudManagedRefusal } from './cloud-managed.js'; import { isCloudManaged } from '@objectstack/core'; @@ -667,9 +672,42 @@ export async function runAdminCreateUser( const permissionSet = typeof rawPermissionSet === 'string' && rawPermissionSet.trim().length > 0 ? rawPermissionSet.trim() : undefined; + // The optional organization the administrator chose for the new user. Empty + // means none chosen: the deployment's own membership reconciliation applies. + const rawOrganizationId = body.organizationId ?? body.organization_id; + if (rawOrganizationId !== undefined && rawOrganizationId !== null && typeof rawOrganizationId !== 'string') { + return badRequest('organizationId must be the id of an organization'); + } + const organizationId = + typeof rawOrganizationId === 'string' && rawOrganizationId.trim().length > 0 ? rawOrganizationId.trim() : undefined; + const resolved = await resolvePassword(deps, body, 'password'); if ('status' in resolved) return resolved; + // Joining a chosen organization is settled BEFORE the account exists too: it + // runs in the same unit of work as the account, so a host that cannot run + // one, or has no organization plugin, refuses with nothing created. + let join: { seam: AdminCreateGrantSeam; addMember: AddMemberCapableApi['addMember'] } | undefined; + if (organizationId !== undefined) { + const seam = deps.createGrant; + const authApi = (await deps.getAuthApi()) as Partial; + if (!seam || typeof authApi.addMember !== 'function') { + return { + status: 501, + body: { + success: false, + error: { + code: 'NOT_IMPLEMENTED', + message: + 'This server cannot add a user to an organization while creating them, so no user was created. ' + + "Create the user without an organization, then use Add Member on the organization's Members list.", + }, + }, + }; + } + join = { seam, addMember: authApi.addMember.bind(authApi) }; + } + // [#25338] Everything the grant needs is settled BEFORE the account exists, // so a host that cannot judge the grant refuses with nothing created. let grant: { seam: AdminCreateGrantSeam; engine: AdminUserDataEngine; context: Record } | undefined; @@ -740,11 +778,15 @@ export async function runAdminCreateUser( ? await stampMustChangePassword(deps, userId, true) : false; - // Match the invite / add-member flows: give the new user a membership so a - // single-org deployment shows them under the Default Organization instead of - // as a member-less account. No-op in multi-org (≥2 orgs), and no-op under + // An organization the administrator chose is joined explicitly (the + // add-member flow, ADR-0093 D1). Otherwise match the invite / add-member + // flows: give the new user a membership so a single-org deployment shows + // them under the Default Organization instead of as a member-less account. + // No-op in multi-org (≥2 orgs), and no-op under // `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper. - const membership = await bindUserToSoleOrganization(deps, userId); + const membership = join && organizationId !== undefined + ? await joinChosenOrganization(deps, join.addMember, userId, organizationId) + : await bindUserToSoleOrganization(deps, userId); // [#25338] The grant, under the administrator's own context (see // `AdminCreateGrantSeam`). A refusal throws, and the unit of work around @@ -757,7 +799,7 @@ export async function runAdminCreateUser( { context: grant.context }, ); } catch (error) { - throw new GrantRefused(grantRefusal(error)); + throw new CreationRefused(grantRefusal(error)); } } @@ -765,13 +807,14 @@ export async function runAdminCreateUser( }; let account: CreatedAccount | EndpointResult; - if (grant) { + const unitOfWork = grant?.seam ?? join?.seam; + if (unitOfWork) { try { // A creation that fails answers without throwing: nothing landed, so // there is nothing to roll back. - account = await grant.seam.runAtomically(createAccount); + account = await unitOfWork.runAtomically(createAccount); } catch (error) { - if (error instanceof GrantRefused) return error.result; + if (error instanceof CreationRefused) return error.result; throw error; } } else { @@ -827,11 +870,66 @@ interface CreatedAccount { } /** [#25338] Thrown out of the unit of work so it rolls back; carries the refusal the caller answers. */ -class GrantRefused extends Error { +class CreationRefused extends Error { constructor(readonly result: EndpointResult) { - super(result.body.error?.message ?? 'permission set grant refused'); - this.name = 'GrantRefused'; + super(result.body.error?.message ?? 'user creation refused'); + this.name = 'CreationRefused'; + } +} + +/** + * Join the new user to the organization the administrator chose, as a + * `member` — the same act as the organization's Add Member action, folded into + * the creation so the administrator does not have to perform it afterwards. + * + * It goes through better-auth's own server-only `addMember` (as the + * `/organization/add-member` mount does), so the vendor's checks — the + * organization exists, the membership limit, its hooks — all stay the + * vendor's. A refusal throws, and the unit of work around the creation rolls + * the account back with it. + * + * ADR-0093: this is NOT the reconciler choosing a target org (D3 — multi mode + * never guesses); the administrator named it, which makes it an explicit flow + * that `membershipPolicy: 'invite-only'` admits (D1). The role is `member`, the + * D1 default: elevation stays the separate, audited `update-member-role`. + * + * A membership already there (in single-org mode the `user.create.after` + * reconciler can bind the default organization first) is respected and + * reported as `membershipCreated: false`, exactly as the reconciler reports it. + */ +async function joinChosenOrganization( + deps: AdminUserEndpointDeps, + addMember: AddMemberCapableApi['addMember'], + userId: string, + organizationId: string, +): Promise<{ organizationId: string; membershipCreated: boolean }> { + const engine = deps.getDataEngine(); + if (engine?.find) { + const existing = await engine.find( + 'sys_member', + { where: { organization_id: organizationId, user_id: userId }, limit: 1 }, + { context: SYSTEM_CTX }, + ); + if (Array.isArray(existing) && existing.length > 0) return { organizationId, membershipCreated: false }; + } + try { + await addMember({ body: { userId, role: 'member', organizationId } }); + } catch (error) { + const refused = mapAuthApiError(error, 'organization/add-member failed'); + throw new CreationRefused({ + status: refused.status, + body: { + success: false, + error: { + code: refused.body.error?.code ?? 'INTERNAL_ERROR', + message: `The user could not be added to the organization, so no user was created: ${ + refused.body.error?.message ?? 'organization/add-member failed' + }`, + }, + }, + }); } + return { organizationId, membershipCreated: true }; } /** From 9360650489e9f17ada4199f2ce6511b44109efc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:28:36 +0000 Subject: [PATCH 02/12] wip: tests and translations --- .../apps/translations/en.objects.generated.ts | 4 + .../translations/es-ES.objects.generated.ts | 4 + .../translations/ja-JP.objects.generated.ts | 4 + .../translations/zh-CN.objects.generated.ts | 4 + .../src/admin-user-endpoints.test.ts | 173 ++++++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index bfbce4b4e01..c4ebc0b4a51 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -187,6 +187,10 @@ export const enObjects: NonNullable = { permissionSet: { label: "Permission Set", helpText: "Optional. Grants this permission set to the new user, so they can open their apps on first sign-in." + }, + organizationId: { + label: "Organization", + helpText: "Optional. Adds the new user to this organization as a member, so you do not have to add them on its Members list afterwards." } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 49f8eb07ed0..cf82a51282a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -187,6 +187,10 @@ export const esESObjects: NonNullable = { permissionSet: { label: "Conjunto de permisos", helpText: "Opcional. Concede este conjunto de permisos al nuevo usuario para que pueda abrir sus aplicaciones desde el primer inicio de sesión." + }, + organizationId: { + label: "Organización", + helpText: "Opcional. Añade el nuevo usuario a esta organización como miembro, para que no tenga que añadirlo después en su lista de miembros." } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 46616a89ce1..0d48825dd75 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -187,6 +187,10 @@ export const jaJPObjects: NonNullable = { permissionSet: { label: "権限セット", helpText: "任意。この権限セットを新しいユーザーに付与し、初回サインインから自分のアプリを開けるようにします。" + }, + organizationId: { + label: "組織", + helpText: "任意。新しいユーザーをメンバーとしてこの組織に追加します。後から組織のメンバー一覧で追加する必要はありません。" } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index b8febdf3fd8..f5d422a9b2d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -187,6 +187,10 @@ export const zhCNObjects: NonNullable = { permissionSet: { label: "权限集", helpText: "可选。将该权限集授予新用户,使其首次登录即可打开自己的应用。" + }, + organizationId: { + label: "组织", + helpText: "可选。将新用户以成员身份加入该组织,无需再到组织的成员列表中手动添加。" } }, resultDialog: { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index b00c8311575..e19ad479fde 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1254,3 +1254,176 @@ describe('create-user: an optional permission set, granted under the caller\'s o expect(param).not.toHaveProperty('objectOverride'); }); }); + +// ── create-user's optional organization ──────────────────────────────────── +// +// The organization's Add Member action folded into the creation: better-auth's +// own `addMember` joins the new user as a `member`, inside the same unit of +// work as the account, so a refused join leaves no user behind. In multi-org +// mode this is the only way the new user gets a membership at creation — the +// reconciler never guesses a target org there (ADR-0093 D3). +describe('create-user: an optional organization the new user joins as a member', () => { + function makeJoinDeps(opts: { + members?: Array<{ organization_id: string; user_id: string }>; + addMember?: (...args: any[]) => Promise; + withSeam?: boolean; + policy?: 'auto' | 'invite-only'; + } = {}) { + const outcomes: string[] = []; + const runAtomically = vi.fn(async (work: () => Promise): Promise => { + try { + const result = await work(); + outcomes.push('committed'); + return result; + } catch (error) { + outcomes.push('rolled back'); + throw error; + } + }); + const resolveActorContext = vi.fn(async () => undefined); + const addMember = vi.fn(opts.addMember ?? (async ({ body }: any) => ({ id: 'mem-1', ...body }))); + const createUser = vi.fn(async ({ body }: any) => ({ + user: { id: 'user-9', email: body.email, name: body.name }, + })); + const members = opts.members ?? []; + const find = vi.fn(async (object: string, query: any) => { + const where = query?.where ?? {}; + if (object === 'sys_member') { + return members.filter((m) => m.organization_id === where.organization_id && m.user_id === where.user_id); + } + // Multi-org: the reconciler must never be what binds here. + if (object === 'sys_organization') return [{ id: 'org_a' }, { id: 'org_b' }]; + return []; + }); + const engineInsert = vi.fn(async () => ({})); + const m = makeDeps({ + getAuthApi: async () => ({ createUser, addMember }) as any, + getDataEngine: () => ({ update: vi.fn(async () => ({})), insert: engineInsert, find }), + getTenancy: () => ({ defaultOrgId: async () => null }), + ...(opts.policy ? { getMembershipPolicy: () => opts.policy } : {}), + ...(opts.withSeam === false ? {} : { createGrant: { resolveActorContext, runAtomically } }), + }); + return { ...m, createUser, addMember, engineInsert, outcomes, runAtomically, resolveActorContext }; + } + + it('none chosen: addMember is never called and the unit of work is not opened', async () => { + const j = makeJoinDeps(); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'n@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.addMember).not.toHaveBeenCalled(); + expect(j.runAtomically).not.toHaveBeenCalled(); + expect((res.body.data as any).membershipCreated).toBe(false); + }); + + it('chosen: joins it as a member through addMember inside the unit of work, and reports it', async () => { + const j = makeJoinDeps(); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'o@b.co', password: 'Explicit1!', organizationId: ' org_b ' }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(j.outcomes).toEqual(['committed']); + expect(j.addMember).toHaveBeenCalledTimes(1); + expect(j.addMember.mock.calls[0][0]).toEqual({ body: { userId: 'user-9', role: 'member', organizationId: 'org_b' } }); + // The caller's context is not needed for the join: it is the vendor's own act. + expect(j.resolveActorContext).not.toHaveBeenCalled(); + // The reconciler's own insert never ran: one membership, the chosen one. + expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_member')).toHaveLength(0); + const data = res.body.data as any; + expect(data.organizationId).toBe('org_b'); + expect(data.membershipCreated).toBe(true); + const audit = callsOf(j.engineInsert).find(([object]) => object === 'sys_audit_log')![1]; + expect(JSON.parse(audit.metadata)).toMatchObject({ organizationId: 'org_b', membershipCreated: true }); + }); + + it('chosen under invite-only: still joined — an organization the administrator named is an explicit flow', async () => { + const j = makeJoinDeps({ policy: 'invite-only' }); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'i@b.co', password: 'Explicit1!', organizationId: 'org_a' }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(j.addMember).toHaveBeenCalledTimes(1); + expect((res.body.data as any).membershipCreated).toBe(true); + }); + + it('a membership already there is respected: no second addMember, membershipCreated false', async () => { + const j = makeJoinDeps({ members: [{ organization_id: 'org_a', user_id: 'user-9' }] }); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'e@b.co', password: 'Explicit1!', organizationId: 'org_a' }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(j.addMember).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ organizationId: 'org_a', membershipCreated: false }); + }); + + it('refused by the vendor: its status and code, the account rolls back, nothing is audited', async () => { + const j = makeJoinDeps({ + addMember: async () => { + throw { statusCode: 400, body: { code: 'ORGANIZATION_NOT_FOUND', message: 'Organization not found' } }; + }, + }); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'r@b.co', password: 'Explicit1!', organizationId: 'org_gone' }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('ORGANIZATION_NOT_FOUND'); + expect(res.body.error?.message).toContain('no user was created'); + expect(j.outcomes).toEqual(['rolled back']); + expect(res.body.data).toBeUndefined(); + expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_audit_log')).toHaveLength(0); + }); + + it('a host with no unit of work refuses 501 before any account exists', async () => { + const j = makeJoinDeps({ withSeam: false }); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'w@b.co', password: 'Explicit1!', organizationId: 'org_a' }), + ACTOR, + ); + expect(res.status).toBe(501); + expect(j.createUser).not.toHaveBeenCalled(); + }); + + it('a server without the organization plugin refuses 501 before any account exists', async () => { + const j = makeJoinDeps(); + const createUser = vi.fn(); + j.deps.getAuthApi = async () => ({ createUser }) as any; + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'p@b.co', password: 'Explicit1!', organizationId: 'org_a' }), + ACTOR, + ); + expect(res.status).toBe(501); + expect(createUser).not.toHaveBeenCalled(); + }); + + it('an organizationId that is not a string is refused 400 before any account exists', async () => { + const j = makeJoinDeps(); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'x@b.co', password: 'Explicit1!', organizationId: { id: 'org_a' } }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(j.createUser).not.toHaveBeenCalled(); + }); + + it('the Create User dialog offers it as an optional organization picker, only where organizations exist', () => { + const action = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); + const param = (action?.params ?? []).find((p) => p.name === 'organizationId'); + expect(param).toMatchObject({ + type: 'lookup', + reference: 'sys_organization', + required: false, + }); + // `requiresFeature` is lowered into `visible` at parse time. + expect(JSON.stringify((param as { visible?: unknown }).visible)).toContain('features.organization'); + }); +}); From aabc41fb2a6c8bc628ea58e385870503507d0167 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:30:19 +0000 Subject: [PATCH 03/12] wip: gate the picker on multiOrgEnabled --- packages/platform-objects/src/identity/sys-user.object.ts | 4 +++- packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts | 4 ++-- packages/spec/src/kernel/public-auth-features.ts | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index ea0dccd7c10..1e9e0b2d030 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -319,7 +319,9 @@ export const SysUser = ObjectSchema.create({ label: 'Organization', required: false, helpText: 'Optional. Adds the new user to this organization as a member, so you do not have to add them on its Members list afterwards.', - requiresFeature: 'organization', + // Offered only where it is needed: single-org already binds every + // new user to the default organization by itself. + requiresFeature: 'multiOrgEnabled', }, ], resultDialog: { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index e19ad479fde..2645455e46c 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1415,7 +1415,7 @@ describe('create-user: an optional organization the new user joins as a member', expect(j.createUser).not.toHaveBeenCalled(); }); - it('the Create User dialog offers it as an optional organization picker, only where organizations exist', () => { + it('the Create User dialog offers it as an optional organization picker, only in multi-org mode', () => { const action = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); const param = (action?.params ?? []).find((p) => p.name === 'organizationId'); expect(param).toMatchObject({ @@ -1424,6 +1424,6 @@ describe('create-user: an optional organization the new user joins as a member', required: false, }); // `requiresFeature` is lowered into `visible` at parse time. - expect(JSON.stringify((param as { visible?: unknown }).visible)).toContain('features.organization'); + expect(JSON.stringify((param as { visible?: unknown }).visible)).toContain('features.multiOrgEnabled'); }); }); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 5b8ba535bc5..5231ad275b3 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -144,6 +144,9 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization', + // Create User's organization picker: only multi-org needs it, since + // single-org binds every new user to the default organization itself. + 'sys_user.actions.create_user.params.organizationId', ], notes: 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' + From 375fca9d735a2036b02f831d1f1bc9e9be7ac392 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:40:11 +0000 Subject: [PATCH 04/12] wip: dogfood --- ...ate-user-organization-join.dogfood.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts diff --git a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts new file mode 100644 index 00000000000..08d65936cee --- /dev/null +++ b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Create User can join the new user to an organization in the same step — + * measured on the real route, in a real multi-org posture. + * + * In multi-org mode the membership reconciler binds no organization by itself + * (ADR-0093 D3 — it never guesses one), so a user created without one is + * member-less and the administrator had to follow up with the organization's + * Add Member action. Naming the organization on create-user folds that second + * step into the first: better-auth's own `addMember` joins the user as a + * `member`, inside the account's transaction. + * + * J1 chosen: the user lands with exactly one membership, `member` of the + * chosen organization. + * J2 none chosen: still member-less in multi-org — the reconciler's + * never-guess posture is unchanged. + * J3 an organization that does not exist is refused, and the account rolls + * back with it — no member-less user is left behind. + * + * Harness: the admin identity routes 501 unless better-auth's `admin` plugin + * is on; `OS_SCIM_ENABLED` is the one env knob that reaches it (see + * `admin-identity-audit-trail.dogfood.test.ts`). The posture is a real, + * non-degraded `isolated` one (`multiTenant: 'posture-only'`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +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 ?? []); +} + +describe('Create User joins a chosen organization in multi-org mode', () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + let orgId: string; + let priorScim: string | undefined; + + beforeAll(async () => { + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + adminToken = await stack.signIn(); // the seeded dev admin (platform admin) + ql = await stack.kernel.getServiceAsync('objectql'); + + const ownerToken = await stack.signUp('owner.create-user-org@example.com'); + const created = await stack.apiAs(ownerToken, 'POST', '/auth/organization/create', { + name: 'Join Target', slug: 'join-target-create-user', + }); + expect(created.status, `org create: ${await created.clone().text()}`).toBe(200); + orgId = ((await created.json()) as { id: string }).id; + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + it('guard the guard: a real multi-org posture', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ mode: string; degraded: boolean }>('tenancy'); + expect(tenancy.mode).toBe('multi'); + expect(tenancy.degraded).toBe(false); + }); + + it('J1 chosen: the user lands as a member of that organization, and only that one', async () => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email: 'joined.create-user-org@example.com', + password: 'Joined!Pass123', + organizationId: orgId, + }); + expect(res.status, await res.clone().text()).toBe(200); + const body = await res.json(); + expect(body.data.organizationId).toBe(orgId); + expect(body.data.membershipCreated).toBe(true); + + const members = await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) }); + expect(members.map((m) => [m.organization_id, m.role])).toEqual([[orgId, 'member']]); + }, 120_000); + + it('J2 none chosen: member-less, as before — multi-org never guesses an organization', async () => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email: 'unbound.create-user-org@example.com', + password: 'Unbound!Pass123', + }); + expect(res.status, await res.clone().text()).toBe(200); + const body = await res.json(); + expect(body.data.membershipCreated).toBe(false); + expect(await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) })).toHaveLength(0); + }, 120_000); + + it('J3 an organization that does not exist: refused, and no user is created', async () => { + const email = 'refused.create-user-org@example.com'; + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email, + password: 'Refused!Pass123', + organizationId: 'org_does_not_exist', + }); + const text = await res.clone().text(); + expect(res.status, text).toBeGreaterThanOrEqual(400); + expect(res.status, text).toBeLessThan(500); + expect((await res.json()).error.message).toContain('no user was created'); + expect(await findRows(ql, 'sys_user', { email })).toHaveLength(0); + }, 120_000); +}); From 69c3c61a1575b0a4854133b51eae52ea52e69f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:44:15 +0000 Subject: [PATCH 05/12] wip: changeset --- .changeset/create-user-organization-join.md | 17 +++++++++++++++++ ...reate-user-organization-join.dogfood.test.ts | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .changeset/create-user-organization-join.md diff --git a/.changeset/create-user-organization-join.md b/.changeset/create-user-organization-join.md new file mode 100644 index 00000000000..67f96d2d8f0 --- /dev/null +++ b/.changeset/create-user-organization-join.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +feat(plugin-auth, platform-objects): Create User can add the new user to an organization in the same step + +Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` accepts an optional `organizationId`; a request without it behaves exactly as before) +Metadata-protocol: none + +In multi-org mode the membership reconciler binds no organization by itself (ADR-0093 D3: it never guesses one). So a user created from Setup had no membership, and the administrator had to open the organization's Members list and use Add Member as a second step. + +- **The route.** `/admin/create-user` takes an optional `organizationId` (`organization_id` is also accepted). When one is given, better-auth's own `addMember` adds the user to that organization as `member`, the same act as Add Member. It runs inside the account's transaction, so an organization that does not exist, or a membership limit, refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. +- **Policy.** An organization the administrator names is an explicit flow, so `membershipPolicy: 'invite-only'` admits it, as it admits Add Member (ADR-0093 D1). The role is always `member`: to grant a higher role, use Change Role as before. +- **Capability.** A server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists. +- **The dialog.** Setup's Create User dialog gains an optional **Organization** picker (an inline lookup on `sys_organization`), shown only in multi-org mode (`features.multiOrgEnabled`). Single-org mode already adds every new user to the default organization, so it does not show the picker. `@objectstack/spec` records the param under `multiOrgEnabled.gatedInputs`. diff --git a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts index 08d65936cee..8cc896fbc59 100644 --- a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts +++ b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts @@ -64,9 +64,14 @@ describe('Create User joins a chosen organization in multi-org mode', () => { }); it('guard the guard: a real multi-org posture', async () => { - const tenancy = await stack.kernel.getServiceAsync<{ mode: string; degraded: boolean }>('tenancy'); - expect(tenancy.mode).toBe('multi'); + const tenancy = await stack.kernel.getServiceAsync<{ + posture: string; isolationActive: boolean; degraded: boolean; defaultOrgId(): Promise; + }>('tenancy'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); expect(tenancy.degraded).toBe(false); + // The reconciler's target: none, so J2's member-less outcome is the posture's, not an accident. + expect(await tenancy.defaultOrgId()).toBeNull(); }); it('J1 chosen: the user lands as a member of that organization, and only that one', async () => { From 9365e66e9f9a46c25a8fa0b35341881254d26103 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:01:42 +0000 Subject: [PATCH 06/12] wip: default to the administrator's current organization in multi-org --- .../apps/translations/en.objects.generated.ts | 2 +- .../src/identity/sys-user.object.ts | 15 +-- .../src/admin-user-endpoints.test.ts | 95 ++++++++++++++++++- .../plugin-auth/src/admin-user-endpoints.ts | 73 +++++++++++--- .../plugins/plugin-auth/src/auth-plugin.ts | 18 +++- ...ate-user-organization-join.dogfood.test.ts | 38 ++++++-- 6 files changed, 207 insertions(+), 34 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index c4ebc0b4a51..0e28c875dfb 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -190,7 +190,7 @@ export const enObjects: NonNullable = { }, organizationId: { label: "Organization", - helpText: "Optional. Adds the new user to this organization as a member, so you do not have to add them on its Members list afterwards." + helpText: "Optional. The new user joins this organization as a member. Leave empty to add them to your current organization." } }, resultDialog: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 1e9e0b2d030..eae8f07791e 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -306,19 +306,20 @@ export const SysUser = ObjectSchema.create({ }, // Optional: join the new user to one organization as a `member` in the // same step — the organization's Add Member action folded into the - // creation. It matters in multi-org mode, where the server binds no - // organization by itself (ADR-0093 D3 — it never guesses one). INLINE - // with `reference`, so the dialog renders an organization picker - // instead of a raw id box. The route joins it through better-auth's - // `addMember` in the account's own transaction: a refused join creates - // no user. + // creation. Left empty, the route defaults to the administrator's own + // current organization (ADR-0093 D3 amendment); a param `defaultValue` + // is a literal, so the default lives on the server and the help text + // says so. INLINE with `reference`, so the dialog renders an + // organization picker instead of a raw id box. The route joins through + // better-auth's `addMember` in the account's own transaction: a + // refused join creates no user. { name: 'organizationId', type: 'lookup', reference: 'sys_organization', label: 'Organization', required: false, - helpText: 'Optional. Adds the new user to this organization as a member, so you do not have to add them on its Members list afterwards.', + helpText: 'Optional. The new user joins this organization as a member. Leave empty to add them to your current organization.', // Offered only where it is needed: single-org already binds every // new user to the default organization by itself. requiresFeature: 'multiOrgEnabled', diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 2645455e46c..7512162a530 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1268,6 +1268,10 @@ describe('create-user: an optional organization the new user joins as a member', addMember?: (...args: any[]) => Promise; withSeam?: boolean; policy?: 'auto' | 'invite-only'; + /** The organization wall in force — `features.multiOrgEnabled`. */ + isolationActive?: boolean; + /** The calling administrator's current organization (`undefined` ⇒ the dep is not wired). */ + callerActiveOrg?: string | null; } = {}) { const outcomes: string[] = []; const runAtomically = vi.fn(async (work: () => Promise): Promise => { @@ -1296,17 +1300,19 @@ describe('create-user: an optional organization the new user joins as a member', return []; }); const engineInsert = vi.fn(async () => ({})); + const callerActiveOrganizationId = vi.fn(async () => opts.callerActiveOrg ?? null); const m = makeDeps({ getAuthApi: async () => ({ createUser, addMember }) as any, getDataEngine: () => ({ update: vi.fn(async () => ({})), insert: engineInsert, find }), - getTenancy: () => ({ defaultOrgId: async () => null }), + getTenancy: () => ({ defaultOrgId: async () => null, isolationActive: opts.isolationActive ?? true }), ...(opts.policy ? { getMembershipPolicy: () => opts.policy } : {}), + ...(opts.callerActiveOrg !== undefined ? { getCallerActiveOrganizationId: callerActiveOrganizationId } : {}), ...(opts.withSeam === false ? {} : { createGrant: { resolveActorContext, runAtomically } }), }); - return { ...m, createUser, addMember, engineInsert, outcomes, runAtomically, resolveActorContext }; + return { ...m, createUser, addMember, engineInsert, outcomes, runAtomically, resolveActorContext, callerActiveOrganizationId }; } - it('none chosen: addMember is never called and the unit of work is not opened', async () => { + it('none chosen and no default wired: addMember is never called and the unit of work is not opened', async () => { const j = makeJoinDeps(); const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'n@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(200); @@ -1415,6 +1421,89 @@ describe('create-user: an optional organization the new user joins as a member', expect(j.createUser).not.toHaveBeenCalled(); }); + // ── left empty: the administrator's current organization, multi-org only ── + + it('multi-org, left empty: the user joins the administrator\'s current organization', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'd@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.outcomes).toEqual(['committed']); + expect(j.addMember.mock.calls[0][0]).toEqual({ + body: { userId: 'user-9', role: 'member', organizationId: 'org_current' }, + }); + expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); + }); + + it('multi-org, left empty, an administrator with no current organization: member-less, as before', async () => { + const j = makeJoinDeps({ callerActiveOrg: null }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'z@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.callerActiveOrganizationId).toHaveBeenCalledTimes(1); + expect(j.addMember).not.toHaveBeenCalled(); + expect(j.runAtomically).not.toHaveBeenCalled(); + expect((res.body.data as any).membershipCreated).toBe(false); + }); + + it('multi-org, left empty, under invite-only: no default — a default is not an explicit choice', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', policy: 'invite-only' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'q@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.callerActiveOrganizationId).not.toHaveBeenCalled(); + expect(j.addMember).not.toHaveBeenCalled(); + expect((res.body.data as any).membershipCreated).toBe(false); + }); + + it('multi-org, left empty, a host that cannot join: the user is still created, member-less — never a 501', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', withSeam: false }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'k@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.addMember).not.toHaveBeenCalled(); + expect((res.body.data as any).membershipCreated).toBe(false); + }); + + it('multi-org, left empty, the current organization refuses the join: no user, and the message says so', async () => { + const j = makeJoinDeps({ + callerActiveOrg: 'org_current', + addMember: async () => { + throw { statusCode: 403, body: { code: 'ORGANIZATION_MEMBERSHIP_LIMIT_REACHED', message: 'Membership limit reached' } }; + }, + }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'l@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(403); + expect(res.body.error?.code).toBe('ORGANIZATION_MEMBERSHIP_LIMIT_REACHED'); + expect(res.body.error?.message).toContain('no user was created'); + expect(j.outcomes).toEqual(['rolled back']); + }); + + it('single-org (or a degraded wall): the default is never read and creation is unchanged', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', isolationActive: false }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 's@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.callerActiveOrganizationId).not.toHaveBeenCalled(); + expect(j.addMember).not.toHaveBeenCalled(); + expect(j.runAtomically).not.toHaveBeenCalled(); + }); + + it('single-org: the reconciler still binds the default organization, with the default dep wired', async () => { + const insert = vi.fn(async () => ({})); + const callerActive = vi.fn(async () => 'org_elsewhere'); + const m = makeDeps({ + getDataEngine: () => ({ + update: vi.fn(async () => ({})), + insert, + find: vi.fn(async (object: string) => (object === 'sys_organization' ? [{ id: 'org_default', slug: 'default' }] : [])), + }), + getTenancy: () => ({ defaultOrgId: async () => 'org_default', isolationActive: false }), + getCallerActiveOrganizationId: callerActive, + }); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'so@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(callerActive).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ organizationId: 'org_default', membershipCreated: true }); + const member = callsOf(insert).find(([object]) => object === 'sys_member'); + expect(member?.[1]).toMatchObject({ organization_id: 'org_default', user_id: 'user-9', role: 'member' }); + }); + it('the Create User dialog offers it as an optional organization picker, only in multi-org mode', () => { const action = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); const param = (action?.params ?? []).find((p) => p.name === 'organizationId'); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 6e747eea7e8..d53656c9fc1 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -101,7 +101,17 @@ export interface AdminUserEndpointDeps { * mis-bind new users into the default org. Wire this everywhere tenancy * is registered. */ - getTenancy?(): { defaultOrgId(): Promise } | undefined; + getTenancy?(): { + defaultOrgId(): Promise; + /** + * True iff an organization wall is in force — the same predicate + * `features.multiOrgEnabled` (and so the Create User dialog's organization + * picker) is derived from. create-user's multi-org default reads it, so + * the default applies exactly where the picker is shown. Optional for lean + * mocks: absent ⇒ not multi-org ⇒ no default. + */ + readonly isolationActive?: boolean; + } | undefined; /** * ADR-0093 D1 — the deployment's membership policy, read LIVE through * `AuthManager.getMembershipPolicy()` (never a captured option). @@ -133,6 +143,13 @@ export interface AdminUserEndpointDeps { * request that names an organization is refused 501 the same way. */ createGrant?: AdminCreateGrantSeam; + /** + * The CALLING administrator's current (active) organization, read from their + * session, or `null` when they have none. create-user's default organization + * in multi-org mode (ADR-0093 D3 amendment). Absent ⇒ no default: a request + * that names no organization joins none, as before. + */ + getCallerActiveOrganizationId?(): Promise; logger?: { warn(msg: string): void }; } @@ -673,7 +690,9 @@ export async function runAdminCreateUser( typeof rawPermissionSet === 'string' && rawPermissionSet.trim().length > 0 ? rawPermissionSet.trim() : undefined; // The optional organization the administrator chose for the new user. Empty - // means none chosen: the deployment's own membership reconciliation applies. + // means none chosen: in multi-org mode the administrator's own current + // organization is the default (see `resolveDefaultJoinOrganization`), and + // otherwise the deployment's own membership reconciliation applies. const rawOrganizationId = body.organizationId ?? body.organization_id; if (rawOrganizationId !== undefined && rawOrganizationId !== null && typeof rawOrganizationId !== 'string') { return badRequest('organizationId must be the id of an organization'); @@ -684,14 +703,18 @@ export async function runAdminCreateUser( const resolved = await resolvePassword(deps, body, 'password'); if ('status' in resolved) return resolved; - // Joining a chosen organization is settled BEFORE the account exists too: it - // runs in the same unit of work as the account, so a host that cannot run - // one, or has no organization plugin, refuses with nothing created. + // Joining an organization is settled BEFORE the account exists too: it runs + // in the same unit of work as the account, so a host that cannot run one, or + // has no organization plugin, refuses a CHOSEN organization with nothing + // created. A defaulted one is skipped instead — nobody asked for it. let join: { seam: AdminCreateGrantSeam; addMember: AddMemberCapableApi['addMember'] } | undefined; - if (organizationId !== undefined) { + const joinOrganizationId = organizationId ?? (await resolveDefaultJoinOrganization(deps)); + if (joinOrganizationId !== undefined) { const seam = deps.createGrant; const authApi = (await deps.getAuthApi()) as Partial; - if (!seam || typeof authApi.addMember !== 'function') { + const capable = Boolean(seam) && typeof authApi.addMember === 'function'; + if (capable) join = { seam: seam!, addMember: authApi.addMember!.bind(authApi) }; + else if (organizationId !== undefined) { return { status: 501, body: { @@ -705,7 +728,6 @@ export async function runAdminCreateUser( }, }; } - join = { seam, addMember: authApi.addMember.bind(authApi) }; } // [#25338] Everything the grant needs is settled BEFORE the account exists, @@ -778,14 +800,15 @@ export async function runAdminCreateUser( ? await stampMustChangePassword(deps, userId, true) : false; - // An organization the administrator chose is joined explicitly (the - // add-member flow, ADR-0093 D1). Otherwise match the invite / add-member + // An organization the administrator chose — or, in multi-org mode, their + // current one by default — is joined explicitly (the add-member flow, + // ADR-0093 D1, D3 amendment). Otherwise match the invite / add-member // flows: give the new user a membership so a single-org deployment shows // them under the Default Organization instead of as a member-less account. // No-op in multi-org (≥2 orgs), and no-op under // `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper. - const membership = join && organizationId !== undefined - ? await joinChosenOrganization(deps, join.addMember, userId, organizationId) + const membership = join && joinOrganizationId !== undefined + ? await joinChosenOrganization(deps, join.addMember, userId, joinOrganizationId) : await bindUserToSoleOrganization(deps, userId); // [#25338] The grant, under the administrator's own context (see @@ -932,6 +955,32 @@ async function joinChosenOrganization( return { organizationId, membershipCreated: true }; } +/** + * The organization a new user joins when the administrator chose none: their + * OWN current organization, in multi-org mode only (ADR-0093 D3 amendment). + * + * - Single-org (no organization wall in force, a degraded walled request + * included): none — the reconciler already binds the default organization. + * Judged by `tenancy.isolationActive`, the predicate behind + * `features.multiOrgEnabled`, so the default applies exactly where the + * dialog shows the organization picker. + * - `membershipPolicy: 'invite-only'`: none — a default is not an explicit + * choice, so the policy keeps withholding it (D1). + * - An administrator with no current organization: none — the user is + * created member-less, exactly as before. + * + * This is not the reconciler guessing a tenant: it is the organization the + * administrator is working in, shown to them as the dialog's default, the + * same organization the Add Member action falls back to. + */ +async function resolveDefaultJoinOrganization(deps: AdminUserEndpointDeps): Promise { + if (!deps.getCallerActiveOrganizationId) return undefined; + if (deps.getTenancy?.()?.isolationActive !== true) return undefined; + if ((deps.getMembershipPolicy?.() ?? 'auto') !== 'auto') return undefined; + const active = await deps.getCallerActiveOrganizationId(); + return typeof active === 'string' && active.length > 0 ? active : undefined; +} + /** * [#25338] The answer for a refused grant: the status and code the refusal * itself declared (403 `PERMISSION_DENIED` from the D12 gate, 400 diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2935b563dd6..0970ba73ea2 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3258,7 +3258,23 @@ export class AuthPlugin implements Plugin { // of recorded as the system. Attribution only: the route's own // authorization already happened above, and the writes stay system. const { status, body } = await runAttributedToUser(actor.id, () => - runAdminCreateUser({ ...adminUserDeps(), createGrant: createGrantFor(c) }, c.req.raw, actor), + runAdminCreateUser( + { + ...adminUserDeps(), + createGrant: createGrantFor(c), + // ADR-0093 D3 amendment — multi-org create-user defaults to the + // administrator's own current organization, read from the + // session this request already authenticated. + getCallerActiveOrganizationId: async () => { + const session = (await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers))) as + | { session?: { activeOrganizationId?: string | null } } + | null; + return session?.session?.activeOrganizationId ?? null; + }, + }, + c.req.raw, + actor, + ), ); return c.json(body, status as any); } catch (error) { diff --git a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts index 8cc896fbc59..6fca06a8be9 100644 --- a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts +++ b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts @@ -12,9 +12,9 @@ * `member`, inside the account's transaction. * * J1 chosen: the user lands with exactly one membership, `member` of the - * chosen organization. - * J2 none chosen: still member-less in multi-org — the reconciler's - * never-guess posture is unchanged. + * chosen organization — not the administrator's own. + * J2 left empty: the user joins the administrator's CURRENT organization + * (ADR-0093 D3 amendment), and only that one. * J3 an organization that does not exist is refused, and the account rolls * back with it — no member-less user is left behind. * @@ -35,11 +35,13 @@ async function findRows(ql: any, object: string, where: any, limit = 50): Promis return Array.isArray(rows) ? rows : (rows?.records ?? []); } -describe('Create User joins a chosen organization in multi-org mode', () => { +describe('Create User joins an organization in multi-org mode — the chosen one, or the administrator\'s current one', () => { let stack: VerifyStack; let ql: any; let adminToken: string; let orgId: string; + /** The administrator's own current organization — the default. */ + let adminOrgId: string; let priorScim: string | undefined; beforeAll(async () => { @@ -55,6 +57,16 @@ describe('Create User joins a chosen organization in multi-org mode', () => { }); expect(created.status, `org create: ${await created.clone().text()}`).toBe(200); orgId = ((await created.json()) as { id: string }).id; + + const home = await stack.apiAs(adminToken, 'POST', '/auth/organization/create', { + name: 'Admin Home', slug: 'admin-home-create-user', + }); + expect(home.status, `admin org create: ${await home.clone().text()}`).toBe(200); + adminOrgId = ((await home.json()) as { id: string }).id; + const active = await stack.apiAs(adminToken, 'POST', '/auth/organization/set-active', { + organizationId: adminOrgId, + }); + expect(active.status, `set-active: ${await active.clone().text()}`).toBe(200); }, 180_000); afterAll(async () => { @@ -70,7 +82,7 @@ describe('Create User joins a chosen organization in multi-org mode', () => { expect(tenancy.posture).toBe('isolated'); expect(tenancy.isolationActive).toBe(true); expect(tenancy.degraded).toBe(false); - // The reconciler's target: none, so J2's member-less outcome is the posture's, not an accident. + // The reconciler's target: none — so J2's membership is the default's doing, not the reconciler's. expect(await tenancy.defaultOrgId()).toBeNull(); }); @@ -89,15 +101,21 @@ describe('Create User joins a chosen organization in multi-org mode', () => { expect(members.map((m) => [m.organization_id, m.role])).toEqual([[orgId, 'member']]); }, 120_000); - it('J2 none chosen: member-less, as before — multi-org never guesses an organization', async () => { + it("J2 left empty: the user joins the administrator's current organization", async () => { + const session = await (await stack.apiAs(adminToken, 'GET', '/auth/get-session')).json(); + expect(session.session.activeOrganizationId).toBe(adminOrgId); + expect(adminOrgId).not.toBe(orgId); + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { - email: 'unbound.create-user-org@example.com', - password: 'Unbound!Pass123', + email: 'defaulted.create-user-org@example.com', + password: 'Default!Pass123', }); expect(res.status, await res.clone().text()).toBe(200); const body = await res.json(); - expect(body.data.membershipCreated).toBe(false); - expect(await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) })).toHaveLength(0); + expect(body.data.organizationId).toBe(adminOrgId); + expect(body.data.membershipCreated).toBe(true); + const members = await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) }); + expect(members.map((m) => [m.organization_id, m.role])).toEqual([[adminOrgId, 'member']]); }, 120_000); it('J3 an organization that does not exist: refused, and no user is created', async () => { From 565c24bc12be6ca21e45268bafa3493475684bf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:05:28 +0000 Subject: [PATCH 07/12] wip: ADR-0093 D3 amendment, translations, changeset --- .changeset/create-user-organization-join.md | 24 +++++++++++++------ ...3-tenancy-mode-and-membership-lifecycle.md | 23 ++++++++++++++++++ .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.changeset/create-user-organization-join.md b/.changeset/create-user-organization-join.md index 67f96d2d8f0..acba2c1e30b 100644 --- a/.changeset/create-user-organization-join.md +++ b/.changeset/create-user-organization-join.md @@ -4,14 +4,24 @@ '@objectstack/spec': patch --- -feat(plugin-auth, platform-objects): Create User can add the new user to an organization in the same step +feat(plugin-auth, platform-objects): in multi-org mode, Create User adds the new user to an organization in the same step, by default the administrator's current one -Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` accepts an optional `organizationId`; a request without it behaves exactly as before) +Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` accepts an optional `organizationId`, and in multi-org mode a request without one joins the administrator's current organization; single-org creation is unchanged) Metadata-protocol: none -In multi-org mode the membership reconciler binds no organization by itself (ADR-0093 D3: it never guesses one). So a user created from Setup had no membership, and the administrator had to open the organization's Members list and use Add Member as a second step. +In multi-org mode a user created from Setup had no membership, so the administrator had to open the organization's Members list and use Add Member as a second step, every time. ADR-0093 D3 gains an amendment for this one endpoint. -- **The route.** `/admin/create-user` takes an optional `organizationId` (`organization_id` is also accepted). When one is given, better-auth's own `addMember` adds the user to that organization as `member`, the same act as Add Member. It runs inside the account's transaction, so an organization that does not exist, or a membership limit, refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. -- **Policy.** An organization the administrator names is an explicit flow, so `membershipPolicy: 'invite-only'` admits it, as it admits Add Member (ADR-0093 D1). The role is always `member`: to grant a higher role, use Change Role as before. -- **Capability.** A server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists. -- **The dialog.** Setup's Create User dialog gains an optional **Organization** picker (an inline lookup on `sys_organization`), shown only in multi-org mode (`features.multiOrgEnabled`). Single-org mode already adds every new user to the default organization, so it does not show the picker. `@objectstack/spec` records the param under `multiOrgEnabled.gatedInputs`. +- **The dialog.** Setup's Create User dialog gains an optional **Organization** picker, shown only in multi-org mode (`features.multiOrgEnabled`). Left empty, the new user joins your current organization. Choose another organization to put them there instead. +- **The route.** `/admin/create-user` takes an optional `organizationId` (`organization_id` is also accepted). Which organization the user joins: + + | Request | Joins | + |:---|:---| + | `organizationId` given | that organization | + | omitted, multi-org, policy `auto` | the calling administrator's current (active) organization; none when they have none | + | omitted, single-org (a degraded wall included) | unchanged: the default organization, as before | + | omitted, policy `invite-only` | none, as before | + + The user joins as `member` through better-auth's own `addMember`, the same act as Add Member, inside the account's transaction. So a refused join (an organization that does not exist, a membership limit) refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. To grant a higher role, use Change Role as before. +- **Policy.** An organization the administrator names is an explicit flow, so `membershipPolicy: 'invite-only'` admits it, as it admits Add Member (ADR-0093 D1). A default is not an explicit choice, so `invite-only` applies no default. +- **Capability.** A server without the organization plugin, or without a unit of work for the creation, answers `501` to a request that names an organization, before any account exists. With nothing named, it creates the user without a membership, as before. +- **Unchanged.** Single-org creation, self sign-up, bulk import, SSO sign-in and invitations. `@objectstack/spec` records the new param under `multiOrgEnabled.gatedInputs`. diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 0b3ec608ab7..2f594bfbb41 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -235,6 +235,29 @@ already relies on ("whichever runs first wins and the other no-ops"). "Framework never guesses in multi mode" is therefore not a hedge — it is exactly the division of labor the production host already assumes. +#### D3 amendment (2026-10-24) — Setup's Create User joins an organization in multi mode + +Decided by the maintainer: in multi mode an administrator creating a user from +Setup had to add the account to an organization as a second step, every time. +`POST /admin/create-user` now joins the new user to an organization in the +same step, as `member`, through better-auth's `addMember` (the Add Member act), +inside the account's own transaction — a refused join creates no user: + +| Request | Joins | +|:---|:---| +| `organizationId` given | that organization, under either policy — an organization the administrator names is an explicit flow (D1) | +| omitted, multi mode (`tenancy.isolationActive`), policy `auto` | the **calling administrator's current (active) organization**; none when they have none | +| omitted, single mode (a degraded wall included) | unchanged — the reconciler binds the default org | +| omitted, policy `invite-only` | none — a default is not an explicit choice | + +The default is not the reconciler guessing a tenant: it is the organization +the administrator is working in, read from their own session — the same +fallback the Add Member action already has. The reconciler itself, and every +other creation path (self sign-up, bulk import, SSO JIT, host hooks), still +binds nothing in multi mode. The Create User dialog's organization picker is +shown only in multi mode (`features.multiOrgEnabled`, the same predicate as +`isolationActive`), and its help text names the default. + ### D4 — The `tenancy` kernel service Registered under the service name **`tenancy`**: diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index cf82a51282a..318656e6102 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -190,7 +190,7 @@ export const esESObjects: NonNullable = { }, organizationId: { label: "Organización", - helpText: "Opcional. Añade el nuevo usuario a esta organización como miembro, para que no tenga que añadirlo después en su lista de miembros." + helpText: "Opcional. El nuevo usuario se une a esta organización como miembro. Déjelo vacío para añadirlo a su organización actual." } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 0d48825dd75..1f057277342 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -190,7 +190,7 @@ export const jaJPObjects: NonNullable = { }, organizationId: { label: "組織", - helpText: "任意。新しいユーザーをメンバーとしてこの組織に追加します。後から組織のメンバー一覧で追加する必要はありません。" + helpText: "任意。新しいユーザーはメンバーとしてこの組織に参加します。空欄の場合は、現在の組織に追加されます。" } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index f5d422a9b2d..e15ae07364f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -190,7 +190,7 @@ export const zhCNObjects: NonNullable = { }, organizationId: { label: "组织", - helpText: "可选。将新用户以成员身份加入该组织,无需再到组织的成员列表中手动添加。" + helpText: "可选。新用户将以成员身份加入该组织。留空则加入你当前所在的组织。" } }, resultDialog: { From d09a863362314d3028039ffd2d273e5e197ce9b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:00:49 +0000 Subject: [PATCH 08/12] docs: Create User's organization join in authentication and tenancy-modes The membership callout said Create User writes no membership under a walled posture; it now joins the chosen organization or the administrator's current one, so both pages say so and the create-user section documents organizationId and the join table. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR --- content/docs/deployment/tenancy-modes.mdx | 5 ++++ content/docs/permissions/authentication.mdx | 31 +++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 4f17d88f413..ca27751feec 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -250,6 +250,11 @@ round-trip that phone-number-only accounts cannot complete. Its admit set is organization](/docs/permissions/authentication#attaching-an-existing-user-to-an-organization). +Under a walled posture the admin **Create User** flow is the one exception: it +joins the new user to the organization the administrator picks or, left empty, +to the administrator's own current organization — never a guess (ADR-0093 D3 +amendment). See [Create a User Directly](/docs/permissions/authentication#create-a-user-directly). + ### Membership policy | Policy | Behavior | diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 1eb68ac30b9..b1d231868ee 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -951,10 +951,10 @@ re-adjudicated. Under a walled tenancy posture the other paths do not reach: -- **Create User** binds a new account to an organization only when there is an - unambiguous one to bind to. Under the organization wall there is not, by - design, so no membership row is written — see [Membership: how new users join - an organization](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization). +- **Create User** joins only the account it is creating — to the organization + the administrator picks, or by default their own current one (see [Create a + User Directly](#create-a-user-directly)). It never attaches an account that + already exists. - **Hand-writing the membership row** is not available: `sys_member` is managed by better-auth and generic CRUD on it is suppressed under the ADR-0010 lock. - **Invitations** need an email round-trip, which phone-number-only accounts @@ -987,12 +987,31 @@ const response = await fetch('http://localhost:3000/api/v1/auth/admin/create-use email: 'new.user@example.com', // and/or phoneNumber (requires plugins.phoneNumber) name: 'New User', generatePassword: true, // or pass an explicit `password` - mustChangePassword: true // default true + mustChangePassword: true, // default true + organizationId: 'org_123' // optional — see "Which organization the user joins" }) }); -// → { success, data: { user, temporaryPassword? } } +// → { success, data: { user, organizationId?, membershipCreated, temporaryPassword? } } ``` +**Which organization the user joins.** The new user joins as `member`, in the +same step as the account: + +| Request | Joins | +|:---|:---| +| `organizationId` given | that organization | +| omitted, under a walled posture (`group` / `isolated`), policy `auto` | the calling administrator's **current** (active) organization; none when they have none | +| omitted, `single` posture (a degraded wall included) | the default organization, as for every new user ([Membership](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization)) | +| omitted, policy `invite-only` | none | + +The join is better-auth's `addMember`, the same act as **Add Member**, inside +the account's transaction: an organization that does not exist or is at its +membership limit refuses the call with that verdict, and **no user is +created**. On the Users list the **Create User** dialog shows an optional +**Organization** picker under a walled posture only; left empty, the default +above applies. To grant a higher organization role, use **Change Role** +afterwards. + A generated temporary password is returned **once** in the response (the Users-list action reveals it in a one-shot dialog) and is never persisted or logged. While `mustChangePassword` is set, the user is blocked with From 0a90e361ad890607036b7b33d6628f2b4771f395 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:09:19 +0000 Subject: [PATCH 09/12] test: pin the create-user organization fakes to the engine update contract; reword a docs line The two new engine fakes in admin-user-endpoints.test.ts now open update() with assertEngineUpdateDispatch, as check:engine-double-contract requires, and its pinned ledger records the grown coverage. The authentication page no longer adds role-word occurrences (ADR-0090 D3). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR --- content/docs/permissions/authentication.mdx | 4 ++-- .../plugin-auth/src/admin-user-endpoints.test.ts | 14 ++++++++++++-- scripts/engine-double-contract.pinned.json | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index b1d231868ee..377aa6f37c9 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -1009,8 +1009,8 @@ the account's transaction: an organization that does not exist or is at its membership limit refuses the call with that verdict, and **no user is created**. On the Users list the **Create User** dialog shows an optional **Organization** picker under a walled posture only; left empty, the default -above applies. To grant a higher organization role, use **Change Role** -afterwards. +above applies. Raising the new member above `member` stays a separate, audited +step on the organization's Members list. A generated temporary password is returned **once** in the response (the Users-list action reveals it in a one-shot dialog) and is never persisted or diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 7512162a530..9f42f5301bb 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1303,7 +1303,14 @@ describe('create-user: an optional organization the new user joins as a member', const callerActiveOrganizationId = vi.fn(async () => opts.callerActiveOrg ?? null); const m = makeDeps({ getAuthApi: async () => ({ createUser, addMember }) as any, - getDataEngine: () => ({ update: vi.fn(async () => ({})), insert: engineInsert, find }), + getDataEngine: () => ({ + update: vi.fn(async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }), + insert: engineInsert, + find, + }), getTenancy: () => ({ defaultOrgId: async () => null, isolationActive: opts.isolationActive ?? true }), ...(opts.policy ? { getMembershipPolicy: () => opts.policy } : {}), ...(opts.callerActiveOrg !== undefined ? { getCallerActiveOrganizationId: callerActiveOrganizationId } : {}), @@ -1489,7 +1496,10 @@ describe('create-user: an optional organization the new user joins as a member', const callerActive = vi.fn(async () => 'org_elsewhere'); const m = makeDeps({ getDataEngine: () => ({ - update: vi.fn(async () => ({})), + update: vi.fn(async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }), insert, find: vi.fn(async (object: string) => (object === 'sys_organization' ? [{ id: 'org_default', slug: 'default' }] : [])), }), diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 12e707eb64b..eb480adc5b2 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2984,7 +2984,7 @@ { "file": "packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts", "verb": "update", - "pinned": 1 + "pinned": 3 }, { "file": "packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts", From 267575e3f8ca6c6c1d92b3f79f1176107f5f936b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:47:17 +0000 Subject: [PATCH 10/12] feat(plugin-auth): multi-org Create User joins the administrator's current organization, no picker Reworked per the maintainer's ruling: under a walled posture the new user always joins the calling administrator's current organization as member (better-auth addMember, inside the account's transaction); invite-only still joins; an administrator with no current organization is refused 400 NO_ACTIVE_ORGANIZATION. The Organization picker, the organizationId body parameter, its multiOrgEnabled gated-input row and the four translations are removed; the ADR-0093 D3 amendment, docs and changeset shrink to that one rule. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR --- .changeset/create-user-organization-join.md | 28 +-- content/docs/deployment/tenancy-modes.mdx | 5 +- content/docs/permissions/authentication.mdx | 37 ++-- ...3-tenancy-mode-and-membership-lifecycle.md | 45 ++-- .../apps/translations/en.objects.generated.ts | 4 - .../translations/es-ES.objects.generated.ts | 4 - .../translations/ja-JP.objects.generated.ts | 4 - .../translations/zh-CN.objects.generated.ts | 4 - .../src/identity/sys-user.object.ts | 20 -- .../src/admin-user-endpoints.test.ts | 199 ++++++------------ .../plugin-auth/src/admin-user-endpoints.ts | 126 +++++------ .../plugins/plugin-auth/src/auth-plugin.ts | 2 +- ...ate-user-organization-join.dogfood.test.ts | 94 ++++----- .../spec/src/kernel/public-auth-features.ts | 3 - 14 files changed, 211 insertions(+), 364 deletions(-) diff --git a/.changeset/create-user-organization-join.md b/.changeset/create-user-organization-join.md index acba2c1e30b..349a056583a 100644 --- a/.changeset/create-user-organization-join.md +++ b/.changeset/create-user-organization-join.md @@ -1,27 +1,19 @@ --- '@objectstack/plugin-auth': patch -'@objectstack/platform-objects': patch -'@objectstack/spec': patch --- -feat(plugin-auth, platform-objects): in multi-org mode, Create User adds the new user to an organization in the same step, by default the administrator's current one +feat(plugin-auth): in multi-org mode, Create User adds the new user to the administrator's current organization -Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` accepts an optional `organizationId`, and in multi-org mode a request without one joins the administrator's current organization; single-org creation is unchanged) +Clause-②: yes (narrowing): under a walled posture `POST /api/v1/auth/admin/create-user` now refuses `400 NO_ACTIVE_ORGANIZATION` when the calling administrator has no current organization, where it used to create a member-less account; a request from an administrator in an organization now also adds the new user to it (widening) Metadata-protocol: none -In multi-org mode a user created from Setup had no membership, so the administrator had to open the organization's Members list and use Add Member as a second step, every time. ADR-0093 D3 gains an amendment for this one endpoint. + -- **The dialog.** Setup's Create User dialog gains an optional **Organization** picker, shown only in multi-org mode (`features.multiOrgEnabled`). Left empty, the new user joins your current organization. Choose another organization to put them there instead. -- **The route.** `/admin/create-user` takes an optional `organizationId` (`organization_id` is also accepted). Which organization the user joins: +**BREAKING** — under a walled tenancy posture (`group` / `isolated`), `/admin/create-user` no longer creates a user with no organization. The administrator's current (active) organization is the new user's organization, the way mainstream admin consoles add people to the workspace you are standing in. ADR-0093 D3 gains an amendment for this one route. - | Request | Joins | - |:---|:---| - | `organizationId` given | that organization | - | omitted, multi-org, policy `auto` | the calling administrator's current (active) organization; none when they have none | - | omitted, single-org (a degraded wall included) | unchanged: the default organization, as before | - | omitted, policy `invite-only` | none, as before | - - The user joins as `member` through better-auth's own `addMember`, the same act as Add Member, inside the account's transaction. So a refused join (an organization that does not exist, a membership limit) refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. To grant a higher role, use Change Role as before. -- **Policy.** An organization the administrator names is an explicit flow, so `membershipPolicy: 'invite-only'` admits it, as it admits Add Member (ADR-0093 D1). A default is not an explicit choice, so `invite-only` applies no default. -- **Capability.** A server without the organization plugin, or without a unit of work for the creation, answers `501` to a request that names an organization, before any account exists. With nothing named, it creates the user without a membership, as before. -- **Unchanged.** Single-org creation, self sign-up, bulk import, SSO sign-in and invitations. `@objectstack/spec` records the new param under `multiOrgEnabled.gatedInputs`. +- **The join.** The new user joins the calling administrator's current organization as `member`, through better-auth's own `addMember`, the same act as Add Member, inside the account's transaction. A refused join (a membership limit) refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. +- **No choice to make.** The dialog and the route take no organization. To add a user to another organization, switch to it first. +- **No current organization.** FROM a member-less account TO `400 NO_ACTIVE_ORGANIZATION`, before any account exists. The fix is to switch to the organization the user belongs to, then create them. +- **Policy.** Under `membershipPolicy: 'invite-only'` the user still joins: creating a user inside your organization is an explicit act, like Add Member (ADR-0093 D1). +- **Capability.** A walled server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists. +- **Unchanged.** The `single` posture (a degraded wall included), self sign-up, bulk import, SSO sign-in and invitations. diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index ca27751feec..a47151eb353 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -251,9 +251,8 @@ organization](/docs/permissions/authentication#attaching-an-existing-user-to-an- Under a walled posture the admin **Create User** flow is the one exception: it -joins the new user to the organization the administrator picks or, left empty, -to the administrator's own current organization — never a guess (ADR-0093 D3 -amendment). See [Create a User Directly](/docs/permissions/authentication#create-a-user-directly). +joins the new user to the administrator's own current organization — the one +they are working in, never a guess (ADR-0093 D3 amendment). See [Create a User Directly](/docs/permissions/authentication#create-a-user-directly). ### Membership policy diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 377aa6f37c9..ccce0f8d44b 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -951,9 +951,9 @@ re-adjudicated. Under a walled tenancy posture the other paths do not reach: -- **Create User** joins only the account it is creating — to the organization - the administrator picks, or by default their own current one (see [Create a - User Directly](#create-a-user-directly)). It never attaches an account that +- **Create User** joins only the account it is creating — to the + administrator's current organization (see [Create a User + Directly](#create-a-user-directly)). It never attaches an account that already exists. - **Hand-writing the membership row** is not available: `sys_member` is managed by better-auth and generic CRUD on it is suppressed under the ADR-0010 lock. @@ -987,30 +987,27 @@ const response = await fetch('http://localhost:3000/api/v1/auth/admin/create-use email: 'new.user@example.com', // and/or phoneNumber (requires plugins.phoneNumber) name: 'New User', generatePassword: true, // or pass an explicit `password` - mustChangePassword: true, // default true - organizationId: 'org_123' // optional — see "Which organization the user joins" + mustChangePassword: true // default true }) }); // → { success, data: { user, organizationId?, membershipCreated, temporaryPassword? } } ``` -**Which organization the user joins.** The new user joins as `member`, in the -same step as the account: - -| Request | Joins | -|:---|:---| -| `organizationId` given | that organization | -| omitted, under a walled posture (`group` / `isolated`), policy `auto` | the calling administrator's **current** (active) organization; none when they have none | -| omitted, `single` posture (a degraded wall included) | the default organization, as for every new user ([Membership](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization)) | -| omitted, policy `invite-only` | none | +**Which organization the user joins.** Under a walled posture (`group` / +`isolated`) the new user joins the calling administrator's **current** +(active) organization as `member`, in the same step as the account — the +organization the administrator has switched to in the console. There is no +organization choice: to add someone to another organization, switch to it +first. An administrator with no current organization is refused with +`400 NO_ACTIVE_ORGANIZATION` and no user is created. Under the `single` posture +(a degraded wall included) the user joins the default organization, as every new +user does ([Membership](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization)). The join is better-auth's `addMember`, the same act as **Add Member**, inside -the account's transaction: an organization that does not exist or is at its -membership limit refuses the call with that verdict, and **no user is -created**. On the Users list the **Create User** dialog shows an optional -**Organization** picker under a walled posture only; left empty, the default -above applies. Raising the new member above `member` stays a separate, audited -step on the organization's Members list. +the account's transaction: an organization at its membership limit refuses the +call with that verdict, and **no user is created**. It applies under +`membershipPolicy: 'invite-only'` too. Raising the new member above `member` +stays a separate, audited step on the organization's Members list. A generated temporary password is returned **once** in the response (the Users-list action reveals it in a one-shot dialog) and is never persisted or diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 2f594bfbb41..dc690be7941 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -235,28 +235,29 @@ already relies on ("whichever runs first wins and the other no-ops"). "Framework never guesses in multi mode" is therefore not a hedge — it is exactly the division of labor the production host already assumes. -#### D3 amendment (2026-10-24) — Setup's Create User joins an organization in multi mode - -Decided by the maintainer: in multi mode an administrator creating a user from -Setup had to add the account to an organization as a second step, every time. -`POST /admin/create-user` now joins the new user to an organization in the -same step, as `member`, through better-auth's `addMember` (the Add Member act), -inside the account's own transaction — a refused join creates no user: - -| Request | Joins | -|:---|:---| -| `organizationId` given | that organization, under either policy — an organization the administrator names is an explicit flow (D1) | -| omitted, multi mode (`tenancy.isolationActive`), policy `auto` | the **calling administrator's current (active) organization**; none when they have none | -| omitted, single mode (a degraded wall included) | unchanged — the reconciler binds the default org | -| omitted, policy `invite-only` | none — a default is not an explicit choice | - -The default is not the reconciler guessing a tenant: it is the organization -the administrator is working in, read from their own session — the same -fallback the Add Member action already has. The reconciler itself, and every -other creation path (self sign-up, bulk import, SSO JIT, host hooks), still -binds nothing in multi mode. The Create User dialog's organization picker is -shown only in multi mode (`features.multiOrgEnabled`, the same predicate as -`isolationActive`), and its help text names the default. +#### D3 amendment (2026-10-24) — Setup's Create User joins the administrator's current organization + +Decided by the maintainer (「25626 按当前组织」): in multi mode an administrator +always works inside one current organization — the browser switches between +them — so a user they create from Setup belongs to that organization, the way +mainstream admin consoles behave. `POST /admin/create-user` under a walled +posture (`tenancy.isolationActive`) therefore joins the new user to the +**calling administrator's current (active) organization**, as `member`, through +better-auth's `addMember` (the Add Member act) inside the account's own +transaction; a refused join creates no user. There is no organization choice: +to add a user to another organization, switch to it first. + +- `membershipPolicy: 'invite-only'` still joins — creating a user inside your + organization is an explicit act, like Add Member (D1). +- An administrator with no current organization is refused + (`400 NO_ACTIVE_ORGANIZATION`, "switch to an organization first"), never + handed a member-less account. +- Single mode (a degraded wall included) is unchanged: the reconciler binds the + default org. + +This is not the reconciler guessing a tenant: the reconciler, and every other +creation path (self sign-up, bulk import, SSO JIT, host hooks), still binds +nothing in multi mode. ### D4 — The `tenancy` kernel service diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index a76f11cae60..e0a569e11f3 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -187,10 +187,6 @@ export const enObjects: NonNullable = { permissionSet: { label: "Permission Set", helpText: "Optional. Grants this permission set to the new user, so they can open their apps on first sign-in." - }, - organizationId: { - label: "Organization", - helpText: "Optional. The new user joins this organization as a member. Leave empty to add them to your current organization." } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index fc5c038272e..f0cac66c7f0 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -187,10 +187,6 @@ export const esESObjects: NonNullable = { permissionSet: { label: "Conjunto de permisos", helpText: "Opcional. Concede este conjunto de permisos al nuevo usuario para que pueda abrir sus aplicaciones desde el primer inicio de sesión." - }, - organizationId: { - label: "Organización", - helpText: "Opcional. El nuevo usuario se une a esta organización como miembro. Déjelo vacío para añadirlo a su organización actual." } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index ec17042126b..5532b684250 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -187,10 +187,6 @@ export const jaJPObjects: NonNullable = { permissionSet: { label: "権限セット", helpText: "任意。この権限セットを新しいユーザーに付与し、初回サインインから自分のアプリを開けるようにします。" - }, - organizationId: { - label: "組織", - helpText: "任意。新しいユーザーはメンバーとしてこの組織に参加します。空欄の場合は、現在の組織に追加されます。" } }, resultDialog: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index e01dffc38b6..eb2ab57c779 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -187,10 +187,6 @@ export const zhCNObjects: NonNullable = { permissionSet: { label: "权限集", helpText: "可选。将该权限集授予新用户,使其首次登录即可打开自己的应用。" - }, - organizationId: { - label: "组织", - helpText: "可选。新用户将以成员身份加入该组织。留空则加入你当前所在的组织。" } }, resultDialog: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index d069acc8fdc..616eb62d078 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -304,26 +304,6 @@ export const SysUser = ObjectSchema.create({ required: false, helpText: 'Optional. Grants this permission set to the new user, so they can open their apps on first sign-in.', }, - // Optional: join the new user to one organization as a `member` in the - // same step — the organization's Add Member action folded into the - // creation. Left empty, the route defaults to the administrator's own - // current organization (ADR-0093 D3 amendment); a param `defaultValue` - // is a literal, so the default lives on the server and the help text - // says so. INLINE with `reference`, so the dialog renders an - // organization picker instead of a raw id box. The route joins through - // better-auth's `addMember` in the account's own transaction: a - // refused join creates no user. - { - name: 'organizationId', - type: 'lookup', - reference: 'sys_organization', - label: 'Organization', - required: false, - helpText: 'Optional. The new user joins this organization as a member. Leave empty to add them to your current organization.', - // Offered only where it is needed: single-org already binds every - // new user to the default organization by itself. - requiresFeature: 'multiOrgEnabled', - }, ], resultDialog: { title: 'User Created', diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 9f42f5301bb..b9052f5dcd7 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1255,14 +1255,15 @@ describe('create-user: an optional permission set, granted under the caller\'s o }); }); -// ── create-user's optional organization ──────────────────────────────────── +// ── multi-org: the administrator's current organization ───────────────────── // -// The organization's Add Member action folded into the creation: better-auth's -// own `addMember` joins the new user as a `member`, inside the same unit of -// work as the account, so a refused join leaves no user behind. In multi-org -// mode this is the only way the new user gets a membership at creation — the -// reconciler never guesses a target org there (ADR-0093 D3). -describe('create-user: an optional organization the new user joins as a member', () => { +// In multi-org mode the new user joins the organization the administrator is +// working in, as a `member` (ADR-0093 D3 amendment) — the organization's Add +// Member action folded into the creation. better-auth's own `addMember` runs +// inside the account's unit of work, so a refused join leaves no user behind, +// and an administrator with no current organization is refused before any +// account exists. Single-org creation is unchanged. +describe('create-user in multi-org mode: the new user joins the administrator\'s current organization', () => { function makeJoinDeps(opts: { members?: Array<{ organization_id: string; user_id: string }>; addMember?: (...args: any[]) => Promise; @@ -1305,9 +1306,9 @@ describe('create-user: an optional organization the new user joins as a member', getAuthApi: async () => ({ createUser, addMember }) as any, getDataEngine: () => ({ update: vi.fn(async (_obj: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - return {}; - }), + assertEngineUpdateDispatch(data, options); + return {}; + }), insert: engineInsert, find, }), @@ -1319,170 +1320,97 @@ describe('create-user: an optional organization the new user joins as a member', return { ...m, createUser, addMember, engineInsert, outcomes, runAtomically, resolveActorContext, callerActiveOrganizationId }; } - it('none chosen and no default wired: addMember is never called and the unit of work is not opened', async () => { - const j = makeJoinDeps(); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'n@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(200); - expect(j.addMember).not.toHaveBeenCalled(); - expect(j.runAtomically).not.toHaveBeenCalled(); - expect((res.body.data as any).membershipCreated).toBe(false); - }); - - it('chosen: joins it as a member through addMember inside the unit of work, and reports it', async () => { - const j = makeJoinDeps(); - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'o@b.co', password: 'Explicit1!', organizationId: ' org_b ' }), - ACTOR, - ); + it('joins the current organization as a member through addMember, inside the unit of work, and reports it', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'd@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(200); expect(j.outcomes).toEqual(['committed']); expect(j.addMember).toHaveBeenCalledTimes(1); - expect(j.addMember.mock.calls[0][0]).toEqual({ body: { userId: 'user-9', role: 'member', organizationId: 'org_b' } }); + expect(j.addMember.mock.calls[0][0]).toEqual({ + body: { userId: 'user-9', role: 'member', organizationId: 'org_current' }, + }); // The caller's context is not needed for the join: it is the vendor's own act. expect(j.resolveActorContext).not.toHaveBeenCalled(); - // The reconciler's own insert never ran: one membership, the chosen one. + // The reconciler's own insert never ran: one membership, the current organization's. expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_member')).toHaveLength(0); - const data = res.body.data as any; - expect(data.organizationId).toBe('org_b'); - expect(data.membershipCreated).toBe(true); + expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); const audit = callsOf(j.engineInsert).find(([object]) => object === 'sys_audit_log')![1]; - expect(JSON.parse(audit.metadata)).toMatchObject({ organizationId: 'org_b', membershipCreated: true }); + expect(JSON.parse(audit.metadata)).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); }); - it('chosen under invite-only: still joined — an organization the administrator named is an explicit flow', async () => { - const j = makeJoinDeps({ policy: 'invite-only' }); + it('an organizationId in the body chooses nothing: the current organization is joined', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); const res = await runAdminCreateUser( j.deps, - makeRequest({ email: 'i@b.co', password: 'Explicit1!', organizationId: 'org_a' }), + makeRequest({ email: 'c@b.co', password: 'Explicit1!', organizationId: 'org_b' }), ACTOR, ); expect(res.status).toBe(200); + expect(j.addMember.mock.calls[0][0].body.organizationId).toBe('org_current'); + }); + + it('under invite-only: still joined — creating a user in your organization is an explicit act', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', policy: 'invite-only' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'i@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); expect(j.addMember).toHaveBeenCalledTimes(1); expect((res.body.data as any).membershipCreated).toBe(true); }); + it('an administrator with no current organization is refused 400 before any account exists', async () => { + const j = makeJoinDeps({ callerActiveOrg: null }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'z@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('NO_ACTIVE_ORGANIZATION'); + expect(res.body.error?.message).toContain('Switch to the organization'); + expect(j.createUser).not.toHaveBeenCalled(); + expect(j.addMember).not.toHaveBeenCalled(); + }); + it('a membership already there is respected: no second addMember, membershipCreated false', async () => { - const j = makeJoinDeps({ members: [{ organization_id: 'org_a', user_id: 'user-9' }] }); - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'e@b.co', password: 'Explicit1!', organizationId: 'org_a' }), - ACTOR, - ); + const j = makeJoinDeps({ + callerActiveOrg: 'org_current', + members: [{ organization_id: 'org_current', user_id: 'user-9' }], + }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'e@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(200); expect(j.addMember).not.toHaveBeenCalled(); - expect(res.body.data).toMatchObject({ organizationId: 'org_a', membershipCreated: false }); + expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: false }); }); it('refused by the vendor: its status and code, the account rolls back, nothing is audited', async () => { const j = makeJoinDeps({ + callerActiveOrg: 'org_current', addMember: async () => { - throw { statusCode: 400, body: { code: 'ORGANIZATION_NOT_FOUND', message: 'Organization not found' } }; + throw { statusCode: 403, body: { code: 'ORGANIZATION_MEMBERSHIP_LIMIT_REACHED', message: 'Membership limit reached' } }; }, }); - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'r@b.co', password: 'Explicit1!', organizationId: 'org_gone' }), - ACTOR, - ); - expect(res.status).toBe(400); - expect(res.body.error?.code).toBe('ORGANIZATION_NOT_FOUND'); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'r@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(403); + expect(res.body.error?.code).toBe('ORGANIZATION_MEMBERSHIP_LIMIT_REACHED'); expect(res.body.error?.message).toContain('no user was created'); expect(j.outcomes).toEqual(['rolled back']); expect(res.body.data).toBeUndefined(); expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_audit_log')).toHaveLength(0); }); - it('a host with no unit of work refuses 501 before any account exists', async () => { - const j = makeJoinDeps({ withSeam: false }); - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'w@b.co', password: 'Explicit1!', organizationId: 'org_a' }), - ACTOR, - ); + it('a host with no unit of work refuses 501 before any account exists — never a member-less user', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', withSeam: false }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'w@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(501); expect(j.createUser).not.toHaveBeenCalled(); }); it('a server without the organization plugin refuses 501 before any account exists', async () => { - const j = makeJoinDeps(); + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); const createUser = vi.fn(); j.deps.getAuthApi = async () => ({ createUser }) as any; - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'p@b.co', password: 'Explicit1!', organizationId: 'org_a' }), - ACTOR, - ); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'p@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(501); expect(createUser).not.toHaveBeenCalled(); }); - it('an organizationId that is not a string is refused 400 before any account exists', async () => { - const j = makeJoinDeps(); - const res = await runAdminCreateUser( - j.deps, - makeRequest({ email: 'x@b.co', password: 'Explicit1!', organizationId: { id: 'org_a' } }), - ACTOR, - ); - expect(res.status).toBe(400); - expect(j.createUser).not.toHaveBeenCalled(); - }); - - // ── left empty: the administrator's current organization, multi-org only ── - - it('multi-org, left empty: the user joins the administrator\'s current organization', async () => { - const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'd@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(200); - expect(j.outcomes).toEqual(['committed']); - expect(j.addMember.mock.calls[0][0]).toEqual({ - body: { userId: 'user-9', role: 'member', organizationId: 'org_current' }, - }); - expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); - }); - - it('multi-org, left empty, an administrator with no current organization: member-less, as before', async () => { - const j = makeJoinDeps({ callerActiveOrg: null }); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'z@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(200); - expect(j.callerActiveOrganizationId).toHaveBeenCalledTimes(1); - expect(j.addMember).not.toHaveBeenCalled(); - expect(j.runAtomically).not.toHaveBeenCalled(); - expect((res.body.data as any).membershipCreated).toBe(false); - }); - - it('multi-org, left empty, under invite-only: no default — a default is not an explicit choice', async () => { - const j = makeJoinDeps({ callerActiveOrg: 'org_current', policy: 'invite-only' }); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'q@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(200); - expect(j.callerActiveOrganizationId).not.toHaveBeenCalled(); - expect(j.addMember).not.toHaveBeenCalled(); - expect((res.body.data as any).membershipCreated).toBe(false); - }); - - it('multi-org, left empty, a host that cannot join: the user is still created, member-less — never a 501', async () => { - const j = makeJoinDeps({ callerActiveOrg: 'org_current', withSeam: false }); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'k@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(200); - expect(j.addMember).not.toHaveBeenCalled(); - expect((res.body.data as any).membershipCreated).toBe(false); - }); - - it('multi-org, left empty, the current organization refuses the join: no user, and the message says so', async () => { - const j = makeJoinDeps({ - callerActiveOrg: 'org_current', - addMember: async () => { - throw { statusCode: 403, body: { code: 'ORGANIZATION_MEMBERSHIP_LIMIT_REACHED', message: 'Membership limit reached' } }; - }, - }); - const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'l@b.co', password: 'Explicit1!' }), ACTOR); - expect(res.status).toBe(403); - expect(res.body.error?.code).toBe('ORGANIZATION_MEMBERSHIP_LIMIT_REACHED'); - expect(res.body.error?.message).toContain('no user was created'); - expect(j.outcomes).toEqual(['rolled back']); - }); - - it('single-org (or a degraded wall): the default is never read and creation is unchanged', async () => { + it('single-org (or a degraded wall): the current organization is never read and creation is unchanged', async () => { const j = makeJoinDeps({ callerActiveOrg: 'org_current', isolationActive: false }); const res = await runAdminCreateUser(j.deps, makeRequest({ email: 's@b.co', password: 'Explicit1!' }), ACTOR); expect(res.status).toBe(200); @@ -1491,7 +1419,7 @@ describe('create-user: an optional organization the new user joins as a member', expect(j.runAtomically).not.toHaveBeenCalled(); }); - it('single-org: the reconciler still binds the default organization, with the default dep wired', async () => { + it('single-org: the reconciler still binds the default organization, with the current-organization dep wired', async () => { const insert = vi.fn(async () => ({})); const callerActive = vi.fn(async () => 'org_elsewhere'); const m = makeDeps({ @@ -1514,15 +1442,8 @@ describe('create-user: an optional organization the new user joins as a member', expect(member?.[1]).toMatchObject({ organization_id: 'org_default', user_id: 'user-9', role: 'member' }); }); - it('the Create User dialog offers it as an optional organization picker, only in multi-org mode', () => { + it('the Create User dialog asks for no organization', () => { const action = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); - const param = (action?.params ?? []).find((p) => p.name === 'organizationId'); - expect(param).toMatchObject({ - type: 'lookup', - reference: 'sys_organization', - required: false, - }); - // `requiresFeature` is lowered into `visible` at parse time. - expect(JSON.stringify((param as { visible?: unknown }).visible)).toContain('features.multiOrgEnabled'); + expect((action?.params ?? []).map((p) => p.name ?? p.field)).not.toContain('organizationId'); }); }); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index d53656c9fc1..69e97bdef46 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -105,10 +105,9 @@ export interface AdminUserEndpointDeps { defaultOrgId(): Promise; /** * True iff an organization wall is in force — the same predicate - * `features.multiOrgEnabled` (and so the Create User dialog's organization - * picker) is derived from. create-user's multi-org default reads it, so - * the default applies exactly where the picker is shown. Optional for lean - * mocks: absent ⇒ not multi-org ⇒ no default. + * `features.multiOrgEnabled` is derived from. create-user's multi-org join + * of the administrator's current organization reads it. Optional for lean + * mocks: absent ⇒ not multi-org ⇒ no join. */ readonly isolationActive?: boolean; } | undefined; @@ -138,16 +137,16 @@ export interface AdminUserEndpointDeps { * request that names a set is refused 501 and no user is created; a request * that names none never reads it. See {@link AdminCreateGrantSeam}. * - * Its unit of work also carries create-user's optional `organizationId`, so - * a refused join rolls the account back as a refused grant does. Absent ⇒ a - * request that names an organization is refused 501 the same way. + * Its unit of work also carries the multi-org join of the administrator's + * current organization, so a refused join rolls the account back as a + * refused grant does. Absent ⇒ a multi-org creation is refused 501. */ createGrant?: AdminCreateGrantSeam; /** * The CALLING administrator's current (active) organization, read from their - * session, or `null` when they have none. create-user's default organization - * in multi-org mode (ADR-0093 D3 amendment). Absent ⇒ no default: a request - * that names no organization joins none, as before. + * session, or `null` when they have none. In multi-org mode the new user + * joins it, and `null` refuses the creation (ADR-0093 D3 amendment). + * Absent ⇒ no join: the creation behaves as before. */ getCallerActiveOrganizationId?(): Promise; logger?: { warn(msg: string): void }; @@ -689,32 +688,36 @@ export async function runAdminCreateUser( const permissionSet = typeof rawPermissionSet === 'string' && rawPermissionSet.trim().length > 0 ? rawPermissionSet.trim() : undefined; - // The optional organization the administrator chose for the new user. Empty - // means none chosen: in multi-org mode the administrator's own current - // organization is the default (see `resolveDefaultJoinOrganization`), and - // otherwise the deployment's own membership reconciliation applies. - const rawOrganizationId = body.organizationId ?? body.organization_id; - if (rawOrganizationId !== undefined && rawOrganizationId !== null && typeof rawOrganizationId !== 'string') { - return badRequest('organizationId must be the id of an organization'); - } - const organizationId = - typeof rawOrganizationId === 'string' && rawOrganizationId.trim().length > 0 ? rawOrganizationId.trim() : undefined; - const resolved = await resolvePassword(deps, body, 'password'); if ('status' in resolved) return resolved; - // Joining an organization is settled BEFORE the account exists too: it runs - // in the same unit of work as the account, so a host that cannot run one, or - // has no organization plugin, refuses a CHOSEN organization with nothing - // created. A defaulted one is skipped instead — nobody asked for it. - let join: { seam: AdminCreateGrantSeam; addMember: AddMemberCapableApi['addMember'] } | undefined; - const joinOrganizationId = organizationId ?? (await resolveDefaultJoinOrganization(deps)); - if (joinOrganizationId !== undefined) { + // ADR-0093 D3 amendment — in multi-org mode the new user joins the + // administrator's CURRENT organization, settled BEFORE the account exists: + // the join runs in the account's own unit of work, so an administrator with + // no current organization, or a host that cannot run the join, is refused + // with nothing created — never handed a member-less account. + let join: + | { seam: AdminCreateGrantSeam; addMember: AddMemberCapableApi['addMember']; organizationId: string } + | undefined; + if (multiOrgCreationJoinsCurrentOrganization(deps)) { + const organizationId = await deps.getCallerActiveOrganizationId!(); + if (typeof organizationId !== 'string' || organizationId.length === 0) { + return { + status: 400, + body: { + success: false, + error: { + code: 'NO_ACTIVE_ORGANIZATION', + message: + 'You are not working in an organization, so no user was created. ' + + 'Switch to the organization the new user belongs to, then create them there.', + }, + }, + }; + } const seam = deps.createGrant; const authApi = (await deps.getAuthApi()) as Partial; - const capable = Boolean(seam) && typeof authApi.addMember === 'function'; - if (capable) join = { seam: seam!, addMember: authApi.addMember!.bind(authApi) }; - else if (organizationId !== undefined) { + if (!seam || typeof authApi.addMember !== 'function') { return { status: 501, body: { @@ -722,12 +725,12 @@ export async function runAdminCreateUser( error: { code: 'NOT_IMPLEMENTED', message: - 'This server cannot add a user to an organization while creating them, so no user was created. ' - + "Create the user without an organization, then use Add Member on the organization's Members list.", + 'This server cannot add a new user to your organization while creating them, so no user was created.', }, }, }; } + join = { seam, addMember: authApi.addMember.bind(authApi), organizationId }; } // [#25338] Everything the grant needs is settled BEFORE the account exists, @@ -800,15 +803,14 @@ export async function runAdminCreateUser( ? await stampMustChangePassword(deps, userId, true) : false; - // An organization the administrator chose — or, in multi-org mode, their - // current one by default — is joined explicitly (the add-member flow, - // ADR-0093 D1, D3 amendment). Otherwise match the invite / add-member + // In multi-org mode the administrator's current organization is joined + // explicitly (the add-member act, ADR-0093 D1, D3 amendment). Otherwise match the invite / add-member // flows: give the new user a membership so a single-org deployment shows // them under the Default Organization instead of as a member-less account. // No-op in multi-org (≥2 orgs), and no-op under // `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper. - const membership = join && joinOrganizationId !== undefined - ? await joinChosenOrganization(deps, join.addMember, userId, joinOrganizationId) + const membership = join + ? await joinCurrentOrganization(deps, join.addMember, userId, join.organizationId) : await bindUserToSoleOrganization(deps, userId); // [#25338] The grant, under the administrator's own context (see @@ -901,7 +903,7 @@ class CreationRefused extends Error { } /** - * Join the new user to the organization the administrator chose, as a + * Join the new user to the administrator's current organization, as a * `member` — the same act as the organization's Add Member action, folded into * the creation so the administrator does not have to perform it afterwards. * @@ -911,16 +913,16 @@ class CreationRefused extends Error { * vendor's. A refusal throws, and the unit of work around the creation rolls * the account back with it. * - * ADR-0093: this is NOT the reconciler choosing a target org (D3 — multi mode - * never guesses); the administrator named it, which makes it an explicit flow - * that `membershipPolicy: 'invite-only'` admits (D1). The role is `member`, the - * D1 default: elevation stays the separate, audited `update-member-role`. + * ADR-0093 D3 amendment: this is NOT the reconciler guessing a tenant (D3 — + * it still binds nothing in multi mode); it is the organization the + * administrator is working in, which makes it an explicit flow that + * `membershipPolicy: 'invite-only'` admits (D1). The role is `member`, the D1 + * default: elevation stays the separate, audited `update-member-role`. * - * A membership already there (in single-org mode the `user.create.after` - * reconciler can bind the default organization first) is respected and - * reported as `membershipCreated: false`, exactly as the reconciler reports it. + * A membership already there is respected and reported as + * `membershipCreated: false`, exactly as the reconciler reports it. */ -async function joinChosenOrganization( +async function joinCurrentOrganization( deps: AdminUserEndpointDeps, addMember: AddMemberCapableApi['addMember'], userId: string, @@ -956,29 +958,15 @@ async function joinChosenOrganization( } /** - * The organization a new user joins when the administrator chose none: their - * OWN current organization, in multi-org mode only (ADR-0093 D3 amendment). - * - * - Single-org (no organization wall in force, a degraded walled request - * included): none — the reconciler already binds the default organization. - * Judged by `tenancy.isolationActive`, the predicate behind - * `features.multiOrgEnabled`, so the default applies exactly where the - * dialog shows the organization picker. - * - `membershipPolicy: 'invite-only'`: none — a default is not an explicit - * choice, so the policy keeps withholding it (D1). - * - An administrator with no current organization: none — the user is - * created member-less, exactly as before. - * - * This is not the reconciler guessing a tenant: it is the organization the - * administrator is working in, shown to them as the dialog's default, the - * same organization the Add Member action falls back to. + * Whether this creation joins the administrator's current organization: multi-org + * mode only (ADR-0093 D3 amendment), judged by `tenancy.isolationActive` — the + * predicate behind `features.multiOrgEnabled`, so a single-org deployment and a + * degraded walled request (which serves single-org-like) keep the reconciler's + * default-organization bind, unchanged. A host that wires no way to read the + * caller's current organization does not join either. */ -async function resolveDefaultJoinOrganization(deps: AdminUserEndpointDeps): Promise { - if (!deps.getCallerActiveOrganizationId) return undefined; - if (deps.getTenancy?.()?.isolationActive !== true) return undefined; - if ((deps.getMembershipPolicy?.() ?? 'auto') !== 'auto') return undefined; - const active = await deps.getCallerActiveOrganizationId(); - return typeof active === 'string' && active.length > 0 ? active : undefined; +function multiOrgCreationJoinsCurrentOrganization(deps: AdminUserEndpointDeps): boolean { + return deps.getTenancy?.()?.isolationActive === true && typeof deps.getCallerActiveOrganizationId === 'function'; } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 4167063f9f5..e84357f8a4d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3298,7 +3298,7 @@ export class AuthPlugin implements Plugin { { ...adminUserDeps(), createGrant: createGrantFor(c), - // ADR-0093 D3 amendment — multi-org create-user defaults to the + // ADR-0093 D3 amendment — multi-org create-user joins the // administrator's own current organization, read from the // session this request already authenticated. getCallerActiveOrganizationId: async () => { diff --git a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts index 6fca06a8be9..0f60f1b3a3b 100644 --- a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts +++ b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts @@ -1,22 +1,22 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Create User can join the new user to an organization in the same step — - * measured on the real route, in a real multi-org posture. + * In multi-org mode, Create User adds the new user to the administrator's + * CURRENT organization — measured on the real route, in a real walled posture. * - * In multi-org mode the membership reconciler binds no organization by itself - * (ADR-0093 D3 — it never guesses one), so a user created without one is - * member-less and the administrator had to follow up with the organization's - * Add Member action. Naming the organization on create-user folds that second - * step into the first: better-auth's own `addMember` joins the user as a - * `member`, inside the account's transaction. + * The membership reconciler binds no organization by itself in multi-org mode + * (ADR-0093 D3 — it never guesses one), so a user created from Setup used to be + * member-less and the administrator had to follow up with Add Member. The D3 + * amendment folds that step in: the organization the administrator is working + * in is the one the new user joins, as `member`, through better-auth's own + * `addMember` inside the account's transaction — the way mainstream admin + * consoles add people to the workspace you are standing in. * - * J1 chosen: the user lands with exactly one membership, `member` of the - * chosen organization — not the administrator's own. - * J2 left empty: the user joins the administrator's CURRENT organization - * (ADR-0093 D3 amendment), and only that one. - * J3 an organization that does not exist is refused, and the account rolls - * back with it — no member-less user is left behind. + * J1 in an organization: the user lands with exactly one membership, + * `member` of the administrator's current organization — not another one + * the request names. + * J2 no current organization: refused, and no user is created — never a + * member-less account. * * Harness: the admin identity routes 501 unless better-auth's `admin` plugin * is on; `OS_SCIM_ENABLED` is the one env knob that reaches it (see @@ -35,12 +35,13 @@ async function findRows(ql: any, object: string, where: any, limit = 50): Promis return Array.isArray(rows) ? rows : (rows?.records ?? []); } -describe('Create User joins an organization in multi-org mode — the chosen one, or the administrator\'s current one', () => { +describe("Create User in multi-org mode joins the administrator's current organization", () => { let stack: VerifyStack; let ql: any; let adminToken: string; - let orgId: string; - /** The administrator's own current organization — the default. */ + /** An organization the administrator is NOT working in. */ + let otherOrgId: string; + /** The administrator's own current organization. */ let adminOrgId: string; let priorScim: string | undefined; @@ -52,21 +53,17 @@ describe('Create User joins an organization in multi-org mode — the chosen one ql = await stack.kernel.getServiceAsync('objectql'); const ownerToken = await stack.signUp('owner.create-user-org@example.com'); - const created = await stack.apiAs(ownerToken, 'POST', '/auth/organization/create', { - name: 'Join Target', slug: 'join-target-create-user', + const other = await stack.apiAs(ownerToken, 'POST', '/auth/organization/create', { + name: 'Elsewhere', slug: 'elsewhere-create-user', }); - expect(created.status, `org create: ${await created.clone().text()}`).toBe(200); - orgId = ((await created.json()) as { id: string }).id; + expect(other.status, `org create: ${await other.clone().text()}`).toBe(200); + otherOrgId = ((await other.json()) as { id: string }).id; const home = await stack.apiAs(adminToken, 'POST', '/auth/organization/create', { name: 'Admin Home', slug: 'admin-home-create-user', }); expect(home.status, `admin org create: ${await home.clone().text()}`).toBe(200); adminOrgId = ((await home.json()) as { id: string }).id; - const active = await stack.apiAs(adminToken, 'POST', '/auth/organization/set-active', { - organizationId: adminOrgId, - }); - expect(active.status, `set-active: ${await active.clone().text()}`).toBe(200); }, 180_000); afterAll(async () => { @@ -75,6 +72,13 @@ describe('Create User joins an organization in multi-org mode — the chosen one else process.env.OS_SCIM_ENABLED = priorScim; }); + async function setActive(organizationId: string | null): Promise { + const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/set-active', { organizationId }); + expect(res.status, `set-active: ${await res.clone().text()}`).toBe(200); + const session = await (await stack.apiAs(adminToken, 'GET', '/auth/get-session')).json(); + expect(session.session.activeOrganizationId ?? null).toBe(organizationId); + } + it('guard the guard: a real multi-org posture', async () => { const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean; degraded: boolean; defaultOrgId(): Promise; @@ -82,33 +86,18 @@ describe('Create User joins an organization in multi-org mode — the chosen one expect(tenancy.posture).toBe('isolated'); expect(tenancy.isolationActive).toBe(true); expect(tenancy.degraded).toBe(false); - // The reconciler's target: none — so J2's membership is the default's doing, not the reconciler's. + // The reconciler's target: none — so the membership below is the join's doing, not the reconciler's. expect(await tenancy.defaultOrgId()).toBeNull(); + expect(adminOrgId).not.toBe(otherOrgId); }); - it('J1 chosen: the user lands as a member of that organization, and only that one', async () => { + it("J1 in an organization: the user joins the administrator's current one, and only that one", async () => { + await setActive(adminOrgId); const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { email: 'joined.create-user-org@example.com', password: 'Joined!Pass123', - organizationId: orgId, - }); - expect(res.status, await res.clone().text()).toBe(200); - const body = await res.json(); - expect(body.data.organizationId).toBe(orgId); - expect(body.data.membershipCreated).toBe(true); - - const members = await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) }); - expect(members.map((m) => [m.organization_id, m.role])).toEqual([[orgId, 'member']]); - }, 120_000); - - it("J2 left empty: the user joins the administrator's current organization", async () => { - const session = await (await stack.apiAs(adminToken, 'GET', '/auth/get-session')).json(); - expect(session.session.activeOrganizationId).toBe(adminOrgId); - expect(adminOrgId).not.toBe(orgId); - - const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { - email: 'defaulted.create-user-org@example.com', - password: 'Default!Pass123', + // Not a choice the route offers: the current organization decides. + organizationId: otherOrgId, }); expect(res.status, await res.clone().text()).toBe(200); const body = await res.json(); @@ -118,17 +107,16 @@ describe('Create User joins an organization in multi-org mode — the chosen one expect(members.map((m) => [m.organization_id, m.role])).toEqual([[adminOrgId, 'member']]); }, 120_000); - it('J3 an organization that does not exist: refused, and no user is created', async () => { - const email = 'refused.create-user-org@example.com'; + it('J2 no current organization: refused, and no user is created', async () => { + await setActive(null); + const email = 'orphan.create-user-org@example.com'; const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { email, - password: 'Refused!Pass123', - organizationId: 'org_does_not_exist', + password: 'Orphan!Pass123', }); const text = await res.clone().text(); - expect(res.status, text).toBeGreaterThanOrEqual(400); - expect(res.status, text).toBeLessThan(500); - expect((await res.json()).error.message).toContain('no user was created'); + expect(res.status, text).toBe(400); + expect((await res.json()).error.code).toBe('NO_ACTIVE_ORGANIZATION'); expect(await findRows(ql, 'sys_user', { email })).toHaveLength(0); }, 120_000); }); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 591950451f4..dcd2b93c2aa 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -147,9 +147,6 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization', - // Create User's organization picker: only multi-org needs it, since - // single-org binds every new user to the default organization itself. - 'sys_user.actions.create_user.params.organizationId', ], notes: 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' + From 48cfe29677e73ea88966df895ec40e391b2520da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:44:48 +0000 Subject: [PATCH 11/12] fix(plugin-auth): register NO_ACTIVE_ORGANIZATION in the error-code ledger; port the flows.screens liveness repoint check:dispatcher-error-vocabulary refused the create-user refusal's code as unregistered: it is now in @objectstack/spec's ledger under @objectstack/plugin-auth, with the generated reference pages regenerated. Spec property liveness was red on main itself (the translation/flows.screens row cites engine.ts, which no longer names the key after the resume-body extraction). The same change as the open fix PR 25751 is ported here so this PR is green; it no-ops once main carries that PR. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR --- .changeset/create-user-organization-join.md | 2 ++ content/docs/references/api/automation-api.mdx | 3 ++- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + packages/spec/liveness/translation.json | 4 ++-- packages/spec/src/api/error-code-ledger.zod.ts | 5 +++++ 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.changeset/create-user-organization-join.md b/.changeset/create-user-organization-join.md index 349a056583a..4726c0dd0a6 100644 --- a/.changeset/create-user-organization-join.md +++ b/.changeset/create-user-organization-join.md @@ -1,5 +1,6 @@ --- '@objectstack/plugin-auth': patch +'@objectstack/spec': patch --- feat(plugin-auth): in multi-org mode, Create User adds the new user to the administrator's current organization @@ -16,4 +17,5 @@ Metadata-protocol: none - **No current organization.** FROM a member-less account TO `400 NO_ACTIVE_ORGANIZATION`, before any account exists. The fix is to switch to the organization the user belongs to, then create them. - **Policy.** Under `membershipPolicy: 'invite-only'` the user still joins: creating a user inside your organization is an explicit act, like Add Member (ADR-0093 D1). - **Capability.** A walled server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists. +- **Error code.** `NO_ACTIVE_ORGANIZATION` is registered in `@objectstack/spec`'s error-code ledger under `@objectstack/plugin-auth`. It is better-auth's own code for the same condition on Add Member. - **Unchanged.** The `single` posture (a degraded wall included), self sign-up, bulk import, SSO sign-in and invitations. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index b39d884bd6f..2c9a40096cc 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -577,6 +577,7 @@ const result = AutomationApiErrorCode.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2b28286be19..c0d1d505b8d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -258,6 +258,7 @@ const result = ApiErrorSchema.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index c8fc503286e..35335308779 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -425,6 +425,7 @@ const result = ErrorCode.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index b6f6dda7e5c..9af318f0d61 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -146,9 +146,9 @@ "status": "live", "verifiedAt": "2026-10-10", "evidenceScope": "cross-repo", - "evidence": "packages/services/service-automation/src/builtin/screen-nodes.ts#registerScreenNodes (the `screen` executor asks for `flows..screens..title` and, where the screen authors body text, `.description`, spelled by `flowScreenCopyKey`, before it renders either template); packages/services/service-automation/src/engine.ts#renderFlowTextSlot (picks the translated template through the `i18n` service in the run's `AutomationContext.locale`, negotiated by `resolveBundleLocale`, and renders it through `renderTextSlot` in place of the authored one; a miss echoes the key and the authored template renders); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", + "evidence": "packages/services/service-automation/src/builtin/screen-nodes.ts#registerScreenNodes (the `screen` executor asks for `flows..screens..title` and, where the screen authors body text, `.description`, spelled by `flowScreenCopyKey`, before it renders either template); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/runtime/src/domains/i18n.ts#handleI18nRequest (the translations route answers `getTranslations(locale)` with no group filter); objectui @31971ff1e: apps/console/src/loadLanguage.ts#loadLanguage (fetches the locale's translations and runs `transformSpecTranslations`); objectui @31971ff1e: packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations (forwards every group it does not flatten, `flows` among them, verbatim under the `app` namespace); objectui @31971ff1e: packages/i18n/src/provider.tsx#I18nProvider (adds the loaded payload to the `translation` resource bundle that `activeFlowsBundle` reads)", - "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own. 2026-10-10 (#22507, maintainer ruling A): the screen face gained `description`, and the screen's heading and body text moved to the ENGINE. Both are `{{ }}` templates the executor renders per run, so the `screen` executor picks each translated template in the run's locale through the one `i18n` channel the refusal message is read through, before it fills the holes; the served `ScreenSpec` arrives translated, and the authored template renders when the bundle has none. The body text is translated only where the screen authors one. objectui's `localizeScreen` still overlays the heading through `resolveFlowScreenTitle` at the `.objectui-sha` pin 20c6d351a; that overlay is retired by the ruling's consumer half (filed into objectui#12075). Until it lands the runner replaces a served heading with the translated template, which draws a hole literally exactly where it did before this change, so the window adds no regression. The per-field copy stays client-side, read by `overlayFieldCopy` as cited. The row stays `live`: the engine reads both screen keys in-repo, and objectui reads the field keys. objectui's `FlowRunner` overlay of `flows..screens` on the screen it draws was read at objectui @f8a9d0fb (the citation moved out of packages/spec/src/system/i18n-resolver.ts, #23434)." + "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own. 2026-10-10 (#22507, maintainer ruling A): the screen face gained `description`, and the screen's heading and body text moved to the ENGINE. Both are `{{ }}` templates the executor renders per run, so the `screen` executor picks each translated template in the run's locale through the one `i18n` channel the refusal message is read through, before it fills the holes; the served `ScreenSpec` arrives translated, and the authored template renders when the bundle has none. The body text is translated only where the screen authors one. objectui's `localizeScreen` still overlays the heading through `resolveFlowScreenTitle` at the `.objectui-sha` pin 20c6d351a; that overlay is retired by the ruling's consumer half (filed into objectui#12075). Until it lands the runner replaces a served heading with the translated template, which draws a hole literally exactly where it did before this change, so the window adds no regression. The per-field copy stays client-side, read by `overlayFieldCopy` as cited. The row stays `live`: the engine reads both screen keys in-repo, and objectui reads the field keys. objectui's `FlowRunner` overlay of `flows..screens` on the screen it draws was read at objectui @f8a9d0fb (the citation moved out of packages/spec/src/system/i18n-resolver.ts, #23434). 2026-10-25 (#25742): the `evidence` pointer at service-automation's `engine.ts#renderFlowTextSlot` is dropped. That (entry, file) pair anchored only through a comment, which #25734 moved to `run-resume.ts`; `engine.ts` now keeps a delegating `renderFlowTextSlot` over `node-text-rendering.ts`. The `screen-nodes.ts#registerScreenNodes` pointer names the key and still anchors. `verifiedAt` is unchanged: nothing else on this row was re-read." }, "refusals": { "status": "live", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 424c142d3a7..f0b02af12d8 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -602,6 +602,11 @@ export const ERROR_CODE_LEDGER = { // can tell an expiry from a reset an administrator asked for. 'PASSWORD_RESET_REQUIRED', 'PASSWORD_REUSE', + // ADR-0093 D3 amendment — 400 on /admin/create-user under a walled posture: + // the calling administrator has no current organization for the new user + // to join, so no account is created. better-auth's own code for the same + // condition on Add Member, so both doors answer it alike. + 'NO_ACTIVE_ORGANIZATION', 'OAUTH_REGISTER_FAILED', // better-auth rejected the client registration 'PHONE_NOT_ENABLED', 'ROLE_NOT_FOUND', // [#24750] 400 on /organization/invite-member and /organization/update-member-role: the requested membership role is outside the closed vocabulary, refused before any invitation or membership row is written; better-auth's code, stamped by plugin-auth on the invite door (`invite-role-not-found-code.ts`), pass-through from better-auth on update-member-role From fc3257d2dec870b14e06360f484611dea128c36f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:36:41 +0000 Subject: [PATCH 12/12] test(plugin-pinyin-search): port the boot-reconcile batching and suite budget fix Test Core (8/8) failed twice on this PR in plugin-pinyin-search's boot-reconcile.integration.test.ts, as it does on other open PRs: the fixture seeds 2,500 rows one awaited create at a time and the suite overruns vitest's 5 s default under CI load. The same change as the open fix PR 25775 is ported here; it no-ops once main carries that PR. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR --- .../src/boot-reconcile.integration.test.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts index 79bac40971d..d3a8d49529f 100644 --- a/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts +++ b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts @@ -37,6 +37,8 @@ const SYSTEM = { isSystem: true } as const; const HISTORIC = '2026-01-01T00:00:00.000Z'; const ROWS = 2_500; const PAGE = 1_000; +/** SQLite's compound-SELECT ceiling: the most rows one `bulkCreate` can carry. */ +const INSERT_BATCH = 500; const PROBE_OBJECT = { name: PROBE, @@ -98,9 +100,19 @@ class FixturePlugin implements Plugin { }; } - /** A write that bypasses every hook and the audit stamp: an import, a migration. */ + /** + * A write that bypasses every hook and the audit stamp: an import, a migration. + * Batched `bulkCreate`, not one awaited `create` per row (#25749): 2,500 + * single-row autocommit inserts timed these cases out under CI load. A batch + * is one `… UNION ALL SELECT …` statement on SQLite, which refuses more than + * 500 terms ("too many terms in compound SELECT"), hence the slices. The + * driver keeps the given `created_at`/`updated_at`, so the history stays older + * than any watermark. + */ async directInsert(rows: Array>): Promise { - for (const row of rows) await this.driver.create(PROBE, row); + for (let i = 0; i < rows.length; i += INSERT_BATCH) { + await this.driver.bulkCreate(PROBE, rows.slice(i, i + INSERT_BATCH)); + } } async row(id: string): Promise | null> { @@ -167,7 +179,12 @@ function history(): Array> { })); } -describe('#25634 the boot reconcile reads what changed since its last completed walk', () => { +// [#25749] Each case pays for real-kernel boots over a SQLite file (two in +// most) and at least one full 2,500-row reconcile walk; the forced-rebuild +// case walks twice. Idle, the slowest case takes about 1 s. On a 4-core box +// with 20 busy loops the boots and walks alone took 6.8 s and 10.1 s (seed +// excluded), past vitest's 5 s default, so the suite carries its own budget. +describe('#25634 the boot reconcile reads what changed since its last completed walk', { timeout: 30_000 }, () => { it('a first boot walks every row; an unchanged second boot reads none — and a receipt sits in sys_migration', async () => { const first = await boot({ before: (f) => f.directInsert(history()) }); await first.plugin.whenReconciled();