From eab2c9bd8cc98e6f1dbea8fcbc52a8b17dea773b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:14:03 +0000 Subject: [PATCH 01/15] wip(plugin-auth): settle membership at creation only; one-time backfill ledger Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 85 ++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 42 ++- .../src/membership-backfill-ledger.ts | 239 ++++++++++++++++++ 3 files changed, 347 insertions(+), 19 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/membership-backfill-ledger.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a6696db0375..6128b8c0898 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4313,7 +4313,9 @@ export class AuthManager { * signup, admin create-user, bulk import, SSO JIT); * - `session.create.before`, which settles the membership before resolving * the session's active organization so a user's FIRST session is not - * minted tenant-less (#8247 rule 2 / #8245). + * minted tenant-less (#8247 rule 2 / #8245) — and ONLY for a session + * minted in the request that created the user (ADR-0093 D7: membership is + * decided at creation; see {@link isCreatingInThisRequest}). * * Assembling the deps at each call site instead would let the two drift on * the axis that matters most: the POLICY. `getMembershipPolicy()` reads a @@ -4355,6 +4357,57 @@ export class AuthManager { } } + /** + * [ADR-0093 D7] The users being created in each in-flight better-auth + * request, keyed by that request's endpoint context — the object better-auth + * hands every database hook of one endpoint call. Values are lowercased + * addresses (better-auth lowercases on `createUser`). + * + * Why the session seam needs it: `user.create.after` is deferred past the + * creation's transaction, so the session a sign-up mints is created BEFORE + * the reconciler has bound anyone. The session seam therefore settles the + * membership itself — but only for the user this same request is creating. + * Any other member-less user signing in is not mid-creation: it is a user + * the policy left unbound, or one whose membership was REMOVED, and + * membership is decided at creation, never re-decided at sign-in. + * + * A `WeakMap` because the scope IS the request: the entry dies with the + * endpoint context, so nothing staged here can outlive the call that staged + * it and be read by a later sign-in. + */ + private creationsInRequest = new WeakMap>(); + + /** Record that the current request is creating the user with `email`. */ + private stageCreationInRequest(ctx: unknown, email: string): void { + if (!ctx || typeof ctx !== 'object' || !email) return; + let staged = this.creationsInRequest.get(ctx); + if (!staged) { + staged = new Set(); + this.creationsInRequest.set(ctx, staged); + } + staged.add(email.toLowerCase()); + } + + /** + * Is `userId` the user the current request is creating? Answers `false` + * without touching the store when the request created nobody — the common + * sign-in — and otherwise compares the user's stored address with the staged + * ones. Never throws (a failed read answers `false`: no settle, which is the + * pre-existing behaviour of every sign-in that is not a creation). + */ + private async isCreatingInThisRequest(ctx: unknown, reader: any, userId: string): Promise { + if (!ctx || typeof ctx !== 'object') return false; + const staged = this.creationsInRequest.get(ctx); + if (!staged || staged.size === 0) return false; + try { + const user = await reader.findOne('sys_user', { where: { id: userId } }); + const email = typeof user?.email === 'string' ? user.email.toLowerCase() : ''; + return email !== '' && staged.has(email); + } catch { + return false; + } + } + // ── [#11739] Audience posture — admission gate + self-registration grant ── /** @@ -7355,7 +7408,7 @@ export class AuthManager { return row?.organization_id; }; - const defaultActiveOrg = async (session: any) => { + const defaultActiveOrg = async (session: any, ctx: any) => { try { if (!session || session.activeOrganizationId) return; const userId = session.userId; @@ -7397,13 +7450,23 @@ export class AuthManager { // sessions with no active organization, which is the LEGAL state the // #8247 ruling declares — this removes a race, never a policy. // + // ⛔ AND IT RUNS ONLY FOR A USER WHOSE CREATION IS IN THIS REQUEST. + // ADR-0093 D7: membership is decided at creation. A member-less user + // signing in later is not a race to close — it is, among others, a + // user whose membership an administrator REMOVED, and settling here + // put them straight back on their next sign-in. The creation seam + // (`user.create.before`, below) stages the address in the request's + // endpoint context; only a session minted in that same request for + // that same user settles here (`isCreatingInThisRequest`). + // // Cost is paid only where there is something to fix: a caller who - // already holds a membership never reaches this branch, and a - // deployment that binds nobody stops at the reconciler's own policy / - // target-org check without touching the store. The re-read is gated on - // an outcome that means a membership now EXISTS, so the common - // no-bind login costs no extra query at all. - if (!orgId) { + // already holds a membership never reaches this branch, a sign-in that + // created nobody stops at an in-memory lookup, and a deployment that + // binds nobody stops at the reconciler's own policy / target-org check + // without touching the store. The re-read is gated on an outcome that + // means a membership now EXISTS, so the common no-bind login costs no + // extra query at all. + if (!orgId && (await this.isCreatingInThisRequest(ctx, reader, userId))) { const outcome = await this.settleMembership(userId); if (outcome === 'bound' || outcome === 'yielded') { orgId = await selectActiveOrg(reader, userId); @@ -7428,7 +7491,7 @@ export class AuthManager { // The host hook fully handled it → keep its result shape. if (draft?.activeOrganizationId) return { data: draft }; } - return (await defaultActiveOrg(draft)) ?? (draft === session ? undefined : { data: draft }); + return (await defaultActiveOrg(draft, ctx)) ?? (draft === session ? undefined : { data: draft }); } : hostSessionBefore; @@ -7511,6 +7574,10 @@ export class AuthManager { } } const email = typeof draft?.email === 'string' ? draft.email : ''; + // ADR-0093 D7 — mark this request as the one creating `email`, so the + // session it mints settles the membership the deferred + // `user.create.after` would (see `defaultActiveOrg`). + this.stageCreationInRequest(ctx, email); if (email && this.takeOwnerVerifiedStamp(email)) { return { data: { ...draft, emailVerified: true } }; } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index fac7d5a0ed6..04f787eba49 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -50,11 +50,11 @@ import { runAttributedToUser } from './auth-actor-attribution.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; import { createTenancyService, type TenancyService } from './tenancy-service.js'; import { - backfillMemberships, isMembershipPolicy, MEMBERSHIP_POLICIES, type MembershipPolicy, } from './reconcile-membership.js'; +import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist, @@ -1142,6 +1142,11 @@ export class AuthPlugin implements Plugin { // seed-ownership step injected. One crisp owner per posture, and the open // package never bootstraps an organization for a deployment whose // multi-organization runtime it does not provide. + // The one-time ADR-0093 D6 backfill (below) is handed to the bootstrap so + // the moment the default organization first EXISTS is a moment it runs: + // until then the pass has no target and records nothing, and leaving it to + // the next boot would let it sweep in members removed during this uptime. + let runBackfillOnDefaultOrg: ((source: string) => Promise) | undefined; if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { const runEnsure = async () => { try { @@ -1152,6 +1157,10 @@ export class AuthPlugin implements Plugin { ctx.logger.info( `[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`, ); + // Not awaited: this can run inside the `sys_user` insert that + // created the platform admin, and the pass is serialized on its + // own chain, which never rejects. + void runBackfillOnDefaultOrg?.('default-org-created'); } } catch (e) { ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', { @@ -1184,7 +1193,8 @@ export class AuthPlugin implements Plugin { } // ADR-0093 D6 — backfill memberships for pre-existing member-less users - // (historical create-user / import rows from before the reconciler existed). + // (historical create-user / import rows from before the reconciler existed), + // ONCE per deployment (`membership-backfill-ledger.ts`, ADR-0093 D7). // Registered AFTER the default-org bootstrap hook so a target org exists by // the time it runs. `backfillMemberships` self-guards: it no-ops under // `invite-only` policy and in multi-org (tenancy.defaultOrgId() → null), @@ -1218,12 +1228,22 @@ export class AuthPlugin implements Plugin { // guard is a precondition, not a degraded mode. const manager = this.authManager; if (!ql || !tenancy || !manager) return; - const res = await backfillMemberships(ql, { - policy: manager.getMembershipPolicy(), - resolveTargetOrg: () => tenancy.defaultOrgId(), - logger: ctx.logger, - }); - if (res.bound > 0) { + // ADR-0093 D7 — membership is decided at creation, so this pass is + // ONE-TIME per deployment: it runs until it reaches a verdict, the + // verdict is recorded in the `sys_migration` ledger, and every later + // trigger reads the record and binds nobody. Re-run on every boot, + // it re-added members an administrator had removed. + const outcome = await runOneTimeMembershipBackfill( + ql, + { + policy: manager.getMembershipPolicy(), + resolveTargetOrg: () => tenancy.defaultOrgId(), + logger: ctx.logger, + }, + ctx.logger, + ); + const res = outcome.backfill; + if (res && res.bound > 0) { ctx.logger.info( `[auth] membership backfill (${source}) bound ${res.bound} member-less user(s) to the default organization (ADR-0093 D6)`, res, @@ -1238,12 +1258,14 @@ export class AuthPlugin implements Plugin { }); return backfillChain; }; + runBackfillOnDefaultOrg = runBackfill; ctx.hook('kernel:ready', () => runBackfill('kernel:ready')); // #2996: app seeds insert `sys_user` via raw engine.insert, bypassing // better-auth's `user.create.after` reconciler. A seed that overruns // OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready, - // so its users miss the one-shot backfill above. Re-run the (idempotent) - // backfill when the app plugin signals seed settle. + // so its users would miss a kernel:ready pass that had no target yet. + // The trigger stays; the ledger makes it a no-op once the one-time pass + // has been recorded. ctx.hook('app:seeded', () => runBackfill('app:seeded')); } diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts new file mode 100644 index 00000000000..4bfaddea6d3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0093 D6 membership backfill runs ONCE per deployment, and the + * deployment remembers that it ran. + * + * ## Why it must be one-time + * + * ADR-0093 D7 rules that membership is decided at CREATION: under the `auto` + * policy a user is bound to the default organization when the user is created, + * and "User leaves their only org — allowed … the invariant governs creation, + * not the full lifecycle". The backfill exists for the users that predate the + * reconciler — rows created before there was anything to bind them at + * creation. It is not a lifecycle reconciler. + * + * Run on every `kernel:ready` and every `app:seeded`, it was one: a scan that + * binds every member-less user cannot tell a user who predates the policy from + * a user whose membership an administrator REMOVED, so every restart put the + * removed member back. The pass therefore runs until it has reached a verdict + * once, records that verdict in the deployment ledger, and every later pass + * reads the record and does nothing. + * + * ## Why the record lives in `sys_migration` + * + * `sys_migration` is the deployment-level ledger that already answers "has + * THIS deployment run this data migration" — one row per migration, written + * against the `DataMigrationFlag` row contract in `@objectstack/spec/system`, + * read-only over the API, provisioned by `PlatformObjectsPlugin` on every + * served kernel. The seed/API tenancy repair in `metadata-protocol` writes its + * receipt there from a boot hook for the same reasons; this module follows its + * shape. The field reading: + * + * - `verified_at: null` — the pass has no self-check; a timestamp would be a + * certificate nothing earned, and null is what `isDataMigrationFlagVerified` + * reads as "not verified", so no other id's gate can be reached by it; + * - `blocking: 0` — no consumer is held closed by this row; + * - `last_run_at` / `applied_at` / `details` — what the pass did. + * + * Every reader of the ledger reads one row by its id, so a new id cannot reach + * another migration's gate. + * + * ## Which verdicts are recorded + * + * Recorded — the deployment has DECIDED, and a later pass must not decide + * again: + * - the pass ran (policy `auto`, a default organization to bind to); + * - the policy was `invite-only`: users created under it were deliberately + * left unbound, so a later switch to `auto` must not sweep them in. + * + * Not recorded — nothing was decided yet, so a later pass still gets its turn: + * - no unambiguous target organization (single mode before the default + * organization exists; multi-org, where the backfill is refused by design); + * - an off-vocabulary policy value, or no usable engine. + * + * ## Failure directions + * + * Without a readable ledger the pass does NOT run: a pass that cannot be + * recorded would run again on the next boot, which is exactly the re-binding + * this module exists to stop. That is a functional absence (pre-existing + * member-less users stay unbound until an administrator adds them) and is + * reported at `warn`. A pass that ran but whose record failed to land is the + * opposite case — nothing looks wrong, and the next boot silently repeats the + * bulk bind — so it is reported at `error`, with the remedy. + */ + +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import { + backfillMemberships, + type BackfillMembershipsResult, + type ReconcileMembershipDeps, +} from './reconcile-membership.js'; + +/** + * Ledger row id of the one-time membership backfill. + * + * Spelled here, not in `@objectstack/spec/system`: the writer and the only + * reader are both this module, so publishing the id would declare a + * cross-package coordination point that does not exist. + */ +export const MEMBERSHIP_BACKFILL_MIGRATION_ID = 'adr-0093-membership-backfill'; + +/** The engine surface the ledger needs, duck-typed. */ +export interface MembershipBackfillLedger { + getObject(name: string): unknown; + findOne(object: string, options: Record): Promise | null>; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options: Record): Promise; +} + +export type OneTimeMembershipBackfillStatus = + /** The pass ran (or reached a recordable verdict) and its record landed. */ + | 'ran' + /** The pass ran but its record did not land — the next boot repeats it. */ + | 'ran-unrecorded' + /** The pass reached no verdict worth recording (see the module doc). */ + | 'undecided' + /** The ledger already records a verdict — nothing was scanned or bound. */ + | 'already-run' + /** No ledger on this kernel — the pass did not run. */ + | 'ledger-unavailable' + /** The ledger could not be read — the pass did not run. */ + | 'ledger-unreadable'; + +export interface OneTimeMembershipBackfillResult { + status: OneTimeMembershipBackfillStatus; + /** The backfill's own summary, present whenever it was consulted. */ + backfill?: BackfillMembershipsResult; +} + +interface LedgerLogger { + info?: (msg: string, meta?: any) => void; + warn: (msg: string, meta?: any) => void; + error?: (msg: string, meta?: any) => void; +} + +const SYSTEM_CTX = { isSystem: true }; + +function resolveLedger(engine: unknown): MembershipBackfillLedger | undefined { + const candidate = engine as any; + for (const method of ['getObject', 'findOne', 'insert', 'update']) { + if (typeof candidate?.[method] !== 'function') return undefined; + } + return candidate as MembershipBackfillLedger; +} + +/** Whether a backfill summary is a verdict the deployment should remember. */ +function isRecordableVerdict(result: BackfillMembershipsResult): boolean { + return result.reason === undefined || result.reason === 'policy'; +} + +/** The ledger row for one decided pass — pure. */ +export function buildMembershipBackfillRecord( + result: BackfillMembershipsResult, + policy: unknown, + now: string, +): DataMigrationFlag { + return { + id: MEMBERSHIP_BACKFILL_MIGRATION_ID, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + details: JSON.stringify({ + policy: typeof policy === 'string' ? policy : String(policy), + scanned: result.scanned, + bound: result.bound, + skipped: result.skipped, + ...(result.reason ? { reason: result.reason } : {}), + }), + }; +} + +/** + * Run the ADR-0093 D6 backfill unless this deployment has already decided it, + * and record the verdict when it decides now. Never throws: it runs inside + * boot hooks, which bookkeeping must not break. + */ +export async function runOneTimeMembershipBackfill( + engine: unknown, + deps: ReconcileMembershipDeps & { limit?: number }, + logger?: LedgerLogger, +): Promise { + const ledger = resolveLedger(engine); + if (!ledger || !ledger.getObject(DATA_MIGRATION_FLAG_OBJECT)) { + logger?.warn?.( + `[auth] membership backfill did NOT run: the deployment ledger (${DATA_MIGRATION_FLAG_OBJECT}) is not ` + + `available on this kernel, so a pass could not be recorded and would repeat on every boot, ` + + `re-adding members an administrator removed. Pre-existing users without a membership stay ` + + `unbound until an administrator adds them. Compose PlatformObjectsPlugin (it provisions the ledger) ` + + `to let the one-time backfill run.`, + ); + return { status: 'ledger-unavailable' }; + } + + let existing: Record | null; + try { + existing = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: MEMBERSHIP_BACKFILL_MIGRATION_ID }, + context: SYSTEM_CTX, + }); + } catch (e: unknown) { + logger?.warn?.( + `[auth] membership backfill did NOT run: reading its record in ${DATA_MIGRATION_FLAG_OBJECT} failed, ` + + `and running without knowing whether it already ran could re-add members an administrator removed. ` + + `It is retried on the next boot.`, + { error: e instanceof Error ? e.message : String(e) }, + ); + return { status: 'ledger-unreadable' }; + } + if (existing?.id === MEMBERSHIP_BACKFILL_MIGRATION_ID) { + return { status: 'already-run' }; + } + + const backfill = await backfillMemberships(engine, deps); + if (!isRecordableVerdict(backfill)) { + return { status: 'undecided', backfill }; + } + + const now = new Date().toISOString(); + const flag = buildMembershipBackfillRecord(backfill, deps.policy, now); + try { + await ledger.insert( + DATA_MIGRATION_FLAG_OBJECT, + { ...flag, created_at: now, updated_at: now }, + { context: SYSTEM_CTX }, + ); + logger?.info?.( + `[auth] membership backfill recorded in ${DATA_MIGRATION_FLAG_OBJECT} (id '${MEMBERSHIP_BACKFILL_MIGRATION_ID}') ` + + `— later boots will not run it again`, + { id: MEMBERSHIP_BACKFILL_MIGRATION_ID, details: flag.details }, + ); + return { status: 'ran', backfill }; + } catch (e: unknown) { + // A concurrent pass (another node of the same deployment) may have written + // the row first — the primary key refuses the second insert. That is the + // record landing, not failing. + try { + const raced = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: MEMBERSHIP_BACKFILL_MIGRATION_ID }, + context: SYSTEM_CTX, + }); + if (raced?.id === MEMBERSHIP_BACKFILL_MIGRATION_ID) return { status: 'ran', backfill }; + } catch { + // Fall through to the loud report below — the record is unconfirmed. + } + // Inline, at `error`: the pass ran and every line reads clean, while the + // record that stops the next boot from repeating the bulk bind is absent. + const detail = e instanceof Error ? e.message : String(e); + const message = + `[auth] membership backfill ran, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed (${detail}). ` + + `The next boot will run it AGAIN and re-add every user without a membership to the default organization, ` + + `including members an administrator removed since. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this ` + + `deployment (it is provisioned by PlatformObjectsPlugin) and verify with ` + + `SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = '${MEMBERSHIP_BACKFILL_MIGRATION_ID}'.`; + if (logger?.error) logger.error(message, { error: detail }); + else logger?.warn?.(message, { error: detail }); + return { status: 'ran-unrecorded', backfill }; + } +} From 63c0c151f710c3cabfd046d2c39087df84d0f2ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:34:12 +0000 Subject: [PATCH 02/15] test(plugin-auth): pin creation-time membership and the one-time backfill ledger Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../plugin-auth/src/auth-plugin.test.ts | 41 ++++- .../first-session-membership-ordering.test.ts | 126 +++++++++++-- .../src/membership-backfill-ledger.test.ts | 167 ++++++++++++++++++ .../src/membership-backfill-ledger.ts | 3 +- .../src/membership-policy-setting.test.ts | 4 + 5 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 74c49bacfe8..28bd5c179c9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthPlugin } from './auth-plugin'; import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; describe('AuthPlugin', () => { let mockContext: PluginContext; @@ -1470,6 +1471,8 @@ describe('AuthPlugin', () => { sys_user: [{ id: 'admin' }], sys_member: [], sys_organization: [], + // The deployment ledger the one-time pass records itself in. + sys_migration: [], }; const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { @@ -1479,9 +1482,14 @@ describe('AuthPlugin', () => { return { tables, registerMiddleware: vi.fn(), + getObject: vi.fn((name: string) => (name in tables ? { name } : undefined)), find: vi.fn(async (object: string, q: any) => (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), ), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return (tables[object] ?? []).find((r) => matches(r, q?.where)) ?? null; + }), insert: vi.fn(async (object: string, data: any) => { (tables[object] ??= []).push(data); return data; @@ -1524,20 +1532,43 @@ describe('AuthPlugin', () => { expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function)); }); - it('binds a user seeded after kernel:ready when app:seeded fires', async () => { + it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => { + // No platform admin at kernel:ready ⇒ no default organization ⇒ the + // pass has no target, decides nothing and records nothing. + ql.tables.sys_user_permission_set = []; await boot(); - // Boot: default org created, admin bound as owner, first backfill pass - // sees no member-less users. await hookCapture.trigger('kernel:ready'); - expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); - // A background seed inserts a member-less user AFTER kernel:ready. + // A background seed inserts a member-less user, and the organization + // appears before the seed settles. ql.tables.sys_user.push({ id: 'seeded_u2' }); + ql.tables.sys_organization.push({ id: 'org_default', slug: 'default' }); await hookCapture.trigger('app:seeded'); const seededMember = ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2'); expect(seededMember).toMatchObject({ user_id: 'seeded_u2', role: 'member' }); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); + }); + + it('once the pass is recorded, a later trigger binds nobody — a removed member stays removed', async () => { + await boot(); + // Boot: default org created, admin bound as owner, the one-time pass + // runs and records itself. + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); + + // A member-less user now exists — the shape a membership removal leaves. + ql.tables.sys_user.push({ id: 'removed_u3' }); + + await hookCapture.trigger('app:seeded'); + await hookCapture.trigger('kernel:ready'); + + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'removed_u3')).toBeUndefined(); + expect(ql.tables.sys_migration).toHaveLength(1); }); it('OS_SKIP_MEMBERSHIP_BACKFILL=1 disables the app:seeded re-run', async () => { diff --git a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts index 233ea63da7a..287004760b5 100644 --- a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts +++ b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts @@ -33,6 +33,16 @@ * store (`insert` never called), not merely on the returned draft. A user whom * policy says must not be bound still mints a session with no active * organization, which is the legal state the #8247 ruling declares. + * + * ## Only the session of the CREATING request settles (ADR-0093 D7) + * + * Membership is decided at creation. The settle at the session seam closes the + * race for the request that creates the user and for nothing else: a + * member-less user who signs in later — for instance one whose membership was + * removed — must not be bound again. The cases below therefore drive the real + * sign-up order (`user.create.before` and `session.create.before` sharing one + * request context) for every settle they expect, and the last block pins the + * sign-ins that must NOT settle. */ import { describe, it, expect, vi } from 'vitest'; @@ -41,6 +51,7 @@ import { loginEventFor } from './auth-session-audit.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql'; const USER = 'u_first_session'; +const USER_EMAIL = 'first.session@example.com'; const DEFAULT_ORG = 'org_default'; interface MemberRow { @@ -60,6 +71,9 @@ interface MemberRow { */ function makeEngine(seed: MemberRow[] = []) { const rows: MemberRow[] = [...seed]; + // The created user's row — what the session seam reads to recognise the + // user the current request is creating. + const users = [{ id: USER, email: USER_EMAIL }, { id: 'u_other', email: 'other@example.com' }]; const match = (r: MemberRow, where: Record = {}) => (where.user_id === undefined || r.user_id === where.user_id) && (where.role === undefined || r.role === where.role) && @@ -78,7 +92,11 @@ function makeEngine(seed: MemberRow[] = []) { ), // eslint-disable-next-line @typescript-eslint/no-explicit-any findOne: vi.fn(async (model: string, q: any) => - { assertEngineFindOnePredicate(model, q); return model === 'sys_member' ? (rows.find((r) => match(r, q?.where)) ?? null) : null; }, + { + assertEngineFindOnePredicate(model, q); + if (model === 'sys_user') return users.find((u) => u.id === q?.where?.id) ?? null; + return model === 'sys_member' ? (rows.find((r) => match(r, q?.where)) ?? null) : null; + }, ), }; } @@ -107,6 +125,19 @@ function hooksFor(opts: ManagerOpts): any { return (manager as any).composeDatabaseHooks(opts.databaseHooks); } +/** + * Drive the sign-up order better-auth runs: `user.create.before` and the + * session's `session.create.before` share ONE endpoint context (the request), + * and `user.create.after` is deferred past both. Returns the session hook's + * result. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function firstSessionOfCreation(hooks: any, session: Record = { userId: USER }): Promise { + const request = {}; + await hooks.user.create.before({ email: USER_EMAIL, name: 'First Session' }, request); + return hooks.session.create.before(session, request); +} + describe("[#8247 rule 2] a user's FIRST session settles the membership before resolving its active org", () => { it('PRECONDITION: the store really does hold no membership for the user before sign-in', async () => { // Without this the whole file could be measuring a user who was already @@ -120,7 +151,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('the FIRST session carries the active organization — the reconciler is run, then re-read', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); // …and it is a real membership, not a value invented for the draft. The // difference matters: a stamped-but-unbacked org would satisfy the session @@ -137,7 +168,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re // where no RLS reader could ever see it again. const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER, id: 'sess_1' }); + const result = await firstSessionOfCreation(hooks, { userId: USER, id: 'sess_1' }); const event = loginEventFor(result?.data); expect(event?.organizationId).toBe(DEFAULT_ORG); expect(event?.userId).toBe(USER); @@ -170,7 +201,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('`invite-only`: nothing is bound, and the session is legitimately org-less', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); // Asserted on the STORE, not just on the draft: a policy that says "no // auto-bind" must not be satisfied by binding and then declining to stamp. @@ -181,7 +212,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('no unambiguous target organization (multi-org): nothing is bound, session org-less', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: null }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); expect(engine.rows).toHaveLength(0); @@ -190,11 +221,12 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('a no-bind sign-in costs NO extra membership read — the re-read is gated on the outcome', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); - await hooks.session.create.before({ userId: USER }); - // The two owner-preferred lookups of the ORIGINAL selection, and nothing - // more: `invite-only` is refused by the reconciler before it touches the + await firstSessionOfCreation(hooks); + // The two owner-preferred lookups of the ORIGINAL selection, the one + // read that recognises the user being created, and nothing more: + // `invite-only` is refused by the reconciler before it touches the // store, and the re-read never runs because nothing was bound. - expect(engine.findOne).toHaveBeenCalledTimes(2); + expect(engine.findOne).toHaveBeenCalledTimes(3); expect(engine.find).not.toHaveBeenCalled(); }); }); @@ -223,7 +255,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re defaultOrgId: DEFAULT_ORG, databaseHooks: { session: { create: { before: hostHook } } }, }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(hostHook).toHaveBeenCalledTimes(1); expect(result?.data?.activeOrganizationId).toBe('org_host'); // The host owned it, so the settle never ran. @@ -239,7 +271,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const hooks = hooksFor({ engine: engine as any, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); }); @@ -262,7 +294,77 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); await hooks.user.create.after({ id: USER }); - await hooks.session.create.before({ userId: USER }); + await firstSessionOfCreation(hooks); + expect(engine.insert).not.toHaveBeenCalled(); + }); + }); + describe('ADR-0093 D7 — membership is decided at creation, never re-decided at sign-in', () => { + it('a member-less user signing in WITHOUT being created in that request is not bound', async () => { + // The removed-member shape: the user exists, holds no membership, and + // signs in. Nothing in this request created them. + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + expect(engine.rows).toHaveLength(0); + }); + + it('CONTROL: the same user, created in the same request, IS bound', async () => { + // Same engine shape, same policy, same target — only the creation + // differs, so the case above measures the gate and not a broken fixture. + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await firstSessionOfCreation(hooks); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.rows).toHaveLength(1); + }); + + it('a sign-in that created nobody costs no read beyond the original selection', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + await hooks.session.create.before({ userId: USER }, {}); + expect(engine.findOne).toHaveBeenCalledTimes(2); + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('a creation staged in ANOTHER request does not reach this sign-in', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + await hooks.user.create.before({ email: USER_EMAIL }, {}); + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('a request creating one user does not bind a DIFFERENT member-less user', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await firstSessionOfCreation(hooks, { userId: 'u_other' }); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('a session minted with no request context settles nothing', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + await hooks.user.create.before({ email: USER_EMAIL }, undefined); + const result = await hooks.session.create.before({ userId: USER }, undefined); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('a creation the HOST refuses is not staged', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ + engine, + defaultOrgId: DEFAULT_ORG, + databaseHooks: { user: { create: { before: vi.fn(async () => false) } } }, + }); + const request = {}; + expect(await hooks.user.create.before({ email: USER_EMAIL }, request)).toBe(false); + const result = await hooks.session.create.before({ userId: USER }, request); + expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); }); }); diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts new file mode 100644 index 00000000000..36557c53552 --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0093 D6 backfill is ONE-TIME per deployment (ADR-0093 D7: + * membership is decided at creation). These cases pin the ledger gate around + * it: which verdicts are remembered, that a remembered verdict binds nobody — + * the removed-member case — and the two failure directions. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { + MEMBERSHIP_BACKFILL_MIGRATION_ID, + runOneTimeMembershipBackfill, +} from './membership-backfill-ledger.js'; + +const ORG = 'org_default'; + +interface Tables { + sys_user: Array<{ id: string }>; + sys_member: Array<{ id?: string; organization_id: string; user_id: string; role?: string }>; + sys_migration: Array>; +} + +function makeEngine(seed: Partial = {}, opts: { ledger?: boolean } = {}) { + const tables: Tables = { + sys_user: [...(seed.sys_user ?? [])], + sys_member: [...(seed.sys_member ?? [])], + sys_migration: [...(seed.sys_migration ?? [])], + }; + const engine = { + tables, + getObject: vi.fn((name: string) => + name === 'sys_migration' && opts.ledger === false ? undefined : { name }, + ), + find: vi.fn(async (object: string) => [...((tables as any)[object] ?? [])]), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return ((tables as any)[object] ?? []).find((r: any) => r.id === q?.where?.id) ?? null; + }), + insert: vi.fn(async (object: string, data: any) => { + ((tables as any)[object] ??= []).push({ ...data }); + return data; + }), + }; + return engine; +} + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +const auto = { policy: 'auto' as const, resolveTargetOrg: async () => ORG }; + +describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { + it('first pass: binds pre-existing member-less users and records the verdict', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_legacy' }] }); + const res = await runOneTimeMembershipBackfill(engine, auto, logger()); + expect(res.status).toBe('ran'); + expect(engine.tables.sys_member).toEqual([ + expect.objectContaining({ organization_id: ORG, user_id: 'u_legacy', role: 'member' }), + ]); + expect(engine.tables.sys_migration).toHaveLength(1); + expect(engine.tables.sys_migration[0]).toMatchObject({ + id: MEMBERSHIP_BACKFILL_MIGRATION_ID, + verified_at: null, + blocking: 0, + }); + expect(JSON.parse(String(engine.tables.sys_migration[0].details))).toMatchObject({ policy: 'auto', bound: 1 }); + }); + + it('a recorded verdict binds nobody — a member-less user (e.g. removed) stays unbound', async () => { + const engine = makeEngine({ + sys_user: [{ id: 'u_removed' }], + sys_migration: [{ id: MEMBERSHIP_BACKFILL_MIGRATION_ID, blocking: 0 }], + }); + const res = await runOneTimeMembershipBackfill(engine, auto, logger()); + expect(res.status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.insert).not.toHaveBeenCalled(); + // Not even scanned: the user table is never read. + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('two consecutive passes: the second is a no-op even after a membership is removed', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('ran'); + engine.tables.sys_member.length = 0; // an administrator removes the membership + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('`invite-only` is a decided verdict: nothing bound, the verdict recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'invite-only', resolveTargetOrg: async () => ORG }, + logger(), + ); + expect(res.status).toBe('ran'); + expect(res.backfill?.reason).toBe('policy'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(1); + // A later switch to `auto` does not sweep in the users created under it. + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('no target organization (multi-org, or before the default org exists): undecided, nothing recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'auto', resolveTargetOrg: async () => null }, + logger(), + ); + expect(res.status).toBe('undecided'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + + it('no ledger on the kernel: the pass does not run, and says so', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }, { ledger: false }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ledger-unavailable'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('an unreadable ledger: the pass does not run', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + engine.findOne.mockRejectedValueOnce(new Error('db down')); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ledger-unreadable'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('a record that fails to land is reported at error', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const realInsert = engine.insert.getMockImplementation()!; + engine.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') throw new Error('read-only'); + return realInsert(object, data); + }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ran-unrecorded'); + expect(log.error).toHaveBeenCalledTimes(1); + expect(String(log.error.mock.calls[0]![0])).toContain('will run it AGAIN'); + }); + + it('a record written first by a concurrent pass counts as recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const realInsert = engine.insert.getMockImplementation()!; + engine.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') { + engine.tables.sys_migration.push({ id: MEMBERSHIP_BACKFILL_MIGRATION_ID }); + throw new Error('duplicate key'); + } + return realInsert(object, data); + }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ran'); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts index 4bfaddea6d3..13148f671ae 100644 --- a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts @@ -84,7 +84,6 @@ export interface MembershipBackfillLedger { getObject(name: string): unknown; findOne(object: string, options: Record): Promise | null>; insert(object: string, data: Record, options?: Record): Promise; - update(object: string, data: Record, options: Record): Promise; } export type OneTimeMembershipBackfillStatus = @@ -117,7 +116,7 @@ const SYSTEM_CTX = { isSystem: true }; function resolveLedger(engine: unknown): MembershipBackfillLedger | undefined { const candidate = engine as any; - for (const method of ['getObject', 'findOne', 'insert', 'update']) { + for (const method of ['getObject', 'findOne', 'insert']) { if (typeof candidate?.[method] !== 'function') return undefined; } return candidate as MembershipBackfillLedger; diff --git a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts index f710ec20969..0287bd97523 100644 --- a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts +++ b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts @@ -86,6 +86,10 @@ function makeEngine(seed: { users?: Array<{ id: string }>; members?: Array<{ use return []; }), findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }), + // The deployment ledger the one-time backfill records itself in is + // present; it holds no record yet (`findOne` answers null), so the pass + // runs on this boot. + getObject: vi.fn((name: string) => ({ name })), }; } From f41cb3fe796c10dd99e2f4afc053ad4635d0aa67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:36:04 +0000 Subject: [PATCH 03/15] test(dogfood): membership under the auto policy is decided at user creation Also adds the plugin-auth changeset and tightens comments. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../21791-membership-decided-at-creation.md | 12 ++ .../plugins/plugin-auth/src/auth-manager.ts | 11 +- .../plugin-auth/src/auth-plugin.test.ts | 2 +- .../plugins/plugin-auth/src/auth-plugin.ts | 8 +- .../src/membership-backfill-ledger.test.ts | 4 +- .../src/membership-backfill-ledger.ts | 16 +-- ...ership-decided-at-creation.dogfood.test.ts | 107 ++++++++++++++++++ 7 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 .changeset/21791-membership-decided-at-creation.md create mode 100644 packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md new file mode 100644 index 00000000000..842110f9339 --- /dev/null +++ b/.changeset/21791-membership-decided-at-creation.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7. + +Clause-②: no + +- **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later. +- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass that finds no default organization records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. +- **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before. +- **Narrowed.** Once the backfill is recorded, a `sys_user` row inserted straight through the data engine is not bound by a later `app:seeded` pass, because it never passes through user creation. The shipped examples create users through sign-up, so they are unaffected. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 6128b8c0898..bf9bf9cc6e0 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4367,9 +4367,8 @@ export class AuthManager { * creation's transaction, so the session a sign-up mints is created BEFORE * the reconciler has bound anyone. The session seam therefore settles the * membership itself — but only for the user this same request is creating. - * Any other member-less user signing in is not mid-creation: it is a user - * the policy left unbound, or one whose membership was REMOVED, and - * membership is decided at creation, never re-decided at sign-in. + * Any other member-less user signing in is not mid-creation, and membership + * is decided at creation, never re-decided at sign-in. * * A `WeakMap` because the scope IS the request: the entry dies with the * endpoint context, so nothing staged here can outlive the call that staged @@ -7452,9 +7451,9 @@ export class AuthManager { // // ⛔ AND IT RUNS ONLY FOR A USER WHOSE CREATION IS IN THIS REQUEST. // ADR-0093 D7: membership is decided at creation. A member-less user - // signing in later is not a race to close — it is, among others, a - // user whose membership an administrator REMOVED, and settling here - // put them straight back on their next sign-in. The creation seam + // signing in later is not a race to close — whatever left them without + // a membership (the policy, or an administrator) has already decided, + // and a sign-in must not decide again. The creation seam // (`user.create.before`, below) stages the address in the request's // endpoint context; only a session minted in that same request for // that same user settles here (`isCreatingInThisRequest`). diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 28bd5c179c9..e18ff60d6b9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1553,7 +1553,7 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); }); - it('once the pass is recorded, a later trigger binds nobody — a removed member stays removed', async () => { + it('once the pass is recorded, a later trigger binds nobody', async () => { await boot(); // Boot: default org created, admin bound as owner, the one-time pass // runs and records itself. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 04f787eba49..355e7668ec8 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1144,8 +1144,9 @@ export class AuthPlugin implements Plugin { // multi-organization runtime it does not provide. // The one-time ADR-0093 D6 backfill (below) is handed to the bootstrap so // the moment the default organization first EXISTS is a moment it runs: - // until then the pass has no target and records nothing, and leaving it to - // the next boot would let it sweep in members removed during this uptime. + // until then the pass has no target and records nothing, and deferring it + // to the next boot would let it judge users created during this uptime, + // whose membership was already decided at their creation. let runBackfillOnDefaultOrg: ((source: string) => Promise) | undefined; if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { const runEnsure = async () => { @@ -1231,8 +1232,7 @@ export class AuthPlugin implements Plugin { // ADR-0093 D7 — membership is decided at creation, so this pass is // ONE-TIME per deployment: it runs until it reaches a verdict, the // verdict is recorded in the `sys_migration` ledger, and every later - // trigger reads the record and binds nobody. Re-run on every boot, - // it re-added members an administrator had removed. + // trigger reads the record and binds nobody. const outcome = await runOneTimeMembershipBackfill( ql, { diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts index 36557c53552..d047e2dffd7 100644 --- a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts @@ -67,7 +67,7 @@ describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { expect(JSON.parse(String(engine.tables.sys_migration[0].details))).toMatchObject({ policy: 'auto', bound: 1 }); }); - it('a recorded verdict binds nobody — a member-less user (e.g. removed) stays unbound', async () => { + it('a recorded verdict binds nobody', async () => { const engine = makeEngine({ sys_user: [{ id: 'u_removed' }], sys_migration: [{ id: MEMBERSHIP_BACKFILL_MIGRATION_ID, blocking: 0 }], @@ -80,7 +80,7 @@ describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { expect(engine.find).not.toHaveBeenCalled(); }); - it('two consecutive passes: the second is a no-op even after a membership is removed', async () => { + it('two consecutive passes: the second is a no-op whatever the membership state', async () => { const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('ran'); engine.tables.sys_member.length = 0; // an administrator removes the membership diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts index 13148f671ae..377366d7b47 100644 --- a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts @@ -13,12 +13,12 @@ * reconciler — rows created before there was anything to bind them at * creation. It is not a lifecycle reconciler. * - * Run on every `kernel:ready` and every `app:seeded`, it was one: a scan that - * binds every member-less user cannot tell a user who predates the policy from - * a user whose membership an administrator REMOVED, so every restart put the - * removed member back. The pass therefore runs until it has reached a verdict - * once, records that verdict in the deployment ledger, and every later pass - * reads the record and does nothing. + * A scan that binds every member-less user cannot tell a user who predates + * the policy from a user whose membership was decided at creation and has + * since changed, so the scan may only ever run before any such decision + * exists. The pass therefore runs until it has reached a verdict once, + * records that verdict in the deployment ledger, and every later pass reads + * the record and does nothing. * * ## Why the record lives in `sys_migration` * @@ -55,8 +55,8 @@ * ## Failure directions * * Without a readable ledger the pass does NOT run: a pass that cannot be - * recorded would run again on the next boot, which is exactly the re-binding - * this module exists to stop. That is a functional absence (pre-existing + * recorded would run again on the next boot and judge users whose + * membership was already decided. That is a functional absence (pre-existing * member-less users stay unbound until an administrator adds them) and is * reported at `warn`. A pass that ran but whose record failed to land is the * opposite case — nothing looks wrong, and the next boot silently repeats the diff --git a/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts new file mode 100644 index 00000000000..d04d4b0bdfa --- /dev/null +++ b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0093 D7 on a real boot: under the `auto` membership policy a user is + * bound to the default organization when the user is CREATED, and a user + * whose membership was removed is never bound again automatically — not by + * signing in again. + * + * Driven through the real HTTP surface: sign-up, better-auth's own + * remove-member endpoint, and sign-in. The positive half (creation binds, and + * the creating request's first session carries the organization) is asserted + * on the same run, so the negative half measures the decision and not a + * deployment that binds nobody. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The creation-time membership may land after the sign-up response. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 60; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('membership under the auto policy is decided at user creation (ADR-0093 D7)', () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + let orgId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // The single-org default organization — reuse the bootstrap's when the + // boot made one, otherwise stand it up the way the bootstrap would. + const [existing] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + orgId = existing + ? String(existing.id) + : String((await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX })).id); + + // The admin must own the organization for remove-member to be authorized. + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const adminUserId = String(adminUser.id); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId, organization_id: orgId }, 5); + if (adminMembers.length > 0) { + await ql.update('sys_member', { id: adminMembers[0].id, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: adminUserId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + }, 240_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('membership is decided once, at creation', async () => { + const email = 'member.decided.at.creation@example.com'; + const password = 'Member!Pass123'; + + // ── creation binds ─────────────────────────────────────────────────── + await stack.signUp(email, password, 'Decided At Creation'); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + const userId = String(user.id); + const member = await waitForMembership(ql, userId); + expect(String(member.organization_id)).toBe(orgId); + expect(member.role).toBe('member'); + + // The creating request's first session carries the organization: the + // creation-time settle still runs ahead of the deferred creation hook. + const firstSessions = await findRows(ql, 'sys_session', { user_id: userId }, 5); + expect(firstSessions.length).toBeGreaterThan(0); + expect(String(firstSessions[0].active_organization_id)).toBe(orgId); + + // ── an administrator removes the membership ────────────────────────── + const removed = await stack.apiAs(adminToken, 'POST', '/auth/organization/remove-member', { + memberIdOrEmail: String(member.id), + organizationId: orgId, + }); + expect(removed.status, await removed.clone().text()).toBe(200); + expect(await findRows(ql, 'sys_member', { user_id: userId }, 5)).toHaveLength(0); + + // ── signing in again does not put them back ────────────────────────── + const token = await stack.signIn(email, password); + const session = await stack.apiAs(token, 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + const body = (await session.json()) as any; + expect(body?.user?.id).toBe(userId); + expect(body?.session?.activeOrganizationId ?? null).toBeNull(); + + // Allow any deferred hook of the sign-in to finish before reading the + // store, so an absence is not merely "not yet". + await new Promise((r) => setTimeout(r, 1_000)); + expect(await findRows(ql, 'sys_member', { user_id: userId }, 5)).toHaveLength(0); + }, 180_000); +}); From a641515ccf346140dccafb60fb90b298bf1a903c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:14:33 +0000 Subject: [PATCH 04/15] chore: refresh the tenant-audit census and engine-double ledger for the new ledger write Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../docs/permissions/tenant-audit-census.mdx | 32 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 17 +++++----- scripts/engine-double-contract.pinned.json | 10 ++++++ 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f0da07d96e7..e7a86256261 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 231** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 112 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 113 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -112 of 231 (48%) as decidably elevated, with 102 more whose elevation is a +113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **34 further sites** have an options argument this @@ -223,23 +223,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | +| write call sites on the application surface | **232** | | …whose object name is statically decidable | 154 | -| …whose object name is chosen at run time | 77 | +| …whose object name is chosen at run time | 78 | | …against an object with tenancy ENABLED | 153 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 147 | +| threading a tenant context | 148 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 34 | -| threading a decidably ELEVATED (`isSystem`) context | 112 | +| threading a decidably ELEVATED (`isSystem`) context | 113 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 183 | +| receiver carried a readable engine type | 184 | | receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -247,7 +247,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 103 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 17 | -| object name is some other run-time expression | 60 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-02 at `b668cf134`. +Measured on 2026-10-05 at `f41cb3fe7`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 602 | -| engine-shaped types recognised | 67 | +| tracked non-test sources scanned | 605 | +| engine-shaped types recognised | 68 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 152 | +| same-named calls subtracted as non-engine | 155 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9aea141c009..c6ee6554ebf 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | +| Write call sites | 232 | | Object name statically decidable | 154 | -| Object name chosen at run time | 77 | +| Object name chosen at run time | 78 | | Against a tenancy-enabled object | 153 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 147 | +| Threading a tenant context | 148 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 34 | -| Threading a decidably elevated context | 112 | +| Threading a decidably elevated context | 113 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-02 at `b668cf134`. +Measured on 2026-10-05 at `f41cb3fe7`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 602 | -| engine-shaped types recognised | 67 | +| tracked non-test sources scanned | 605 | +| engine-shaped types recognised | 68 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 152 | +| same-named calls subtracted as non-engine | 155 | ## Every site @@ -135,6 +135,7 @@ Measured on 2026-10-02 at `b668cf134`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 | diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 08439f380ae..fcd217ae871 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2671,6 +2671,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/auth-plugin.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts", "verb": "findOne", @@ -2751,6 +2756,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/membership-policy-setting.test.ts", "verb": "findOne", From a476ec387bf0dadd649bde25e60c42fe5d29fb5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:50:30 +0000 Subject: [PATCH 05/15] fix(plugin-auth): decide the default-org owner bind once; record multi-org refusals; scan the backfill in full Also stages the created user from the adapter's create result, so the session seam recognises the creating request without a store read. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../21791-membership-decided-at-creation.md | 5 +- .../plugins/plugin-auth/src/auth-manager.ts | 82 +++---- .../plugin-auth/src/auth-plugin.test.ts | 26 +- .../plugins/plugin-auth/src/auth-plugin.ts | 43 +++- .../src/ensure-default-organization.ts | 15 +- .../first-session-membership-ordering.test.ts | 61 ++--- .../src/membership-backfill-ledger.test.ts | 57 ++++- .../src/membership-backfill-ledger.ts | 229 ++++++++++++------ .../plugin-auth/src/objectql-adapter.ts | 24 +- .../plugin-auth/src/reconcile-membership.ts | 59 ++++- .../src/user-created-in-request.test.ts | 108 +++++++++ ...check-durability-degradation-log-level.mjs | 4 + 12 files changed, 545 insertions(+), 168 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/user-created-in-request.test.ts diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md index 842110f9339..883f6d595ee 100644 --- a/.changeset/21791-membership-decided-at-creation.md +++ b/.changeset/21791-membership-decided-at-creation.md @@ -7,6 +7,9 @@ Membership under the `auto` policy is settled when the user is created, per ADR- Clause-②: no - **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later. -- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass that finds no default organization records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. +- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. +- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it. That decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind`. Without a readable ledger, the bind happens only when the bootstrap creates the default organization. +- **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded. +- **Upgrade.** The first boot of an upgraded deployment runs the backfill once. - **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before. - **Narrowed.** Once the backfill is recorded, a `sys_user` row inserted straight through the data engine is not bound by a later `app:seeded` pass, because it never passes through user creation. The shipped examples create users through sign-up, so they are unaffected. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index bf9bf9cc6e0..f95f8054f48 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4058,7 +4058,16 @@ export class AuthManager { // The factory is created by better-auth's createAdapterFactory and // automatically applies modelName/fields transformations declared in // the betterAuth config above. - return createObjectQLAdapterFactory(this.config.dataEngine); + return createObjectQLAdapterFactory(this.config.dataEngine, { + // ADR-0093 D7 — the adapter is the one place that sees the created + // user's id INSIDE the creating endpoint call; staging it here lets the + // session seam recognise that user without reading the row back. + onRecordCreated: async (objectName, row) => { + if (objectName !== 'sys_user') return; + const { tryGetCurrentAuthEndpointContext } = await import('@better-auth/core/context'); + this.stageUserCreatedInRequest(tryGetCurrentAuthEndpointContext(), row?.id); + }, + }); } // Fallback warning if no dataEngine is provided @@ -4315,7 +4324,7 @@ export class AuthManager { * the session's active organization so a user's FIRST session is not * minted tenant-less (#8247 rule 2 / #8245) — and ONLY for a session * minted in the request that created the user (ADR-0093 D7: membership is - * decided at creation; see {@link isCreatingInThisRequest}). + * decided at creation; see {@link isCreatedInThisRequest}). * * Assembling the deps at each call site instead would let the two drift on * the axis that matters most: the POLICY. `getMembershipPolicy()` reads a @@ -4358,53 +4367,43 @@ export class AuthManager { } /** - * [ADR-0093 D7] The users being created in each in-flight better-auth - * request, keyed by that request's endpoint context — the object better-auth - * hands every database hook of one endpoint call. Values are lowercased - * addresses (better-auth lowercases on `createUser`). + * [ADR-0093 D7] The users created in each in-flight better-auth request, + * keyed by that request's endpoint context — the object better-auth hands + * every database hook of one endpoint call and that `objectql-adapter`'s + * `onRecordCreated` reads from the same call. Values are user ids. * * Why the session seam needs it: `user.create.after` is deferred past the * creation's transaction, so the session a sign-up mints is created BEFORE * the reconciler has bound anyone. The session seam therefore settles the - * membership itself — but only for the user this same request is creating. - * Any other member-less user signing in is not mid-creation, and membership - * is decided at creation, never re-decided at sign-in. + * membership itself — but only for the user this same request created. Any + * other member-less user signing in is not mid-creation, and membership is + * decided at creation, never re-decided at sign-in. + * + * The id is captured from the adapter's own `create` result, so recognising + * the user needs no read of the store (a read that, on a driver writing + * through another connection, could miss the row). * * A `WeakMap` because the scope IS the request: the entry dies with the * endpoint context, so nothing staged here can outlive the call that staged * it and be read by a later sign-in. */ - private creationsInRequest = new WeakMap>(); + private usersCreatedInRequest = new WeakMap>(); - /** Record that the current request is creating the user with `email`. */ - private stageCreationInRequest(ctx: unknown, email: string): void { - if (!ctx || typeof ctx !== 'object' || !email) return; - let staged = this.creationsInRequest.get(ctx); + /** Record that the current request created the user `userId`. */ + private stageUserCreatedInRequest(ctx: unknown, userId: unknown): void { + if (!ctx || typeof ctx !== 'object' || typeof userId !== 'string' || !userId) return; + let staged = this.usersCreatedInRequest.get(ctx); if (!staged) { staged = new Set(); - this.creationsInRequest.set(ctx, staged); + this.usersCreatedInRequest.set(ctx, staged); } - staged.add(email.toLowerCase()); + staged.add(userId); } - /** - * Is `userId` the user the current request is creating? Answers `false` - * without touching the store when the request created nobody — the common - * sign-in — and otherwise compares the user's stored address with the staged - * ones. Never throws (a failed read answers `false`: no settle, which is the - * pre-existing behaviour of every sign-in that is not a creation). - */ - private async isCreatingInThisRequest(ctx: unknown, reader: any, userId: string): Promise { + /** Is `userId` a user the current request created? In-memory only. */ + private isCreatedInThisRequest(ctx: unknown, userId: string): boolean { if (!ctx || typeof ctx !== 'object') return false; - const staged = this.creationsInRequest.get(ctx); - if (!staged || staged.size === 0) return false; - try { - const user = await reader.findOne('sys_user', { where: { id: userId } }); - const email = typeof user?.email === 'string' ? user.email.toLowerCase() : ''; - return email !== '' && staged.has(email); - } catch { - return false; - } + return this.usersCreatedInRequest.get(ctx)?.has(userId) === true; } // ── [#11739] Audience posture — admission gate + self-registration grant ── @@ -7454,18 +7453,19 @@ export class AuthManager { // signing in later is not a race to close — whatever left them without // a membership (the policy, or an administrator) has already decided, // and a sign-in must not decide again. The creation seam - // (`user.create.before`, below) stages the address in the request's - // endpoint context; only a session minted in that same request for - // that same user settles here (`isCreatingInThisRequest`). + // (the adapter's `create`, via `onRecordCreated`) stages the new + // user's id in the request's endpoint context; only a session minted + // in that same request for that same user settles here + // (`isCreatedInThisRequest`). // // Cost is paid only where there is something to fix: a caller who - // already holds a membership never reaches this branch, a sign-in that - // created nobody stops at an in-memory lookup, and a deployment that + // already holds a membership never reaches this branch, every other + // sign-in stops at an in-memory lookup, and a deployment that // binds nobody stops at the reconciler's own policy / target-org check // without touching the store. The re-read is gated on an outcome that // means a membership now EXISTS, so the common no-bind login costs no // extra query at all. - if (!orgId && (await this.isCreatingInThisRequest(ctx, reader, userId))) { + if (!orgId && this.isCreatedInThisRequest(ctx, userId)) { const outcome = await this.settleMembership(userId); if (outcome === 'bound' || outcome === 'yielded') { orgId = await selectActiveOrg(reader, userId); @@ -7573,10 +7573,6 @@ export class AuthManager { } } const email = typeof draft?.email === 'string' ? draft.email : ''; - // ADR-0093 D7 — mark this request as the one creating `email`, so the - // session it mints settles the membership the deferred - // `user.create.after` would (see `defaultActiveOrg`). - this.stageCreationInRequest(ctx, email); if (email && this.takeOwnerVerifiedStamp(email)) { return { data: { ...draft, emailVerified: true } }; } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index e18ff60d6b9..e01f245bd50 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1553,13 +1553,35 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); }); + it('a restart after memberships are removed re-binds nobody — the admin included', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + ql.tables.sys_user.push({ id: 'member_u4' }); + ql.tables.sys_member.push({ id: 'm_u4', organization_id: 'x', user_id: 'member_u4', role: 'member' }); + expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ + 'adr-0093-default-org-owner-bind', + 'adr-0093-membership-backfill', + ]); + + // Both memberships are removed, then the process restarts. + ql.tables.sys_member.length = 0; + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + await boot(); + await hookCapture.trigger('kernel:ready'); + await hookCapture.trigger('app:seeded'); + + expect(ql.tables.sys_member).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(2); + }); + it('once the pass is recorded, a later trigger binds nobody', async () => { await boot(); // Boot: default org created, admin bound as owner, the one-time pass // runs and records itself. await hookCapture.trigger('kernel:ready'); expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); - expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-membership-backfill'); // A member-less user now exists — the shape a membership removal leaves. ql.tables.sys_user.push({ id: 'removed_u3' }); @@ -1568,7 +1590,7 @@ describe('AuthPlugin', () => { await hookCapture.trigger('kernel:ready'); expect(ql.tables.sys_member.find((m: any) => m.user_id === 'removed_u3')).toBeUndefined(); - expect(ql.tables.sys_migration).toHaveLength(1); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-membership-backfill'); }); it('OS_SKIP_MEMBERSHIP_BACKFILL=1 disables the app:seeded re-run', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 355e7668ec8..8bf5fd35126 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -54,7 +54,13 @@ import { MEMBERSHIP_POLICIES, type MembershipPolicy, } from './reconcile-membership.js'; -import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js'; +import { + buildLedgerDecisionRecord, + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + readLedgerDecision, + recordLedgerDecision, + runOneTimeMembershipBackfill, +} from './membership-backfill-ledger.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist, @@ -1149,11 +1155,44 @@ export class AuthPlugin implements Plugin { // whose membership was already decided at their creation. let runBackfillOnDefaultOrg: ((source: string) => Promise) | undefined; if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { + // ADR-0093 D7 — the platform admin's owner bind is decided ONCE, by the + // bootstrap that first resolves it, and recorded in the `sys_migration` + // ledger; after that this hook binds nobody, so a later removal of the + // admin's membership stands. Without a readable ledger the bind happens + // only on the call that CREATES the default organization (fresh + // installs keep working; an existing organization is never re-bound). + let ownerBindDecided = false; const runEnsure = async () => { try { const ql = ctx.getService('objectql'); if (!ql) return; - const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + if (ownerBindDecided) return; + const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + if (reading === 'recorded') { + ownerBindDecided = true; + return; + } + const res = await ensureDefaultOrganization(ql, { + logger: ctx.logger, + bindOnlyOnCreate: reading !== 'absent', + }); + // Decided: the admin was bound now, or already held a membership. + // `no_admin` and the two failed writes leave it open for the next pass. + if (reading === 'absent' && (res.memberCreated || res.reason === 'admin_already_in_org')) { + ownerBindDecided = await recordLedgerDecision( + ql, + buildLedgerDecisionRecord( + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + { + outcome: res.memberCreated ? 'bound' : 'admin-already-member', + ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), + }, + new Date().toISOString(), + ), + 'default organization owner bind', + ctx.logger, + ); + } if (res.defaultOrgCreated) { ctx.logger.info( `[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`, diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index affaa356c02..7775ad0ddda 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -150,6 +150,14 @@ export interface EnsureDefaultOrganizationOptions { userId: string, options: { logger?: BootstrapLogger }, ) => Promise>; + /** + * ADR-0093 D7 — bind the admin ONLY on the call that creates the default + * organization. The caller sets it when it cannot tell from its own record + * whether the owner bind was already decided (no readable deployment + * ledger): binding to an organization that already exists could then put + * back a membership that was removed. Default `false`. + */ + bindOnlyOnCreate?: boolean; } const SYSTEM_CTX = { isSystem: true }; @@ -284,7 +292,7 @@ export interface EnsureDefaultOrganizationResult { /** Whether a sys_member row was inserted binding the admin to the default org. */ memberCreated: boolean; /** Human-readable reason when the helper short-circuited. */ - reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed'; + reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed' | 'owner_bind_decided'; /** Count of the default org's seeded rows re-owned to the platform admin. */ ownershipClaimed?: number; } @@ -369,6 +377,11 @@ export async function ensureDefaultOrganization( const existingDefault = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1); if (existingDefault.length > 0 && existingDefault[0].id) { defaultOrgId = String(existingDefault[0].id); + if (options.bindOnlyOnCreate) { + // The organization predates this call, so whether its owner was bound + // was decided earlier — never re-decided here (ADR-0093 D7). + return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, reason: 'owner_bind_decided' }; + } } else { const newOrgId = genId('org'); const orgRow = await tryInsert(ql, 'sys_organization', { diff --git a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts index 287004760b5..0e80a84e407 100644 --- a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts +++ b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts @@ -40,8 +40,8 @@ * race for the request that creates the user and for nothing else: a * member-less user who signs in later — for instance one whose membership was * removed — must not be bound again. The cases below therefore drive the real - * sign-up order (`user.create.before` and `session.create.before` sharing one - * request context) for every settle they expect, and the last block pins the + * sign-up order (the user row created and the session minted in one request + * context) for every settle they expect, and the last block pins the * sign-ins that must NOT settle. */ @@ -122,19 +122,24 @@ function hooksFor(opts: ManagerOpts): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (manager as any).composeDatabaseHooks(opts.databaseHooks); + const hooks = (manager as any).composeDatabaseHooks(opts.databaseHooks); + // What the adapter's `create` does for a `sys_user` row inside the creating + // request (`onRecordCreated`; the real wiring is pinned in + // `user-created-in-request.test.ts`). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hooks.__stageCreated = (request: unknown, userId: string) => (manager as any).stageUserCreatedInRequest(request, userId); + return hooks; } /** - * Drive the sign-up order better-auth runs: `user.create.before` and the - * session's `session.create.before` share ONE endpoint context (the request), - * and `user.create.after` is deferred past both. Returns the session hook's - * result. + * Drive the sign-up order better-auth runs: the user row is created and the + * session minted inside ONE endpoint context (the request), and + * `user.create.after` is deferred past both. Returns the session hook's result. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function firstSessionOfCreation(hooks: any, session: Record = { userId: USER }): Promise { const request = {}; - await hooks.user.create.before({ email: USER_EMAIL, name: 'First Session' }, request); + hooks.__stageCreated(request, USER); return hooks.session.create.before(session, request); } @@ -222,11 +227,11 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); await firstSessionOfCreation(hooks); - // The two owner-preferred lookups of the ORIGINAL selection, the one - // read that recognises the user being created, and nothing more: - // `invite-only` is refused by the reconciler before it touches the - // store, and the re-read never runs because nothing was bound. - expect(engine.findOne).toHaveBeenCalledTimes(3); + // The two owner-preferred lookups of the ORIGINAL selection, and nothing + // more: recognising the user being created reads nothing, `invite-only` + // is refused by the reconciler before it touches the store, and the + // re-read never runs because nothing was bound. + expect(engine.findOne).toHaveBeenCalledTimes(2); expect(engine.find).not.toHaveBeenCalled(); }); }); @@ -331,7 +336,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('a creation staged in ANOTHER request does not reach this sign-in', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - await hooks.user.create.before({ email: USER_EMAIL }, {}); + hooks.__stageCreated({}, USER); const result = await hooks.session.create.before({ userId: USER }, {}); expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); @@ -345,25 +350,25 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re expect(engine.insert).not.toHaveBeenCalled(); }); - it('a session minted with no request context settles nothing', async () => { + it('email verification required: the creation seam binds with no session, and the later sign-in finds it', async () => { + // A deployment that requires verification mints no session at sign-up; + // the membership is still decided at creation, by `user.create.after`. const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - await hooks.user.create.before({ email: USER_EMAIL }, undefined); - const result = await hooks.session.create.before({ userId: USER }, undefined); - expect(result?.data?.activeOrganizationId).toBeUndefined(); - expect(engine.insert).not.toHaveBeenCalled(); + await hooks.user.create.after({ id: USER }); + expect(engine.rows).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG, user_id: USER })]); + // The first sign-in after verification is a different request: it + // decides nothing, and resolves the membership creation wrote. + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.insert).toHaveBeenCalledTimes(1); }); - it('a creation the HOST refuses is not staged', async () => { + it('a session minted with no request context settles nothing', async () => { const engine = makeEngine(); - const hooks = hooksFor({ - engine, - defaultOrgId: DEFAULT_ORG, - databaseHooks: { user: { create: { before: vi.fn(async () => false) } } }, - }); - const request = {}; - expect(await hooks.user.create.before({ email: USER_EMAIL }, request)).toBe(false); - const result = await hooks.session.create.before({ userId: USER }, request); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + hooks.__stageCreated(undefined, USER); + const result = await hooks.session.create.before({ userId: USER }, undefined); expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts index d047e2dffd7..08bd9795065 100644 --- a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts @@ -17,6 +17,7 @@ import { const ORG = 'org_default'; interface Tables { + sys_organization: Array<{ id: string }>; sys_user: Array<{ id: string }>; sys_member: Array<{ id?: string; organization_id: string; user_id: string; role?: string }>; sys_migration: Array>; @@ -24,6 +25,7 @@ interface Tables { function makeEngine(seed: Partial = {}, opts: { ledger?: boolean } = {}) { const tables: Tables = { + sys_organization: [...(seed.sys_organization ?? [])], sys_user: [...(seed.sys_user ?? [])], sys_member: [...(seed.sys_member ?? [])], sys_migration: [...(seed.sys_migration ?? [])], @@ -33,7 +35,14 @@ function makeEngine(seed: Partial = {}, opts: { ledger?: boolean } = {}) getObject: vi.fn((name: string) => name === 'sys_migration' && opts.ledger === false ? undefined : { name }, ), - find: vi.fn(async (object: string) => [...((tables as any)[object] ?? [])]), + // Honours the keyset walk's seek (`id > cursor`), order and page size. + find: vi.fn(async (object: string, q: any) => { + const gt = q?.where?.id?.$gt; + const rows = [...((tables as any)[object] ?? [])] + .filter((r: any) => gt === undefined || String(r.id) > String(gt)) + .sort((a: any, b: any) => (String(a.id) < String(b.id) ? -1 : 1)); + return rows.slice(0, q?.limit ?? rows.length); + }), findOne: vi.fn(async (object: string, q: any) => { assertEngineFindOnePredicate(object, q); return ((tables as any)[object] ?? []).find((r: any) => r.id === q?.where?.id) ?? null; @@ -104,7 +113,49 @@ describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { expect(engine.tables.sys_member).toHaveLength(0); }); - it('no target organization (multi-org, or before the default org exists): undecided, nothing recorded', async () => { + it('no target organization while organizations exist (multi-org): the refusal is recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }], sys_organization: [{ id: 'org_a' }, { id: 'org_b' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'auto', resolveTargetOrg: async () => null }, + logger(), + ); + expect(res.status).toBe('ran'); + expect(res.backfill?.reason).toBe('no-target-org'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(1); + // A later pass that now has a target binds nobody. + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('scans past one page: every member-less user is bound, members are not', async () => { + const users = Array.from({ length: 7 }, (_, i) => ({ id: `u_${i}` })); + const engine = makeEngine({ + sys_user: users, + sys_member: [{ id: 'm_1', organization_id: ORG, user_id: 'u_5', role: 'member' }], + }); + const res = await runOneTimeMembershipBackfill(engine, { ...auto, limit: 2 }, logger()); + expect(res.status).toBe('ran'); + expect(res.backfill).toMatchObject({ scanned: 7, bound: 6, skipped: 1 }); + expect(engine.tables.sys_member.map((m) => m.user_id).sort()).toEqual(users.map((u) => u.id).sort()); + }); + + it('a scan that cannot read a table in full binds nobody and records nothing', async () => { + const engine = makeEngine({ sys_user: Array.from({ length: 5 }, (_, i) => ({ id: `u_${i}` })) }); + const realFind = engine.find.getMockImplementation()!; + engine.find.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_user' && q?.where?.id?.$gt !== undefined) throw new Error('page 2 failed'); + return realFind(object, q); + }); + const res = await runOneTimeMembershipBackfill(engine, { ...auto, limit: 2 }, logger()); + expect(res.status).toBe('undecided'); + expect(res.backfill?.reason).toBe('scan-incomplete'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + + it('no target organization and no organization at all (fresh install): undecided, nothing recorded', async () => { const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); const res = await runOneTimeMembershipBackfill( engine, @@ -146,7 +197,7 @@ describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { const res = await runOneTimeMembershipBackfill(engine, auto, log); expect(res.status).toBe('ran-unrecorded'); expect(log.error).toHaveBeenCalledTimes(1); - expect(String(log.error.mock.calls[0]![0])).toContain('will run it AGAIN'); + expect(String(log.error.mock.calls[0]![0])).toContain('will decide it AGAIN'); }); it('a record written first by a concurrent pass counts as recorded', async () => { diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts index 377366d7b47..ebca910c4ab 100644 --- a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts @@ -45,13 +45,19 @@ * again: * - the pass ran (policy `auto`, a default organization to bind to); * - the policy was `invite-only`: users created under it were deliberately - * left unbound, so a later switch to `auto` must not sweep them in. + * left unbound, so a later switch to `auto` must not sweep them in; + * - no unambiguous target organization while organizations DO exist + * (multi-org, where the backfill is refused by design). * * Not recorded — nothing was decided yet, so a later pass still gets its turn: - * - no unambiguous target organization (single mode before the default - * organization exists; multi-org, where the backfill is refused by design); + * - no target organization and no organization at all (a fresh install + * before its default organization exists); + * - a scan that could not read the user or membership table in full; * - an off-vocabulary policy value, or no usable engine. * + * The same ledger also records the one-time default-organization owner bind + * ({@link DEFAULT_ORG_OWNER_BIND_MIGRATION_ID}), through the same helpers. + * * ## Failure directions * * Without a readable ledger the pass does NOT run: a pass that cannot be @@ -79,6 +85,13 @@ import { */ export const MEMBERSHIP_BACKFILL_MIGRATION_ID = 'adr-0093-membership-backfill'; +/** + * Ledger row id of the one-time default-organization owner bind: the platform + * admin is bound as owner of the default organization once, when the + * bootstrap first decides it, and never again automatically (ADR-0093 D7). + */ +export const DEFAULT_ORG_OWNER_BIND_MIGRATION_ID = 'adr-0093-default-org-owner-bind'; + /** The engine surface the ledger needs, duck-typed. */ export interface MembershipBackfillLedger { getObject(name: string): unknown; @@ -106,12 +119,15 @@ export interface OneTimeMembershipBackfillResult { backfill?: BackfillMembershipsResult; } -interface LedgerLogger { +export interface LedgerLogger { info?: (msg: string, meta?: any) => void; warn: (msg: string, meta?: any) => void; error?: (msg: string, meta?: any) => void; } +/** What the ledger says about one decision id. */ +export type LedgerDecisionReading = 'recorded' | 'absent' | 'unavailable' | 'unreadable'; + const SYSTEM_CTX = { isSystem: true }; function resolveLedger(engine: unknown): MembershipBackfillLedger | undefined { @@ -122,31 +138,137 @@ function resolveLedger(engine: unknown): MembershipBackfillLedger | undefined { return candidate as MembershipBackfillLedger; } -/** Whether a backfill summary is a verdict the deployment should remember. */ -function isRecordableVerdict(result: BackfillMembershipsResult): boolean { - return result.reason === undefined || result.reason === 'policy'; +/** + * Read whether the deployment ledger records decision `id`. Never throws: + * `unavailable` (no ledger on this kernel) and `unreadable` (the read failed) + * are answers the caller must handle, never "absent". + */ +export async function readLedgerDecision(engine: unknown, id: string): Promise { + const ledger = resolveLedger(engine); + try { + if (!ledger || !ledger.getObject(DATA_MIGRATION_FLAG_OBJECT)) return 'unavailable'; + const row = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { where: { id }, context: SYSTEM_CTX }); + return row?.id === id ? 'recorded' : 'absent'; + } catch { + return 'unreadable'; + } } -/** The ledger row for one decided pass — pure. */ -export function buildMembershipBackfillRecord( - result: BackfillMembershipsResult, - policy: unknown, - now: string, -): DataMigrationFlag { +/** The ledger row for one recorded decision — pure. */ +export function buildLedgerDecisionRecord(id: string, details: Record, now: string): DataMigrationFlag { return { - id: MEMBERSHIP_BACKFILL_MIGRATION_ID, + id, last_run_at: now, applied_at: now, verified_at: null, blocking: 0, - details: JSON.stringify({ + details: JSON.stringify(details), + }; +} + +/** The ledger row for one decided backfill pass — pure. */ +export function buildMembershipBackfillRecord( + result: BackfillMembershipsResult, + policy: unknown, + now: string, +): DataMigrationFlag { + return buildLedgerDecisionRecord( + MEMBERSHIP_BACKFILL_MIGRATION_ID, + { policy: typeof policy === 'string' ? policy : String(policy), scanned: result.scanned, bound: result.bound, skipped: result.skipped, ...(result.reason ? { reason: result.reason } : {}), - }), - }; + }, + now, + ); +} + +/** + * Insert one decision row. THROWS on failure — the caller decides what a lost + * record means. Named in `DURABILITY_CRITICAL_CALLEES` + * (`scripts/check-durability-degradation-log-level.mjs`): a lost record leaves + * every log line clean while the next boot decides again. + */ +async function persistLedgerDecisionRow(ledger: MembershipBackfillLedger, flag: DataMigrationFlag): Promise { + const now = flag.last_run_at; + await ledger.insert( + DATA_MIGRATION_FLAG_OBJECT, + { ...flag, created_at: now, updated_at: now }, + { context: SYSTEM_CTX }, + ); +} + +/** + * Record decision `flag` in the ledger. Never throws. A concurrent writer that + * landed the same id first counts as recorded; any other failure is reported + * at `error` (nothing else looks wrong, and the next boot decides again). + */ +export async function recordLedgerDecision( + engine: unknown, + flag: DataMigrationFlag, + what: string, + logger?: LedgerLogger, +): Promise { + const ledger = resolveLedger(engine); + if (!ledger) return false; + try { + await persistLedgerDecisionRow(ledger, flag); + logger?.info?.( + `[auth] ${what} recorded in ${DATA_MIGRATION_FLAG_OBJECT} (id '${flag.id}') — later boots will not decide it again`, + { id: flag.id, details: flag.details }, + ); + return true; + } catch (e: unknown) { + if ((await readLedgerDecision(engine, flag.id)) === 'recorded') return true; + // Inline, at `error`: the decision was acted on and every line reads + // clean, while the record that stops the next boot from deciding again is + // absent. + const detail = e instanceof Error ? e.message : String(e); + const message = + `[auth] ${what} was carried out, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed (${detail}). ` + + `The next boot will decide it AGAIN, re-deciding membership for users whose membership was already ` + + `decided. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this deployment (it is provisioned by ` + + `PlatformObjectsPlugin) and verify with SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = '${flag.id}'.`; + if (logger?.error) logger.error(message, { error: detail }); + else logger?.warn?.(message, { error: detail }); + return false; + } +} + +/** Whether any organization exists — `undefined` when that cannot be read. */ +async function anyOrganizationExists(engine: any): Promise { + try { + if (typeof engine?.count === 'function') { + const n = await engine.count('sys_organization', { where: {}, context: SYSTEM_CTX }); + if (typeof n === 'number') return n > 0; + } + if (typeof engine?.find !== 'function') return undefined; + const rows = await engine.find( + 'sys_organization', + { where: {}, orderBy: [{ field: 'id', order: 'asc' }], limit: 1 }, + { context: SYSTEM_CTX }, + ); + return Array.isArray(rows) ? rows.length > 0 : undefined; + } catch { + return undefined; + } +} + +/** + * Whether a backfill summary is a verdict the deployment should remember. + * + * `no-target-org` is deferred ONLY while the deployment holds no organization + * at all (a fresh install before its default organization exists). With + * organizations present and still no unambiguous target — multi-org, where + * the backfill is refused by design — the refusal IS the decision, and it is + * recorded so a later pass cannot reverse it. + */ +async function isRecordableVerdict(engine: unknown, result: BackfillMembershipsResult): Promise { + if (result.reason === undefined || result.reason === 'policy') return true; + if (result.reason === 'no-target-org') return (await anyOrganizationExists(engine)) === true; + return false; } /** @@ -159,80 +281,33 @@ export async function runOneTimeMembershipBackfill( deps: ReconcileMembershipDeps & { limit?: number }, logger?: LedgerLogger, ): Promise { - const ledger = resolveLedger(engine); - if (!ledger || !ledger.getObject(DATA_MIGRATION_FLAG_OBJECT)) { + const reading = await readLedgerDecision(engine, MEMBERSHIP_BACKFILL_MIGRATION_ID); + if (reading === 'unavailable') { logger?.warn?.( `[auth] membership backfill did NOT run: the deployment ledger (${DATA_MIGRATION_FLAG_OBJECT}) is not ` + `available on this kernel, so a pass could not be recorded and would repeat on every boot, ` + - `re-adding members an administrator removed. Pre-existing users without a membership stay ` + - `unbound until an administrator adds them. Compose PlatformObjectsPlugin (it provisions the ledger) ` + - `to let the one-time backfill run.`, + `re-deciding membership for users whose membership was already decided. Pre-existing users without ` + + `a membership stay unbound until an administrator adds them. Compose PlatformObjectsPlugin (it ` + + `provisions the ledger) to let the one-time backfill run.`, ); return { status: 'ledger-unavailable' }; } - - let existing: Record | null; - try { - existing = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: MEMBERSHIP_BACKFILL_MIGRATION_ID }, - context: SYSTEM_CTX, - }); - } catch (e: unknown) { + if (reading === 'unreadable') { logger?.warn?.( `[auth] membership backfill did NOT run: reading its record in ${DATA_MIGRATION_FLAG_OBJECT} failed, ` + - `and running without knowing whether it already ran could re-add members an administrator removed. ` + - `It is retried on the next boot.`, - { error: e instanceof Error ? e.message : String(e) }, + `and running without knowing whether it already ran could re-decide membership for users whose ` + + `membership was already decided. It is retried on the next boot.`, ); return { status: 'ledger-unreadable' }; } - if (existing?.id === MEMBERSHIP_BACKFILL_MIGRATION_ID) { - return { status: 'already-run' }; - } + if (reading === 'recorded') return { status: 'already-run' }; const backfill = await backfillMemberships(engine, deps); - if (!isRecordableVerdict(backfill)) { + if (!(await isRecordableVerdict(engine, backfill))) { return { status: 'undecided', backfill }; } - const now = new Date().toISOString(); - const flag = buildMembershipBackfillRecord(backfill, deps.policy, now); - try { - await ledger.insert( - DATA_MIGRATION_FLAG_OBJECT, - { ...flag, created_at: now, updated_at: now }, - { context: SYSTEM_CTX }, - ); - logger?.info?.( - `[auth] membership backfill recorded in ${DATA_MIGRATION_FLAG_OBJECT} (id '${MEMBERSHIP_BACKFILL_MIGRATION_ID}') ` + - `— later boots will not run it again`, - { id: MEMBERSHIP_BACKFILL_MIGRATION_ID, details: flag.details }, - ); - return { status: 'ran', backfill }; - } catch (e: unknown) { - // A concurrent pass (another node of the same deployment) may have written - // the row first — the primary key refuses the second insert. That is the - // record landing, not failing. - try { - const raced = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: MEMBERSHIP_BACKFILL_MIGRATION_ID }, - context: SYSTEM_CTX, - }); - if (raced?.id === MEMBERSHIP_BACKFILL_MIGRATION_ID) return { status: 'ran', backfill }; - } catch { - // Fall through to the loud report below — the record is unconfirmed. - } - // Inline, at `error`: the pass ran and every line reads clean, while the - // record that stops the next boot from repeating the bulk bind is absent. - const detail = e instanceof Error ? e.message : String(e); - const message = - `[auth] membership backfill ran, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed (${detail}). ` + - `The next boot will run it AGAIN and re-add every user without a membership to the default organization, ` + - `including members an administrator removed since. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this ` + - `deployment (it is provisioned by PlatformObjectsPlugin) and verify with ` + - `SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = '${MEMBERSHIP_BACKFILL_MIGRATION_ID}'.`; - if (logger?.error) logger.error(message, { error: detail }); - else logger?.warn?.(message, { error: detail }); - return { status: 'ran-unrecorded', backfill }; - } + const flag = buildMembershipBackfillRecord(backfill, deps.policy, new Date().toISOString()); + const recorded = await recordLedgerDecision(engine, flag, 'membership backfill', logger); + return { status: recorded ? 'ran' : 'ran-unrecorded', backfill }; } diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 3535477b3a0..61785b0bc40 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -736,7 +736,22 @@ export const withSystemReadContext = withSystemContext; * @param dataEngine - ObjectQL data engine instance * @returns better-auth AdapterFactory */ -export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { +export interface ObjectQLAdapterFactoryOptions { + /** + * Called after every successful `create`, with the protocol object name and + * the row the engine returned, before `create` resolves — so it runs inside + * the better-auth endpoint call that wrote the row, ahead of any later write + * of that call. Awaited; a throw is swallowed (bookkeeping must never fail a + * write that landed). The AuthManager uses it to know which user the current + * request created without reading the row back (ADR-0093 D7). + */ + onRecordCreated?: (objectName: string, row: Record) => void | Promise; +} + +export function createObjectQLAdapterFactory( + rawDataEngine: IDataEngine, + factoryOptions: ObjectQLAdapterFactoryOptions = {}, +) { const dataEngine = withSystemContext(rawDataEngine); // [#8009] The OIDC `clientSecret` seam needs the engine's PRIVILEGED secret // dereference, which `withSystemContext` deliberately does not carry (it @@ -867,6 +882,13 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { // why the seam is here and what adoption does to the role. const adopted = await adoptExistingMembership(dataEngine as any, objectName, row); const result = adopted ?? (await dataEngine.insert(objectName, row)); + if (factoryOptions.onRecordCreated && result && typeof result === 'object') { + try { + await factoryOptions.onRecordCreated(objectName, result as Record); + } catch { + // Bookkeeping only — the row landed and the write stands. + } + } const norm = normaliseLegacyDates(model, result); return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.ts b/packages/plugins/plugin-auth/src/reconcile-membership.ts index 3b75122e621..0bc5c20422b 100644 --- a/packages/plugins/plugin-auth/src/reconcile-membership.ts +++ b/packages/plugins/plugin-auth/src/reconcile-membership.ts @@ -47,6 +47,7 @@ * value is rejected loudly, never coerced to `auto`. */ +import { keysetWalk } from '@objectstack/types'; import { authSystemWriteContext } from './auth-actor-attribution.js'; /** @@ -269,7 +270,7 @@ export interface BackfillMembershipsResult { scanned: number; bound: number; skipped: number; - reason?: 'policy' | 'invalid-policy' | 'no-target-org' | 'engine-unavailable'; + reason?: 'policy' | 'invalid-policy' | 'no-target-org' | 'engine-unavailable' | 'scan-incomplete'; /** * Present with `reason: 'invalid-policy'` — the refusal message, naming the * offending value. Carried on the result, not only logged, so the diagnosis @@ -278,14 +279,44 @@ export interface BackfillMembershipsResult { error?: string; } +/** Rows per page of the backfill's keyset walks. */ +const BACKFILL_PAGE_SIZE = 500; + /** - * One-shot backfill for pre-existing member-less users (ADR-0093 D6). Run on - * `kernel:ready` in single mode with `membershipPolicy: 'auto'` only — multi-org - * backfill is refused by design (there is no correct guess, and a wrong org in a - * tenant-isolated deployment is a data-exposure bug, not a convenience). + * Walk every row of `object` in id order (keyset pagination, no row cap). + * Resolves `undefined` when the walk could not see the whole table — a read + * failed, or the reader could not seek — because the backfill's verdict is + * recorded once (ADR-0093 D7) and a partial scan must not become permanent. + */ +async function walkAll(engine: any, object: string, pageSize: number): Promise { + const walk = keysetWalk>( + async (q) => { + const page = await engine.find(object, { ...q }, { context: SYSTEM_CTX }); + return Array.isArray(page) ? page : []; + }, + { pageSize }, + ); + const rows: any[] = []; + try { + for await (const page of walk.pages()) rows.push(...page); + } catch { + return undefined; + } + return walk.truncated ? undefined : rows; +} + +/** + * One-time backfill for pre-existing member-less users (ADR-0093 D6; one-time + * per ADR-0093 D7, see `membership-backfill-ledger.ts`). Single mode with + * `membershipPolicy: 'auto'` only — multi-org backfill is refused by design + * (there is no correct guess, and a wrong org in a tenant-isolated deployment + * is a data-exposure bug, not a convenience). * - * Bounded, idempotent, failure-isolated per user. Ordered after the default-org - * bootstrap so a target org exists. + * Reads both tables WHOLE through keyset pages — no row cap, because the + * verdict is recorded once and a capped scan would leave every user past the + * cap unbound for good. A scan that cannot complete binds nobody and answers + * `scan-incomplete`. Idempotent, failure-isolated per user. Ordered after the + * default-org bootstrap so a target org exists. `limit` is the page size. */ export async function backfillMemberships( engine: any, @@ -309,9 +340,17 @@ export async function backfillMemberships( return { ...summary, reason: 'no-target-org' }; } - const limit = deps.limit ?? 5000; - const users = await findRows(engine, 'sys_user', {}, limit); - const members = await findRows(engine, 'sys_member', {}, limit); + const pageSize = deps.limit ?? BACKFILL_PAGE_SIZE; + const members = await walkAll(engine, 'sys_member', pageSize); + const users = members ? await walkAll(engine, 'sys_user', pageSize) : undefined; + if (!members || !users) { + deps.logger?.warn?.( + '[membership] backfill did not run: the user or membership table could not be read in full, ' + + 'so no user was bound and the pass is retried on the next boot', + { organizationId }, + ); + return { ...summary, reason: 'scan-incomplete' }; + } const membered = new Set(members.map((m: any) => String(m?.user_id ?? '')).filter(Boolean)); for (const user of users) { diff --git a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts new file mode 100644 index 00000000000..9922e4fe738 --- /dev/null +++ b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0093 D7 — the session seam recognises the user its request CREATED from + * the adapter's own `create` result, with no read of the store. These cases + * pin the real wiring: the AuthManager's adapter factory, a `sys_user` create + * inside a better-auth endpoint context, and the session hook of that context. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runWithEndpointContext } from '@better-auth/core/context'; +import { AuthManager } from './auth-manager.js'; +import { createObjectQLAdapterFactory } from './objectql-adapter.js'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; + +const DEFAULT_ORG = 'org_default'; + +function makeEngine() { + const tables: Record = { sys_user: [], sys_member: [] }; + let seq = 0; + const match = (r: any, where: any = {}) => + Object.entries(where).every(([k, v]) => r[k] === v); + return { + tables, + insert: vi.fn(async (object: string, data: any) => { + const row = { ...data, id: data.id ?? `${object}_${++seq}` }; + (tables[object] ??= []).push(row); + return row; + }), + find: vi.fn(async (object: string, q: any) => + (tables[object] ?? []).filter((r) => match(r, q?.where)).slice(0, q?.limit ?? 100), + ), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return (tables[object] ?? []).find((r) => match(r, q?.where)) ?? null; + }), + }; +} + +async function setup() { + const engine = makeEngine(); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + dataEngine: engine, + getTenancy: () => ({ defaultOrgId: async () => DEFAULT_ORG }), + } as any); + const factory = await (manager as any).createDatabaseConfig(); + const adapter: any = factory({} as any); + const hooks: any = (manager as any).composeDatabaseHooks(undefined); + const createUser = (request: object) => + runWithEndpointContext(request as any, () => + adapter.create({ + model: 'user', + data: { email: 'created.here@example.com', name: 'Created Here', emailVerified: false }, + }), + ); + return { engine, hooks, createUser }; +} + +describe('the creating request is recognised from the adapter create result (ADR-0093 D7)', () => { + it('a user created through the adapter in this request is settled by its session', async () => { + const { engine, hooks, createUser } = await setup(); + const request = {}; + const created = await createUser(request); + const reads = engine.findOne.mock.calls.length; + const result = await hooks.session.create.before({ userId: created.id }, request); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.tables.sys_member).toEqual([ + expect.objectContaining({ organization_id: DEFAULT_ORG, user_id: created.id }), + ]); + // Recognising the user read nothing from `sys_user`. + const userReads = engine.findOne.mock.calls.slice(reads).filter((c) => c[0] === 'sys_user'); + expect(userReads).toHaveLength(0); + }); + + it('CONTROL: the same user signing in from another request is not settled', async () => { + const { engine, hooks, createUser } = await setup(); + const created = await createUser({}); + const result = await hooks.session.create.before({ userId: created.id }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.tables.sys_member).toHaveLength(0); + }); +}); + +describe('objectql-adapter onRecordCreated', () => { + it('reports the protocol object name and the stored row', async () => { + const engine = makeEngine(); + const onRecordCreated = vi.fn(); + const adapter: any = (createObjectQLAdapterFactory(engine as any, { onRecordCreated }) as any)({} as any); + const row = await adapter.create({ model: 'user', data: { email: 'a@example.com', name: 'A', emailVerified: false } }); + expect(onRecordCreated).toHaveBeenCalledTimes(1); + expect(onRecordCreated.mock.calls[0]![0]).toBe('sys_user'); + expect(onRecordCreated.mock.calls[0]![1]).toMatchObject({ id: row.id }); + }); + + it('a throwing callback does not fail a write that landed', async () => { + const engine = makeEngine(); + const onRecordCreated = vi.fn(async () => { + throw new Error('bookkeeping down'); + }); + const adapter: any = (createObjectQLAdapterFactory(engine as any, { onRecordCreated }) as any)({} as any); + await expect( + adapter.create({ model: 'user', data: { email: 'b@example.com', name: 'B', emailVerified: false } }), + ).resolves.toMatchObject({ email: 'b@example.com' }); + expect(engine.tables.sys_user).toHaveLength(1); + }); +}); diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index f6a74d76a5e..e105dceffc1 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -400,6 +400,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistSeedTenancyReceiptRow', "The `sys_migration` receipt for the #8686 seed/API tenancy repair was never written — the repair itself SUCCEEDED, so the rows already carry the organization, the `__global__` counter is already gone, and every log line reads clean, while the only durable record that a boot rewrote stored data does not exist. Not retried: the repair is idempotent, so the next boot finds no split and writes no receipt either, and the one `logger.info` line that named it is gone as soon as the container is replaced. That leaves \"was my data rewritten, and when\" unanswerable from the deployment — which is the defect #9451 exists to remove, reproduced by its own fix.", ], + [ + 'persistLedgerDecisionRow', + "A one-time membership decision (the ADR-0093 D6 backfill, or the default organization's owner bind) was carried out but its `sys_migration` record was never written. Every log line reads clean and the memberships it wrote stand, while the record that makes the decision ONE-TIME is absent, so the next boot decides it again — re-deciding membership for users whose membership was already decided at creation (ADR-0093 D7).", + ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", From 10d5aab79183f886ff8ca952e61c1da969c8c8d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:56:55 +0000 Subject: [PATCH 06/15] fix(scripts): mirror the new durability seam in the swallow census vocabulary copy Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- scripts/measure-durability-swallow-family.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 8b79efa73f8..1f5656b05ff 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -328,6 +328,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['deleteMetaItemFromLoader', 'gate-vocabulary'], ['persistPackageCommitRow', 'gate-vocabulary'], ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], + ['persistLedgerDecisionRow', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'], From 34782539ce688ae053a377e21f286a77325bee78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:57:48 +0000 Subject: [PATCH 07/15] test(plugin-auth): give the backfill fixture membership its primary key Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- packages/plugins/plugin-auth/src/reconcile-membership.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.test.ts b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts index 696fce5bed3..137b1f3af81 100644 --- a/packages/plugins/plugin-auth/src/reconcile-membership.test.ts +++ b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts @@ -128,7 +128,9 @@ describe('backfillMemberships', () => { it('binds only the member-less users (single/auto)', async () => { const engine = makeEngine({ users: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }], - members: [{ organization_id: 'org_default', user_id: 'u2' }], + // A stored membership carries its primary key; the backfill's keyset + // walk seeks on it, and a row without one cannot be paged past. + members: [{ id: 'm2', organization_id: 'org_default', user_id: 'u2' } as any], }); const res = await backfillMemberships(engine, { policy: 'auto', From 80573475cddf9fbb73aae5d7b63bc27b0abb2df9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:31:39 +0000 Subject: [PATCH 08/15] chore: record the new pinned engine double and refresh the census stamp Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- content/docs/permissions/tenant-audit-census.mdx | 2 +- docs/audits/2026-08-tenant-audit-write-call-sites.counts.md | 2 +- scripts/engine-double-contract.pinned.json | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index e7a86256261..ab081f68f9c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -297,7 +297,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `f41cb3fe7`. +Measured on 2026-10-05 at `34782539c`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index c6ee6554ebf..5bfd9bfd4ab 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -90,7 +90,7 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `f41cb3fe7`. +Measured on 2026-10-05 at `34782539c`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 0500c5f8252..f8480f99826 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2881,6 +2881,11 @@ "verb": "delete", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/user-created-in-request.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-email/src/attachment-reclaim.test.ts", "verb": "delete", From cce21c3ca51ae4fecf976c9bf124419f5fe30944 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:30:13 +0000 Subject: [PATCH 09/15] test(plugin-auth): the created-in-request engine double refuses combinators it does not implement Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../plugins/plugin-auth/src/user-created-in-request.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts index 9922e4fe738..07845eb076c 100644 --- a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts +++ b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts @@ -19,7 +19,10 @@ function makeEngine() { const tables: Record = { sys_user: [], sys_member: [] }; let seq = 0; const match = (r: any, where: any = {}) => - Object.entries(where).every(([k, v]) => r[k] === v); + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + return r[k] === v; + }); return { tables, insert: vi.fn(async (object: string, data: any) => { From 8a49c6d79102c0fdbc1cbd3354a5a7ea27b428b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:54:13 +0000 Subject: [PATCH 10/15] fix(plugin-auth,organizations): one owner-bind gate for both default-org wirings; latch one-time decisions in-process Also: the showcase seed writes its personas' membership at creation, and the keyset walk judges a stalled cursor by equality instead of JS string order. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../21791-membership-decided-at-creation.md | 10 +- .../src/security/seed-approval-demo.ts | 29 +++++- .../organizations/src/organizations-plugin.ts | 15 ++- ...ed-default-org-self-registrant.pin.test.ts | 48 +++++++++ .../plugin-auth/src/auth-plugin.test.ts | 87 +++++++++++++++++ .../plugins/plugin-auth/src/auth-plugin.ts | 64 ++++-------- .../src/default-org-bootstrap-once.ts | 97 +++++++++++++++++++ .../src/ensure-default-organization.ts | 11 +++ packages/plugins/plugin-auth/src/index.ts | 3 + .../src/user-created-in-request.test.ts | 10 ++ ...se-demo-personas-loginable.dogfood.test.ts | 12 +++ packages/types/src/keyset-walk.test.ts | 22 +++++ packages/types/src/keyset-walk.ts | 14 ++- 13 files changed, 368 insertions(+), 54 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md index 883f6d595ee..5f0d4f43d49 100644 --- a/.changeset/21791-membership-decided-at-creation.md +++ b/.changeset/21791-membership-decided-at-creation.md @@ -1,5 +1,7 @@ --- '@objectstack/plugin-auth': patch +'@objectstack/organizations': patch +'@objectstack/types': patch --- Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7. @@ -7,9 +9,11 @@ Membership under the `auto` policy is settled when the user is created, per ADR- Clause-②: no - **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later. -- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. -- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it. That decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind`. Without a readable ledger, the bind happens only when the bootstrap creates the default organization. +- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment, and once per process even if its record cannot be written. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. +- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. Without a readable ledger, the owner is bound only when the bootstrap creates the default organization. - **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded. - **Upgrade.** The first boot of an upgraded deployment runs the backfill once. - **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before. -- **Narrowed.** Once the backfill is recorded, a `sys_user` row inserted straight through the data engine is not bound by a later `app:seeded` pass, because it never passes through user creation. The shipped examples create users through sign-up, so they are unaffected. +- **Narrowed.** A `sys_user` row inserted straight through the data engine never passes through user creation. Once the backfill is recorded, a later `app:seeded` pass leaves it unbound. That includes users written by a seed that finishes after its inline budget. Code that inserts users this way must write their membership itself; the showcase approval-demo personas now do. +- **`keysetWalk` (`@objectstack/types`).** The walk now decides that a page did not advance only when it gets back the same cursor key or the same page again. It no longer compares keys in JavaScript string order, which disagrees with database collations and could report a healthy walk as truncated. +- **`createEnsureDefaultOrganizationOnce`** (new export of `@objectstack/plugin-auth`) is the gated bootstrap both wirings call. `ensureDefaultOrganization` gains the `bindOnlyOnCreate` and `bindOwner` options and the `owner_bind_decided` reason. diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 99ed7939e74..a0d0b09a7e7 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -235,6 +235,7 @@ async function ensureCredentialAccount( async function ensureDemoUser( ctx: ApprovalDemoContext, user: { id: string; name: string; email: string; phone_number?: string }, + organizationId: string | null, ): Promise { const existing = await findOne(ctx, 'sys_user', { email: user.email }); if (existing?.id) return String(existing.id); @@ -246,6 +247,12 @@ async function ensureDemoUser( // never provisioned and its surfaces render empty. await ctx.ql.insert('sys_user', { ...user }, { context: SYS }); ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email }); + // This raw insert never crosses the platform's user-creation seam, so the + // membership a created user gets there (ADR-0093 D7: decided at creation) + // is written here, once, by the code that creates the persona. A persona + // that already exists is left as it is — its membership was decided when + // it was created. + if (organizationId) await ensureDemoMembership(ctx, user.id, organizationId); return user.id; } catch (err) { // Non-fatal, and the whole persona is lost when it happens: with no row @@ -260,6 +267,24 @@ async function ensureDemoUser( } } +/** Bind a freshly created persona to the admin's organization as a plain member. */ +async function ensureDemoMembership(ctx: ApprovalDemoContext, userId: string, organizationId: string): Promise { + const existing = await findOne(ctx, 'sys_member', { user_id: userId, organization_id: organizationId }); + if (existing) return; + try { + await ctx.ql.insert( + 'sys_member', + { id: `mem_showcase_${userId}`, organization_id: organizationId, user_id: userId, role: 'member' }, + { context: SYS }, + ); + } catch (err) { + ctx.logger?.warn?.('[showcase] approval-demo persona membership failed (persona has no organization)', { + userId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + /** * Launch a signoff flow on a record through the real automation engine, unless * a pending request already exists for it. @@ -350,11 +375,11 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin'); // Mei holds no approval position, which makes her a clean *submitter* — a // requester who is never also one of her own approvers. - const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER)) ?? null; + const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER, organizationId)) ?? null; // The auditor persona backs the `finance` group of the per-group demo. It // deliberately holds ONLY `auditor`, so the two groups have distinct // holders and the request stays open until each group has answered. - const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER); + const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER, organizationId); if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor'); // [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 02c022f8787..597275e19bf 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -3,8 +3,8 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; import type { OrgScopingEngine } from './org-scoping-engine.js'; -import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; -import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { createEnsureDefaultOrganizationOnce, isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; import { organizationsObjects, @@ -468,9 +468,18 @@ export class OrganizationsPlugin implements Plugin { // ── Default-org bootstrap on kernel:ready + on admin grant ──────── if (this.opts.ensureDefaultOrganization) { + // ADR-0093 D7 — the SAME once-gate the single-org AuthPlugin uses: the + // owner bind (and the seed-ownership handoff that follows it) is decided + // once, recorded in the `sys_migration` ledger and latched in-process; + // after that a missing default organization is recreated without + // binding anyone, so a removed owner's membership stays removed. + const ensureOnce = createEnsureDefaultOrganizationOnce({ + logger: ctx.logger, + claimSeedOwnership: claimOrgSeedOwnership, + }); const runEnsure = async () => { try { - const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + const res = await ensureOnce(ql); if (res.defaultOrgCreated) { ctx.logger.info( `[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`, diff --git a/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts index a0d55d0183a..e0c465f914f 100644 --- a/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts +++ b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts @@ -50,6 +50,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { OrganizationsPlugin } from './organizations-plugin.js'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; // ⛔ No entitlement grant: the open package has no licence gate to satisfy // (ADR-0132 boundary 3). @@ -373,3 +374,50 @@ describe('walled + invite-only: the multi-org default-org bootstrap never joins expect(inserts).toEqual([]); }); }); + +// ADR-0093 D7 — the walled wiring calls the SAME once-gate as the single-org +// AuthPlugin: the operator's owner bind is decided once, recorded in the +// `sys_migration` ledger, and a removed owner membership is never re-bound by a +// later bootstrap trigger. +describe('walled: the default-org owner bind is decided once (ADR-0093 D7)', () => { + it('after the recorded bind, removing the owner membership sticks across later triggers', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + resetPlatformAdminEmailMemo(); + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [{ id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-03-03T00:00:00.000Z' }], + sys_organization: [], + sys_member: [], + sys_migration: [], + }; + const { ql, inserts, middlewares } = makeEngine(store); + // The deployment ledger: registered, read by primary key. + ql.getObject = (name: string) => (name in store ? { name } : undefined); + ql.findOne = vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query); + return (store[object] ?? []).find((r) => r.id === query?.where?.id) ?? null; + }); + const { ctx, trigger } = makeCtx(ql); + const plugin = new OrganizationsPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + await trigger('kernel:bootstrapped'); + await trigger('kernel:ready'); + const memberInserts = () => inserts.filter((i) => i.object === 'sys_member'); + + // CONTROL: the first bootstrap binds the operator and records the decision. + expect(memberInserts()).toHaveLength(1); + expect(memberInserts()[0]!.data).toMatchObject({ user_id: 'usr_ops', role: 'owner' }); + expect(store.sys_migration!.map((r) => r.id)).toEqual(['adr-0093-default-org-owner-bind']); + + // The owner's membership is removed; every later trigger binds nobody. + store.sys_member!.length = 0; + const bootstrapMw = middlewares[middlewares.length - 1]; + await bootstrapMw({ object: 'sys_user', operation: 'update', data: { id: 'usr_ops', email_verified: true } }, async () => {}); + await trigger('kernel:ready'); + expect(memberInserts()).toHaveLength(1); + expect(store.sys_member).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index e01f245bd50..4dc1f6bb9a6 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1575,6 +1575,93 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_migration).toHaveLength(2); }); + const fireBootstrapWrite = async (opCtx: any) => { + for (const [mw] of ql.registerMiddleware.mock.calls) { + await mw(opCtx, async () => {}); + } + }; + + it('the default-org-created trigger runs the one-time pass', async () => { + // No platform admin at kernel:ready: no organization, the pass has no + // target and records nothing. + ql.tables.sys_user_permission_set = []; + ql.tables.sys_user.push({ id: 'early_u5' }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); + + // The admin's grant lands: the bootstrap creates the organization, and + // that moment runs the pass — no restart, no app:seeded. + ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null }); + await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' }); + await vi.waitFor(() => { + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' }); + }); + expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ + 'adr-0093-default-org-owner-bind', + 'adr-0093-membership-backfill', + ]); + }); + + it('unreadable ledger + an existing default organization + an unbound admin: the admin is NOT bound', async () => { + ql.tables.sys_organization.push({ id: 'org_existing', slug: 'default' }); + const realFindOne = ql.findOne.getMockImplementation(); + ql.findOne.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_migration') throw new Error('ledger read failed'); + return realFindOne(object, q); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'admin')).toBeUndefined(); + expect(ql.tables.sys_migration).toHaveLength(0); + }); + + it('CONTROL: unreadable ledger on a fresh install still creates the organization and binds its owner', async () => { + const realFindOne = ql.findOne.getMockImplementation(); + ql.findOne.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_migration') throw new Error('ledger read failed'); + return realFindOne(object, q); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'admin', role: 'owner' })]); + }); + + it('a failed ledger write is latched in-process: a second trigger re-binds nobody', async () => { + const realInsert = ql.insert.getMockImplementation(); + ql.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') throw new Error('ledger read-only'); + return realInsert(object, data); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_migration).toHaveLength(0); + expect(mockContext.logger.error).toHaveBeenCalled(); + + // Memberships are removed, a member-less user appears, and every + // trigger fires again in the same process. + ql.tables.sys_member.length = 0; + ql.tables.sys_user.push({ id: 'later_u6' }); + await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); + await hookCapture.trigger('app:seeded'); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('recorded owner bind + a deleted default organization: the organization is recreated, nobody is bound', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind'); + ql.tables.sys_organization.length = 0; + ql.tables.sys_member.length = 0; + await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toHaveLength(0); + }); + it('once the pass is recorded, a later trigger binds nobody', async () => { await boot(); // Boot: default org created, admin bound as owner, the one-time pass diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8bf5fd35126..f36179871dd 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -41,10 +41,7 @@ import { DEFAULT_AUTH_BASE_PATH, type AuthManagerOptions, } from './auth-manager.js'; -import { - ensureDefaultOrganization, - isDefaultOrganizationBootstrapTrigger, -} from './ensure-default-organization.js'; +import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; @@ -54,13 +51,8 @@ import { MEMBERSHIP_POLICIES, type MembershipPolicy, } from './reconcile-membership.js'; -import { - buildLedgerDecisionRecord, - DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, - readLedgerDecision, - recordLedgerDecision, - runOneTimeMembershipBackfill, -} from './membership-backfill-ledger.js'; +import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js'; +import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist, @@ -1155,44 +1147,17 @@ export class AuthPlugin implements Plugin { // whose membership was already decided at their creation. let runBackfillOnDefaultOrg: ((source: string) => Promise) | undefined; if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { - // ADR-0093 D7 — the platform admin's owner bind is decided ONCE, by the - // bootstrap that first resolves it, and recorded in the `sys_migration` - // ledger; after that this hook binds nobody, so a later removal of the - // admin's membership stands. Without a readable ledger the bind happens - // only on the call that CREATES the default organization (fresh - // installs keep working; an existing organization is never re-bound). - let ownerBindDecided = false; + // ADR-0093 D7 — the platform admin's owner bind is decided ONCE + // (`default-org-bootstrap-once.ts`, the gate the walled wiring in + // `@objectstack/organizations` calls too): recorded in the + // `sys_migration` ledger and latched in-process, after which this hook + // still recreates a missing default organization but binds nobody. + const ensureOnce = createEnsureDefaultOrganizationOnce({ logger: ctx.logger }); const runEnsure = async () => { try { const ql = ctx.getService('objectql'); if (!ql) return; - if (ownerBindDecided) return; - const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); - if (reading === 'recorded') { - ownerBindDecided = true; - return; - } - const res = await ensureDefaultOrganization(ql, { - logger: ctx.logger, - bindOnlyOnCreate: reading !== 'absent', - }); - // Decided: the admin was bound now, or already held a membership. - // `no_admin` and the two failed writes leave it open for the next pass. - if (reading === 'absent' && (res.memberCreated || res.reason === 'admin_already_in_org')) { - ownerBindDecided = await recordLedgerDecision( - ql, - buildLedgerDecisionRecord( - DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, - { - outcome: res.memberCreated ? 'bound' : 'admin-already-member', - ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), - }, - new Date().toISOString(), - ), - 'default organization owner bind', - ctx.logger, - ); - } + const res = await ensureOnce(ql); if (res.defaultOrgCreated) { ctx.logger.info( `[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`, @@ -1246,8 +1211,14 @@ export class AuthPlugin implements Plugin { // `app:seeded` from multiple app bundles) don't race the same scan and // trip the (organization_id, user_id) unique index into warn noise. let backfillChain: Promise = Promise.resolve(); + // Latched once the one-time pass has decided in this process — read + // from the ledger, or carried out now. A record that failed to land + // ('ran-unrecorded') does not unlatch it: a second trigger in this + // process must not run the pass again (ADR-0093 D7). + let backfillDecided = false; const runBackfill = (source: string): Promise => { backfillChain = backfillChain.then(async () => { + if (backfillDecided) return; try { // #5152 — the policy this pass runs under is a SETTING, so bind the // namespace before reading it. This hook is registered in `init()` @@ -1281,6 +1252,9 @@ export class AuthPlugin implements Plugin { }, ctx.logger, ); + if (outcome.status === 'ran' || outcome.status === 'ran-unrecorded' || outcome.status === 'already-run') { + backfillDecided = true; + } const res = outcome.backfill; if (res && res.bound > 0) { ctx.logger.info( diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts new file mode 100644 index 00000000000..4a449b82777 --- /dev/null +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The default-organization bootstrap with its owner bind decided ONCE + * (ADR-0093 D7: membership is decided at creation, never re-decided). + * + * `ensureDefaultOrganization` binds the platform admin as owner whenever the + * admin holds no membership, and its wirings run it on every `kernel:ready` + * and on every `sys_user` write that can move the platform-admin answer. On + * its own that re-binds an admin whose membership was removed. This wrapper + * is the ONE gate both wirings call — the single-org `AuthPlugin` and the + * walled `@objectstack/organizations` plugin — so the two postures cannot + * disagree about it: + * + * - the decision is recorded in the `sys_migration` ledger + * ({@link DEFAULT_ORG_OWNER_BIND_MIGRATION_ID}) the first time the + * bootstrap binds the admin, or finds the admin already holding a + * membership; + * - once recorded — or once acted on in this process, whether or not the + * record landed — the bootstrap still (re)creates a missing default + * organization but binds nobody and hands over no seed ownership; + * - with no readable ledger it binds only on the call that CREATES the + * default organization, so a fresh install still gets its owner and an + * existing organization is never re-bound. + */ + +import { + ensureDefaultOrganization, + type EnsureDefaultOrganizationOptions, + type EnsureDefaultOrganizationResult, +} from './ensure-default-organization.js'; +import { + buildLedgerDecisionRecord, + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + readLedgerDecision, + recordLedgerDecision, + type LedgerLogger, +} from './membership-backfill-ledger.js'; + +export interface EnsureDefaultOrganizationOnceOptions { + logger?: LedgerLogger & EnsureDefaultOrganizationOptions['logger']; + /** Injected seed-ownership handoff (the walled wiring passes its own). */ + claimSeedOwnership?: EnsureDefaultOrganizationOptions['claimSeedOwnership']; + /** The bootstrap to gate — the plugin-auth helper unless a test injects one. */ + ensure?: typeof ensureDefaultOrganization; +} + +/** + * Build the gated bootstrap. The returned function carries the in-process + * latch, so a wiring creates ONE and calls it from every trigger. + */ +export function createEnsureDefaultOrganizationOnce( + options: EnsureDefaultOrganizationOnceOptions = {}, +): (ql: any) => Promise { + const ensure = options.ensure ?? ensureDefaultOrganization; + // Latched once the owner bind is decided in this process — read from the + // ledger, or acted on now. A record that failed to land does not unlatch + // it: the next trigger in this process must not bind again. + let decided = false; + + return async (ql: any): Promise => { + const base: EnsureDefaultOrganizationOptions = { + logger: options.logger, + ...(options.claimSeedOwnership ? { claimSeedOwnership: options.claimSeedOwnership } : {}), + }; + if (decided) return ensure(ql, { ...base, bindOwner: false }); + + const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + if (reading === 'recorded') { + decided = true; + return ensure(ql, { ...base, bindOwner: false }); + } + + const res = await ensure(ql, { ...base, bindOnlyOnCreate: reading !== 'absent' }); + // Decided: the admin was bound now, or already held a membership. + // `no_admin` and the failed writes leave it open for the next trigger. + const actedOn = res.memberCreated || res.reason === 'admin_already_in_org'; + if (!actedOn) return res; + decided = true; + if (reading === 'absent') { + await recordLedgerDecision( + ql, + buildLedgerDecisionRecord( + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + { + outcome: res.memberCreated ? 'bound' : 'admin-already-member', + ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), + }, + new Date().toISOString(), + ), + 'default organization owner bind', + options.logger, + ); + } + return res; + }; +} diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 7775ad0ddda..8d76fe4c064 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -158,6 +158,12 @@ export interface EnsureDefaultOrganizationOptions { * back a membership that was removed. Default `false`. */ bindOnlyOnCreate?: boolean; + /** + * ADR-0093 D7 — `false` once the owner bind has been decided: the default + * organization is still (re)created when it is missing, but nobody is bound + * to it and no seed ownership is handed over. Default `true`. + */ + bindOwner?: boolean; } const SYSTEM_CTX = { isSystem: true }; @@ -416,6 +422,11 @@ export async function ensureDefaultOrganization( defaultOrgCreated = true; } + if (options.bindOwner === false) { + // The owner bind was decided earlier (ADR-0093 D7) — never re-decided. + return { defaultOrgCreated, defaultOrgId, memberCreated: false, reason: 'owner_bind_decided' }; + } + // 5. Bind the admin as owner. const memRow = await tryInsert(ql, 'sys_member', { id: genId('mem'), diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index e1612f1b725..21cbc0f50e6 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -11,6 +11,9 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './ensure-default-organization.js'; +// ADR-0093 D7 — the default-org bootstrap with its owner bind decided once; +// the walled `@objectstack/organizations` wiring calls the same gate. +export * from './default-org-bootstrap-once.js'; // #17440 — the read-only preflight that guards the retirement of // `sys_account.issuer`, plus the re-point guard that answers the one case the // retired column still discriminated. Exported because a host that upgrades diff --git a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts index 07845eb076c..1d65db095e8 100644 --- a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts +++ b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts @@ -77,6 +77,16 @@ describe('the creating request is recognised from the adapter create result (ADR expect(userReads).toHaveLength(0); }); + it('a request that created one user does not settle a DIFFERENT pre-existing member-less user', async () => { + const { engine, hooks, createUser } = await setup(); + engine.tables.sys_user.push({ id: 'usr_preexisting', email: 'pre@example.com' }); + const request = {}; + await createUser(request); + const result = await hooks.session.create.before({ userId: 'usr_preexisting' }, request); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.tables.sys_member).toHaveLength(0); + }); + it('CONTROL: the same user signing in from another request is not settled', async () => { const { engine, hooks, createUser } = await setup(); const created = await createUser({}); diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index e1f6f4d7f15..73f89fadb1c 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -118,6 +118,18 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { } }); + it('each persona is created INTO the admin\'s organization — a raw-inserted user gets its membership from its creator', async () => { + const adminMembers = rowsOf(await ql.find('sys_member', { where: { user_id: adminId }, limit: 5, context: SYS })); + expect(adminMembers.length, 'the dev admin holds a membership the seed resolves its organization from').toBeGreaterThan(0); + const adminOrgs = adminMembers.map((m) => String(m.organization_id)); + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + const members = rowsOf(await ql.find('sys_member', { where: { user_id: persona.id }, limit: 5, context: SYS })); + expect(members.length, `${persona.email} has an organization`).toBe(1); + expect(adminOrgs, `${persona.email} is a member of the admin's organization`).toContain(String(members[0]!.organization_id)); + expect(members[0]!.role, `${persona.email} is a plain member`).toBe('member'); + } + }); + it('each persona holds a credential account resolvable under the SAME key better-auth uses for the admin', async () => { const adminAccount = await credentialAccountOf(adminId); // The control: better-auth really does write an account of this shape for diff --git a/packages/types/src/keyset-walk.test.ts b/packages/types/src/keyset-walk.test.ts index 008281ec544..6cfc4d6052d 100644 --- a/packages/types/src/keyset-walk.test.ts +++ b/packages/types/src/keyset-walk.test.ts @@ -179,6 +179,28 @@ describe('keysetWalk', () => { expect(calls).toBeLessThan(5); }); + it('a driver collation that orders keys differently from JS strings is NOT truncation', async () => { + // A case-insensitive collation (the PG/MySQL default shape) sorts 'b02' + // before 'C03' before 'd04', while JS string order puts 'C03' first. The + // reader seeks by ITS collation; the walk must only ask whether the + // cursor came back the same, never whether it is greater in JS order. + const keys = ['a01', 'b02', 'C03', 'd04', 'E05']; + const collate = (x: string, y: string) => x.toLowerCase().localeCompare(y.toLowerCase()); + const walk = keysetWalk( + async (q) => { + const gt = (q.where as any)?.id?.$gt as string | undefined; + return keys + .filter((k) => gt === undefined || collate(k, gt) > 0) + .sort(collate) + .slice(0, q.limit) + .map((id) => ({ id, status: 'open' })); + }, + { pageSize: 2 }, + ); + expect((await collect(walk)).map((r) => r.id)).toEqual(keys); + expect(walk.truncated).toBe(false); + }); + it('stops instead of spinning when a row has no key', async () => { const store = makeStore([{ id: 'r01', status: 'open' }, { id: undefined as any, status: 'open' }]); const walk = keysetWalk( diff --git a/packages/types/src/keyset-walk.ts b/packages/types/src/keyset-walk.ts index cac22cee8c3..8a76d9b8a9c 100644 --- a/packages/types/src/keyset-walk.ts +++ b/packages/types/src/keyset-walk.ts @@ -120,6 +120,7 @@ export function keysetWalk>( async function* pages(): AsyncGenerator { let cursor: unknown = undefined; + let previousFirst: unknown = undefined; for (;;) { const want = options.max == null ? pageSize : Math.min(pageSize, options.max - scanned); if (want <= 0) { @@ -163,10 +164,21 @@ export function keysetWalk>( // this page again, forever. Production drivers execute the predicate; // a test double or a future reader that quietly drops it would otherwise // hang rather than fail, and a hang is the one failure nobody can read. - if (cursor !== undefined && !(String(last) > String(cursor))) { + // + // Judged by EQUALITY, never by order: the driver orders the key by its + // own collation (PG/MySQL collations do not sort strings the way JS + // compares them), so "did not advance" is "came back as the same key", + // or the same page again — never "is not greater in JS string order", + // which would report a perfectly healthy walk as truncated. + const first = emit[0]?.[key]; + if ( + cursor !== undefined && + (String(last) === String(cursor) || (previousFirst !== undefined && String(first) === String(previousFirst))) + ) { truncated = true; return; } + previousFirst = first; cursor = last; // A short page means the source is exhausted. From a5a8e314a57d0cfe88b27b26ceb4ca204a90adc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:04:27 +0000 Subject: [PATCH 11/15] test(types): the collation case reads the walk's collected ids Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- packages/types/src/keyset-walk.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/keyset-walk.test.ts b/packages/types/src/keyset-walk.test.ts index 6cfc4d6052d..3afd137a7ee 100644 --- a/packages/types/src/keyset-walk.test.ts +++ b/packages/types/src/keyset-walk.test.ts @@ -197,7 +197,7 @@ describe('keysetWalk', () => { }, { pageSize: 2 }, ); - expect((await collect(walk)).map((r) => r.id)).toEqual(keys); + expect(await collect(walk)).toEqual(keys); expect(walk.truncated).toBe(false); }); From e4283d507a9d39c25ee4020cd063d8b759ae096c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:23:53 +0000 Subject: [PATCH 12/15] test(plugin-auth): the one-time pass latches; the policy pin reads it on an undecided pass Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../plugin-auth/src/membership-policy-setting.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts index 0287bd97523..e49884d3d2a 100644 --- a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts +++ b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts @@ -167,7 +167,9 @@ describe('auth.membership_policy — the setting', () => { } = {}) => { settingsStore.values = opts.settings ?? {}; const engine = opts.engine ?? makeEngine(); - const defaultOrgId = vi.fn(async () => opts.defaultOrgId ?? 'org_default'); + // `null` is a real answer (no unambiguous target), so only an omitted + // option falls back to the default organization. + const defaultOrgId = vi.fn(async () => ('defaultOrgId' in opts ? (opts.defaultOrgId ?? null) : 'org_default')); (mockContext.getService as any).mockImplementation((name: string) => { if (name === 'manifest') return { register: vi.fn() }; if (name === 'settings') return makeSettings(); @@ -328,7 +330,10 @@ describe('auth.membership_policy — the setting', () => { // read sites that happen to agree today. This one fails the moment either // path goes back to reading a captured copy, whatever value it holds. const engine = makeEngine({ users: [{ id: 'usr_legacy_1' }] }); - const { manager } = await boot({ engine }); + // No target organization and no organization at all: the kernel:ready pass + // stays undecided, so the `app:seeded` pass below runs (and reads the + // policy) instead of being latched off as already decided. + const { manager } = await boot({ engine, defaultOrgId: null }); const spy = vi.spyOn(manager, 'getMembershipPolicy'); await signUpHook(manager)({ id: 'usr_new' }); From cf3670d74fe5c184d71ea97d23f48680b8cd8d62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:48:04 +0000 Subject: [PATCH 13/15] test(dogfood): the showcase demo personas are created into the admin's organization Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- ...se-demo-personas-loginable.dogfood.test.ts | 12 ---- ...e-demo-personas-membership.dogfood.test.ts | 64 +++++++++++++++++++ 2 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index 73f89fadb1c..e1f6f4d7f15 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -118,18 +118,6 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { } }); - it('each persona is created INTO the admin\'s organization — a raw-inserted user gets its membership from its creator', async () => { - const adminMembers = rowsOf(await ql.find('sys_member', { where: { user_id: adminId }, limit: 5, context: SYS })); - expect(adminMembers.length, 'the dev admin holds a membership the seed resolves its organization from').toBeGreaterThan(0); - const adminOrgs = adminMembers.map((m) => String(m.organization_id)); - for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { - const members = rowsOf(await ql.find('sys_member', { where: { user_id: persona.id }, limit: 5, context: SYS })); - expect(members.length, `${persona.email} has an organization`).toBe(1); - expect(adminOrgs, `${persona.email} is a member of the admin's organization`).toContain(String(members[0]!.organization_id)); - expect(members[0]!.role, `${persona.email} is a plain member`).toBe('member'); - } - }); - it('each persona holds a credential account resolvable under the SAME key better-auth uses for the admin', async () => { const adminAccount = await credentialAccountOf(adminId); // The control: better-auth really does write an account of this shape for diff --git a/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts new file mode 100644 index 00000000000..a97dd70287c --- /dev/null +++ b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The showcase approval-demo personas are raw `sys_user` inserts (the seed in + * `examples/app-showcase/src/security/seed-approval-demo.ts`), so they never + * cross the platform's user-creation seam. Under ADR-0093 D7 membership is + * decided at creation, and the one-time backfill does not pick up users + * inserted after it ran — so the code that creates them writes their + * membership. This boot gives the admin a real organization (`orgContext`) + * and runs the showcase hook, the shape a `pnpm dev:showcase` operator gets. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '@objectstack/example-showcase/security-personas'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const showcaseBundleWithHook = { ...(showcaseStack as Record), onEnable }; +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? (r as Array>) : ((r as { records?: unknown[] })?.records as Array>) ?? []; + +describe('showcase demo personas are created into the admin\'s organization (ADR-0093 D7)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + + const membersOf = async (userId: string) => + rowsOf(await ql.find('sys_member', { where: { user_id: userId }, limit: 5, context: SYS })); + + /** The persona seed runs on `kernel:bootstrapped`; give it a moment to land. */ + const waitForUser = async (email: string) => { + for (let i = 0; i < 60; i++) { + const row = rowsOf(await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]; + if (row?.id) return row; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`persona ${email} was never provisioned`); + }; + + beforeAll(async () => { + stack = await bootStack(showcaseBundleWithHook, { security: showcaseAppDefaultSecurity(), orgContext: true }); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('each persona holds exactly one plain membership, in the admin\'s organization', async () => { + const admin = await waitForUser(ADMIN_EMAIL); + const adminOrgs = (await membersOf(String(admin.id))).map((m) => String(m.organization_id)); + expect(adminOrgs.length, 'PREMISE: the admin is bound to an organization (orgContext)').toBeGreaterThan(0); + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + await waitForUser(persona.email); + const members = await membersOf(persona.id); + expect(members.length, `${persona.email} has an organization`).toBe(1); + expect(adminOrgs, `${persona.email} is a member of the admin's organization`).toContain(String(members[0]!.organization_id)); + expect(members[0]!.role, `${persona.email} is a plain member`).toBe('member'); + } + }); +}); From 3ed15b1836de49ed4405298196b64199dd7a6f90 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:31:14 +0000 Subject: [PATCH 14/15] fix(plugin-auth): an unreadable ledger binds nobody; deprecate the ungated bootstrap; declare the widened surface Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-Authored-By: Claude --- .../21791-membership-decided-at-creation.md | 15 +++++++--- .../src/ensure-default-organization.ts | 22 ++++++++------ .../plugin-auth/src/auth-plugin.test.ts | 29 +++++++++++++++++-- .../src/default-org-bootstrap-once.ts | 18 ++++++++++-- .../src/ensure-default-organization.ts | 6 ++++ 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md index 5f0d4f43d49..495d00be227 100644 --- a/.changeset/21791-membership-decided-at-creation.md +++ b/.changeset/21791-membership-decided-at-creation.md @@ -1,19 +1,26 @@ --- -'@objectstack/plugin-auth': patch +'@objectstack/plugin-auth': minor '@objectstack/organizations': patch '@objectstack/types': patch --- Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7. -Clause-②: no +Clause-②: yes (widening) - **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later. - **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment, and once per process even if its record cannot be written. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. -- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. Without a readable ledger, the owner is bound only when the bootstrap creates the default organization. +- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. To recover, an administrator re-adds members, including themselves, through member management. On a kernel without the ledger, the owner is bound only when the bootstrap creates the default organization. If the ledger exists but cannot be read, that call binds nobody and the next trigger decides. - **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded. - **Upgrade.** The first boot of an upgraded deployment runs the backfill once. - **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before. - **Narrowed.** A `sys_user` row inserted straight through the data engine never passes through user creation. Once the backfill is recorded, a later `app:seeded` pass leaves it unbound. That includes users written by a seed that finishes after its inline budget. Code that inserts users this way must write their membership itself; the showcase approval-demo personas now do. - **`keysetWalk` (`@objectstack/types`).** The walk now decides that a page did not advance only when it gets back the same cursor key or the same page again. It no longer compares keys in JavaScript string order, which disagrees with database collations and could report a healthy walk as truncated. -- **`createEnsureDefaultOrganizationOnce`** (new export of `@objectstack/plugin-auth`) is the gated bootstrap both wirings call. `ensureDefaultOrganization` gains the `bindOnlyOnCreate` and `bindOwner` options and the `owner_bind_decided` reason. +- **New public surface of `@objectstack/plugin-auth` (additive).** + - `createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions` are the gated bootstrap both wirings call. + - `ObjectQLAdapterFactoryOptions` adds `onRecordCreated`, passed as the new optional second argument of `createObjectQLAdapterFactory`. + - `EnsureDefaultOrganizationOptions` gains `bindOnlyOnCreate` and `bindOwner`. + - `EnsureDefaultOrganizationResult.reason` gains `'owner_bind_decided'`. + - `BackfillMembershipsResult.reason` gains `'scan-incomplete'`. + - Code that switches exhaustively over those reasons sees one more member. +- **Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`. diff --git a/packages/plugins/organizations/src/ensure-default-organization.ts b/packages/plugins/organizations/src/ensure-default-organization.ts index 4f69b681234..a3a21122f3a 100644 --- a/packages/plugins/organizations/src/ensure-default-organization.ts +++ b/packages/plugins/organizations/src/ensure-default-organization.ts @@ -1,16 +1,14 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * ensureDefaultOrganization — multi-org flavour of the default-org bootstrap. + * ensureDefaultOrganization — the walled-posture default-org bootstrap with the + * per-org seed-ownership handoff (`claimOrgSeedOwnership`) injected. * - * The helper itself moved to `@objectstack/plugin-auth` (cloud ADR-0081 D1: the - * open member-management basics own it — single-org mode runs it too, from - * AuthPlugin). This wrapper keeps the multi-org semantics this plugin always - * had by injecting the per-org seed-ownership handoff step - * (`claimOrgSeedOwnership`), which belongs to the org seed pipeline here, - * not to the basics. - * - * See the plugin-auth helper for the full strategy documentation. + * The helper itself lives in `@objectstack/plugin-auth` (cloud ADR-0081 D1). + * `OrganizationsPlugin` no longer calls this wrapper: it calls plugin-auth's + * `createEnsureDefaultOrganizationOnce` with the same handoff injected, which + * decides the owner bind once (ADR-0093 D7). This wrapper is kept for + * existing importers only. */ import { @@ -32,6 +30,12 @@ export type { EnsureDefaultOrganizationResult }; * Ensure the platform admin has a Default Organization to operate in, * then hand the org's seeded rows to them. Idempotent (stable slug * `default` + the admin's existing memberships short-circuit). + * + * @deprecated Use `createEnsureDefaultOrganizationOnce({ claimSeedOwnership: + * claimOrgSeedOwnership })` from `@objectstack/plugin-auth`. Called directly, + * this re-binds an owner whose membership was removed and re-runs the seed + * handoff on every call; the gated factory decides the bind once + * (ADR-0093 D7). */ export async function ensureDefaultOrganization( ql: any, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 4dc1f6bb9a6..a3fec8e5535 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1617,16 +1617,41 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_migration).toHaveLength(0); }); - it('CONTROL: unreadable ledger on a fresh install still creates the organization and binds its owner', async () => { + it('fresh install, ledger UNREADABLE: the org is created, nobody is bound, and the next readable trigger decides', async () => { const realFindOne = ql.findOne.getMockImplementation(); + let ledgerDown = true; ql.findOne.mockImplementation(async (object: string, q: any) => { - if (object === 'sys_migration') throw new Error('ledger read failed'); + if (object === 'sys_migration' && ledgerDown) throw new Error('ledger read failed'); return realFindOne(object, q); }); await boot(); await hookCapture.trigger('kernel:ready'); expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); + + // The ledger answers again: the bind is decided now, once, and recorded. + ledgerDown = false; + await fireBootstrapWrite({ object: 'sys_user', operation: 'update', data: { email_verified: true } }); + expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'admin', role: 'owner' })]); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind'); + }); + + it('fresh install, NO ledger on the kernel: the org is created and its owner bound on that call', async () => { + ql.getObject.mockImplementation((name: string) => (name === 'sys_migration' ? undefined : { name })); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(1); expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'admin', role: 'owner' })]); + + // Removed, then a later trigger in a new process: the org exists, so + // the no-ledger rule binds nobody. + ql.tables.sys_member.length = 0; + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member).toHaveLength(0); }); it('a failed ledger write is latched in-process: a second trigger re-binds nobody', async () => { diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts index 4a449b82777..a2d30eeee50 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -19,9 +19,12 @@ * - once recorded — or once acted on in this process, whether or not the * record landed — the bootstrap still (re)creates a missing default * organization but binds nobody and hands over no seed ownership; - * - with no readable ledger it binds only on the call that CREATES the + * - with no ledger on the kernel it binds only on the call that CREATES the * default organization, so a fresh install still gets its owner and an - * existing organization is never re-bound. + * existing organization is never re-bound; + * - with a ledger that failed to answer it binds nobody on that call (it may + * still create the organization) and leaves the decision to the next + * trigger. */ import { @@ -71,7 +74,16 @@ export function createEnsureDefaultOrganizationOnce( return ensure(ql, { ...base, bindOwner: false }); } - const res = await ensure(ql, { ...base, bindOnlyOnCreate: reading !== 'absent' }); + // `unavailable` (this kernel has no ledger) binds only on the call that + // CREATES the default organization, so a fresh install without a ledger + // still gets its owner. `unreadable` is a ledger that exists but failed to + // answer: the decision may well be recorded, so this call binds nobody — + // it can still (re)create the organization — and the next trigger, with a + // readable ledger, decides. + const res = + reading === 'unreadable' + ? await ensure(ql, { ...base, bindOwner: false }) + : await ensure(ql, { ...base, bindOnlyOnCreate: reading === 'unavailable' }); // Decided: the admin was bound now, or already held a membership. // `no_admin` and the failed writes leave it open for the next trigger. const actedOn = res.memberCreated || res.reason === 'admin_already_in_org'; diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 8d76fe4c064..cea8ce4d719 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -307,6 +307,12 @@ export interface EnsureDefaultOrganizationResult { * Ensure the platform admin has a Default Organization to operate in. * Safe to call multiple times — idempotent on stable slug `default` * and on the presence of any existing `sys_member` row for the admin. + * + * @deprecated Call it through {@link createEnsureDefaultOrganizationOnce} + * (`default-org-bootstrap-once.ts`). Called directly, this helper binds the + * platform admin as owner whenever they hold no membership, so a bootstrap + * trigger re-binds an admin whose membership was removed — ADR-0093 D7 + * decides membership once. Kept as the primitive the gated factory wraps. */ export async function ensureDefaultOrganization( ql: any, From 5c52ad32f1ce53f9985a4a7107c6270751596ce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:14:56 +0000 Subject: [PATCH 15/15] docs(plugin-auth): state backfillMemberships' changed contract and its ungated direct call Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/21791-membership-decided-at-creation.md | 2 ++ packages/plugins/plugin-auth/src/reconcile-membership.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md index 495d00be227..ed308049d92 100644 --- a/.changeset/21791-membership-decided-at-creation.md +++ b/.changeset/21791-membership-decided-at-creation.md @@ -23,4 +23,6 @@ Clause-②: yes (widening) - `EnsureDefaultOrganizationResult.reason` gains `'owner_bind_decided'`. - `BackfillMembershipsResult.reason` gains `'scan-incomplete'`. - Code that switches exhaustively over those reasons sees one more member. +- **`backfillMemberships` (exported) changed behaviour.** Its `limit` option used to cap the rows scanned (default 5000); it is now the page size of a full scan with no cap. The function now needs a reader that can page by `id`; a reader that cannot gets `scan-incomplete` and binds nobody, where it used to bind. A direct call is not gated by the one-time ledger and decides membership again on every call; call it through the one-time pass instead. +- **Policy switch.** Once a pass under `invite-only` is recorded, switching the policy to `auto` later does not backfill the users who existed then; they get membership through invitation or member management. - **Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`. diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.ts b/packages/plugins/plugin-auth/src/reconcile-membership.ts index 0bc5c20422b..3395f3bb638 100644 --- a/packages/plugins/plugin-auth/src/reconcile-membership.ts +++ b/packages/plugins/plugin-auth/src/reconcile-membership.ts @@ -317,6 +317,13 @@ async function walkAll(engine: any, object: string, pageSize: number): Promise