Skip to content

Commit 0ee2d15

Browse files
fix(plugin-auth): a seeded boot no longer reads the auth settings before the settings engine binds (#22257) (#22312)
Fixes #22257 Clause-②: no ## What was wrong On `examples/app-showcase`, `os dev --seed-admin --fresh` logged one `[SettingsService] Pre-bind READ of namespace 'auth'` on every boot. Re-measured on `origin/main` `79c35d45` (after PR #22295): 1 line, in the boot-diagnostics block. **The reader.** A temporary stack capture in the built `reportPreBindRead` (applied to `service-settings/dist/index.js` and reverted; the sha256 matched before and after, and the marker count after the revert was 0) named it: `SettingsService.getNamespace` from `AuthPlugin.bindAuthSettings`, then `applySettings` (`auth-plugin.ts:1535` at `79c35d45`), from `AuthPlugin.ensureAuthSettingsBound`, from `runBackfill` (`:1235` / `:1244`), from the kernel's `trigger`. The handler is the `app:seeded` hook registered in `AuthPlugin.start()` (`:1298`), which calls `runBackfill('app:seeded')`. It is the ADR-0093 D6 one-time membership backfill. **The phase.** A debug-level boot puts the read in Phase 2 (start). It comes after `[Seeder] Seed loading complete` (132 rows, `plugin.app.com.example.showcase`) and before `Triggering kernel:ready hook`. `AppPlugin.start()` emits `app:seeded` when its inline seed lands. On `os dev`, the auth plugin started first, so its handler ran during Phase 2. `SettingsServicePlugin` binds the engine in its `kernel:ready` hook (`settings-service-plugin.ts:221`), which is later. The `optionalDependencies: ['com.objectstack.service.settings']` edge on `AuthPlugin` orders only `kernel:ready` HOOKS. An event fired during Phase 2 is outside it. **The consequence.** `ensureAuthSettingsBound` is memoized, so the auth binding was computed from the manifest defaults. In the debug log, `Auth: bound to settings namespace=auth` appears at 14:18:11.661, before `kernel:ready` at 11.672. The persisted rows were re-applied later only through a fire-and-forget subscription callback. The one-time pass's first run read the default policy `auto`. **Why `check:settings-bind-window` missed it.** The gate walks `init()` / `start()` bodies and `kernel:ready` handlers only (`READY_HOOK`). It reached this same read through the `kernel:ready` registration of `runBackfill`, and classified it `declared`. The `app:seeded` registration fires during Phase 2, from another plugin's `start()`, and is not in the gate's population. This is reported as a blind spot below; this PR adds no gate. ## The fix (`packages/plugins/plugin-auth/src/auth-plugin.ts`) The one-time pass is now armed by its own `kernel:ready` hook. Any trigger before that does nothing: `app:seeded` during Phase 2, or `default-org-created` from the bootstrap middleware. The `kernel:ready` pass still runs afterwards, after the settings bind, and scans every row such a trigger was about. Triggers after `kernel:ready` behave exactly as before. That includes an over-budget seed that settles in the background (the #2996 path). - No change to which settings are read, only to when. - No change to the reporter's level or text. - No `packages/spec` change. - The comment above `ensureAuthSettingsBound` in `runBackfill` was stale: it said "registered in `init()`". It is corrected. ## Boot, before and after | | `Pre-bind READ` lines | `Auth: bound to settings namespace=auth` | |---|---|---| | `79c35d45` (base) | 1 (`auth`) | Phase 2, before the `kernel:ready` trigger | | `9b7ac6cf` (fix; plugin-auth dist rebuilt, `backfillArmed` grep 3 in `dist/index.mjs`) | 0 | after the `kernel:ready` trigger (16.538 vs 16.381) | In both boots the D6 pass still records `adr-0093-membership-backfill` once. The other boot-diagnostic lines are unchanged: `sys_migration` UNIQUE and `[sharing-rule]`. See the acceptance notes. ## Pins `packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts` composes: - a real `ObjectKernel` - ObjectQL on in-memory SQLite - the REAL `SettingsServicePlugin` - the real `AuthPlugin`, used before the settings plugin (the `os serve` order) - an app plugin in `AppPlugin`'s place: it writes the persisted `auth.membership_policy = invite-only` row, then emits `app:seeded` from its `start()`. The dogfood harness cannot compose this. `bootStack` registers `AppPlugin` before `AuthPlugin`, so `app:seeded` fires before auth registers its handler. The main case asserts four things: 1. The window is real: at `app:seeded`, the settings engine is unbound. 2. No `Pre-bind READ` is reported. 3. The auth binding's first `getNamespace('auth')` answers `{ value: 'invite-only', source: 'global' }`. 4. Every run of the one-time pass gets `policy: 'invite-only'`. A control case forces a pre-bind read of `auth` in the same composition and expects exactly one report. `@objectstack/service-settings` is added as a devDependency of plugin-auth. It is aliased to `src/` in `vitest.config.ts` (anchored, like the three existing entries), so `KNOWN_UNALIASED_TEST_IMPORTS` is unchanged. The lockfile gains only that importer entry. **Control.** `settings-prebind-read-warning.test.ts` passes 9/9 unchanged. It still fires on a forced pre-bind read. **Ablation**, via `scripts/ablation-replace.mjs` from committed `d907051b`, with an EXIT/INT/TERM restore and the restore proven by blob == HEAD and an empty `git diff HEAD`. `AuthPlugin` is imported relatively, so `src/` is what runs and no dist rebuild was involved. - Leg 1: delete `if (!backfillArmed) return backfillChain;` (anchor 1 to 0, blob `d559d607` to `83e4649d`). The main case goes RED at assertion 2: one `Pre-bind READ of namespace 'auth'`. The control stays green. Restored to `d559d607`. - Leg 2: the same deletion held, plus assertion 2 removed from the test. The main case goes RED at assertion 3: the first `auth` read answered `{ value: 'auto', source: 'default' }`. Both files restored (blob == HEAD). ## Verification (the gate union was run on `b67e95f1`) - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 129 files, 2639 passed, 10 skipped, exit 0. This ran on `d907051b`. The merge after it brought only `docs/adr/0096`. - `pnpm --filter @objectstack/plugin-auth run typecheck`: exit 0. `check:test-typecheck` is OK, and the new file is in the `tsconfig.test.json` program (`--listFiles`: 1). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `b67e95f1` derived 79 commands, and all 79 exited 0. `--ran`: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:settings-bind-window`: `4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned)`. - Selected verdict lines: - `check-test-source-alias OK — 73 packages with tests scanned; 60 registered` - `check:workspace-manifest-cycles OK: 80 workspace package(s), 515 workspace: edge(s)` - `check-nul-bytes: OK` - `check:undeclared-dep-imports` passed - `check-adr-0087-registration: 1 non-breaking changeset(s) seen` - eslint, narrowed to the 3 changed TS files: 0 errors and 0 warnings in `--format json`. Each file has a non-empty rule set under `--print-config`. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. - NOT MEASURED: CI's Test Core, Dogfood, Build Core and the type-check lanes. These are CI-owned. ## Acceptance notes - **Gate blind spot (not fixed; no gate added).** `check:settings-bind-window` cannot see two things: - a settings read reached through a hook other than `kernel:ready` that fires during Phase 2 (`app:seeded`, emitted from `AppPlugin.start()`); - a read reached through a data-middleware or callback closure fired by a Phase-2 write. Its header says "Everything that runs before that bind hook is the window." Its population is narrower than that window. - **Landing order with PR #22186.** That PR makes `AppPlugin` declare `com.objectstack.auth` as an optional dependency, and creates the Default Organization in `AuthPlugin.start()`. Without this fix, the Phase-2 `app:seeded` pass would then have a target and would DECIDE the one-time backfill under the manifest-default policy. This fix arms the pass at `kernel:ready`, so it holds under either landing order. The two diffs touch different regions of `auth-plugin.ts`: theirs is around `:725` and `:1168`, this one is `:1232` to `:1316`. - **Unchanged boot line.** `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` appears on the fresh showcase boot both before and after this change. It comes right after `adr-0093-default-org-owner-bind` is recorded. Root cause is NOT MEASURED; it is reported to the seat. - **Merge state.** `origin/main` was merged at `799eb000`. `main` has since moved 4 commits. None touches plugin-auth or service-settings; plugin-security's strict mode is the nearest, and this composition mounts no `SecurityPlugin`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4069c5 commit 0ee2d15

7 files changed

Lines changed: 301 additions & 13 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
A seeded boot no longer reads the `auth` settings before the settings engine binds
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
- **Which settings are read is unchanged.** Only the moment of the first read moves.
14+
- **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.

‎packages/plugins/plugin-auth/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@objectstack/objectql": "workspace:*",
4747
"@objectstack/plugin-hono-server": "workspace:*",
4848
"@objectstack/plugin-security": "workspace:*",
49+
"@objectstack/service-settings": "workspace:*",
4950
"@types/node": "^26.6.3",
5051
"hono": "^4.13.9",
5152
"tsx": "^4.23.15",

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,15 +1232,29 @@ export class AuthPlugin implements Plugin {
12321232
// ('ran-unrecorded') does not unlatch it: a second trigger in this
12331233
// process must not run the pass again (ADR-0093 D7).
12341234
let backfillDecided = false;
1235+
// [#22257] Armed by this pass's own `kernel:ready` hook below; every
1236+
// trigger before it is a no-op. The pass's policy is a SETTING, and the
1237+
// settings data engine binds in `SettingsServicePlugin`'s `kernel:ready`
1238+
// hook. The `optionalDependencies` edge orders that hook ahead of this
1239+
// plugin's hooks, and orders nothing else: an event fired during Phase 2
1240+
// reaches this pass before the bind. `app:seeded` is one —
1241+
// `AppPlugin.start()` emits it when its inline seed lands, after this
1242+
// plugin started — and on a seeded boot it bound the `auth` namespace
1243+
// from the manifest defaults (the `Pre-bind READ` warning), so the
1244+
// one-time pass could decide under a policy the deployment never chose.
1245+
// A pre-ready trigger loses nothing: the `kernel:ready` pass is still
1246+
// ahead, and it scans every row such a trigger was about.
1247+
let backfillArmed = false;
12351248
const runBackfill = (source: string): Promise<void> => {
1249+
if (!backfillArmed) return backfillChain;
12361250
backfillChain = backfillChain.then(async () => {
12371251
if (backfillDecided) return;
12381252
try {
12391253
// #5152 — the policy this pass runs under is a SETTING, so bind the
1240-
// namespace before reading it. This hook is registered in `init()`
1241-
// and therefore fires ahead of the one in `start()` that normally
1242-
// binds; without this the first pass of a fresh boot would run the
1243-
// pre-settings policy. Idempotent and shared with that hook.
1254+
// namespace before reading it. The composition `kernel:ready` hook
1255+
// registered earlier in `start()` has normally bound it by the time
1256+
// the pass is armed; awaiting it here keeps the policy independent
1257+
// of hook order. Idempotent and shared with that hook.
12441258
await this.ensureAuthSettingsBound(ctx);
12451259
const ql = ctx.getService<IDataEngine>('objectql');
12461260
const tenancy = this.tenancy;
@@ -1288,13 +1302,17 @@ export class AuthPlugin implements Plugin {
12881302
return backfillChain;
12891303
};
12901304
runBackfillOnDefaultOrg = runBackfill;
1291-
ctx.hook('kernel:ready', () => runBackfill('kernel:ready'));
1305+
ctx.hook('kernel:ready', () => {
1306+
backfillArmed = true;
1307+
return runBackfill('kernel:ready');
1308+
});
12921309
// #2996: app seeds insert `sys_user` via raw engine.insert, bypassing
12931310
// better-auth's `user.create.after` reconciler. A seed that overruns
12941311
// OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready,
12951312
// so its users would miss a kernel:ready pass that had no target yet.
12961313
// The trigger stays; the ledger makes it a no-op once the one-time pass
1297-
// has been recorded.
1314+
// has been recorded. An in-budget seed settles during Phase 2, before the
1315+
// pass is armed, and the `kernel:ready` pass covers its rows.
12981316
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
12991317
}
13001318

‎packages/plugins/plugin-auth/src/auth-settings-ordering.pin.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@
5353
* subject.
5454
*
5555
* The settings plugin is a NAME-ONLY stub rather than the real
56-
* `SettingsServicePlugin`: `@objectstack/service-settings` is not a dependency
57-
* of `@objectstack/plugin-auth`, and adding one so a test could import it
58-
* would both create a workspace edge that exists for nothing else and force a
59-
* new entry into the shrink-only registry above. `resolvePluginOrder` reads
60-
* only the `OrderablePlugin` surface — `name`, `dependencies`,
61-
* `optionalDependencies` — and the property under test is `AuthPlugin`'s
62-
* declaration, so the stub is the whole of what the resolver would see.
56+
* `SettingsServicePlugin`: `resolvePluginOrder` reads only the
57+
* `OrderablePlugin` surface — `name`, `dependencies`, `optionalDependencies` —
58+
* and the property under test is `AuthPlugin`'s declaration, so the stub is
59+
* the whole of what the resolver would see. The real plugin is composed where
60+
* its bind hook is the subject: `auth-settings-seeded-boot.pin.test.ts` boots
61+
* it (a devDependency, aliased to `src/` in this package's `vitest.config.ts`)
62+
* and pins the read that this edge does NOT order — a Phase-2 `app:seeded`.
6363
*/
6464

6565
import { describe, it, expect } from 'vitest';
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A seeded boot reads the `auth` settings AFTER the settings engine binds
5+
* (#22257).
6+
*
7+
* ## The defect
8+
*
9+
* `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook it
10+
* registers in `start()`. `AuthPlugin` declares
11+
* `optionalDependencies: ['com.objectstack.service.settings']`, so its own
12+
* `kernel:ready` hooks are registered later and fire after the bind
13+
* (`auth-settings-ordering.pin.test.ts`). That edge orders HOOKS and nothing
14+
* else. `AppPlugin.start()` emits `app:seeded` when its inline seed lands, and
15+
* on `os serve` / `os dev` the app plugin starts after the auth plugin, so the
16+
* event arrives during Phase 2 — and the ADR-0093 D6 backfill's `app:seeded`
17+
* handler went `runBackfill` → `ensureAuthSettingsBound` → `bindAuthSettings` →
18+
* `getNamespace('auth')` while the engine was still unbound. Measured on the
19+
* showcase (`os dev --seed-admin --fresh`): one `[SettingsService] Pre-bind READ
20+
* of namespace 'auth'` on every boot, logged between the seed's completion and
21+
* the `kernel:ready` trigger, and the auth binding computed from the manifest
22+
* defaults. `check:settings-bind-window` walks `kernel:ready` handlers only, so
23+
* an `app:seeded` handler was outside its population.
24+
*
25+
* ## The composition
26+
*
27+
* The cheapest real one that reproduces it: a real `ObjectKernel`, a real
28+
* ObjectQL engine on in-memory SQLite, the REAL `SettingsServicePlugin` (its
29+
* bind hook and its reporter are the subject), the real `AuthPlugin` used
30+
* BEFORE the settings plugin (the `os serve` order), and an app plugin in
31+
* `AppPlugin`'s place: it starts after the auth plugin, writes the row a
32+
* previous boot persisted, and emits `app:seeded` from its own `start()`.
33+
*
34+
* It awaits the event, which `AppPlugin` does not. That is the stricter form:
35+
* the handler's whole chain then runs inside Phase 2, so a read it makes cannot
36+
* slip past the bind by timing.
37+
*
38+
* ## What is asserted
39+
*
40+
* 1. the window is real in this composition — at the moment `app:seeded`
41+
* fires, the settings engine is NOT bound. Without it the next assertion
42+
* could pass vacuously;
43+
* 2. no `Pre-bind READ` is reported (the card's Done-when);
44+
* 3. the first `getNamespace('auth')` the auth plugin issues answers the
45+
* PERSISTED value, and the one-time pass decides under it — the value is
46+
* observable, so the pin names it rather than inferring it from silence.
47+
*
48+
* The control case forces a pre-bind read of `auth` in the same composition
49+
* and expects exactly one report: proof that the logger spy here sees the
50+
* reporter at all. The reporter's own suite
51+
* (`settings-prebind-read-warning.test.ts`) keeps pinning its text.
52+
*
53+
* ## Resolution
54+
*
55+
* `AuthPlugin` is imported from SOURCE (relative). `@objectstack/service-settings`
56+
* is aliased to its `src/` in this package's `vitest.config.ts`, so the
57+
* reporter and the bind hook are the checkout's too. `ObjectKernel`,
58+
* `ObjectQLPlugin` and `SqlDriver` are the fixed instruments, read from `dist/`
59+
* as this package's other kernel tests read them.
60+
*/
61+
62+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
63+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
64+
import { ObjectQLPlugin } from '@objectstack/objectql';
65+
import { SqlDriver } from '@objectstack/driver-sql';
66+
import {
67+
InMemoryCryptoProvider,
68+
SettingsService,
69+
SettingsServicePlugin,
70+
} from '@objectstack/service-settings';
71+
import { AuthPlugin } from './auth-plugin.js';
72+
73+
vi.mock('./membership-backfill-ledger.js', async (importOriginal) => {
74+
const actual = await importOriginal<typeof import('./membership-backfill-ledger.js')>();
75+
return {
76+
...actual,
77+
runOneTimeMembershipBackfill: vi.fn(actual.runOneTimeMembershipBackfill),
78+
};
79+
});
80+
81+
import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js';
82+
83+
const backfillSpy = vi.mocked(runOneTimeMembershipBackfill);
84+
85+
const SYS = { isSystem: true } as const;
86+
87+
/**
88+
* The persisted value. The manifest default for `auth.membership_policy` is
89+
* `auto`, so a read answered from the in-memory fallback cannot produce this.
90+
*/
91+
const PERSISTED_POLICY = 'invite-only';
92+
93+
/** Env the auth settings read or the backfill branches on; cleared per case. */
94+
const ENV_KEYS = [
95+
'OS_AUTH_MEMBERSHIP_POLICY',
96+
'OS_SKIP_MEMBERSHIP_BACKFILL',
97+
'OS_TENANCY_POSTURE',
98+
'OS_MULTI_ORG_ENABLED',
99+
] as const;
100+
101+
/** Publishes an in-memory SQLite driver the way a datasource plugin does. */
102+
function sqliteDriverPlugin(): Plugin {
103+
return {
104+
name: 'test.driver.sqlite',
105+
type: 'standard',
106+
version: '1.0.0',
107+
async init(ctx: PluginContext) {
108+
ctx.registerService(
109+
'driver.default',
110+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
111+
);
112+
},
113+
};
114+
}
115+
116+
/** What the app plugin saw at the moment it emitted `app:seeded`. */
117+
interface SeedObservation {
118+
/** Whether `SettingsService` had its data engine bound at that instant. */
119+
engineBound?: boolean;
120+
}
121+
122+
/**
123+
* An app plugin in `AppPlugin`'s place: it starts after the auth plugin (no
124+
* edge between them, registered later), and its `start()` writes rows and then
125+
* emits `app:seeded`, as `AppPlugin.start()` does when its inline seed lands.
126+
*
127+
* The row is the `auth.membership_policy` a previous boot persisted — the
128+
* `global` rung, so `sys_platform_setting` (ADR-0131 D7).
129+
*
130+
* `forcePreBindRead` is the control: the same plugin reads the `auth`
131+
* namespace itself, inside the window.
132+
*/
133+
function seedingAppPlugin(seen: SeedObservation, opts: { forcePreBindRead?: boolean } = {}): Plugin {
134+
return {
135+
name: 'com.example.seeded-app',
136+
type: 'app',
137+
version: '1.0.0',
138+
dependencies: ['com.objectstack.engine.objectql'],
139+
async init() {},
140+
async start(ctx: PluginContext) {
141+
const ql = ctx.getService<{ insert(o: string, d: unknown, opts?: unknown): Promise<unknown> }>('objectql');
142+
await ql.insert(
143+
'sys_platform_setting',
144+
{ namespace: 'auth', key: 'membership_policy', value: PERSISTED_POLICY, encrypted: false, locked: false },
145+
{ context: SYS },
146+
);
147+
const settings = ctx.getService<SettingsService>('settings');
148+
// Reaching into the private field on purpose, as the reporter's own suite
149+
// does: "was the engine bound at this instant" is the fact the window is
150+
// defined by, and there is no public spelling of it.
151+
seen.engineBound = Boolean((settings as unknown as { engine?: unknown }).engine);
152+
if (opts.forcePreBindRead) await settings.getNamespace('auth');
153+
await ctx.trigger('app:seeded', { appId: 'com.example.seeded-app', overBudget: false });
154+
},
155+
};
156+
}
157+
158+
describe('a seeded boot reads the auth settings after the settings engine binds (#22257)', () => {
159+
let kernel: ObjectKernel | undefined;
160+
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
161+
162+
beforeEach(() => {
163+
for (const key of ENV_KEYS) {
164+
savedEnv[key] = process.env[key];
165+
delete process.env[key];
166+
}
167+
backfillSpy.mockClear();
168+
});
169+
170+
afterEach(async () => {
171+
try {
172+
await kernel?.shutdown();
173+
} catch {
174+
/* a refused boot leaves the kernel stopped */
175+
}
176+
kernel = undefined;
177+
for (const key of ENV_KEYS) {
178+
if (savedEnv[key] === undefined) delete process.env[key];
179+
else process.env[key] = savedEnv[key];
180+
}
181+
vi.restoreAllMocks();
182+
});
183+
184+
/**
185+
* Boot the composition. The kernel logger's `warn` is spied on the INSTANCE:
186+
* `SettingsServicePlugin.init` stores `ctx.logger`, which is that same object,
187+
* so this is what the reporter really calls.
188+
*/
189+
async function boot(opts: { forcePreBindRead?: boolean } = {}) {
190+
const namespaceReads = vi.spyOn(SettingsService.prototype, 'getNamespace');
191+
kernel = new ObjectKernel({ logger: { level: 'silent' } });
192+
const kernelLogger = (kernel as unknown as {
193+
logger: { warn: (message: string, meta?: unknown) => void };
194+
}).logger;
195+
const warn = vi.spyOn(kernelLogger, 'warn').mockImplementation(() => {});
196+
197+
const seen: SeedObservation = {};
198+
await kernel.use(sqliteDriverPlugin());
199+
await kernel.use(new ObjectQLPlugin());
200+
// `os serve` uses the auth plugin before its capability loop registers the
201+
// settings plugin; the declared edge, not this order, puts settings first.
202+
await kernel.use(new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }));
203+
await kernel.use(new SettingsServicePlugin({ registerRoutes: false, cryptoProvider: new InMemoryCryptoProvider() }));
204+
await kernel.use(seedingAppPlugin(seen, opts));
205+
await kernel.bootstrap();
206+
207+
const preBindReads = () =>
208+
warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('Pre-bind READ'));
209+
return { seen, preBindReads, namespaceReads };
210+
}
211+
212+
it('reports no Pre-bind READ, and the auth binding and the one-time pass read the persisted value', async () => {
213+
const { seen, preBindReads, namespaceReads } = await boot();
214+
215+
// 1. The window is real here: `app:seeded` fired before the bind.
216+
expect(seen.engineBound, 'app:seeded must fire inside the pre-bind window, or this case measures nothing').toBe(false);
217+
218+
// 2. The card's Done-when.
219+
expect(preBindReads()).toEqual([]);
220+
221+
// 3. The value the auth binding saw is the persisted one, not the default.
222+
const authReadIndex = namespaceReads.mock.calls.findIndex(([namespace]) => namespace === 'auth');
223+
expect(authReadIndex, 'the auth plugin never read the auth namespace').toBeGreaterThanOrEqual(0);
224+
const firstAuthRead = (await namespaceReads.mock.results[authReadIndex]!.value) as {
225+
values: Record<string, { value?: unknown; source?: unknown }>;
226+
};
227+
expect(firstAuthRead.values.membership_policy).toMatchObject({ value: PERSISTED_POLICY, source: 'global' });
228+
229+
// …and the one-time membership pass decided under it, on every run.
230+
expect(backfillSpy).toHaveBeenCalled();
231+
expect(backfillSpy.mock.calls.map(([, deps]) => deps.policy)).toEqual(
232+
backfillSpy.mock.calls.map(() => PERSISTED_POLICY),
233+
);
234+
}, 30_000);
235+
236+
it('control: a pre-bind read forced in the same composition IS reported, once, for auth', async () => {
237+
const { seen, preBindReads } = await boot({ forcePreBindRead: true });
238+
239+
expect(seen.engineBound).toBe(false);
240+
const reports = preBindReads();
241+
expect(reports).toHaveLength(1);
242+
expect(reports[0]).toContain("Pre-bind READ of namespace 'auth'");
243+
}, 30_000);
244+
});

‎packages/plugins/plugin-auth/vitest.config.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export default defineConfig({
4848
find: /^@objectstack\/formula$/,
4949
replacement: path.resolve(here, '../../formula/src/index.ts'),
5050
},
51+
// [#22257] `auth-settings-seeded-boot.pin.test.ts` boots the REAL
52+
// `SettingsServicePlugin` beside `AuthPlugin`: the pre-bind window it
53+
// pins is that plugin's bind hook and that service's reporter. Same
54+
// reason and the same anchoring as the entries above.
55+
{
56+
find: /^@objectstack\/service-settings$/,
57+
replacement: path.resolve(here, '../../services/service-settings/src/index.ts'),
58+
},
5159
],
5260
},
5361
});

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)