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
19 changes: 19 additions & 0 deletions .changeset/25158-refuse-plugin-auth-load-failure.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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). -->

**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.
4 changes: 3 additions & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 10 additions & 6 deletions content/docs/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<home>/auth-secret`.
affected by the missing-secret refusal: it generates an auth secret on its first
run and persists it to `<home>/auth-secret`.

What the plan lists, how a blocked row is resolved, and every flag are in the
command's reference:
Expand Down
43 changes: 41 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 '
Expand Down Expand Up @@ -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');
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/plugin-auth-load-failure.hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 <this file's URL>?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. 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:
*
* - `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);
},
});
Loading
Loading