From 68cb61c7751728f25bd39efa4d01c4b16c298169 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:20:12 +0000 Subject: [PATCH 1/4] wip(cli): refuse a production boot whose plugin-auth fails to load (#25158) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01YD4v2ErCfZk8sfaZe8AYVu --- packages/cli/src/commands/serve.ts | 43 +++- .../plugin-auth-load-failure.hook.mjs | 64 ++++++ ...h-load-failure-refusal.integration.test.ts | 184 ++++++++++++++++++ 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs create mode 100644 packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4f5e52a6937..47ae01cdc1e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4138,6 +4138,16 @@ export default class Serve extends Command { // have to set OS_AUTH_SECRET just to try the login/register flow. secret: resolveAuthSecret({ isDev }), }); + const productionBoot = !isDev; + // [ADR-0131 D9] Whether the AuthPlugin the rule composed reached the + // kernel. The `try` below is broad — it wraps the import, the + // construction and the `kernel.use` — and its catch only warns, so a + // production boot whose `@objectstack/plugin-auth` is missing or broken + // used to boot with no AuthPlugin, and so with no organization object. + // The catch records that failure here; it is refused below, beside the + // other two compositions that reach the same state. + let authPluginMounted = false; + let authPluginLoadFailure: string | undefined; if (!hasAuthPlugin && tierEnabled('auth')) { try { const authPkg = '@objectstack/plugin-auth'; @@ -4307,6 +4317,7 @@ export default class Serve extends Command { // precedence, and no second switch for the author to keep in step. registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(config, plugins), })); + authPluginMounted = true; trackPlugin('Auth'); // ADR-0048 — the platform apps (Setup/Account) moved out of @@ -4572,7 +4583,12 @@ export default class Serve extends Command { } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - if (!Serve.isModuleNotFoundError(err)) { + if (productionBoot && authComposition.composes && !authPluginMounted) { + // Refused below, naming this error. A development boot, and a + // composition the rule did not compose (the host kernel, a missing + // secret), keep the old handling. + authPluginLoadFailure = msg; + } else if (!Serve.isModuleNotFoundError(err)) { console.warn(chalk.yellow(` ⚠ AuthPlugin failed to load: ${msg}`)); } // @objectstack/plugin-auth not installed — login/register endpoints unavailable @@ -4671,7 +4687,6 @@ export default class Serve extends Command { // the text no longer offers it. A development boot keeps the old shape — // refused only when it serves — and so does programmatic embedding, which // never passes through this command. - const productionBoot = !isDev; if ((flags.server || productionBoot) && !(tierEnabled('auth') || hasAuthPlugin)) { throw new Error( 'This stack mounts no auth, so no caller can authenticate — and anonymous access to object ' @@ -4712,6 +4727,30 @@ export default class Serve extends Command { + 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.', ); } + // [ADR-0131 D9] The third composition that reaches the same state: the + // `auth` tier is on and a secret resolved, so the rule composed + // AuthPlugin, but `@objectstack/plugin-auth` failed to load — an install + // that is missing or broken, or a construction that threw, all caught by + // the broad `try` above before AuthPlugin was mounted. No AuthPlugin, so + // no organization object: the boot's writes would be stored ownerless on + // an 18.0.0-born store (nullable columns, MongoDB) and refused at NOT + // NULL on a fresh one. Refused here for the reason the two refusals above + // are, while nothing has started, and naming the load error instead of + // warning and booting. The catch records it on a production boot alone, + // and only when the rule composed AuthPlugin, so `--dev` and the host + // kernel keep their warn-only paths. + if (authPluginLoadFailure !== undefined) { + throw new Error( + 'This stack enables auth and an auth secret is set, but @objectstack/plugin-auth failed to load, so ' + + 'AuthPlugin was not composed and no caller can authenticate — anonymous access to object data is ' + + 'always denied, so the data API would be unusable, and with no organization every row this boot ' + + 'writes would have no owner.\n' + + `The load error: ${authPluginLoadFailure}\n` + + 'Fix the install the error names: @objectstack/plugin-auth is a dependency of @objectstack/cli, so ' + + "reinstall the deployment's dependencies (e.g. `npm ci` or `pnpm install --frozen-lockfile`).\n" + + 'Running kernel-only (--no-server) does not avoid this: the boot still writes rows, with no owner.', + ); + } try { const { createRestApiPlugin } = await import('@objectstack/rest'); diff --git a/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs b/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs new file mode 100644 index 00000000000..a88493edf9d --- /dev/null +++ b/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An installed `@objectstack/plugin-auth` made to fail its load in the one + * process that imports this hook, the way a missing or broken install fails it + * — the stub `serve-plugin-auth-load-failure-refusal.integration.test.ts` + * needs. Loaded with `node --import ?mode=missing|broken`, so + * it is in place before `os serve` imports anything; a `resolve` hook and not a + * file operation, for the reason `unbuilt-spec-dist.hook.mjs` gives (several + * agents share one container, and nothing here touches the disk). + * + * Only the package's ROOT entry is denied — the specifier `os serve`'s + * AuthPlugin auto-registration imports. Its subpaths stay reachable on purpose: + * `@objectstack/runtime` imports `@objectstack/plugin-auth/rate-limit-storage` + * and `/client-address` statically, so denying them would crash the CLI before + * the boot reaches the auth tier, which is a different failure from the one + * under test. + * + * Both modes make node produce its OWN error, so the refusal is asserted against + * a sentence this file did not write: + * + * - `missing` hands `nextResolve` an absolute path that does not exist, and + * node answers its real `Cannot find module '…'` (`ERR_MODULE_NOT_FOUND`) — + * the error the CLI used to swallow without a word; + * - `broken` resolves the entry to a module whose static named import has no + * export behind it, and node answers its real link-time `SyntaxError` + * (`does not provide an export named …`) — the shape of the broken install + * the publish smoke once caught, which the CLI used to warn about and boot. + */ + +import { registerHooks } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */ +const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +const DENIED = '@objectstack/plugin-auth'; + +const MODE = new URL(import.meta.url).searchParams.get('mode'); +if (MODE !== 'missing' && MODE !== 'broken') { + throw new Error(`plugin-auth-load-failure.hook.mjs: import it with ?mode=missing or ?mode=broken, not ${JSON.stringify(MODE)}`); +} + +/** Where a missing install's root entry is looked for; the last segment is deliberately not a real one. */ +const MISSING_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/plugin-auth/dist/__missing-install-simulation__/index.mjs'); + +/** The broken install's root entry: a module that cannot link. */ +const BROKEN_URL = 'objectstack-test:plugin-auth-broken-install'; +const BROKEN_SOURCE = "import { createLocalAccountIssuer } from 'node:path';\nexport class AuthPlugin {}\nvoid createLocalAccountIssuer;\n"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === DENIED) { + if (MODE === 'missing') return nextResolve(MISSING_TARGET, context); + return { url: BROKEN_URL, format: 'module', shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === BROKEN_URL) return { format: 'module', source: BROKEN_SOURCE, shortCircuit: true }; + return nextLoad(url, context); + }, +}); diff --git a/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts b/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts new file mode 100644 index 00000000000..8d1563ffc7d --- /dev/null +++ b/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 30 — spawns the real producer once per single-cause fixture: four boots of the built CLI (a missing and a broken `@objectstack/plugin-auth` in production, the broken one under `--dev`, and production with no stub), each differing only in the input under test; MEASURED_PLACEHOLDER + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +/** + * ADR-0131 D9 — the third composition that leaves a production `os serve` with + * no organization object, beside the two `serve-no-auth-secret-refusal` + * pins: the `auth` tier is on and an auth secret is set, so + * `resolvePlatformAuthComposition` composes AuthPlugin, but + * `@objectstack/plugin-auth` fails to load. The broad `try` around the + * auto-registration used to catch that and boot on — silently for a missing + * install, with one warning for a broken one — with no AuthPlugin, so no + * organization object, so every row the boot wrote landed with no owner (or + * was refused at NOT NULL on a fresh store). + * + * The failure is stubbed with `fixtures/plugin-auth-load-failure.hook.mjs`, a + * `--import` resolve hook that fails the package's root entry the two ways an + * install does, with node's own error each time. The refused legs pin the + * order as much as the refusal — the database is never opened — and that the + * refusal names the load error. The control legs are what make the stub the + * cause: the same fixture and secret with no stub boots, and the same broken + * stub under `--dev` keeps the development warn-and-boot path. + * + * Spawned through `bin/run.js` with `NODE_ENV` unset, the only shape that + * reaches production posture (see the sibling pin's header). + */ + +const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run.js'); +const HOOK = pathToFileURL(resolve(HERE, 'fixtures/plugin-auth-load-failure.hook.mjs')).href; + +/** Auth on (the default preset's tiers), nothing else declared. */ +const AUTH_TIER_CONFIG = 'export default {};\n'; + +const LOAD_FAILURE_TEXT = '@objectstack/plugin-auth failed to load'; +/** Node's own sentences for the two stubbed installs — the hook writes neither. */ +const MISSING_CAUSE = 'Cannot find module'; +const BROKEN_CAUSE = 'does not provide an export named'; +const SECRET = 'os-adr-0131-d9-load-failure-leg-secret-not-a-real-credential'; + +type BootChild = ChildProcessByStdio; + +const children: BootChild[] = []; +let root: string; +let fixtureDir: string; + +interface Outcome { + output: string; + exitCode: number | null; +} + +interface Leg { + databaseFile: string; + /** The `plugin-auth-load-failure` hook's mode, or no stub at all. */ + stub?: 'missing' | 'broken'; + args?: string[]; +} + +/** Boot until the ready banner (returns `exitCode: null`) or until the process exits. */ +async function boot({ databaseFile, stub, args = [] }: Leg): Promise { + const nodeArgs = stub ? ['--import', `${HOOK}?mode=${stub}`] : []; + const child = spawn( + process.execPath, + [...nodeArgs, CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT, ...args], + { + cwd: fixtureDir, + stdio: ['ignore', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: `file:${databaseFile}`, + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + // Every leg has the secret: the subject is the load, not the secret. + // The two legacy spellings are unset so an ambient one cannot differ. + OS_AUTH_SECRET: SECRET, + AUTH_SECRET: undefined, + BETTER_AUTH_SECRET: undefined, + OS_MIGRATE_AND_EXIT: undefined, + // Production posture — see the header. The `--dev` leg reaches + // development through the flag, which is the posture under test. + NODE_ENV: undefined, + }), + }, + ) as BootChild; + children.push(child); + + let output = ''; + return new Promise((done, fail) => { + const timer = setTimeout(() => { + fail(new Error(`serve neither refused nor reached ready\n${output}`)); + }, 150_000); + const onData = (d: unknown) => { + output += String(d); + if (READY_BANNER_TAIL.test(output)) { + clearTimeout(timer); + done({ output, exitCode: null }); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => { + clearTimeout(timer); + done({ output, exitCode: code }); + }); + }); +} + +/** A refused leg: non-zero exit, no banner, the load error named, nothing written. */ +async function expectRefused(leg: Leg, cause: string): Promise { + const { output, exitCode } = await boot(leg); + expect(exitCode, `the boot was not refused\n${output}`).not.toBeNull(); + expect(exitCode).not.toBe(0); + expect(output).not.toMatch(READY_BANNER_TAIL); + expect(output).toContain(LOAD_FAILURE_TEXT); + expect(output).toContain(`The load error: `); + expect(output).toContain(cause); + // Refused instead of the old warn-and-boot line, not after it. + expect(output).not.toContain('⚠ AuthPlugin failed to load'); + expect(existsSync(leg.databaseFile), `the refused boot opened its database\n${output}`).toBe(false); + return output; +} + +/** A control leg: reaches ready and opens its database, with no refusal. */ +async function expectBoots(leg: Leg): Promise { + const { output, exitCode } = await boot(leg); + expect(exitCode, `the control leg exited\n${output}`).toBeNull(); + expect(output).not.toContain(LOAD_FAILURE_TEXT); + expect(existsSync(leg.databaseFile), `the control leg never opened its database\n${output}`).toBe(true); + return output; +} + +const db = (name: string) => join(root, `${name}.db`); + +beforeAll(() => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + root = mkdtempSync(join(tmpdir(), 'os-plugin-auth-load-failure-')); + fixtureDir = join(root, 'auth-tier'); + mkdirSync(fixtureDir); + writeFileSync(join(fixtureDir, 'objectstack.config.ts'), AUTH_TIER_CONFIG, 'utf8'); +}); + +afterAll(() => { + for (const child of children) { + try { child.kill('SIGTERM'); } catch { /* already gone */ } + } + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('os serve — a production boot whose plugin-auth fails to load is refused (ADR-0131 D9)', () => { + it('refuses a missing install — once swallowed without a word — before the kernel starts', async () => { + await expectRefused({ databaseFile: db('missing-server'), stub: 'missing' }, MISSING_CAUSE); + }, 240_000); + + it('refuses a broken install on a kernel-only (--no-server) boot before the kernel starts', async () => { + await expectRefused({ databaseFile: db('broken-no-server'), stub: 'broken', args: ['--no-server'] }, BROKEN_CAUSE); + }, 240_000); + + it('control: the same boot with no stub reaches ready and opens its database', async () => { + await expectBoots({ databaseFile: db('no-stub-no-server'), args: ['--no-server'] }); + }, 240_000); + + it('control: --dev with the broken install keeps the warn-and-boot path', async () => { + const output = await expectBoots({ databaseFile: db('broken-dev-no-server'), stub: 'broken', args: ['--dev', '--no-server'] }); + expect(output).toContain('⚠ AuthPlugin failed to load'); + expect(output).toContain(BROKEN_CAUSE); + }, 240_000); +}); From d634fe5777ad190f672937a9dc33b65a41c26819 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:23:13 +0000 Subject: [PATCH 2/4] wip: changeset and docs for #25158 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01YD4v2ErCfZk8sfaZe8AYVu --- .../25158-refuse-plugin-auth-load-failure.md | 19 +++++++++++++++++++ content/docs/deployment/cli.mdx | 4 +++- content/docs/upgrading.mdx | 16 ++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 .changeset/25158-refuse-plugin-auth-load-failure.md diff --git a/.changeset/25158-refuse-plugin-auth-load-failure.md b/.changeset/25158-refuse-plugin-auth-load-failure.md new file mode 100644 index 00000000000..538b43f3b8a --- /dev/null +++ b/.changeset/25158-refuse-plugin-auth-load-failure.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli)!: a production `os serve` whose `@objectstack/plugin-auth` fails to load is refused, naming the load error, instead of booting with no organization object + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** boot-refusal narrowing (ADR-0131 D9), the third composition beside the two the previous change refused. It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. + +- FROM: a production `os serve` or `os start` (no `--dev`) whose stack enables the `auth` tier and resolves an auth secret, but whose `@objectstack/plugin-auth` fails to load — a missing install (silently) or a broken one (one `⚠ AuthPlugin failed to load: …` warning) — booted with no `AuthPlugin`. It composed no organization object, so every row the boot wrote landed with no organization: stored ownerless on an 18.0.0-born store (nullable columns, MongoDB), refused at NOT NULL on a fresh SQL table. +- TO: that boot is refused before any plugin starts, with or without `--server`, and the database is never opened. The refusal names the load error. The same holds when the AuthPlugin construction itself throws before it is mounted. + +**Unchanged:** `os dev` and `--dev` (a development boot keeps the warning and boots without auth); the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`), whose auth is each project's; a production boot with no auth secret or with no auth at all, which is still refused by the existing refusals; and an embedded kernel, which never runs this command. + +**The remedy.** Repair the install the error names: `@objectstack/plugin-auth` is a dependency of `@objectstack/cli`, so reinstall the deployment's dependencies (e.g. `npm ci` or `pnpm install --frozen-lockfile`). Running kernel-only (`--no-server`) does not avoid the refusal. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 81a1be5fad4..5c474467990 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -402,7 +402,9 @@ The `auth` tier requires `OS_AUTH_SECRET` in production: without it the boot is refused before any plugin starts, with or without `--server`, because a boot with no `AuthPlugin` has no organization and every row it wrote would have no owner. A production boot whose tiers leave `auth` out (`--preset minimal` -included) is refused the same way unless the stack mounts `AuthPlugin` itself. +included) is refused the same way unless the stack mounts `AuthPlugin` itself, +and so is one whose `@objectstack/plugin-auth` fails to load (a missing or broken +install): the refusal names the load error instead of booting without auth. In `--dev` mode the CLI falls back to an insecure local secret, so login works out of the box. (The cloud runtime's host kernel, where auth is each project's, only warns.) To take full control, set `tiers` on the stack config: diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index a770667ae3c..9a42afaa237 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -417,18 +417,22 @@ one it is refused: `✖ FATAL: tenancy posture 'group' is walled, but this deplo refused before any plugin starts, so set the secret before you start such a deployment on 18.0. A production boot whose stack mounts no auth at all (the `auth` tier off and no `AuthPlugin` of its own) is refused the same way: enable -auth and set the secret. Either boot has no organization, so every row it wrote -would have no owner. - -`--no-server` does not avoid either refusal: a kernel-only boot runs the same +auth and set the secret. So is a production boot that has the secret but whose +`@objectstack/plugin-auth` fails to load (a missing or broken install), `os start` +included: earlier 18 releases booted it without auth, silently or with one +`⚠ AuthPlugin failed to load` warning, and the refusal now names the load error — +reinstall the deployment's dependencies. Each of these boots has no +organization, so every row it wrote would have no owner. + +`--no-server` does not avoid any of these refusals: a kernel-only boot runs the same seed and would write the same ownerless rows. Earlier 18 releases let a `--no-server` boot through, and their refusal text offered `--no-server` as a remedy; it is not one. The same holds for a migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`): give it `OS_AUTH_SECRET` too. A development boot (`os dev`, `--dev`) and an embedded kernel are not affected, and neither is the cloud runtime's host kernel, whose auth is each project's. `os start` is not -affected: it generates an auth secret on its first run and persists it to -`/auth-secret`. +affected by the missing-secret refusal: it generates an auth secret on its first +run and persists it to `/auth-secret`. What the plan lists, how a blocked row is resolved, and every flag are in the command's reference: From 17f172c8d6cb9e799f5bd0d476788412469d2209 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:26:45 +0000 Subject: [PATCH 3/4] wip: tighten load-error assertions, measured test budget Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01YD4v2ErCfZk8sfaZe8AYVu --- .../plugin-auth-load-failure.hook.mjs | 5 ++++- ...h-load-failure-refusal.integration.test.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs b/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs index a88493edf9d..598eb5f4640 100644 --- a/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs +++ b/packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs @@ -14,7 +14,10 @@ * `@objectstack/runtime` imports `@objectstack/plugin-auth/rate-limit-storage` * and `/client-address` statically, so denying them would crash the CLI before * the boot reaches the auth tier, which is a different failure from the one - * under test. + * under test. One side effect is expected and harmless to `serve`: + * `@objectstack/verify` imports the root entry too, so in either mode oclif + * prints a `ModuleLoadError` warning for the `verify` command while it indexes + * the command table. * * Both modes make node produce its OWN error, so the refusal is asserted against * a sentence this file did not write: diff --git a/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts b/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts index 8d1563ffc7d..4ea72c38545 100644 --- a/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts +++ b/packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 30 — spawns the real producer once per single-cause fixture: four boots of the built CLI (a missing and a broken `@objectstack/plugin-auth` in production, the broken one under `--dev`, and production with no stub), each differing only in the input under test; MEASURED_PLACEHOLDER +// test-budget: 20 — spawns the real producer once per single-cause fixture: four boots of the built CLI (a missing and a broken `@objectstack/plugin-auth` in production, the broken one under `--dev`, and production with no stub), each differing only in the input under test; 13.3 s, the slowest of three local runs on a shared container, x 1.25, no CI reading yet import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawn, type ChildProcessByStdio } from 'node:child_process'; @@ -48,9 +48,14 @@ const HOOK = pathToFileURL(resolve(HERE, 'fixtures/plugin-auth-load-failure.hook const AUTH_TIER_CONFIG = 'export default {};\n'; const LOAD_FAILURE_TEXT = '@objectstack/plugin-auth failed to load'; -/** Node's own sentences for the two stubbed installs — the hook writes neither. */ -const MISSING_CAUSE = 'Cannot find module'; -const BROKEN_CAUSE = 'does not provide an export named'; +/** + * The openings of node's own sentences for the two stubbed installs — the hook + * writes neither. Asserted right after `The load error: `, because the same + * sentence also appears in oclif's warning for the `verify` command (see the + * hook's header), which alone would pass a leg that named no load error. + */ +const MISSING_CAUSE = "Cannot find module '"; +const BROKEN_CAUSE = "The requested module 'node:path' does not provide an export named 'createLocalAccountIssuer'"; const SECRET = 'os-adr-0131-d9-load-failure-leg-secret-not-a-real-credential'; type BootChild = ChildProcessByStdio; @@ -129,8 +134,7 @@ async function expectRefused(leg: Leg, cause: string): Promise { expect(exitCode).not.toBe(0); expect(output).not.toMatch(READY_BANNER_TAIL); expect(output).toContain(LOAD_FAILURE_TEXT); - expect(output).toContain(`The load error: `); - expect(output).toContain(cause); + expect(output).toContain(`The load error: ${cause}`); // Refused instead of the old warn-and-boot line, not after it. expect(output).not.toContain('⚠ AuthPlugin failed to load'); expect(existsSync(leg.databaseFile), `the refused boot opened its database\n${output}`).toBe(false); @@ -178,7 +182,6 @@ describe('os serve — a production boot whose plugin-auth fails to load is refu it('control: --dev with the broken install keeps the warn-and-boot path', async () => { const output = await expectBoots({ databaseFile: db('broken-dev-no-server'), stub: 'broken', args: ['--dev', '--no-server'] }); - expect(output).toContain('⚠ AuthPlugin failed to load'); - expect(output).toContain(BROKEN_CAUSE); + expect(output).toContain(`⚠ AuthPlugin failed to load: ${BROKEN_CAUSE}`); }, 240_000); }); From 916795acdc58203315ab384b3a1b9414fe863d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 13:47:57 +0000 Subject: [PATCH 4/4] wip: the child-env gate's built-entrypoint population names the new pin Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01YD4v2ErCfZk8sfaZe8AYVu --- scripts/check-cli-test-child-env.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 51f4cbec651..48300d26ca0 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the fourteen files that spawn bin/run.js', + t('the built-entrypoint population is exactly the fifteen files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ // [#21733] Joined the population deliberately: the card's acceptance is // a plain `os dev` restart on the BUILT packages, and its production leg @@ -2677,6 +2677,13 @@ export function selfTest() { // spawn hands its child `NODE_ENV: undefined`. 'packages/cli/test/serve-no-storage-boot-guard.integration.test.ts', 'packages/cli/test/serve-node-env-production-default.e2e.test.ts', + // [ADR-0131 D9] Joined the population deliberately, beside + // `serve-no-auth-secret-refusal`: its subject is the refusal of a + // PRODUCTION boot whose `@objectstack/plugin-auth` fails to load, and + // `bin/run-dev.js` sets `NODE_ENV=development` before argv is parsed, + // where the failure still warns and boots. Every spawn hands its child + // `NODE_ENV: undefined`. + 'packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts', // [#16630] Joined the population deliberately: the degraded-boot the // ready line has to report is only reachable in PRODUCTION posture, and // `bin/run-dev.js` sets `NODE_ENV=development` before argv is parsed —