Skip to content

Commit 23925e9

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): re-run membership backfill when app seeding settles (#2996) (#3000)
The ADR-0093 D6 membership backfill is the only safety net for users created by app seeds — a raw `engine.insert` into `sys_user` bypasses better-auth's `user.create.after` reconciler — but it 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`, leaving its users 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 }` — `overBudget: true` marks the post-`kernel:ready` background case. plugin-auth subscribes and re-runs the idempotent `backfillMemberships` on that signal, serializing runs so overlapping triggers don't trip the unique index into warn noise. No behavior change when a seed completes within budget, in multi-tenant mode, or under `invite-only`; `OS_SKIP_MEMBERSHIP_BACKFILL=1` still opts out. Also corrects the stale `kernel:bootstrapped` docstrings (spec + both kernels) that claimed seed data had settled by that anchor, which is false under budget overflow. Tests: new runtime seed-settle emission coverage (app-plugin.seed.test.ts) and plugin-auth re-run coverage (auth-plugin.test.ts). Claude-Session: https://claude.ai/code/session_01VcDBuUsuX7FBuUJuLuX8Pi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a10503 commit 23925e9

9 files changed

Lines changed: 361 additions & 30 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-auth': patch
5+
---
6+
7+
fix(plugin-auth): re-run membership backfill when app seeding settles (#2996)
8+
9+
The ADR-0093 D6 membership backfill — the only safety net for users created
10+
by app seeds (raw `engine.insert` into `sys_user` bypasses better-auth's
11+
`user.create.after` reconciler) — ran only once on `kernel:ready`. When a seed
12+
bundle overruns its inline budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) it
13+
finishes in the background *after* `kernel:ready`, so its users stayed
14+
member-less in single-org `auto` mode until the next restart re-ran the backfill.
15+
16+
`AppPlugin` now emits a new **`app:seeded`** lifecycle event when an app's inline
17+
seed settles (success, partial, or fallback) — carrying `{ appId, overBudget }`,
18+
where `overBudget: true` marks the post-`kernel:ready` background case. plugin-auth
19+
subscribes and re-runs the (idempotent, self-guarding, opt-out-able)
20+
`backfillMemberships` on that signal, closing the window without waiting for a
21+
restart. No behavior change when a seed completes within budget, in multi-tenant
22+
mode, or under `invite-only` policy; `OS_SKIP_MEMBERSHIP_BACKFILL=1` still opts out.

docs/adr/0093-tenancy-mode-and-membership-lifecycle.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,16 @@ On `kernel:ready`, **single mode + `membershipPolicy: 'auto'` only**:
342342
- opt-out: `OS_SKIP_MEMBERSHIP_BACKFILL=1` for operators who curate
343343
memberships manually.
344344

345+
The backfill **also re-runs on the app plugin's `app:seeded` hook** (#2996).
346+
App seeds insert `sys_user` via a raw `engine.insert`, bypassing the
347+
`user.create.after` reconciler, so seeded users depend on this backfill as
348+
their only net. A seed bundle that overruns its inline budget
349+
(`OS_INLINE_SEED_BUDGET_MS`) finishes in the background *after* `kernel:ready`,
350+
so the one-shot `kernel:ready` pass would miss its users until the next
351+
restart. Re-running when seeding settles closes that window. The re-run keeps
352+
every property above — idempotent, self-guarding (no-op under `invite-only` /
353+
multi-org), and disabled by the same `OS_SKIP_MEMBERSHIP_BACKFILL=1` opt-out.
354+
345355
Multi-org backfill is **refused by design** — there is no correct guess, and a
346356
wrong org assignment in a tenant-isolated deployment is a data-exposure bug,
347357
not a convenience. Multi-org operators repair membership through the existing

packages/core/src/kernel.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,13 @@ export class ObjectKernel {
364364
await this.context.trigger('kernel:ready');
365365

366366
// Phase 3.5: Trigger kernel:bootstrapped AFTER every kernel:ready
367-
// handler has settled — the "all bootstrap + seed data is ready"
367+
// handler has settled — the "all synchronous bootstrap has settled"
368368
// anchor. Reconcile/backfill work that consumes data produced by a
369369
// later-starting plugin's kernel:ready handler belongs here, not in
370-
// kernel:ready (where handler order would race the data). See
370+
// kernel:ready (where handler order would race the data). NOTE: this
371+
// does NOT guarantee background app seed data has settled (an inline
372+
// seed that overruns OS_INLINE_SEED_BUDGET_MS finishes later) —
373+
// subscribe `app:seeded` for that. See
371374
// packages/spec/src/contracts/plugin-lifecycle-events.ts.
372375
this.logger.debug('Triggering kernel:bootstrapped hook');
373376
await this.context.trigger('kernel:bootstrapped');

packages/core/src/lite-kernel.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ export class LiteKernel extends ObjectKernelBase {
7777

7878
// Trigger ready hook (route/middleware registration phase)
7979
await this.triggerHook('kernel:ready');
80-
// Trigger bootstrapped hook — "all bootstrap + seed data is ready"
80+
// Trigger bootstrapped hook — "all synchronous bootstrap has settled"
8181
// anchor, strictly after every kernel:ready handler has settled and
82-
// before any HTTP socket opens (see plugin-lifecycle-events.ts).
82+
// before any HTTP socket opens. NOTE: does not guarantee background app
83+
// seed data has settled — subscribe `app:seeded` for that
84+
// (see plugin-lifecycle-events.ts).
8385
await this.triggerHook('kernel:bootstrapped');
8486
// Trigger listening hook (HTTP servers open their socket here —
8587
// strictly after every kernel:ready handler has completed).

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,4 +1107,106 @@ describe('AuthPlugin', () => {
11071107
expect(ql.tables.sys_member).toHaveLength(0);
11081108
});
11091109
});
1110+
1111+
// #2996 — the ADR-0093 D6 membership backfill re-runs on `app:seeded` so
1112+
// users inserted by an over-budget background seed (which finishes AFTER
1113+
// `kernel:ready` and bypasses the `user.create.after` reconciler) still get
1114+
// bound to the default org without waiting for the next restart.
1115+
describe('Membership backfill re-run on app:seeded (#2996)', () => {
1116+
const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
1117+
const OLD_SKIP = process.env.OS_SKIP_MEMBERSHIP_BACKFILL;
1118+
let hookCapture: ReturnType<typeof createHookCapture>;
1119+
let ql: any;
1120+
1121+
const makeQl = () => {
1122+
const tables: Record<string, any[]> = {
1123+
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
1124+
sys_user_permission_set: [
1125+
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null },
1126+
],
1127+
// The platform admin already exists; the default-org bootstrap binds it
1128+
// as `owner` on kernel:ready. A member-less seeded user is added later.
1129+
sys_user: [{ id: 'admin' }],
1130+
sys_member: [],
1131+
sys_organization: [],
1132+
};
1133+
const matches = (row: any, where: any) =>
1134+
Object.entries(where ?? {}).every(([k, v]) => (v === null ? row[k] == null : row[k] === v));
1135+
return {
1136+
tables,
1137+
registerMiddleware: vi.fn(),
1138+
find: vi.fn(async (object: string, q: any) =>
1139+
(tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100),
1140+
),
1141+
insert: vi.fn(async (object: string, data: any) => {
1142+
(tables[object] ??= []).push(data);
1143+
return data;
1144+
}),
1145+
};
1146+
};
1147+
1148+
beforeEach(() => {
1149+
delete process.env.OS_MULTI_ORG_ENABLED;
1150+
delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL;
1151+
hookCapture = createHookCapture();
1152+
ql = makeQl();
1153+
mockContext.hook = hookCapture.hookFn;
1154+
mockContext.getService = vi.fn((name: string) => {
1155+
if (name === 'manifest') return { register: vi.fn() };
1156+
if (name === 'objectql') return ql;
1157+
return undefined;
1158+
});
1159+
});
1160+
1161+
afterEach(() => {
1162+
if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
1163+
else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
1164+
if (OLD_SKIP === undefined) delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL;
1165+
else process.env.OS_SKIP_MEMBERSHIP_BACKFILL = OLD_SKIP;
1166+
});
1167+
1168+
const boot = async (options: any = {}) => {
1169+
authPlugin = new AuthPlugin({
1170+
secret: 'test-secret-at-least-32-chars-long',
1171+
baseUrl: 'http://localhost:3000',
1172+
...options,
1173+
});
1174+
await authPlugin.init(mockContext);
1175+
await authPlugin.start(mockContext);
1176+
};
1177+
1178+
it('registers an app:seeded hook alongside kernel:ready', async () => {
1179+
await boot();
1180+
expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function));
1181+
});
1182+
1183+
it('binds a user seeded after kernel:ready when app:seeded fires', async () => {
1184+
await boot();
1185+
// Boot: default org created, admin bound as owner, first backfill pass
1186+
// sees no member-less users.
1187+
await hookCapture.trigger('kernel:ready');
1188+
expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']);
1189+
1190+
// A background seed inserts a member-less user AFTER kernel:ready.
1191+
ql.tables.sys_user.push({ id: 'seeded_u2' });
1192+
1193+
await hookCapture.trigger('app:seeded');
1194+
1195+
const seededMember = ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2');
1196+
expect(seededMember).toMatchObject({ user_id: 'seeded_u2', role: 'member' });
1197+
});
1198+
1199+
it('OS_SKIP_MEMBERSHIP_BACKFILL=1 disables the app:seeded re-run', async () => {
1200+
process.env.OS_SKIP_MEMBERSHIP_BACKFILL = '1';
1201+
await boot();
1202+
// No app:seeded hook is registered when the backfill is opted out.
1203+
expect(mockContext.hook).not.toHaveBeenCalledWith('app:seeded', expect.any(Function));
1204+
1205+
await hookCapture.trigger('kernel:ready');
1206+
ql.tables.sys_user.push({ id: 'seeded_u2' });
1207+
await hookCapture.trigger('app:seeded');
1208+
1209+
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2')).toBeUndefined();
1210+
});
1211+
});
11101212
});

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

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -601,28 +601,43 @@ export class AuthPlugin implements Plugin {
601601
// Opt out entirely via OS_SKIP_MEMBERSHIP_BACKFILL=1 (operators who curate
602602
// memberships by hand).
603603
if (String(process.env.OS_SKIP_MEMBERSHIP_BACKFILL ?? '').trim() !== '1') {
604-
ctx.hook('kernel:ready', async () => {
605-
try {
606-
const ql: any = ctx.getService<any>('objectql');
607-
const tenancy = this.tenancy;
608-
if (!ql || !tenancy) return;
609-
const res = await backfillMemberships(ql, {
610-
policy: this.options.membershipPolicy ?? 'auto',
611-
resolveTargetOrg: () => tenancy.defaultOrgId(),
612-
logger: ctx.logger,
613-
});
614-
if (res.bound > 0) {
615-
ctx.logger.info(
616-
`[auth] membership backfill bound ${res.bound} pre-existing member-less user(s) to the default organization (ADR-0093 D6)`,
617-
res,
618-
);
604+
// Serialize runs so overlapping triggers (kernel:ready + one or more
605+
// `app:seeded` from multiple app bundles) don't race the same scan and
606+
// trip the (organization_id, user_id) unique index into warn noise.
607+
let backfillChain: Promise<void> = Promise.resolve();
608+
const runBackfill = (source: string): Promise<void> => {
609+
backfillChain = backfillChain.then(async () => {
610+
try {
611+
const ql: any = ctx.getService<any>('objectql');
612+
const tenancy = this.tenancy;
613+
if (!ql || !tenancy) return;
614+
const res = await backfillMemberships(ql, {
615+
policy: this.options.membershipPolicy ?? 'auto',
616+
resolveTargetOrg: () => tenancy.defaultOrgId(),
617+
logger: ctx.logger,
618+
});
619+
if (res.bound > 0) {
620+
ctx.logger.info(
621+
`[auth] membership backfill (${source}) bound ${res.bound} member-less user(s) to the default organization (ADR-0093 D6)`,
622+
res,
623+
);
624+
}
625+
} catch (e) {
626+
ctx.logger.warn?.('[auth] membership backfill failed', {
627+
source,
628+
error: (e as Error).message,
629+
});
619630
}
620-
} catch (e) {
621-
ctx.logger.warn?.('[auth] membership backfill failed', {
622-
error: (e as Error).message,
623-
});
624-
}
625-
});
631+
});
632+
return backfillChain;
633+
};
634+
ctx.hook('kernel:ready', () => runBackfill('kernel:ready'));
635+
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
636+
// better-auth's `user.create.after` reconciler. A seed that overruns
637+
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
638+
// so its users miss the one-shot backfill above. Re-run the (idempotent)
639+
// backfill when the app plugin signals seed settle.
640+
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
626641
}
627642

628643
// Identity-source provenance for accounts created OUTSIDE better-auth's
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { AppPlugin } from './app-plugin';
5+
import type { PluginContext } from '@objectstack/core';
6+
7+
/**
8+
* #2996 — AppPlugin emits `app:seeded` when its inline seed settles, so
9+
* reconcilers that read seeded rows (plugin-auth's ADR-0093 D6 membership
10+
* backfill) can re-run for users inserted by a seed that overran the inline
11+
* budget and finished in the background AFTER `kernel:ready`.
12+
*
13+
* These tests force the basic-insert fallback path (no `metadata` service) so
14+
* the SeedLoaderService plumbing is unnecessary — the settle signalling is the
15+
* same on both branches.
16+
*/
17+
describe('AppPlugin inline-seed settle signal (app:seeded, #2996)', () => {
18+
const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS;
19+
const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
20+
21+
let trigger: ReturnType<typeof vi.fn>;
22+
let insert: ReturnType<typeof vi.fn>;
23+
24+
const makeContext = (overrides: Partial<PluginContext> = {}): PluginContext =>
25+
({
26+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
27+
registerService: vi.fn(),
28+
getService: vi.fn((name: string) => {
29+
if (name === 'objectql') return { insert };
30+
// `metadata` absent → basic-insert fallback; all others absent too.
31+
return undefined;
32+
}),
33+
getServices: vi.fn(() => new Map()),
34+
hook: vi.fn(),
35+
trigger,
36+
...overrides,
37+
}) as unknown as PluginContext;
38+
39+
const bundleWithUser = () => ({
40+
id: 'seed-test-app',
41+
data: [{ object: 'sys_user', records: [{ id: 'seeded_u1' }] }],
42+
});
43+
44+
beforeEach(() => {
45+
delete process.env.OS_MULTI_ORG_ENABLED;
46+
trigger = vi.fn();
47+
});
48+
49+
afterEach(() => {
50+
if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS;
51+
else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET;
52+
if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
53+
else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
54+
});
55+
56+
it('emits app:seeded with overBudget=true after a background seed finishes past the budget', async () => {
57+
process.env.OS_INLINE_SEED_BUDGET_MS = '10';
58+
// Insert resolves well after the 10ms budget, so the race yields to the
59+
// budget and the seed continues in the background.
60+
insert = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 60)));
61+
const ctx = makeContext();
62+
const plugin = new AppPlugin(bundleWithUser());
63+
64+
await plugin.start(ctx);
65+
66+
// start() returned once the budget elapsed — the background seed (and
67+
// thus the settle signal) has NOT fired yet.
68+
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
69+
70+
await vi.waitFor(() => {
71+
expect(trigger).toHaveBeenCalledWith('app:seeded', {
72+
appId: 'seed-test-app',
73+
overBudget: true,
74+
});
75+
});
76+
// The user was still inserted by the background seed.
77+
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything());
78+
});
79+
80+
it('emits app:seeded with overBudget=false when the seed completes within budget', async () => {
81+
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
82+
insert = vi.fn(async () => undefined); // resolves immediately
83+
const ctx = makeContext();
84+
const plugin = new AppPlugin(bundleWithUser());
85+
86+
await plugin.start(ctx);
87+
88+
expect(trigger).toHaveBeenCalledWith('app:seeded', {
89+
appId: 'seed-test-app',
90+
overBudget: false,
91+
});
92+
});
93+
94+
it('does not emit app:seeded when the app has no seed datasets', async () => {
95+
insert = vi.fn(async () => undefined);
96+
const ctx = makeContext();
97+
const plugin = new AppPlugin({ id: 'seed-test-app' });
98+
99+
await plugin.start(ctx);
100+
101+
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
102+
});
103+
104+
it('does not throw when the kernel context has no trigger() function', async () => {
105+
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
106+
insert = vi.fn(async () => undefined);
107+
const ctx = makeContext({ trigger: undefined as any });
108+
const plugin = new AppPlugin(bundleWithUser());
109+
110+
await expect(plugin.start(ctx)).resolves.toBeUndefined();
111+
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'seeded_u1' }, expect.anything());
112+
});
113+
114+
it('does not run the inline seed (or emit) in multi-tenant mode', async () => {
115+
process.env.OS_MULTI_ORG_ENABLED = 'true';
116+
insert = vi.fn(async () => undefined);
117+
const ctx = makeContext();
118+
const plugin = new AppPlugin(bundleWithUser());
119+
120+
await plugin.start(ctx);
121+
122+
expect(insert).not.toHaveBeenCalled();
123+
expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything());
124+
});
125+
});

0 commit comments

Comments
 (0)