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
14 changes: 14 additions & 0 deletions .changeset/22257-auth-settings-read-after-bind.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 24 additions & 6 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<IDataEngine>('objectql');
const tenancy = this.tenancy;
Expand Down Expand Up @@ -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'));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
244 changes: 244 additions & 0 deletions packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('./membership-backfill-ledger.js')>();
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<unknown> }>('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<SettingsService>('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<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};

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<string, { value?: unknown; source?: unknown }>;
};
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);
});
8 changes: 8 additions & 0 deletions packages/plugins/plugin-auth/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
],
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading