Skip to content

Commit 42b3c0e

Browse files
committed
fix(cli): classify organizations failure by STAGE — import ≠ mount (#4818)
`os serve` ran `importFromHost('@objectstack/organizations')` and `kernel.use(new mod.OrganizationsPlugin())` inside one `try`, so an error the plugin threw while constructing or mounting was reported as "@objectstack/organizations could not be loaded" — 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. Those are two facts with opposite remedies. Split into two stages: - import fails => package ABSENT => unchanged ADR-0093 D5 message and escape hatch; - construct/mount fails => the plugin itself declined => report its error verbatim (message + any `code`, printed generically, never interpreted), say the package WAS found so nobody chases module resolution, state that OS_ALLOW_DEGRADED_TENANCY does not apply, and exit(1) unconditionally. Classification is by which stage threw, never by the error's shape: the package is loaded through `importFromHost`, so CLI and plugin may hold different module instances, and the framework must not encode the plugin's private refusal semantics. D5's posture is unchanged — a deployment that asked for isolation still refuses to boot without it. Only the diagnosis, and the escape hatch's reach, change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
1 parent 04b9776 commit 42b3c0e

3 files changed

Lines changed: 363 additions & 4 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os serve` 区分「多组织包缺席」与「插件自己拒绝挂载」(#4818)
6+
7+
`os serve` 在走 walled posture(`OS_TENANCY_POSTURE=group` / `isolated`)时,
8+
`importFromHost('@objectstack/organizations')`
9+
`kernel.use(new mod.OrganizationsPlugin())` 放在**同一个 `try`** 里,于是插件在
10+
**构造 / 挂载**阶段抛出的任何错误都被当成「包加载不出来」上报:文案说
11+
`@objectstack/organizations could not be loaded`,给出的出路里包含
12+
`OS_ALLOW_DEGRADED_TENANCY=1`,而该 env 已设时更会把它**降级成一条 warning 并继续启动**
13+
14+
这是两件事,解法相反:
15+
16+
| 事实 | 解法 | `OS_ALLOW_DEGRADED_TENANCY` |
17+
|---|---|---|
18+
| 包缺席 | 装上它 / 改单组织 | 适用(operator 明确接受能力缺席) |
19+
| 插件拒绝挂载 | 按插件自己报的原因处理 | **不适用** |
20+
21+
合并后的代价是实打实的:包明明在镜像里,日志却把人指向模块解析 / `NODE_PATH` /
22+
依赖 prune;更糟的是那条逃生口会吞掉插件自己的拒绝,等于把插件在守的闸门搬到一个
23+
env 变量上。
24+
25+
现在按**哪个阶段抛错**分类(不看错误形状 —— 该包是 `importFromHost` 动态加载的,
26+
CLI 与它可能持有不同模块实例,`instanceof` 和具名 `code` 判据都脆;framework 也不该
27+
编码插件的私有语义):
28+
29+
- **import 阶段失败 = 包缺席** —— 行为完全不变:同样的 ADR-0093 D5 文案,
30+
`OS_ALLOW_DEGRADED_TENANCY=1` 依旧可以显式降级启动。
31+
- **构造 / 挂载阶段失败 = 插件自己拒绝** —— 原样上报插件的错误(message,以及它自带的
32+
`code`,通用打印、不作解释),明说包**已找到并加载**、不必去查模块解析,并声明
33+
`OS_ALLOW_DEGRADED_TENANCY` 对这条路径**不适用**;**无条件 `process.exit(1)`**
34+
35+
ADR-0093 D5 的态度不变:要求了隔离就不能假装有,仍然拒绝启动 —— 变的只是「为什么拒绝」
36+
和「告诉 operator 什么」。唯一的行为变化是 `OS_ALLOW_DEGRADED_TENANCY=1` 不再能让一个
37+
拒绝挂载的多组织插件被吞掉并继续启动;若你此前依赖这一点,请改用
38+
`OS_TENANCY_POSTURE=single`,或处理插件报出的原因。

packages/cli/src/commands/serve.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,8 +1746,28 @@ export default class Serve extends Command {
17461746
const tenancyPosture = resolveTenancyPosture();
17471747
const multiTenant = tenancyPosture !== 'single';
17481748
if (multiTenant) {
1749+
// #4818 — TWO STAGES, TWO FAILURES, TWO DIAGNOSES. `import` and
1750+
// `kernel.use(new mod.OrganizationsPlugin())` used to share one
1751+
// `try`, so anything the plugin threw while CONSTRUCTING or
1752+
// MOUNTING was reported as "@objectstack/organizations could not
1753+
// be loaded" — i.e. as an absent package — and was swallowed by
1754+
// OS_ALLOW_DEGRADED_TENANCY. Those are different facts with
1755+
// different remedies (install it vs. address what the plugin
1756+
// reported), and the escape hatch only ever meant "the capability
1757+
// is ABSENT and I accept the degradation".
1758+
//
1759+
// The classifier is WHICH STAGE THREW — deliberately not the
1760+
// error's shape. The framework must not know any of the plugin's
1761+
// private refusal semantics (a layering violation that would need
1762+
// updating per refusal reason), and the package is loaded through
1763+
// `importFromHost`, so CLI and plugin may hold different module
1764+
// instances: `instanceof` and named `code` checks are both
1765+
// fragile here. Stage is the only classifier that needs to know
1766+
// nothing about the plugin's internals.
1767+
const organizationsPkg = '@objectstack/organizations';
1768+
let orgMod: any;
1769+
// ── Stage 1: import. Failure here = the package is ABSENT. ──
17491770
try {
1750-
const organizationsPkg = '@objectstack/organizations';
17511771
// Resolve from the HOST APP (cloud#1013). This package is
17521772
// cloud-private: it is installed in the served app's
17531773
// node_modules, never in the framework workspace the CLI's own
@@ -1757,9 +1777,7 @@ export default class Serve extends Command {
17571777
// it was OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled
17581778
// state D5 exists to prevent. The host app declares the package;
17591779
// this resolves it from there.
1760-
const mod: any = await importFromHost(organizationsPkg);
1761-
await kernel.use(new mod.OrganizationsPlugin());
1762-
trackPlugin('Organizations');
1780+
orgMod = await importFromHost(organizationsPkg);
17631781
} catch (orgErr) {
17641782
// ADR-0093 D5 — degraded tenancy fails fast. Multi-org was
17651783
// requested but the enterprise package can't provide tenant
@@ -1802,6 +1820,52 @@ export default class Serve extends Command {
18021820
'Organization boundaries are NOT enforced. (ADR-0093 D5)',
18031821
),
18041822
);
1823+
// Degraded boot: `orgMod` stays undefined, so stage 2 below is
1824+
// skipped. Nothing was loaded, so nothing can be mounted.
1825+
}
1826+
1827+
// ── Stage 2: construct + mount. Failure here = the package IS
1828+
// present and the plugin itself declined. Report what it said,
1829+
// verbatim, and exit unconditionally: OS_ALLOW_DEGRADED_TENANCY
1830+
// does not cover this (#4818). Honouring it here would move
1831+
// whatever gate the plugin is enforcing onto an env var. ──
1832+
if (orgMod) {
1833+
try {
1834+
await kernel.use(new orgMod.OrganizationsPlugin());
1835+
trackPlugin('Organizations');
1836+
} catch (mountErr) {
1837+
// The framework does NOT interpret this error — it does not
1838+
// know why the plugin refused and must not guess a cause.
1839+
// Surface the plugin's own words (plus any `code` it carries,
1840+
// printed generically) and let them be the authority.
1841+
const mountMessage = mountErr instanceof Error ? mountErr.message : String(mountErr);
1842+
const mountCode = (mountErr as any)?.code;
1843+
// process.exit (not throw): this sits inside the broad
1844+
// AuthPlugin try below, which swallows errors — a throw would
1845+
// be caught and boot would continue with the wall inactive.
1846+
console.error(
1847+
chalk.red(
1848+
`\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested and ` +
1849+
'@objectstack/organizations WAS found and loaded,\n' +
1850+
' but its OrganizationsPlugin refused to mount, so the organization wall is INACTIVE.\n' +
1851+
' Refusing to boot — a deployment that requested multi-organization isolation must not\n' +
1852+
' serve traffic without it (ADR-0093 D5).\n\n' +
1853+
' This is NOT a missing-package problem: the runtime is installed and resolvable here,\n' +
1854+
' so module resolution / NODE_PATH / dependency pruning are not the place to look.\n\n' +
1855+
' The plugin reported (verbatim — the framework does not interpret it):\n' +
1856+
(mountCode !== undefined ? ` code: ${String(mountCode)}\n` : '') +
1857+
` ${mountMessage}\n\n` +
1858+
' Fix one of:\n' +
1859+
' • resolve what the plugin reported above — its message is the authority on the\n' +
1860+
' remedy; this CLI has no further detail to add, or\n' +
1861+
" • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org.\n\n" +
1862+
' OS_ALLOW_DEGRADED_TENANCY does NOT apply to this failure and will not get past it:\n' +
1863+
' it covers an ABSENT multi-org runtime the operator accepts doing without, not a\n' +
1864+
' present one that declined to mount. (#4818)\n',
1865+
),
1866+
);
1867+
process.exit(1);
1868+
}
18051869
}
18061870
}
18071871

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4818 — `os serve` must tell an operator WHICH of two different things went
5+
* wrong with the enterprise multi-org runtime, over the REAL CLI process.
6+
*
7+
* The defect: `importFromHost('@objectstack/organizations')` and
8+
* `kernel.use(new mod.OrganizationsPlugin())` shared a single `try`, so an
9+
* error the plugin threw while CONSTRUCTING or MOUNTING was reported as
10+
* "@objectstack/organizations could not be loaded" — i.e. as an ABSENT package
11+
* — offered `OS_ALLOW_DEGRADED_TENANCY=1` as the way out, and, when that was
12+
* already set, was downgraded to a warning and the boot continued. Two facts
13+
* with opposite remedies had one diagnosis:
14+
*
15+
* | fact | remedy | OS_ALLOW_DEGRADED_TENANCY |
16+
* |---------------------|-------------------------|---------------------------|
17+
* | package absent | install it / go single | applies (operator accepts |
18+
* | | | the missing capability) |
19+
* | plugin refused | whatever it reported | does NOT apply |
20+
*
21+
* The fix classifies by WHICH STAGE THREW — never by the error's shape, since
22+
* the package is loaded through `importFromHost` and the CLI may hold a
23+
* different module instance than the plugin does, and since the framework must
24+
* not encode any of the plugin's private refusal semantics.
25+
*
26+
* WHY THIS FILE SPAWNS THE CLI (same reason as its neighbour
27+
* `serve-organizations-host-resolution.e2e.test.ts`): every other test of the
28+
* walled postures hands the plugin in as `extraPlugins` or mocks the module,
29+
* which bypasses the CLI's own load/mount sequence — the only thing under test
30+
* here. The fixtures stand in for the closed-source enterprise package: one app
31+
* simply does not ship it, another ships a version whose plugin throws on
32+
* construction (the shape cloud#1020 gave its license gate). What is asserted
33+
* is the CLI's CLASSIFICATION and its message, not any enterprise semantics.
34+
*/
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join } from 'node:path';
40+
import { runServe, randomPort } from './helpers/serve-process.js';
41+
42+
const CONFIG = `
43+
export default {
44+
manifest: {
45+
id: 'com.example.orgmount',
46+
namespace: 'orgmount',
47+
version: '1.0.0',
48+
type: 'app',
49+
name: 'Organizations Mount-Failure Fixture',
50+
},
51+
objects: [{
52+
name: 'orgmount_task',
53+
label: 'Task',
54+
sharingModel: 'private',
55+
fields: {
56+
title: { type: 'text', label: 'Title' },
57+
},
58+
}],
59+
};
60+
`;
61+
62+
/** What the refusing fixture throws — asserted verbatim below. */
63+
const REFUSAL_MESSAGE = 'organizations runtime declined to mount in this deployment (fixture)';
64+
const REFUSAL_CODE = 'FIXTURE_MOUNT_REFUSED';
65+
66+
/**
67+
* A resolvable `@objectstack/organizations` whose plugin refuses at CONSTRUCTION
68+
* — the shape cloud#1020 gave its enterprise entitlement gate. Note the error
69+
* carries a structured `code`: the CLI prints it generically and must never
70+
* branch on its value.
71+
*/
72+
const REFUSING_ORGANIZATIONS = `
73+
export class OrganizationsPlugin {
74+
constructor() {
75+
const err = new Error(${JSON.stringify(REFUSAL_MESSAGE)});
76+
err.code = ${JSON.stringify(REFUSAL_CODE)};
77+
throw err;
78+
}
79+
}
80+
`;
81+
82+
/** App that does NOT ship the package — the import stage fails. */
83+
let appAbsent: string;
84+
/** App that ships a package whose plugin refuses — the mount stage fails. */
85+
let appRefusing: string;
86+
87+
function writeApp(prefix: string, organizationsSource: string | null): string {
88+
const dir = mkdtempSync(join(tmpdir(), prefix));
89+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8');
90+
writeFileSync(
91+
join(dir, 'package.json'),
92+
JSON.stringify(
93+
{
94+
name: 'orgmount-fixture',
95+
private: true,
96+
type: 'module',
97+
...(organizationsSource ? { dependencies: { '@objectstack/organizations': '*' } } : {}),
98+
},
99+
null,
100+
2,
101+
),
102+
'utf8',
103+
);
104+
if (organizationsSource) {
105+
const pkgDir = join(dir, 'node_modules', '@objectstack', 'organizations');
106+
mkdirSync(pkgDir, { recursive: true });
107+
writeFileSync(
108+
join(pkgDir, 'package.json'),
109+
JSON.stringify({
110+
name: '@objectstack/organizations',
111+
version: '0.0.0-fixture',
112+
type: 'module',
113+
main: 'index.js',
114+
}),
115+
'utf8',
116+
);
117+
writeFileSync(join(pkgDir, 'index.js'), organizationsSource, 'utf8');
118+
}
119+
return dir;
120+
}
121+
122+
beforeAll(() => {
123+
appAbsent = writeApp('os-org-mount-absent-', null);
124+
appRefusing = writeApp('os-org-mount-refusing-', REFUSING_ORGANIZATIONS);
125+
});
126+
127+
afterAll(() => {
128+
for (const dir of [appAbsent, appRefusing]) {
129+
if (dir) rmSync(dir, { recursive: true, force: true });
130+
}
131+
});
132+
133+
/** Auth must be wired for the organizations block to be reached at all. */
134+
const SERVE_ENV = {
135+
OS_AUTH_SECRET: 'org-mount-failure-e2e-secret',
136+
OS_TENANCY_POSTURE: 'isolated',
137+
};
138+
139+
const BANNER = 'Press Ctrl+C to stop';
140+
const BANNER_RE = /Press Ctrl\+C to stop/;
141+
142+
function seenOf(stdout: string, stderr: string): string {
143+
return `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
144+
}
145+
146+
describe('os serve — organizations import stage vs mount stage (#4818)', () => {
147+
describe('import stage fails — the package is ABSENT (behaviour must be unchanged)', () => {
148+
it(
149+
'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis',
150+
async () => {
151+
const port = randomPort();
152+
const { stdout, stderr } = await runServe(appAbsent, ['--port', port], {
153+
waitFor: BANNER_RE,
154+
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
155+
timeoutMs: 240_000,
156+
});
157+
const seen = seenOf(stdout, stderr);
158+
159+
expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch(
160+
/FATAL: tenancy posture 'isolated' was requested/,
161+
);
162+
// This wording is CORRECT here and must survive the stage split: the
163+
// package really is not on this machine.
164+
expect(stderr, `the absent-package diagnosis was lost${seen}`).toMatch(/could not be loaded/);
165+
// …and the escape hatch is still offered on the path it belongs to.
166+
expect(stderr).toMatch(/set OS_ALLOW_DEGRADED_TENANCY=1 to boot/);
167+
expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
168+
},
169+
300_000,
170+
);
171+
172+
it(
173+
'boots degraded when the operator explicitly sets OS_ALLOW_DEGRADED_TENANCY=1',
174+
async () => {
175+
// The escape hatch keeps its one legitimate meaning: "the capability is
176+
// absent and I accept running without it". Pinned so the stage split
177+
// cannot regress it.
178+
const port = randomPort();
179+
const { stdout, stderr } = await runServe(appAbsent, ['--port', port], {
180+
waitFor: BANNER_RE,
181+
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
182+
timeoutMs: 240_000,
183+
});
184+
const seen = seenOf(stdout, stderr);
185+
186+
expect(stdout, `serve never reached its banner${seen}`).toContain(BANNER);
187+
expect(stderr, `the degraded boot was not branded${seen}`).toMatch(/DEGRADED TENANCY/);
188+
expect(stderr, `the degraded opt-in still fired the fail-fast${seen}`).not.toMatch(/ FATAL/);
189+
},
190+
300_000,
191+
);
192+
});
193+
194+
describe('mount stage fails — the package is PRESENT and its plugin refused', () => {
195+
it(
196+
"surfaces the plugin's own error verbatim and exits, without the absent-package wording",
197+
async () => {
198+
const port = randomPort();
199+
const { stdout, stderr } = await runServe(appRefusing, ['--port', port], {
200+
waitFor: BANNER_RE,
201+
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
202+
timeoutMs: 240_000,
203+
});
204+
const seen = seenOf(stdout, stderr);
205+
206+
// D5's posture is unchanged: isolation was requested and cannot be
207+
// delivered, so the boot still dies.
208+
expect(stdout, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
209+
expect(stderr, `no fail-fast fired for a refusing plugin${seen}`).toMatch(/ FATAL/);
210+
211+
// The crux: the operator is told the package IS there, and reads the
212+
// plugin's own words — not a fabricated cause, and not a module
213+
// resolution wild goose chase.
214+
expect(stderr, `the mount refusal was misreported as an absent package${seen}`).not.toMatch(
215+
/could not be loaded/,
216+
);
217+
expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
218+
expect(stderr, `the plugin's structured code was not surfaced${seen}`).toContain(REFUSAL_CODE);
219+
expect(stderr, `the message does not say the package was found${seen}`).toMatch(
220+
/WAS found and loaded/,
221+
);
222+
// Honest remaining alternative, and no dead-end suggestion.
223+
expect(stderr).toMatch(/OS_TENANCY_POSTURE=single/);
224+
expect(stderr, `the escape hatch was offered on a path it cannot fix${seen}`).toMatch(
225+
/OS_ALLOW_DEGRADED_TENANCY does NOT apply/,
226+
);
227+
},
228+
300_000,
229+
);
230+
231+
it(
232+
'still exits 1 when OS_ALLOW_DEGRADED_TENANCY=1 is set — the hatch must not swallow a refusal',
233+
async () => {
234+
// THE issue. Before the fix this booted with a warning: an env var
235+
// silently overrode whatever gate the plugin was enforcing.
236+
const port = randomPort();
237+
const { stdout, stderr } = await runServe(appRefusing, ['--port', port], {
238+
waitFor: BANNER_RE,
239+
env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
240+
timeoutMs: 240_000,
241+
});
242+
const seen = seenOf(stdout, stderr);
243+
244+
expect(
245+
stdout,
246+
`OS_ALLOW_DEGRADED_TENANCY swallowed a plugin refusal and served traffic${seen}`,
247+
).not.toContain(BANNER);
248+
expect(stderr, `the refusal did not fail fast under the escape hatch${seen}`).toMatch(/ FATAL/);
249+
expect(stderr, `the refusal was downgraded to a degraded-boot warning${seen}`).not.toMatch(
250+
/DEGRADED TENANCY \(OS_ALLOW_DEGRADED_TENANCY=1\)/,
251+
);
252+
expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
253+
},
254+
300_000,
255+
);
256+
});
257+
});

0 commit comments

Comments
 (0)