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
38 changes: 38 additions & 0 deletions .changeset/serve-organizations-mount-vs-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/cli": patch
---

fix(cli): `os serve` 区分「多组织包缺席」与「插件自己拒绝挂载」(#4818)

`os serve` 在走 walled posture(`OS_TENANCY_POSTURE=group` / `isolated`)时,
把 `importFromHost('@objectstack/organizations')` 和
`kernel.use(new mod.OrganizationsPlugin())` 放在**同一个 `try`** 里,于是插件在
**构造 / 挂载**阶段抛出的任何错误都被当成「包加载不出来」上报:文案说
`@objectstack/organizations could not be loaded`,给出的出路里包含
`OS_ALLOW_DEGRADED_TENANCY=1`,而该 env 已设时更会把它**降级成一条 warning 并继续启动**。

这是两件事,解法相反:

| 事实 | 解法 | `OS_ALLOW_DEGRADED_TENANCY` |
|---|---|---|
| 包缺席 | 装上它 / 改单组织 | 适用(operator 明确接受能力缺席) |
| 插件拒绝挂载 | 按插件自己报的原因处理 | **不适用** |

合并后的代价是实打实的:包明明在镜像里,日志却把人指向模块解析 / `NODE_PATH` /
依赖 prune;更糟的是那条逃生口会吞掉插件自己的拒绝,等于把插件在守的闸门搬到一个
env 变量上。

现在按**哪个阶段抛错**分类(不看错误形状 —— 该包是 `importFromHost` 动态加载的,
CLI 与它可能持有不同模块实例,`instanceof` 和具名 `code` 判据都脆;framework 也不该
编码插件的私有语义):

- **import 阶段失败 = 包缺席** —— 行为完全不变:同样的 ADR-0093 D5 文案,
`OS_ALLOW_DEGRADED_TENANCY=1` 依旧可以显式降级启动。
- **构造 / 挂载阶段失败 = 插件自己拒绝** —— 原样上报插件的错误(message,以及它自带的
`code`,通用打印、不作解释),明说包**已找到并加载**、不必去查模块解析,并声明
`OS_ALLOW_DEGRADED_TENANCY` 对这条路径**不适用**;**无条件 `process.exit(1)`**。

ADR-0093 D5 的态度不变:要求了隔离就不能假装有,仍然拒绝启动 —— 变的只是「为什么拒绝」
和「告诉 operator 什么」。唯一的行为变化是 `OS_ALLOW_DEGRADED_TENANCY=1` 不再能让一个
拒绝挂载的多组织插件被吞掉并继续启动;若你此前依赖这一点,请改用
`OS_TENANCY_POSTURE=single`,或处理插件报出的原因。
72 changes: 68 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1746,8 +1746,28 @@ export default class Serve extends Command {
const tenancyPosture = resolveTenancyPosture();
const multiTenant = tenancyPosture !== 'single';
if (multiTenant) {
// #4818 — TWO STAGES, TWO FAILURES, TWO DIAGNOSES. `import` and
// `kernel.use(new mod.OrganizationsPlugin())` used to share one
// `try`, so anything the plugin threw while CONSTRUCTING or
// MOUNTING was reported as "@objectstack/organizations could not
// be loaded" — i.e. as an absent package — and was swallowed by
// OS_ALLOW_DEGRADED_TENANCY. Those are different facts with
// different remedies (install it vs. address what the plugin
// reported), and the escape hatch only ever meant "the capability
// is ABSENT and I accept the degradation".
//
// The classifier is WHICH STAGE THREW — deliberately not the
// error's shape. The framework must not know any of the plugin's
// private refusal semantics (a layering violation that would need
// updating per refusal reason), and the package is loaded through
// `importFromHost`, so CLI and plugin may hold different module
// instances: `instanceof` and named `code` checks are both
// fragile here. Stage is the only classifier that needs to know
// nothing about the plugin's internals.
const organizationsPkg = '@objectstack/organizations';
let orgMod: any;
// ── Stage 1: import. Failure here = the package is ABSENT. ──
try {
const organizationsPkg = '@objectstack/organizations';
// Resolve from the HOST APP (cloud#1013). This package is
// cloud-private: it is installed in the served app's
// node_modules, never in the framework workspace the CLI's own
Expand All @@ -1757,9 +1777,7 @@ export default class Serve extends Command {
// it was OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled
// state D5 exists to prevent. The host app declares the package;
// this resolves it from there.
const mod: any = await importFromHost(organizationsPkg);
await kernel.use(new mod.OrganizationsPlugin());
trackPlugin('Organizations');
orgMod = await importFromHost(organizationsPkg);
} catch (orgErr) {
// ADR-0093 D5 — degraded tenancy fails fast. Multi-org was
// requested but the enterprise package can't provide tenant
Expand Down Expand Up @@ -1802,6 +1820,52 @@ export default class Serve extends Command {
'Organization boundaries are NOT enforced. (ADR-0093 D5)',
),
);
// Degraded boot: `orgMod` stays undefined, so stage 2 below is
// skipped. Nothing was loaded, so nothing can be mounted.
}

// ── Stage 2: construct + mount. Failure here = the package IS
// present and the plugin itself declined. Report what it said,
// verbatim, and exit unconditionally: OS_ALLOW_DEGRADED_TENANCY
// does not cover this (#4818). Honouring it here would move
// whatever gate the plugin is enforcing onto an env var. ──
if (orgMod) {
try {
await kernel.use(new orgMod.OrganizationsPlugin());
trackPlugin('Organizations');
} catch (mountErr) {
// The framework does NOT interpret this error — it does not
// know why the plugin refused and must not guess a cause.
// Surface the plugin's own words (plus any `code` it carries,
// printed generically) and let them be the authority.
const mountMessage = mountErr instanceof Error ? mountErr.message : String(mountErr);
const mountCode = (mountErr as any)?.code;
// process.exit (not throw): this sits inside the broad
// AuthPlugin try below, which swallows errors — a throw would
// be caught and boot would continue with the wall inactive.
console.error(
chalk.red(
`\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested and ` +
'@objectstack/organizations WAS found and loaded,\n' +
' but its OrganizationsPlugin refused to mount, so the organization wall is INACTIVE.\n' +
' Refusing to boot — a deployment that requested multi-organization isolation must not\n' +
' serve traffic without it (ADR-0093 D5).\n\n' +
' This is NOT a missing-package problem: the runtime is installed and resolvable here,\n' +
' so module resolution / NODE_PATH / dependency pruning are not the place to look.\n\n' +
' The plugin reported (verbatim — the framework does not interpret it):\n' +
(mountCode !== undefined ? ` code: ${String(mountCode)}\n` : '') +
` ${mountMessage}\n\n` +
' Fix one of:\n' +
' • resolve what the plugin reported above — its message is the authority on the\n' +
' remedy; this CLI has no further detail to add, or\n' +
" • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org.\n\n" +
' OS_ALLOW_DEGRADED_TENANCY does NOT apply to this failure and will not get past it:\n' +
' it covers an ABSENT multi-org runtime the operator accepts doing without, not a\n' +
' present one that declined to mount. (#4818)\n',
),
);
process.exit(1);
}
}
}

Expand Down
257 changes: 257 additions & 0 deletions packages/cli/test/serve-organizations-mount-failure.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #4818 — `os serve` must tell an operator WHICH of two different things went
* wrong with the enterprise multi-org runtime, over the REAL CLI process.
*
* The defect: `importFromHost('@objectstack/organizations')` and
* `kernel.use(new mod.OrganizationsPlugin())` shared a single `try`, so an
* error the plugin threw while CONSTRUCTING or MOUNTING was reported as
* "@objectstack/organizations could not be loaded" — i.e. as an ABSENT package
* — offered `OS_ALLOW_DEGRADED_TENANCY=1` as the way out, and, when that was
* already set, was downgraded to a warning and the boot continued. Two facts
* with opposite remedies had one diagnosis:
*
* | fact | remedy | OS_ALLOW_DEGRADED_TENANCY |
* |---------------------|-------------------------|---------------------------|
* | package absent | install it / go single | applies (operator accepts |
* | | | the missing capability) |
* | plugin refused | whatever it reported | does NOT apply |
*
* The fix classifies by WHICH STAGE THREW — never by the error's shape, since
* the package is loaded through `importFromHost` and the CLI may hold a
* different module instance than the plugin does, and since the framework must
* not encode any of the plugin's private refusal semantics.
*
* WHY THIS FILE SPAWNS THE CLI (same reason as its neighbour
* `serve-organizations-host-resolution.e2e.test.ts`): every other test of the
* walled postures hands the plugin in as `extraPlugins` or mocks the module,
* which bypasses the CLI's own load/mount sequence — the only thing under test
* here. The fixtures stand in for the closed-source enterprise package: one app
* simply does not ship it, another ships a version whose plugin throws on
* construction (the shape cloud#1020 gave its license gate). What is asserted
* is the CLI's CLASSIFICATION and its message, not any enterprise semantics.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runServe, randomPort } from './helpers/serve-process.js';

const CONFIG = `
export default {
manifest: {
id: 'com.example.orgmount',
namespace: 'orgmount',
version: '1.0.0',
type: 'app',
name: 'Organizations Mount-Failure Fixture',
},
objects: [{
name: 'orgmount_task',
label: 'Task',
sharingModel: 'private',
fields: {
title: { type: 'text', label: 'Title' },
},
}],
};
`;

/** What the refusing fixture throws — asserted verbatim below. */
const REFUSAL_MESSAGE = 'organizations runtime declined to mount in this deployment (fixture)';
const REFUSAL_CODE = 'FIXTURE_MOUNT_REFUSED';

/**
* A resolvable `@objectstack/organizations` whose plugin refuses at CONSTRUCTION
* — the shape cloud#1020 gave its enterprise entitlement gate. Note the error
* carries a structured `code`: the CLI prints it generically and must never
* branch on its value.
*/
const REFUSING_ORGANIZATIONS = `
export class OrganizationsPlugin {
constructor() {
const err = new Error(${JSON.stringify(REFUSAL_MESSAGE)});
err.code = ${JSON.stringify(REFUSAL_CODE)};
throw err;
}
}
`;

/** App that does NOT ship the package — the import stage fails. */
let appAbsent: string;
/** App that ships a package whose plugin refuses — the mount stage fails. */
let appRefusing: string;

function writeApp(prefix: string, organizationsSource: string | null): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8');
writeFileSync(
join(dir, 'package.json'),
JSON.stringify(
{
name: 'orgmount-fixture',
private: true,
type: 'module',
...(organizationsSource ? { dependencies: { '@objectstack/organizations': '*' } } : {}),
},
null,
2,
),
'utf8',
);
if (organizationsSource) {
const pkgDir = join(dir, 'node_modules', '@objectstack', 'organizations');
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, 'package.json'),
JSON.stringify({
name: '@objectstack/organizations',
version: '0.0.0-fixture',
type: 'module',
main: 'index.js',
}),
'utf8',
);
writeFileSync(join(pkgDir, 'index.js'), organizationsSource, 'utf8');
}
return dir;
}

beforeAll(() => {
appAbsent = writeApp('os-org-mount-absent-', null);
appRefusing = writeApp('os-org-mount-refusing-', REFUSING_ORGANIZATIONS);
});

afterAll(() => {
for (const dir of [appAbsent, appRefusing]) {
if (dir) rmSync(dir, { recursive: true, force: true });
}
});

/** Auth must be wired for the organizations block to be reached at all. */
const SERVE_ENV = {
OS_AUTH_SECRET: 'org-mount-failure-e2e-secret',
OS_TENANCY_POSTURE: 'isolated',
};

const BANNER = 'Press Ctrl+C to stop';
const BANNER_RE = /Press Ctrl\+C to stop/;

function seenOf(stdout: string, stderr: string): string {
return `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
}

describe('os serve — organizations import stage vs mount stage (#4818)', () => {
describe('import stage fails — the package is ABSENT (behaviour must be unchanged)', () => {
it(
'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis',
async () => {
const port = randomPort();
const { stdout, stderr } = await runServe(appAbsent, ['--port', port], {
waitFor: BANNER_RE,
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
timeoutMs: 240_000,
});
const seen = seenOf(stdout, stderr);

expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch(
/FATAL: tenancy posture 'isolated' was requested/,
);
// This wording is CORRECT here and must survive the stage split: the
// package really is not on this machine.
expect(stderr, `the absent-package diagnosis was lost${seen}`).toMatch(/could not be loaded/);
// …and the escape hatch is still offered on the path it belongs to.
expect(stderr).toMatch(/set OS_ALLOW_DEGRADED_TENANCY=1 to boot/);
expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
},
300_000,
);

it(
'boots degraded when the operator explicitly sets OS_ALLOW_DEGRADED_TENANCY=1',
async () => {
// The escape hatch keeps its one legitimate meaning: "the capability is
// absent and I accept running without it". Pinned so the stage split
// cannot regress it.
const port = randomPort();
const { stdout, stderr } = await runServe(appAbsent, ['--port', port], {
waitFor: BANNER_RE,
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
timeoutMs: 240_000,
});
const seen = seenOf(stdout, stderr);

expect(stdout, `serve never reached its banner${seen}`).toContain(BANNER);
expect(stderr, `the degraded boot was not branded${seen}`).toMatch(/DEGRADED TENANCY/);
expect(stderr, `the degraded opt-in still fired the fail-fast${seen}`).not.toMatch(/✖ FATAL/);
},
300_000,
);
});

describe('mount stage fails — the package is PRESENT and its plugin refused', () => {
it(
"surfaces the plugin's own error verbatim and exits, without the absent-package wording",
async () => {
const port = randomPort();
const { stdout, stderr } = await runServe(appRefusing, ['--port', port], {
waitFor: BANNER_RE,
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
timeoutMs: 240_000,
});
const seen = seenOf(stdout, stderr);

// D5's posture is unchanged: isolation was requested and cannot be
// delivered, so the boot still dies.
expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
expect(stderr, `no fail-fast fired for a refusing plugin${seen}`).toMatch(/✖ FATAL/);

// The crux: the operator is told the package IS there, and reads the
// plugin's own words — not a fabricated cause, and not a module
// resolution wild goose chase.
expect(stderr, `the mount refusal was misreported as an absent package${seen}`).not.toMatch(
/could not be loaded/,
);
expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
expect(stderr, `the plugin's structured code was not surfaced${seen}`).toContain(REFUSAL_CODE);
expect(stderr, `the message does not say the package was found${seen}`).toMatch(
/WAS found and loaded/,
);
// Honest remaining alternative, and no dead-end suggestion.
expect(stderr).toMatch(/OS_TENANCY_POSTURE=single/);
expect(stderr, `the escape hatch was offered on a path it cannot fix${seen}`).toMatch(
/OS_ALLOW_DEGRADED_TENANCY does NOT apply/,
);
},
300_000,
);

it(
'still exits 1 when OS_ALLOW_DEGRADED_TENANCY=1 is set — the hatch must not swallow a refusal',
async () => {
// THE issue. Before the fix this booted with a warning: an env var
// silently overrode whatever gate the plugin was enforcing.
const port = randomPort();
const { stdout, stderr } = await runServe(appRefusing, ['--port', port], {
waitFor: BANNER_RE,
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
timeoutMs: 240_000,
});
const seen = seenOf(stdout, stderr);

expect(
stdout,
`OS_ALLOW_DEGRADED_TENANCY swallowed a plugin refusal and served traffic${seen}`,
).not.toContain(BANNER);
expect(stderr, `the refusal did not fail fast under the escape hatch${seen}`).toMatch(/✖ FATAL/);
expect(stderr, `the refusal was downgraded to a degraded-boot warning${seen}`).not.toMatch(
/DEGRADED TENANCY \(OS_ALLOW_DEGRADED_TENANCY=1\)/,
);
expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
},
300_000,
);
});
});
Loading