diff --git a/.changeset/membership-backfill-app-seeded.md b/.changeset/membership-backfill-app-seeded.md new file mode 100644 index 000000000..1f070fb7d --- /dev/null +++ b/.changeset/membership-backfill-app-seeded.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): re-run membership backfill when app seeding settles (#2996) + +The ADR-0093 D6 membership backfill — the only safety net for users created +by app seeds (raw `engine.insert` into `sys_user` bypasses better-auth's +`user.create.after` reconciler) — ran only once on `kernel:ready`. When a seed +bundle overruns its inline budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) it +finishes in the background *after* `kernel:ready`, so its users stayed +member-less in single-org `auto` mode until the next restart re-ran the backfill. + +`AppPlugin` now emits a new **`app:seeded`** lifecycle event when an app's inline +seed settles (success, partial, or fallback) — carrying `{ appId, overBudget }`, +where `overBudget: true` marks the post-`kernel:ready` background case. plugin-auth +subscribes and re-runs the (idempotent, self-guarding, opt-out-able) +`backfillMemberships` on that signal, closing the window without waiting for a +restart. No behavior change when a seed completes within budget, in multi-tenant +mode, or under `invite-only` policy; `OS_SKIP_MEMBERSHIP_BACKFILL=1` still opts out. diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 832f7e451..002aba2c3 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -342,6 +342,16 @@ On `kernel:ready`, **single mode + `membershipPolicy: 'auto'` only**: - opt-out: `OS_SKIP_MEMBERSHIP_BACKFILL=1` for operators who curate memberships manually. +The backfill **also re-runs on the app plugin's `app:seeded` hook** (#2996). +App seeds insert `sys_user` via a raw `engine.insert`, bypassing the +`user.create.after` reconciler, so seeded users depend on this backfill as +their only net. A seed bundle that overruns its inline budget +(`OS_INLINE_SEED_BUDGET_MS`) finishes in the background *after* `kernel:ready`, +so the one-shot `kernel:ready` pass would miss its users until the next +restart. Re-running when seeding settles closes that window. The re-run keeps +every property above — idempotent, self-guarding (no-op under `invite-only` / +multi-org), and disabled by the same `OS_SKIP_MEMBERSHIP_BACKFILL=1` opt-out. + Multi-org backfill is **refused by design** — there is no correct guess, and a wrong org assignment in a tenant-isolated deployment is a data-exposure bug, not a convenience. Multi-org operators repair membership through the existing diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 06badf17a..c1c552c03 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -364,10 +364,13 @@ export class ObjectKernel { await this.context.trigger('kernel:ready'); // Phase 3.5: Trigger kernel:bootstrapped AFTER every kernel:ready - // handler has settled — the "all bootstrap + seed data is ready" + // handler has settled — the "all synchronous bootstrap has settled" // anchor. Reconcile/backfill work that consumes data produced by a // later-starting plugin's kernel:ready handler belongs here, not in - // kernel:ready (where handler order would race the data). See + // kernel:ready (where handler order would race the data). NOTE: this + // does NOT guarantee background app seed data has settled (an inline + // seed that overruns OS_INLINE_SEED_BUDGET_MS finishes later) — + // subscribe `app:seeded` for that. See // packages/spec/src/contracts/plugin-lifecycle-events.ts. this.logger.debug('Triggering kernel:bootstrapped hook'); await this.context.trigger('kernel:bootstrapped'); diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index e5667a0c6..bfdcccdd1 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -77,9 +77,11 @@ export class LiteKernel extends ObjectKernelBase { // Trigger ready hook (route/middleware registration phase) await this.triggerHook('kernel:ready'); - // Trigger bootstrapped hook — "all bootstrap + seed data is ready" + // Trigger bootstrapped hook — "all synchronous bootstrap has settled" // anchor, strictly after every kernel:ready handler has settled and - // before any HTTP socket opens (see plugin-lifecycle-events.ts). + // before any HTTP socket opens. NOTE: does not guarantee background app + // seed data has settled — subscribe `app:seeded` for that + // (see plugin-lifecycle-events.ts). await this.triggerHook('kernel:bootstrapped'); // Trigger listening hook (HTTP servers open their socket here — // strictly after every kernel:ready handler has completed). diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 8d9af79b6..8ea624ae6 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1107,4 +1107,106 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_member).toHaveLength(0); }); }); + + // #2996 — the ADR-0093 D6 membership backfill re-runs on `app:seeded` so + // users inserted by an over-budget background seed (which finishes AFTER + // `kernel:ready` and bypasses the `user.create.after` reconciler) still get + // bound to the default org without waiting for the next restart. + describe('Membership backfill re-run on app:seeded (#2996)', () => { + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + const OLD_SKIP = process.env.OS_SKIP_MEMBERSHIP_BACKFILL; + let hookCapture: ReturnType; + let ql: any; + + const makeQl = () => { + const tables: Record = { + sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], + sys_user_permission_set: [ + { id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null }, + ], + // The platform admin already exists; the default-org bootstrap binds it + // as `owner` on kernel:ready. A member-less seeded user is added later. + sys_user: [{ id: 'admin' }], + sys_member: [], + sys_organization: [], + }; + const matches = (row: any, where: any) => + Object.entries(where ?? {}).every(([k, v]) => (v === null ? row[k] == null : row[k] === v)); + return { + tables, + registerMiddleware: vi.fn(), + find: vi.fn(async (object: string, q: any) => + (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), + ), + insert: vi.fn(async (object: string, data: any) => { + (tables[object] ??= []).push(data); + return data; + }), + }; + }; + + beforeEach(() => { + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL; + hookCapture = createHookCapture(); + ql = makeQl(); + mockContext.hook = hookCapture.hookFn; + mockContext.getService = vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'objectql') return ql; + return undefined; + }); + }); + + afterEach(() => { + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + if (OLD_SKIP === undefined) delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL; + else process.env.OS_SKIP_MEMBERSHIP_BACKFILL = OLD_SKIP; + }); + + const boot = async (options: any = {}) => { + authPlugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + ...options, + }); + await authPlugin.init(mockContext); + await authPlugin.start(mockContext); + }; + + it('registers an app:seeded hook alongside kernel:ready', async () => { + await boot(); + expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function)); + }); + + it('binds a user seeded after kernel:ready when app:seeded fires', async () => { + 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']); + + // A background seed inserts a member-less user AFTER kernel:ready. + ql.tables.sys_user.push({ id: 'seeded_u2' }); + + 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' }); + }); + + it('OS_SKIP_MEMBERSHIP_BACKFILL=1 disables the app:seeded re-run', async () => { + process.env.OS_SKIP_MEMBERSHIP_BACKFILL = '1'; + await boot(); + // No app:seeded hook is registered when the backfill is opted out. + expect(mockContext.hook).not.toHaveBeenCalledWith('app:seeded', expect.any(Function)); + + await hookCapture.trigger('kernel:ready'); + ql.tables.sys_user.push({ id: 'seeded_u2' }); + await hookCapture.trigger('app:seeded'); + + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2')).toBeUndefined(); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index ca9fcd57a..2fea38d34 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -601,28 +601,43 @@ export class AuthPlugin implements Plugin { // Opt out entirely via OS_SKIP_MEMBERSHIP_BACKFILL=1 (operators who curate // memberships by hand). if (String(process.env.OS_SKIP_MEMBERSHIP_BACKFILL ?? '').trim() !== '1') { - ctx.hook('kernel:ready', async () => { - try { - const ql: any = ctx.getService('objectql'); - const tenancy = this.tenancy; - if (!ql || !tenancy) return; - const res = await backfillMemberships(ql, { - policy: this.options.membershipPolicy ?? 'auto', - resolveTargetOrg: () => tenancy.defaultOrgId(), - logger: ctx.logger, - }); - if (res.bound > 0) { - ctx.logger.info( - `[auth] membership backfill bound ${res.bound} pre-existing member-less user(s) to the default organization (ADR-0093 D6)`, - res, - ); + // Serialize runs so overlapping triggers (kernel:ready + one or more + // `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(); + const runBackfill = (source: string): Promise => { + backfillChain = backfillChain.then(async () => { + try { + const ql: any = ctx.getService('objectql'); + const tenancy = this.tenancy; + if (!ql || !tenancy) return; + const res = await backfillMemberships(ql, { + policy: this.options.membershipPolicy ?? 'auto', + resolveTargetOrg: () => tenancy.defaultOrgId(), + logger: ctx.logger, + }); + if (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, + ); + } + } catch (e) { + ctx.logger.warn?.('[auth] membership backfill failed', { + source, + error: (e as Error).message, + }); } - } catch (e) { - ctx.logger.warn?.('[auth] membership backfill failed', { - error: (e as Error).message, - }); - } - }); + }); + return backfillChain; + }; + 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. + ctx.hook('app:seeded', () => runBackfill('app:seeded')); } // Identity-source provenance for accounts created OUTSIDE better-auth's diff --git a/packages/runtime/src/app-plugin.seed.test.ts b/packages/runtime/src/app-plugin.seed.test.ts new file mode 100644 index 000000000..ad443b9a7 --- /dev/null +++ b/packages/runtime/src/app-plugin.seed.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppPlugin } from './app-plugin'; +import type { PluginContext } from '@objectstack/core'; + +/** + * #2996 — AppPlugin emits `app:seeded` when its inline seed settles, so + * reconcilers that read seeded rows (plugin-auth's ADR-0093 D6 membership + * backfill) can re-run for users inserted by a seed that overran the inline + * budget and finished in the background AFTER `kernel:ready`. + * + * These tests force the basic-insert fallback path (no `metadata` service) so + * the SeedLoaderService plumbing is unnecessary — the settle signalling is the + * same on both branches. + */ +describe('AppPlugin inline-seed settle signal (app:seeded, #2996)', () => { + const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS; + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + + let trigger: ReturnType; + let insert: ReturnType; + + const makeContext = (overrides: Partial = {}): PluginContext => + ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'objectql') return { insert }; + // `metadata` absent → basic-insert fallback; all others absent too. + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger, + ...overrides, + }) as unknown as PluginContext; + + const bundleWithUser = () => ({ + id: 'seed-test-app', + data: [{ object: 'sys_user', records: [{ id: 'seeded_u1' }] }], + }); + + beforeEach(() => { + delete process.env.OS_MULTI_ORG_ENABLED; + trigger = vi.fn(); + }); + + afterEach(() => { + if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS; + else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET; + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + }); + + it('emits app:seeded with overBudget=true after a background seed finishes past the budget', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '10'; + // Insert resolves well after the 10ms budget, so the race yields to the + // budget and the seed continues in the background. + insert = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 60))); + const ctx = makeContext(); + const plugin = new AppPlugin(bundleWithUser()); + + await plugin.start(ctx); + + // start() returned once the budget elapsed — the background seed (and + // thus the settle signal) has NOT fired yet. + expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything()); + + await vi.waitFor(() => { + expect(trigger).toHaveBeenCalledWith('app:seeded', { + appId: 'seed-test-app', + overBudget: true, + }); + }); + // The user was still inserted by the background seed. + expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything()); + }); + + it('emits app:seeded with overBudget=false when the seed completes within budget', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '8000'; + insert = vi.fn(async () => undefined); // resolves immediately + const ctx = makeContext(); + const plugin = new AppPlugin(bundleWithUser()); + + await plugin.start(ctx); + + expect(trigger).toHaveBeenCalledWith('app:seeded', { + appId: 'seed-test-app', + overBudget: false, + }); + }); + + it('does not emit app:seeded when the app has no seed datasets', async () => { + insert = vi.fn(async () => undefined); + const ctx = makeContext(); + const plugin = new AppPlugin({ id: 'seed-test-app' }); + + await plugin.start(ctx); + + expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything()); + }); + + it('does not throw when the kernel context has no trigger() function', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '8000'; + insert = vi.fn(async () => undefined); + const ctx = makeContext({ trigger: undefined as any }); + const plugin = new AppPlugin(bundleWithUser()); + + await expect(plugin.start(ctx)).resolves.toBeUndefined(); + expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything()); + }); + + it('does not run the inline seed (or emit) in multi-tenant mode', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + insert = vi.fn(async () => undefined); + const ctx = makeContext(); + const plugin = new AppPlugin(bundleWithUser()); + + await plugin.start(ctx); + + expect(insert).not.toHaveBeenCalled(); + expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything()); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 9e4d96ac3..bf0352662 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -855,16 +855,41 @@ export class AppPlugin implements Plugin { const budget = new Promise<'budget'>((resolve) => { timer = setTimeout(() => resolve('budget'), seedBudgetMs); }); + // Signal seed settle so reconcilers that read seeded rows can + // re-run past this point. plugin-auth's ADR-0093 D6 membership + // backfill runs once on `kernel:ready`, but seeded users are raw + // `engine.insert` into `sys_user` (bypassing better-auth's + // `user.create.after` reconciler). If this seed overruns the + // budget below and finishes in the background — AFTER + // `kernel:ready` — those users would stay member-less until the + // next restart. Emitting on settle lets the backfill re-run (#2996). + const emitSeedSettled = (overBudget: boolean) => { + const trigger = (ctx as any).trigger; + if (typeof trigger !== 'function') return; + try { + const p = trigger.call(ctx, 'app:seeded', { appId, overBudget }); + if (p && typeof p.catch === 'function') { + p.catch((err: any) => ctx.logger.debug('[Seeder] app:seeded trigger failed', { appId, error: err?.message ?? String(err) })); + } + } catch (err: any) { + ctx.logger.debug('[Seeder] app:seeded trigger failed', { appId, error: err?.message ?? String(err) }); + } + }; const winner = await Promise.race([seedPromise.then(() => 'done' as const), budget]); if (timer) clearTimeout(timer); if (winner === 'budget') { ctx.logger.warn( `[Seeder] Inline seed exceeded ${seedBudgetMs}ms budget for ${appId}; continuing in background to avoid blocking kernel start.`, ); - // Don't leave the promise unobserved. - seedPromise.catch((err: any) => { - ctx.logger.warn('[Seeder] Background seed failed after budget', { appId, error: err?.message ?? String(err) }); - }); + // Don't leave the promise unobserved; emit the settle signal + // once the background seed finishes (fires past kernel:ready). + seedPromise + .catch((err: any) => { + ctx.logger.warn('[Seeder] Background seed failed after budget', { appId, error: err?.message ?? String(err) }); + }) + .then(() => emitSeedSettled(true)); + } else { + emitSeedSettled(false); } } } diff --git a/packages/spec/src/contracts/plugin-lifecycle-events.ts b/packages/spec/src/contracts/plugin-lifecycle-events.ts index 11707625d..f8faee4ae 100644 --- a/packages/spec/src/contracts/plugin-lifecycle-events.ts +++ b/packages/spec/src/contracts/plugin-lifecycle-events.ts @@ -23,7 +23,7 @@ export interface IPluginLifecycleEvents { * Emitted AFTER every `kernel:ready` handler has completed, but BEFORE * `kernel:listening` (so before any HTTP socket opens). * - * This is the "all bootstrap + seed data has settled" anchor. Because + * This is the "all synchronous bootstrap has settled" anchor. Because * `kernel:ready` handlers run sequentially in plugin-registration order, * a handler cannot rely on data produced by a plugin that starts later * (e.g. the security bootstrap seeds `sys_position`, the app plugin's @@ -33,6 +33,12 @@ export interface IPluginLifecycleEvents { * fires. HTTP `listen()` is deliberately deferred one more phase to * `kernel:listening` so late route registration still lands. * + * CAVEAT: this does NOT guarantee app *seed* data has settled. The app + * plugin's inline seed only blocks the kernel for `OS_INLINE_SEED_BUDGET_MS` + * (default 8s); a bundle that exceeds that budget continues seeding in the + * background and can outlast `kernel:bootstrapped` and even `kernel:listening` + * (#2996). Subscribe `app:seeded` for the true per-app seed-settle point. + * * Payload: [] */ 'kernel:bootstrapped': []; @@ -59,6 +65,27 @@ export interface IPluginLifecycleEvents { */ 'kernel:listening': []; + /** + * Emitted by the app plugin when an app's inline seed attempt has settled + * — success, partial (dropped records), or fallback insert. Single-tenant + * mode only, and only when the app actually has seed datasets. + * + * When the inline seed completes within `OS_INLINE_SEED_BUDGET_MS` this + * fires during plugin start (before `kernel:ready`); when it overruns the + * budget and finishes in the background it fires AFTER `kernel:ready` / + * `kernel:bootstrapped` / `kernel:listening` — `overBudget` distinguishes + * the two. May fire once per registered app bundle. + * + * Consumers MUST be idempotent: this is the settle signal for reconcilers + * that read seeded rows. plugin-auth re-runs the ADR-0093 D6 membership + * backfill here so users inserted by an over-budget seed (which bypass + * better-auth's `user.create.after` reconciler) still get bound to the + * default org without waiting for the next restart (#2996). + * + * Payload: [{ appId, overBudget }] + */ + 'app:seeded': [payload: { appId: string; overBudget: boolean }]; + /** * Emitted when kernel is shutting down * Payload: []