Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/22328-auth-seeded-hook-registered-at-ready.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
28 changes: 20 additions & 8 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading