Skip to content

Commit b2e964f

Browse files
fix(cli)!: refuse a production os serve whose @objectstack/plugin-auth fails to load, naming the load error (#25231)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuNsiqYysBbcDRE7iuEMwM2A)_ Fixes #25158 Clause-②: yes (narrowing) Metadata-protocol: none Card thread of the `domain:cli` lane (seat post #6024, 由Sales账户的项目负责), session `session_01YD4v2ErCfZk8sfaZe8AYVu`, claim 6341648045. The rule is #25133's (ADR-0131 D9, ruling 6328987873's reasoning); this is the third composition that reaches the same state. Review tier: `CONTRACT_REVIEW_TIER`, because the diff declares a narrowing. The branch is `claude/project-thread-m47h05`, not `claude/issue-25158-*`: this session can push only that branch, as the claim says. ## What changes `packages/cli/src/commands/serve.ts`, the `5d` AuthPlugin auto-registration: - **Before.** The `auth` tier is on and a secret resolved, so `resolvePlatformAuthComposition` composes AuthPlugin. The block's broad `try` wraps the `@objectstack/plugin-auth` import, the construction and the `kernel.use`. When any of them threw, the catch warned (or said nothing, for `ERR_MODULE_NOT_FOUND`) and the boot went on with no AuthPlugin, so with no organization object. - **After.** On a production boot (no `--dev`) whose rule composed AuthPlugin, a throw before AuthPlugin is mounted is recorded (`authPluginLoadFailure`). It is refused beside the two existing ADR-0131 D9 refusals, after composition and before anything starts, whether or not `--server`. The refusal names the load error verbatim and the remedy (reinstall the deployment's dependencies; `@objectstack/plugin-auth` is a dependency of `@objectstack/cli`). An `authPluginMounted` flag keeps a throw that happens after the mount on the old path. - `const productionBoot = !isDev` moved up from the no-auth refusal so the catch can read it. That refusal's condition is unchanged. - **Unchanged:** - `--dev` keeps the warn-and-boot path; - the host kernel: its rule composes nothing (`host-kernel`), so the catch keeps its old handling; - a production boot with no secret, or with no auth: still refused by the existing two refusals; - programmatic embedding, which never runs this command. `scripts/check-cli-test-child-env.mjs`: its self-test pins the exact set of test files that spawn the built `bin/run.js`, and the new pin joins that set ("fourteen" becomes "fifteen"). The entry carries the same kind of reason as `serve-no-auth-secret-refusal`'s. This is the one line outside `packages/cli`; `scripts/` gates anchor to `domain:spec`, so it is declared here as a cross-lane touch. ## Pins `packages/cli/test/serve-plugin-auth-load-failure-refusal.integration.test.ts`, 4 legs, next to `serve-no-auth-secret-refusal.integration.test.ts`. All legs spawn `bin/run.js` with `NODE_ENV` unset and `OS_AUTH_SECRET` set. The load failure is stubbed with `packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs`, a `node --import` resolve hook. It denies only the package's root entry; the subpaths `@objectstack/runtime` imports stay reachable. In both modes node produces its own error: - `?mode=missing` resolves to a path that does not exist (`Cannot find module '…'`); - `?mode=broken` resolves to a module that cannot link (`The requested module 'node:path' does not provide an export named …`, the shape of the publish-smoke specimen). The legs: - **Refused, the database never opened, the load error named after `The load error: `, and no `⚠ AuthPlugin failed to load` line:** - a missing install, with `--server`; - a broken install, with `--no-server`. - **Boots, reaching ready and opening the database:** - the same fixture and secret with no stub (`--no-server`); - the broken stub under `--dev --no-server`, which asserts the warn line still carries node's sentence. The cause is asserted right after `The load error: ` because, with the stub on, oclif also prints a `ModuleLoadError` warning for the `verify` command: `@objectstack/verify` imports the root entry too. The hook's header says so. ## Proof Local, on this branch: - **The pin file:** 4/4, three runs at 13.3 s, 13.1 s and 12.4 s. It declares `test-budget: 20` (13.3 s × 1.25), with no CI reading yet. - **Ablation:** the recording condition was turned off (`if (false && productionBoot …`) and the cli rebuilt; the marker is in `dist/commands/serve.js`. Result: 2 failed and 2 passed. Both refused legs are red with "the boot was not refused", and the broken leg shows the old `⚠ AuthPlugin failed to load:` line. The controls stay green. Restored with `git restore --source=HEAD`, blob `47ae01cd` == HEAD, rebuilt, and the marker is absent from `dist`. - **The sibling pin `serve-no-auth-secret-refusal.integration.test.ts`:** 10/10. - **cli typecheck:** exit 0. - **cli unit layer:** `vitest run --project unit` reports 324/324 files and 4777/4777 tests. - **`node scripts/pm/dispatch-gates.mjs --commands`:** 101 families, each exit code captured before any pipe. The first sweep had one real finding and four prerequisite refusals: - `check:cli-test-child-env` (exit 1): its self-test pins the set of test files that spawn the built `bin/run.js`, and the new pin joins it. The new file is named there with the reason it needs production posture, the same way `serve-no-auth-secret-refusal` is. - exit 3, not measured: `check-plugin-teardown-shape --self-test` (a shallow clone), and `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage` (packages outside the cli closure were not built). All four re-ran at exit 0 after the pinned fixture commit was fetched and `pnpm build` ran. - The gate-script edit derives 20 more families (121 in all). They ran too, all at exit 0. `--ran` accounts for 121 of 121, with 0 NOT-MEASURED. - **`pnpm lint`:** exit 0. - **`check-governed-merges --branch`:** exit 0, NOT governed (0 of 7 paths), +335 / -10. ## Docs - `content/docs/deployment/cli.mdx`: the auth-tier paragraph names the third refusal. - `content/docs/upgrading.mdx`: the 18.0 auth-secret paragraph covers a failed `@objectstack/plugin-auth` load, `os start` included. "Either refusal" becomes "any of these refusals", and the `os start` sentence is scoped to the missing-secret refusal. - `content/docs/releases/**` is not touched. ## Acceptance notes - **The refusal also covers a throw from the AuthPlugin construction, before the mount, not only from the import.** Either way no AuthPlugin and no organization object, and the error is named. The text says "failed to load", which reads a little wide for a constructor throw. I kept one sentence rather than branching the text on the stage. - **`os start`'s `serve` child is a production boot**, so a broken install under `os start` is now refused too. It used to boot without auth. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d609f60 commit b2e964f

7 files changed

Lines changed: 335 additions & 10 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
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
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A boot-time refusal in `os serve`: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is removed or renamed. The remedy is a deployment one (repair the install), stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**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.
13+
14+
- 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.
15+
- 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.
16+
17+
**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.
18+
19+
**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.

‎content/docs/deployment/cli.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,9 @@ The `auth` tier requires `OS_AUTH_SECRET` in production: without it the boot
402402
is refused before any plugin starts, with or without `--server`, because a boot
403403
with no `AuthPlugin` has no organization and every row it wrote would have no
404404
owner. A production boot whose tiers leave `auth` out (`--preset minimal`
405-
included) is refused the same way unless the stack mounts `AuthPlugin` itself.
405+
included) is refused the same way unless the stack mounts `AuthPlugin` itself,
406+
and so is one whose `@objectstack/plugin-auth` fails to load (a missing or broken
407+
install): the refusal names the load error instead of booting without auth.
406408
In `--dev` mode the CLI falls back to an insecure local secret, so login works
407409
out of the box. (The cloud runtime's host kernel, where auth is each project's,
408410
only warns.) To take full control, set `tiers` on the stack config:

‎content/docs/upgrading.mdx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,18 +417,22 @@ one it is refused: `✖ FATAL: tenancy posture 'group' is walled, but this deplo
417417
refused before any plugin starts, so set the secret before you start such a
418418
deployment on 18.0. A production boot whose stack mounts no auth at all (the
419419
`auth` tier off and no `AuthPlugin` of its own) is refused the same way: enable
420-
auth and set the secret. Either boot has no organization, so every row it wrote
421-
would have no owner.
422-
423-
`--no-server` does not avoid either refusal: a kernel-only boot runs the same
420+
auth and set the secret. So is a production boot that has the secret but whose
421+
`@objectstack/plugin-auth` fails to load (a missing or broken install), `os start`
422+
included: earlier 18 releases booted it without auth, silently or with one
423+
`⚠ AuthPlugin failed to load` warning, and the refusal now names the load error —
424+
reinstall the deployment's dependencies. Each of these boots has no
425+
organization, so every row it wrote would have no owner.
426+
427+
`--no-server` does not avoid any of these refusals: a kernel-only boot runs the same
424428
seed and would write the same ownerless rows. Earlier 18 releases let a
425429
`--no-server` boot through, and their refusal text offered `--no-server` as a
426430
remedy; it is not one. The same holds for a migrate-and-exit job
427431
(`OS_MIGRATE_AND_EXIT=1`): give it `OS_AUTH_SECRET` too. A development boot
428432
(`os dev`, `--dev`) and an embedded kernel are not affected, and neither is the
429433
cloud runtime's host kernel, whose auth is each project's. `os start` is not
430-
affected: it generates an auth secret on its first run and persists it to
431-
`<home>/auth-secret`.
434+
affected by the missing-secret refusal: it generates an auth secret on its first
435+
run and persists it to `<home>/auth-secret`.
432436

433437
What the plan lists, how a blocked row is resolved, and every flag are in the
434438
command's reference:

‎packages/cli/src/commands/serve.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4138,6 +4138,16 @@ export default class Serve extends Command {
41384138
// have to set OS_AUTH_SECRET just to try the login/register flow.
41394139
secret: resolveAuthSecret({ isDev }),
41404140
});
4141+
const productionBoot = !isDev;
4142+
// [ADR-0131 D9] Whether the AuthPlugin the rule composed reached the
4143+
// kernel. The `try` below is broad — it wraps the import, the
4144+
// construction and the `kernel.use` — and its catch only warns, so a
4145+
// production boot whose `@objectstack/plugin-auth` is missing or broken
4146+
// used to boot with no AuthPlugin, and so with no organization object.
4147+
// The catch records that failure here; it is refused below, beside the
4148+
// other two compositions that reach the same state.
4149+
let authPluginMounted = false;
4150+
let authPluginLoadFailure: string | undefined;
41414151
if (!hasAuthPlugin && tierEnabled('auth')) {
41424152
try {
41434153
const authPkg = '@objectstack/plugin-auth';
@@ -4307,6 +4317,7 @@ export default class Serve extends Command {
43074317
// precedence, and no second switch for the author to keep in step.
43084318
registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(config, plugins),
43094319
}));
4320+
authPluginMounted = true;
43104321
trackPlugin('Auth');
43114322

43124323
// ADR-0048 — the platform apps (Setup/Account) moved out of
@@ -4572,7 +4583,12 @@ export default class Serve extends Command {
45724583
}
45734584
} catch (err: unknown) {
45744585
const msg = err instanceof Error ? err.message : String(err);
4575-
if (!Serve.isModuleNotFoundError(err)) {
4586+
if (productionBoot && authComposition.composes && !authPluginMounted) {
4587+
// Refused below, naming this error. A development boot, and a
4588+
// composition the rule did not compose (the host kernel, a missing
4589+
// secret), keep the old handling.
4590+
authPluginLoadFailure = msg;
4591+
} else if (!Serve.isModuleNotFoundError(err)) {
45764592
console.warn(chalk.yellow(` ⚠ AuthPlugin failed to load: ${msg}`));
45774593
}
45784594
// @objectstack/plugin-auth not installed — login/register endpoints unavailable
@@ -4671,7 +4687,6 @@ export default class Serve extends Command {
46714687
// the text no longer offers it. A development boot keeps the old shape —
46724688
// refused only when it serves — and so does programmatic embedding, which
46734689
// never passes through this command.
4674-
const productionBoot = !isDev;
46754690
if ((flags.server || productionBoot) && !(tierEnabled('auth') || hasAuthPlugin)) {
46764691
throw new Error(
46774692
'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 {
47124727
+ 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.',
47134728
);
47144729
}
4730+
// [ADR-0131 D9] The third composition that reaches the same state: the
4731+
// `auth` tier is on and a secret resolved, so the rule composed
4732+
// AuthPlugin, but `@objectstack/plugin-auth` failed to load — an install
4733+
// that is missing or broken, or a construction that threw, all caught by
4734+
// the broad `try` above before AuthPlugin was mounted. No AuthPlugin, so
4735+
// no organization object: the boot's writes would be stored ownerless on
4736+
// an 18.0.0-born store (nullable columns, MongoDB) and refused at NOT
4737+
// NULL on a fresh one. Refused here for the reason the two refusals above
4738+
// are, while nothing has started, and naming the load error instead of
4739+
// warning and booting. The catch records it on a production boot alone,
4740+
// and only when the rule composed AuthPlugin, so `--dev` and the host
4741+
// kernel keep their warn-only paths.
4742+
if (authPluginLoadFailure !== undefined) {
4743+
throw new Error(
4744+
'This stack enables auth and an auth secret is set, but @objectstack/plugin-auth failed to load, so '
4745+
+ 'AuthPlugin was not composed and no caller can authenticate — anonymous access to object data is '
4746+
+ 'always denied, so the data API would be unusable, and with no organization every row this boot '
4747+
+ 'writes would have no owner.\n'
4748+
+ `The load error: ${authPluginLoadFailure}\n`
4749+
+ 'Fix the install the error names: @objectstack/plugin-auth is a dependency of @objectstack/cli, so '
4750+
+ "reinstall the deployment's dependencies (e.g. `npm ci` or `pnpm install --frozen-lockfile`).\n"
4751+
+ 'Running kernel-only (--no-server) does not avoid this: the boot still writes rows, with no owner.',
4752+
);
4753+
}
47154754

47164755
try {
47174756
const { createRestApiPlugin } = await import('@objectstack/rest');
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* An installed `@objectstack/plugin-auth` made to fail its load in the one
5+
* process that imports this hook, the way a missing or broken install fails it
6+
* — the stub `serve-plugin-auth-load-failure-refusal.integration.test.ts`
7+
* needs. Loaded with `node --import <this file's URL>?mode=missing|broken`, so
8+
* it is in place before `os serve` imports anything; a `resolve` hook and not a
9+
* file operation, for the reason `unbuilt-spec-dist.hook.mjs` gives (several
10+
* agents share one container, and nothing here touches the disk).
11+
*
12+
* Only the package's ROOT entry is denied — the specifier `os serve`'s
13+
* AuthPlugin auto-registration imports. Its subpaths stay reachable on purpose:
14+
* `@objectstack/runtime` imports `@objectstack/plugin-auth/rate-limit-storage`
15+
* and `/client-address` statically, so denying them would crash the CLI before
16+
* the boot reaches the auth tier, which is a different failure from the one
17+
* under test. One side effect is expected and harmless to `serve`:
18+
* `@objectstack/verify` imports the root entry too, so in either mode oclif
19+
* prints a `ModuleLoadError` warning for the `verify` command while it indexes
20+
* the command table.
21+
*
22+
* Both modes make node produce its OWN error, so the refusal is asserted against
23+
* a sentence this file did not write:
24+
*
25+
* - `missing` hands `nextResolve` an absolute path that does not exist, and
26+
* node answers its real `Cannot find module '…'` (`ERR_MODULE_NOT_FOUND`) —
27+
* the error the CLI used to swallow without a word;
28+
* - `broken` resolves the entry to a module whose static named import has no
29+
* export behind it, and node answers its real link-time `SyntaxError`
30+
* (`does not provide an export named …`) — the shape of the broken install
31+
* the publish smoke once caught, which the CLI used to warn about and boot.
32+
*/
33+
34+
import { registerHooks } from 'node:module';
35+
import { dirname, resolve } from 'node:path';
36+
import { fileURLToPath } from 'node:url';
37+
38+
/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
39+
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
40+
41+
const DENIED = '@objectstack/plugin-auth';
42+
43+
const MODE = new URL(import.meta.url).searchParams.get('mode');
44+
if (MODE !== 'missing' && MODE !== 'broken') {
45+
throw new Error(`plugin-auth-load-failure.hook.mjs: import it with ?mode=missing or ?mode=broken, not ${JSON.stringify(MODE)}`);
46+
}
47+
48+
/** Where a missing install's root entry is looked for; the last segment is deliberately not a real one. */
49+
const MISSING_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/plugin-auth/dist/__missing-install-simulation__/index.mjs');
50+
51+
/** The broken install's root entry: a module that cannot link. */
52+
const BROKEN_URL = 'objectstack-test:plugin-auth-broken-install';
53+
const BROKEN_SOURCE = "import { createLocalAccountIssuer } from 'node:path';\nexport class AuthPlugin {}\nvoid createLocalAccountIssuer;\n";
54+
55+
registerHooks({
56+
resolve(specifier, context, nextResolve) {
57+
if (specifier === DENIED) {
58+
if (MODE === 'missing') return nextResolve(MISSING_TARGET, context);
59+
return { url: BROKEN_URL, format: 'module', shortCircuit: true };
60+
}
61+
return nextResolve(specifier, context);
62+
},
63+
load(url, context, nextLoad) {
64+
if (url === BROKEN_URL) return { format: 'module', source: BROKEN_SOURCE, shortCircuit: true };
65+
return nextLoad(url, context);
66+
},
67+
});

0 commit comments

Comments
 (0)