diff --git a/.changeset/22328-auth-seeded-hook-registered-at-ready.md b/.changeset/22328-auth-seeded-hook-registered-at-ready.md new file mode 100644 index 00000000000..1dd7c6f4d5a --- /dev/null +++ b/.changeset/22328-auth-seeded-hook-registered-at-ready.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-auth': patch +--- + +The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts + +Clause-②: no + +The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag. + +- **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass. +- **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 59117093927..2359e3f6e69 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1635,9 +1635,13 @@ describe('AuthPlugin', () => { await authPlugin.start(mockContext); }; - it('registers an app:seeded hook alongside kernel:ready', async () => { + it('registers its app:seeded hook from the arming kernel:ready handler, never from start()', async () => { + // #22257 — the handler cannot run before the settings engine binds + // because it does not exist before this plugin's kernel:ready handler. await boot(); - expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function)); + expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(0); + await hookCapture.trigger('kernel:ready'); + expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(1); }); it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 07dd012853f..49113b925d9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1352,16 +1352,28 @@ export class AuthPlugin implements Plugin { runBackfillOnDefaultOrg = runBackfill; ctx.hook('kernel:ready', () => { backfillArmed = true; + // #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 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. An in-budget seed settles during Phase 2, before the + // pass is armed, and the `kernel:ready` pass covers its rows. + // + // [#22257] Registered HERE, in the same synchronous step that arms the + // pass, never from `start()`: a handler that does not exist before the + // bind cannot run before it. Every `app:seeded` that fires before this + // point was already a no-op through `backfillArmed`, and every one after + // it reaches this handler — the kernel's hook map is read at dispatch + // time, so an emit still in flight picks it up too. The structure is + // what `check:settings-bind-window` can see; a flag is not. + // `backfillArmed` stays: the `default-org-created` trigger + // (`runBackfillOnDefaultOrg`) can still reach `runBackfill` from the + // `objectql` middleware during Phase 2 and from the bootstrap's own + // `kernel:ready` hook, which runs before this one. + ctx.hook('app:seeded', () => runBackfill('app:seeded')); return 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 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. An in-budget seed settles during Phase 2, before the - // pass is armed, and the `kernel:ready` pass covers its rows. - ctx.hook('app:seeded', () => runBackfill('app:seeded')); } // Identity-source provenance for accounts created OUTSIDE better-auth's