From 9b7ac6cfc3883516d468439b2275c68185278beb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:26:52 +0000 Subject: [PATCH 1/3] fix(plugin-auth): arm the one-time membership backfill at kernel:ready, so a Phase-2 app:seeded no longer reads the auth settings before the engine bind AppPlugin.start() emits app:seeded when its inline seed lands. On os serve / os dev the auth plugin starts first, so its app:seeded handler ran the ADR-0093 D6 pass during Phase 2, and the pass's ensureAuthSettingsBound read getNamespace('auth') before SettingsServicePlugin's kernel:ready hook bound the data engine: the showcase boot logged one SettingsService Pre-bind READ for namespace auth, and the binding was computed from manifest defaults. The pass is now armed by its own kernel:ready hook. A trigger before that is a no-op; the kernel:ready pass, ordered after the settings bind by the existing optionalDependencies edge, covers every row such a trigger was about. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 975bd2dae44..d559d60788d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1232,15 +1232,29 @@ export class AuthPlugin implements Plugin { // ('ran-unrecorded') does not unlatch it: a second trigger in this // process must not run the pass again (ADR-0093 D7). let backfillDecided = false; + // [#22257] Armed by this pass's own `kernel:ready` hook below; every + // trigger before it is a no-op. The pass's policy is a SETTING, and the + // settings data engine binds in `SettingsServicePlugin`'s `kernel:ready` + // hook. The `optionalDependencies` edge orders that hook ahead of this + // plugin's hooks, and orders nothing else: an event fired during Phase 2 + // reaches this pass before the bind. `app:seeded` is one — + // `AppPlugin.start()` emits it when its inline seed lands, after this + // plugin started — and on a seeded boot it bound the `auth` namespace + // from the manifest defaults (the `Pre-bind READ` warning), so the + // one-time pass could decide under a policy the deployment never chose. + // A pre-ready trigger loses nothing: the `kernel:ready` pass is still + // ahead, and it scans every row such a trigger was about. + let backfillArmed = false; const runBackfill = (source: string): Promise => { + if (!backfillArmed) return backfillChain; backfillChain = backfillChain.then(async () => { if (backfillDecided) return; try { // #5152 — the policy this pass runs under is a SETTING, so bind the - // namespace before reading it. This hook is registered in `init()` - // and therefore fires ahead of the one in `start()` that normally - // binds; without this the first pass of a fresh boot would run the - // pre-settings policy. Idempotent and shared with that hook. + // namespace before reading it. The composition `kernel:ready` hook + // registered earlier in `start()` has normally bound it by the time + // the pass is armed; awaiting it here keeps the policy independent + // of hook order. Idempotent and shared with that hook. await this.ensureAuthSettingsBound(ctx); const ql = ctx.getService('objectql'); const tenancy = this.tenancy; @@ -1288,13 +1302,17 @@ export class AuthPlugin implements Plugin { return backfillChain; }; runBackfillOnDefaultOrg = runBackfill; - ctx.hook('kernel:ready', () => runBackfill('kernel:ready')); + ctx.hook('kernel:ready', () => { + backfillArmed = true; + 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. + // 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')); } From d907051b5dfe6a6c703439eb39a53d16e461f8dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:31:51 +0000 Subject: [PATCH 2/3] test(plugin-auth): pin that a seeded boot reports no Pre-bind READ and reads the persisted auth settings A real ObjectKernel with in-memory SQLite, the real SettingsServicePlugin, the real AuthPlugin used before it (the os serve order), and an app plugin that emits app:seeded from its start(). Asserts the event fires inside the pre-bind window, that no Pre-bind READ is reported, that the auth binding's first getNamespace('auth') answers the persisted membership_policy, and that the one-time pass decides under it. A control forces a pre-bind read in the same composition and expects exactly one report. @objectstack/service-settings becomes a devDependency of plugin-auth, aliased to its src in vitest.config.ts. One patch changeset for plugin-auth. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../22257-auth-settings-read-after-bind.md | 14 + packages/plugins/plugin-auth/package.json | 1 + .../src/auth-settings-seeded-boot.pin.test.ts | 244 ++++++++++++++++++ packages/plugins/plugin-auth/vitest.config.ts | 8 + pnpm-lock.yaml | 3 + 5 files changed, 270 insertions(+) create mode 100644 .changeset/22257-auth-settings-read-after-bind.md create mode 100644 packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts diff --git a/.changeset/22257-auth-settings-read-after-bind.md b/.changeset/22257-auth-settings-read-after-bind.md new file mode 100644 index 00000000000..13bed71b6b4 --- /dev/null +++ b/.changeset/22257-auth-settings-read-after-bind.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +--- + +A seeded boot no longer reads the `auth` settings before the settings engine binds + +Clause-②: no + +Under `os serve` and `os dev`, an app with inline seed data emits `app:seeded` while plugins are still starting. The auth plugin's one-time membership backfill (ADR-0093 D6) ran on that event. Through it, the plugin bound the `auth` settings namespace before `SettingsServicePlugin` had bound its data engine. The boot logged `[SettingsService] Pre-bind READ of namespace 'auth'`, and the binding was computed from the manifest defaults, not from the saved `auth` settings. Measured on the showcase: one such line on every boot. + +The backfill is now armed by its own `kernel:ready` hook, which runs after the settings plugin binds its engine. A trigger of the pass before that hook does nothing: the `kernel:ready` pass runs afterwards and scans the same rows. A seed that settles after `kernel:ready` still re-runs the pass, as before. + +- **Which settings are read is unchanged.** Only the moment of the first read moves. +- **A saved `auth.membership_policy` governs the one-time pass.** Before, the pass's first run could read the manifest default `auto` while a saved `invite-only` sat unread. diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 262e626919a..b207681c0e7 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -46,6 +46,7 @@ "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/service-settings": "workspace:*", "@types/node": "^26.6.3", "hono": "^4.13.9", "tsx": "^4.23.15", diff --git a/packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts b/packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts new file mode 100644 index 00000000000..35c0f88ffc2 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A seeded boot reads the `auth` settings AFTER the settings engine binds + * (#22257). + * + * ## The defect + * + * `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook it + * registers in `start()`. `AuthPlugin` declares + * `optionalDependencies: ['com.objectstack.service.settings']`, so its own + * `kernel:ready` hooks are registered later and fire after the bind + * (`auth-settings-ordering.pin.test.ts`). That edge orders HOOKS and nothing + * else. `AppPlugin.start()` emits `app:seeded` when its inline seed lands, and + * on `os serve` / `os dev` the app plugin starts after the auth plugin, so the + * event arrives during Phase 2 — and the ADR-0093 D6 backfill's `app:seeded` + * handler went `runBackfill` → `ensureAuthSettingsBound` → `bindAuthSettings` → + * `getNamespace('auth')` while the engine was still unbound. Measured on the + * showcase (`os dev --seed-admin --fresh`): one `[SettingsService] Pre-bind READ + * of namespace 'auth'` on every boot, logged between the seed's completion and + * the `kernel:ready` trigger, and the auth binding computed from the manifest + * defaults. `check:settings-bind-window` walks `kernel:ready` handlers only, so + * an `app:seeded` handler was outside its population. + * + * ## The composition + * + * The cheapest real one that reproduces it: a real `ObjectKernel`, a real + * ObjectQL engine on in-memory SQLite, the REAL `SettingsServicePlugin` (its + * bind hook and its reporter are the subject), the real `AuthPlugin` used + * BEFORE the settings plugin (the `os serve` order), and an app plugin in + * `AppPlugin`'s place: it starts after the auth plugin, writes the row a + * previous boot persisted, and emits `app:seeded` from its own `start()`. + * + * It awaits the event, which `AppPlugin` does not. That is the stricter form: + * the handler's whole chain then runs inside Phase 2, so a read it makes cannot + * slip past the bind by timing. + * + * ## What is asserted + * + * 1. the window is real in this composition — at the moment `app:seeded` + * fires, the settings engine is NOT bound. Without it the next assertion + * could pass vacuously; + * 2. no `Pre-bind READ` is reported (the card's Done-when); + * 3. the first `getNamespace('auth')` the auth plugin issues answers the + * PERSISTED value, and the one-time pass decides under it — the value is + * observable, so the pin names it rather than inferring it from silence. + * + * The control case forces a pre-bind read of `auth` in the same composition + * and expects exactly one report: proof that the logger spy here sees the + * reporter at all. The reporter's own suite + * (`settings-prebind-read-warning.test.ts`) keeps pinning its text. + * + * ## Resolution + * + * `AuthPlugin` is imported from SOURCE (relative). `@objectstack/service-settings` + * is aliased to its `src/` in this package's `vitest.config.ts`, so the + * reporter and the bind hook are the checkout's too. `ObjectKernel`, + * `ObjectQLPlugin` and `SqlDriver` are the fixed instruments, read from `dist/` + * as this package's other kernel tests read them. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + InMemoryCryptoProvider, + SettingsService, + SettingsServicePlugin, +} from '@objectstack/service-settings'; +import { AuthPlugin } from './auth-plugin.js'; + +vi.mock('./membership-backfill-ledger.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOneTimeMembershipBackfill: vi.fn(actual.runOneTimeMembershipBackfill), + }; +}); + +import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js'; + +const backfillSpy = vi.mocked(runOneTimeMembershipBackfill); + +const SYS = { isSystem: true } as const; + +/** + * The persisted value. The manifest default for `auth.membership_policy` is + * `auto`, so a read answered from the in-memory fallback cannot produce this. + */ +const PERSISTED_POLICY = 'invite-only'; + +/** Env the auth settings read or the backfill branches on; cleared per case. */ +const ENV_KEYS = [ + 'OS_AUTH_MEMBERSHIP_POLICY', + 'OS_SKIP_MEMBERSHIP_BACKFILL', + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', +] as const; + +/** Publishes an in-memory SQLite driver the way a datasource plugin does. */ +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +/** What the app plugin saw at the moment it emitted `app:seeded`. */ +interface SeedObservation { + /** Whether `SettingsService` had its data engine bound at that instant. */ + engineBound?: boolean; +} + +/** + * An app plugin in `AppPlugin`'s place: it starts after the auth plugin (no + * edge between them, registered later), and its `start()` writes rows and then + * emits `app:seeded`, as `AppPlugin.start()` does when its inline seed lands. + * + * The row is the `auth.membership_policy` a previous boot persisted — the + * `global` rung, so `sys_platform_setting` (ADR-0131 D7). + * + * `forcePreBindRead` is the control: the same plugin reads the `auth` + * namespace itself, inside the window. + */ +function seedingAppPlugin(seen: SeedObservation, opts: { forcePreBindRead?: boolean } = {}): Plugin { + return { + name: 'com.example.seeded-app', + type: 'app', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init() {}, + async start(ctx: PluginContext) { + const ql = ctx.getService<{ insert(o: string, d: unknown, opts?: unknown): Promise }>('objectql'); + await ql.insert( + 'sys_platform_setting', + { namespace: 'auth', key: 'membership_policy', value: PERSISTED_POLICY, encrypted: false, locked: false }, + { context: SYS }, + ); + const settings = ctx.getService('settings'); + // Reaching into the private field on purpose, as the reporter's own suite + // does: "was the engine bound at this instant" is the fact the window is + // defined by, and there is no public spelling of it. + seen.engineBound = Boolean((settings as unknown as { engine?: unknown }).engine); + if (opts.forcePreBindRead) await settings.getNamespace('auth'); + await ctx.trigger('app:seeded', { appId: 'com.example.seeded-app', overBudget: false }); + }, + }; +} + +describe('a seeded boot reads the auth settings after the settings engine binds (#22257)', () => { + let kernel: ObjectKernel | undefined; + const savedEnv: Partial> = {}; + + beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + backfillSpy.mockClear(); + }); + + afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + vi.restoreAllMocks(); + }); + + /** + * Boot the composition. The kernel logger's `warn` is spied on the INSTANCE: + * `SettingsServicePlugin.init` stores `ctx.logger`, which is that same object, + * so this is what the reporter really calls. + */ + async function boot(opts: { forcePreBindRead?: boolean } = {}) { + const namespaceReads = vi.spyOn(SettingsService.prototype, 'getNamespace'); + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + const kernelLogger = (kernel as unknown as { + logger: { warn: (message: string, meta?: unknown) => void }; + }).logger; + const warn = vi.spyOn(kernelLogger, 'warn').mockImplementation(() => {}); + + const seen: SeedObservation = {}; + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + // `os serve` uses the auth plugin before its capability loop registers the + // settings plugin; the declared edge, not this order, puts settings first. + await kernel.use(new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' })); + await kernel.use(new SettingsServicePlugin({ registerRoutes: false, cryptoProvider: new InMemoryCryptoProvider() })); + await kernel.use(seedingAppPlugin(seen, opts)); + await kernel.bootstrap(); + + const preBindReads = () => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('Pre-bind READ')); + return { seen, preBindReads, namespaceReads }; + } + + it('reports no Pre-bind READ, and the auth binding and the one-time pass read the persisted value', async () => { + const { seen, preBindReads, namespaceReads } = await boot(); + + // 1. The window is real here: `app:seeded` fired before the bind. + expect(seen.engineBound, 'app:seeded must fire inside the pre-bind window, or this case measures nothing').toBe(false); + + // 2. The card's Done-when. + expect(preBindReads()).toEqual([]); + + // 3. The value the auth binding saw is the persisted one, not the default. + const authReadIndex = namespaceReads.mock.calls.findIndex(([namespace]) => namespace === 'auth'); + expect(authReadIndex, 'the auth plugin never read the auth namespace').toBeGreaterThanOrEqual(0); + const firstAuthRead = (await namespaceReads.mock.results[authReadIndex]!.value) as { + values: Record; + }; + expect(firstAuthRead.values.membership_policy).toMatchObject({ value: PERSISTED_POLICY, source: 'global' }); + + // …and the one-time membership pass decided under it, on every run. + expect(backfillSpy).toHaveBeenCalled(); + expect(backfillSpy.mock.calls.map(([, deps]) => deps.policy)).toEqual( + backfillSpy.mock.calls.map(() => PERSISTED_POLICY), + ); + }, 30_000); + + it('control: a pre-bind read forced in the same composition IS reported, once, for auth', async () => { + const { seen, preBindReads } = await boot({ forcePreBindRead: true }); + + expect(seen.engineBound).toBe(false); + const reports = preBindReads(); + expect(reports).toHaveLength(1); + expect(reports[0]).toContain("Pre-bind READ of namespace 'auth'"); + }, 30_000); +}); diff --git a/packages/plugins/plugin-auth/vitest.config.ts b/packages/plugins/plugin-auth/vitest.config.ts index d846c74a142..bf0b4c0055a 100644 --- a/packages/plugins/plugin-auth/vitest.config.ts +++ b/packages/plugins/plugin-auth/vitest.config.ts @@ -48,6 +48,14 @@ export default defineConfig({ find: /^@objectstack\/formula$/, replacement: path.resolve(here, '../../formula/src/index.ts'), }, + // [#22257] `auth-settings-seeded-boot.pin.test.ts` boots the REAL + // `SettingsServicePlugin` beside `AuthPlugin`: the pre-bind window it + // pins is that plugin's bind hook and that service's reporter. Same + // reason and the same anchoring as the entries above. + { + find: /^@objectstack\/service-settings$/, + replacement: path.resolve(here, '../../services/service-settings/src/index.ts'), + }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7b19c79680..0e3d1796e74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1716,6 +1716,9 @@ importers: '@objectstack/plugin-security': specifier: workspace:* version: link:../plugin-security + '@objectstack/service-settings': + specifier: workspace:* + version: link:../../services/service-settings '@types/node': specifier: ^26.6.3 version: 26.6.3 From 380124af94c2c93e730ddf3f9d49f1395e6558f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:04:15 +0000 Subject: [PATCH 3/3] test(plugin-auth): the ordering pin's resolution note no longer says service-settings is not a dependency The seeded-boot pin made @objectstack/service-settings a devDependency, aliased to src, so the note's "not a dependency ... would force a new registry entry" became false. The stub's own justification (resolvePluginOrder reads only names and declarations) stands; the note now points at the pin that composes the real plugin. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/auth-settings-ordering.pin.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts b/packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts index c3a770e944f..6c900f4a86a 100644 --- a/packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts @@ -53,13 +53,13 @@ * subject. * * The settings plugin is a NAME-ONLY stub rather than the real - * `SettingsServicePlugin`: `@objectstack/service-settings` is not a dependency - * of `@objectstack/plugin-auth`, and adding one so a test could import it - * would both create a workspace edge that exists for nothing else and force a - * new entry into the shrink-only registry above. `resolvePluginOrder` reads - * only the `OrderablePlugin` surface — `name`, `dependencies`, - * `optionalDependencies` — and the property under test is `AuthPlugin`'s - * declaration, so the stub is the whole of what the resolver would see. + * `SettingsServicePlugin`: `resolvePluginOrder` reads only the + * `OrderablePlugin` surface — `name`, `dependencies`, `optionalDependencies` — + * and the property under test is `AuthPlugin`'s declaration, so the stub is + * the whole of what the resolver would see. The real plugin is composed where + * its bind hook is the subject: `auth-settings-seeded-boot.pin.test.ts` boots + * it (a devDependency, aliased to `src/` in this package's `vitest.config.ts`) + * and pins the read that this edge does NOT order — a Phase-2 `app:seeded`. */ import { describe, it, expect } from 'vitest';