Skip to content

Commit 9c8ed2b

Browse files
committed
fix(plugin-auth): register the app:seeded backfill handler from the arming kernel:ready handler
The ADR-0093 D6 backfill's app:seeded handler was registered in start() and kept from acting before the settings engine binds by the backfillArmed runtime flag, which a static walk cannot see. It is now registered by the kernel:ready handler that arms the pass, in the same synchronous step, so it does not exist before the bind. backfillArmed stays: the default-org-created trigger still reaches runBackfill before arming. The unit test that pinned the start()-time registration now pins the new structure: no app:seeded handler after start(), one after kernel:ready. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28bff18 commit 9c8ed2b

3 files changed

Lines changed: 38 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **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.
12+
- **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all.

‎packages/plugins/plugin-auth/src/auth-plugin.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,9 +1635,13 @@ describe('AuthPlugin', () => {
16351635
await authPlugin.start(mockContext);
16361636
};
16371637

1638-
it('registers an app:seeded hook alongside kernel:ready', async () => {
1638+
it('registers its app:seeded hook from the arming kernel:ready handler, never from start()', async () => {
1639+
// #22257 — the handler cannot run before the settings engine binds
1640+
// because it does not exist before this plugin's kernel:ready handler.
16391641
await boot();
1640-
expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function));
1642+
expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(0);
1643+
await hookCapture.trigger('kernel:ready');
1644+
expect(hookCapture.handlers.get('app:seeded') ?? []).toHaveLength(1);
16411645
});
16421646

16431647
it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => {

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,16 +1352,28 @@ export class AuthPlugin implements Plugin {
13521352
runBackfillOnDefaultOrg = runBackfill;
13531353
ctx.hook('kernel:ready', () => {
13541354
backfillArmed = true;
1355+
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
1356+
// better-auth's `user.create.after` reconciler. A seed that overruns
1357+
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
1358+
// so its users would miss a kernel:ready pass that had no target yet.
1359+
// The trigger stays; the ledger makes it a no-op once the one-time pass
1360+
// has been recorded. An in-budget seed settles during Phase 2, before the
1361+
// pass is armed, and the `kernel:ready` pass covers its rows.
1362+
//
1363+
// [#22257] Registered HERE, in the same synchronous step that arms the
1364+
// pass, never from `start()`: a handler that does not exist before the
1365+
// bind cannot run before it. Every `app:seeded` that fires before this
1366+
// point was already a no-op through `backfillArmed`, and every one after
1367+
// it reaches this handler — the kernel's hook map is read at dispatch
1368+
// time, so an emit still in flight picks it up too. The structure is
1369+
// what `check:settings-bind-window` can see; a flag is not.
1370+
// `backfillArmed` stays: the `default-org-created` trigger
1371+
// (`runBackfillOnDefaultOrg`) can still reach `runBackfill` from the
1372+
// `objectql` middleware during Phase 2 and from the bootstrap's own
1373+
// `kernel:ready` hook, which runs before this one.
1374+
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
13551375
return runBackfill('kernel:ready');
13561376
});
1357-
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
1358-
// better-auth's `user.create.after` reconciler. A seed that overruns
1359-
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
1360-
// so its users would miss a kernel:ready pass that had no target yet.
1361-
// The trigger stays; the ledger makes it a no-op once the one-time pass
1362-
// has been recorded. An in-budget seed settles during Phase 2, before the
1363-
// pass is armed, and the `kernel:ready` pass covers its rows.
1364-
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
13651377
}
13661378

13671379
// Identity-source provenance for accounts created OUTSIDE better-auth's

0 commit comments

Comments
 (0)