diff --git a/.changeset/serve-organizations-mount-vs-import.md b/.changeset/serve-organizations-mount-vs-import.md new file mode 100644 index 0000000000..89d852cff0 --- /dev/null +++ b/.changeset/serve-organizations-mount-vs-import.md @@ -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`,或处理插件报出的原因。 diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 81ebf047a8..675026a1ee 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 @@ -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 @@ -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); + } } } diff --git a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts new file mode 100644 index 0000000000..f199e27b9e --- /dev/null +++ b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts @@ -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, + ); + }); +});