From 21b71838b58f47e701cf8fac9f7e64a00bd99887 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:44:29 +0000 Subject: [PATCH] =?UTF-8?q?fix(plugin-dev):=20=E8=AF=B7=E6=B1=82=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E5=A2=99=E8=80=8C=E4=BC=81=E4=B8=9A=E5=8C=85=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=97=B6=E6=8B=92=E7=BB=9D=20init=20(ADR-009?= =?UTF-8?q?3=20D5)=20(#5301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DevPlugin 请求了有墙 posture 却加载不到企业 @objectstack/organizations 时, 只打一条 logger.warn 就继续 boot——同一台机器上,objectstack serve 对同一个 事实是拒绝启动。ADR-0093 D5 是部署的性质而非某个入口的性质,dev 装配路径欠 同一个答案。 改为 throw(不是 process.exit):DevPlugin 是库形态装配插件,对宿主进程没有 处置权;boot 链不吞异常(kernel.use 只登记、initPluginWithTimeout 不 catch、 bootstrap rethrow),与同文件 assertNotProduction() 的既有依据一致。 照 #4818 分两阶段:import 失败(缺包)受 OS_ALLOW_DEGRADED_TENANCY 管辖, 用的是 serve.ts 同一个 resolveAllowDegradedTenancy();construct/init 失败 (包在、插件自己拒绝)一律中止,hatch 不覆盖。阶段 2 在 DevPlugin 里多一处 落点——它自己 init 子插件的 best-effort 循环会吞掉 init 拒绝——故对这一个 子插件单独例外,其余子插件容错不变。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW --- .changeset/plugin-dev-tenancy-d5-failfast.md | 46 ++++ .../src/dev-plugin-tenancy-failfast.test.ts | 200 +++++++++++++++++ .../dev-plugin-tenancy-mount-refusal.test.ts | 205 ++++++++++++++++++ .../src/dev-plugin-tenancy-posture.test.ts | 12 + packages/plugins/plugin-dev/src/dev-plugin.ts | 116 +++++++++- 5 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 .changeset/plugin-dev-tenancy-d5-failfast.md create mode 100644 packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts create mode 100644 packages/plugins/plugin-dev/src/dev-plugin-tenancy-mount-refusal.test.ts diff --git a/.changeset/plugin-dev-tenancy-d5-failfast.md b/.changeset/plugin-dev-tenancy-d5-failfast.md new file mode 100644 index 0000000000..b3543fa3da --- /dev/null +++ b/.changeset/plugin-dev-tenancy-d5-failfast.md @@ -0,0 +1,46 @@ +--- +"@objectstack/plugin-dev": patch +--- + +fix(plugin-dev): 请求了组织墙而企业包不可用时拒绝 init,不再只 warn 就无墙跑 (#5301) + +`DevPlugin` 请求了有墙 tenancy posture(`isolated` / `group`)却加载不到企业 +`@objectstack/organizations` 时,只打一条 `logger.warn` 就继续 boot。于是同一台机器上, +**同一个事实**有两个相反的答案: + +| 入口 | 请求 `isolated`、企业包缺失 | 结果 | +|---|---|---| +| `objectstack serve` | 拒绝启动(除非显式 `OS_ALLOW_DEGRADED_TENANCY=1`) | 安全 | +| `DevPlugin`(改前) | warn 后继续 | **无墙服务流量**,且没人显式同意过 | + +ADR-0093 D5「请求了隔离就不得在没有隔离的情况下服务流量」是**部署**的性质,不是某一个 +入口的性质,所以 dev 装配路径欠同一个答案。#5262 让这条更容易被触发而不是更难:在它之前, +只设 `OS_TENANCY_POSTURE` 的 dev 栈根本不进这个分支(那是 #5262 本身的缺陷),修好读数之后 +它会进分支、会加载失败,然后正好走这条 fail-open 的路。 + +**改为 `throw`,不是 `process.exit(1)`。** `serve.ts` 必须 `process.exit`,因为它那道闸 +嵌在会吞异常的 AuthPlugin `try` 里;`DevPlugin` 是**库形态**的装配插件,对宿主进程没有处置权, +嵌入方(测试、脚本、父应用)有权 catch 它。而且它的 boot 链不吞异常——`kernel.use()` 只登记、 +`initPluginWithTimeout` 不 catch、`bootstrap()` 会 rethrow——所以 `throw` 能真的中止 boot, +与同文件 `assertNotProduction()` 的既有依据一致。 + +**照 #4818 分两阶段,两种失败两种诊断:** + +- **阶段 1(import 失败 = 包缺失)**:`OS_ALLOW_DEGRADED_TENANCY` 生效。未设则拒绝 init, + 报文里点名被请求的 posture 和全部出路;设了则照旧 warn 后降级继续,而且这条 warn 仍然 + 如实说明墙是 INACTIVE。判定用的是 `resolveAllowDegradedTenancy()`——和 `serve.ts` + 同一个 resolver,所以两个入口对「显式同意」的定义不可能漂移。 +- **阶段 2(construct / init 失败 = 包在、插件自己拒绝)**:hatch **不覆盖**,一律中止。 + 该 hatch 的含义始终是「这个能力**缺席**,我接受降级」,而不是「替我越过插件正在执行的闸」; + 让它放行会把插件的许可证/前置条件检查降格成一个环境变量。报文原样转述插件自己的说法, + 框架不解释,并明说这**不是**缺包问题,省掉一轮「去查安装」的排查。 + +阶段 2 在 `DevPlugin` 里比 `serve.ts` 多一处落点:`serve` 把插件交给 `kernel.use()`, +其 Phase-1 循环会 rethrow init 失败;而 `DevPlugin` 自己 init 子插件,那个循环刻意是 +best-effort(记一条 error 继续,dev 栈才能在缺包时照常起)。对这一个子插件,best-effort +默认就是同一个 fail-open,所以它现在单独例外——其余子插件的容错**完全不变**。 + +**迁移。** 只影响「请求了有墙 posture 且企业包不可用」的 dev 栈——此前它静默降级,现在会 +拒绝启动。若确实要在无墙状态下继续跑,显式设 `OS_ALLOW_DEGRADED_TENANCY=1`,与 +`objectstack serve` 的做法一致。单组织(`single` posture,即默认)栈完全不受影响, +不进这个分支,也不需要这个 hatch。 diff --git a/packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts new file mode 100644 index 0000000000..f9877a7fe1 --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5301 — DevPlugin enforces ADR-0093 D5: a stack that REQUESTED the +// organization wall must not serve traffic without it. +// +// Before this, `objectstack serve` and `DevPlugin` gave OPPOSITE answers to one +// fact on one machine. Walled posture requested, enterprise +// `@objectstack/organizations` absent: +// +// objectstack serve → refuses to boot (unless OS_ALLOW_DEGRADED_TENANCY=1) +// DevPlugin → one logger.warn, then boots and serves UNWALLED, +// with nobody having consented to the degradation +// +// D5 is a property of the DEPLOYMENT, not of one entrypoint, so the dev +// assembly path owes the same answer. #5262 made this MORE reachable, not less: +// before it, a dev stack setting only `OS_TENANCY_POSTURE` never entered the +// branch at all, so the warn-only path was dead code for the documented +// configuration. After it, that stack enters, fails to load, and took the +// fail-open path — which is what this file now forbids. +// +// ── What is observed, and why it is honest ────────────────────────────────── +// `@objectstack/organizations` is a cloud-private enterprise package genuinely +// absent from this workspace, so the dynamic import genuinely fails and the +// real stage-1 catch runs — no stubbing of the thing under test. That makes +// this file the faithful witness for the ABSENT-package half of #4818's split. +// The PRESENT-but-refusing half needs the package to resolve, so it lives in +// `dev-plugin-tenancy-mount-refusal.test.ts`, which mocks it. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// #3060 — same treatment as the sibling suites: init() dynamically imports ~10 +// real workspace packages, whose vite transforms alone can blow the test +// timeout under a parallel `pnpm test`. Each factory throws the shape an absent +// package produces, so the graceful-degradation branches run for real with zero +// module resolution on the hot path. `@objectstack/organizations` is +// deliberately NOT listed: it is really absent, and its real failure is the +// signal this file reads. +vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); + +import { DevPlugin } from './dev-plugin'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; +const OLD_NODE_ENV = process.env.NODE_ENV; +const OLD_DEGRADED = process.env.OS_ALLOW_DEGRADED_TENANCY; + +const makeCtx = () => { + const registered = new Map(); + return { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn((name: string) => { + if (registered.has(name)) return registered.get(name); + throw new Error('not found'); + }), + getServices: vi.fn(() => new Map()), + registerService: vi.fn((name: string, svc: unknown) => registered.set(name, svc)), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + } as any; +}; + +/** Boot DevPlugin under a tenancy configuration; never swallows. */ +const init = async (env: { posture?: string; legacy?: string; degraded?: string }) => { + if (env.posture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = env.posture; + if (env.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = env.legacy; + if (env.degraded === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = env.degraded; + + const ctx = makeCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + const lines = [ + ...ctx.logger.warn.mock.calls, + ...ctx.logger.info.mock.calls, + ...ctx.logger.error.mock.calls, + ].map((c: unknown[]) => String(c[0])); + return { ctx, lines }; +}; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_ALLOW_DEGRADED_TENANCY; + process.env.NODE_ENV = 'development'; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + if (OLD_DEGRADED === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = OLD_DEGRADED; + vi.restoreAllMocks(); +}); + +describe('#5301 — stage 1 (package ABSENT): D5 fail-fast unless the operator opted in', () => { + it('walled posture + absent enterprise package + no hatch → REFUSES to init', async () => { + // THE regression. This exact configuration used to emit one warning and + // boot on, serving traffic with the organization wall inactive. + await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/); + }); + + it('`group` is walled too — it refuses on the same terms as `isolated`', async () => { + // `group` has no legacy-boolean spelling at all, so it is the posture most + // likely to reach here by the documented configuration alone. + await expect(init({ posture: 'group' })).rejects.toThrow(/ADR-0093 D5/); + }); + + it('the legacy boolean requests the wall too, and is refused the same way', async () => { + await expect(init({ legacy: 'true' })).rejects.toThrow(/ADR-0093 D5/); + }); + + it('the refusal names the requested posture and every way out', async () => { + // A refusal that does not say how to get past it just moves the operator's + // problem from "no wall" to "no boot and no idea why". + const err = await init({ posture: 'isolated' }).catch((e: Error) => e); + const msg = (err as Error).message; + expect(msg).toContain("posture 'isolated'"); + expect(msg).toContain('@objectstack/organizations'); + expect(msg).toContain('OS_TENANCY_POSTURE=single'); + expect(msg).toContain('OS_ALLOW_DEGRADED_TENANCY=1'); + // The framework's own words about WHY, not just what: D5 is the authority. + expect(msg).toContain('must not serve traffic without it'); + }); + + it('throws rather than exiting the process — DevPlugin is a library', async () => { + // The distinction #5301 turns on. serve.ts must `process.exit(1)` because + // its guard sits inside a broad AuthPlugin `try` that swallows throws. + // DevPlugin has no claim on the host process: embedders (tests, scripts, a + // parent app) are entitled to catch this, and the boot chain does not + // swallow it — `kernel.use()` only registers, `initPluginWithTimeout` does + // not catch, `bootstrap()` rethrows. Consistent with the same file's + // `assertNotProduction()`. That this assertion can run AT ALL is the proof: + // a `process.exit(1)` would take the test runner down with it. + const exit = vi.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit must not be called from a library plugin'); + }) as any); + await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/); + expect(exit).not.toHaveBeenCalled(); + }); + + it('with OS_ALLOW_DEGRADED_TENANCY=1 it boots degraded, and says so', async () => { + // The hatch's whole meaning: "the capability is ABSENT and I accept the + // degradation". Boot continues — but branded, never silent. + const run = await init({ posture: 'isolated', degraded: '1' }); + const warning = run.lines.find((l) => l.includes('@objectstack/organizations')); + expect(warning).toBeDefined(); + expect(warning).toContain('DEGRADED TENANCY'); + expect(warning).toContain("posture 'isolated'"); + // The line must stay honest about what is NOT being enforced. + expect(warning).toContain('organization wall INACTIVE'); + expect(warning).toContain('ADR-0093 D5'); + }); + + it('the hatch shares the OS_ALLOW_* family truthiness, exactly as serve.ts reads it', async () => { + // `resolveAllowDegradedTenancy()` — the SAME resolver serve.ts calls, so the + // two entrypoints can never drift on what "opted in" means. A hand-rolled + // `=== '1'` here would have made `true`/`on`/`yes` work for serve and fail + // for dev, on one machine, from one .env file. + for (const truthy of ['1', 'true', 'on', 'yes', 'YES', ' True ']) { + // Resolving AT ALL is the assertion: it means init() ran to completion + // instead of refusing. (The helper resolves with its captured log lines.) + await expect(init({ posture: 'isolated', degraded: truthy })).resolves.toBeTruthy(); + } + for (const falsy of ['0', 'false', 'off', 'no', '']) { + await expect(init({ posture: 'isolated', degraded: falsy })).rejects.toThrow(/ADR-0093 D5/); + } + }); +}); + +describe('#5301 — unwalled postures are untouched', () => { + it('single-org dev stacks never enter the branch, and never refuse', async () => { + // The guard must not become a tax on the default configuration: a stack + // that never asked for the wall is not degraded by not having one. + for (const env of [{ posture: 'single' }, { legacy: 'false' }, {}]) { + const run = await init(env); + expect(run.lines.some((l) => l.includes('@objectstack/organizations'))).toBe(false); + expect(run.lines.some((l) => l.includes('ADR-0093 D5'))).toBe(false); + } + }); + + it('a single posture does not need the hatch to boot', async () => { + await expect(init({ posture: 'single' })).resolves.toBeTruthy(); + }); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin-tenancy-mount-refusal.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-mount-refusal.test.ts new file mode 100644 index 0000000000..22b1dcabcb --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-mount-refusal.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5301 / #4818 — stage 2: the enterprise package IS present and the plugin +// ITSELF declined. Two failures, two diagnoses, and only ONE of them is +// covered by the escape hatch. +// +// `OS_ALLOW_DEGRADED_TENANCY` means exactly "the multi-org capability is ABSENT +// and I accept doing without it". It has never meant "override whatever gate +// the plugin is enforcing" — a licence check, a schema precondition, a refusal +// to run against this datasource. Letting the hatch past a PRESENT plugin's +// refusal would move that gate onto an env var, which is why serve.ts exits +// unconditionally here (#4818) and why DevPlugin now throws unconditionally. +// +// The classifier is WHICH STAGE THREW — deliberately not the error's shape. +// The framework must not encode any of the plugin's private refusal semantics +// (a layering violation needing an update per refusal reason), and CLI and +// plugin may hold different module instances, so `instanceof` and named `code` +// checks are both fragile. Stage is the only classifier that needs to know +// nothing about the plugin's internals. +// +// ── Why this file mocks what its sibling refuses to mock ──────────────────── +// `dev-plugin-tenancy-failfast.test.ts` reads the ABSENT-package path off the +// genuinely-absent cloud-private package, stubbing nothing. That is impossible +// here by construction: "the package is present and refused" cannot be observed +// without a present package. The mock therefore supplies only the thing the +// open-source workspace cannot have — a resolvable `@objectstack/organizations` +// — and the refusal semantics under test stay entirely in dev-plugin.ts. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** How the fake enterprise plugin should misbehave for the current test. */ +const orgBehaviour = vi.hoisted(() => ({ mode: 'healthy' as 'healthy' | 'construct-throws' | 'init-throws' })); + +vi.mock('@objectstack/organizations', () => ({ + OrganizationsPlugin: class { + name = 'com.objectstack.plugin.organizations'; + version = '1.0.0'; + constructor() { + if (orgBehaviour.mode === 'construct-throws') { + throw Object.assign(new Error('ORG_LICENCE_INVALID: seat count exceeded'), { code: 'ORG_LICENCE_INVALID' }); + } + } + async init() { + if (orgBehaviour.mode === 'init-throws') { + throw Object.assign(new Error('ORG_SCHEMA_PRECONDITION: organization_id column missing'), { code: 'ORG_SCHEMA_PRECONDITION' }); + } + } + }, +})); + +// #3060 — as in the sibling suites: keep ~10 real workspace packages off the +// hot path so vite transforms cannot blow the timeout under a parallel run. +vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); + +import { DevPlugin } from './dev-plugin'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_NODE_ENV = process.env.NODE_ENV; +const OLD_DEGRADED = process.env.OS_ALLOW_DEGRADED_TENANCY; + +const init = async (degraded?: string) => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + if (degraded === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = degraded; + + const registered = new Map(); + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn((name: string) => { + if (registered.has(name)) return registered.get(name); + throw new Error('not found'); + }), + getServices: vi.fn(() => new Map()), + registerService: vi.fn((name: string, svc: unknown) => registered.set(name, svc)), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + await new DevPlugin({ seedAdminUser: false }).init(ctx); + return ctx; +}; + +beforeEach(() => { + orgBehaviour.mode = 'healthy'; + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_ALLOW_DEGRADED_TENANCY; + process.env.NODE_ENV = 'development'; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + if (OLD_DEGRADED === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = OLD_DEGRADED; + vi.restoreAllMocks(); +}); + +describe('#5301 — positive control: a present, healthy enterprise runtime boots', () => { + it('mounts the wall and reports it, with no hatch and no refusal', async () => { + // Without this case the whole file could pass on "any walled posture + // throws" — the assertions below would then be pinning nothing. + const ctx = await init(); + const lines = ctx.logger.info.mock.calls.map((c: unknown[]) => String(c[0])); + expect(lines.some((l: string) => l.includes('Organizations plugin enabled'))).toBe(true); + const warns = ctx.logger.warn.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warns.some((l: string) => l.includes('ADR-0093 D5'))).toBe(false); + }); +}); + +describe('#5301 — stage 2a: the plugin refuses to CONSTRUCT', () => { + it('refuses to init, and does NOT call it a missing package', async () => { + orgBehaviour.mode = 'construct-throws'; + const err = await init().catch((e: Error) => e); + const msg = (err as Error).message; + expect(msg).toContain('ADR-0093 D5'); + // The #4818 diagnosis: sending an operator to check their install when the + // package is installed and the plugin objected costs a whole lap. + expect(msg).toContain('WAS found and loaded'); + expect(msg).toContain('NOT a missing-package problem'); + // The plugin's own words are the authority; the framework does not guess. + expect(msg).toContain('ORG_LICENCE_INVALID: seat count exceeded'); + expect(msg).toContain('code: ORG_LICENCE_INVALID'); + }); + + it('the hatch does NOT get past it (#4818)', async () => { + // The load-bearing half of the two-stage split. Under one shared `try` this + // refusal was indistinguishable from an absent package, so + // OS_ALLOW_DEGRADED_TENANCY swallowed it — i.e. an env var silently + // overrode whatever gate the enterprise plugin was enforcing. + orgBehaviour.mode = 'construct-throws'; + for (const hatch of ['1', 'true', 'yes']) { + const err = await init(hatch).catch((e: Error) => e); + expect((err as Error).message).toContain('ADR-0093 D5'); + expect((err as Error).message).toContain('OS_ALLOW_DEGRADED_TENANCY does NOT apply'); + } + }); +}); + +describe('#5301 — stage 2b: the plugin constructs, then refuses to INITIALIZE', () => { + it('refuses to init instead of logging the refusal and booting unwalled', async () => { + // Where DevPlugin structurally differs from serve.ts, and the reason + // mirroring only the construct stage would have left this file's own hole + // open. serve.ts hands the plugin to `kernel.use()`, whose Phase-1 loop + // RETHROWS an init failure — so serve needs no special line for this. + // DevPlugin inits its children itself, in a deliberately best-effort loop + // that logs and continues (a dev stack should survive an absent service). + // For THIS child that best-effort default is the D5 fail-open all over + // again: the wall the operator asked for is inactive, and the process + // serves traffic anyway. + orgBehaviour.mode = 'init-throws'; + const err = await init().catch((e: Error) => e); + const msg = (err as Error).message; + expect(msg).toContain('ADR-0093 D5'); + expect(msg).toContain('failed to initialize'); + expect(msg).toContain('ORG_SCHEMA_PRECONDITION: organization_id column missing'); + }); + + it('the hatch does NOT get past it either — same fact, same stage', async () => { + orgBehaviour.mode = 'init-throws'; + for (const hatch of ['1', 'true']) { + const err = await init(hatch).catch((e: Error) => e); + expect((err as Error).message).toContain('OS_ALLOW_DEGRADED_TENANCY does NOT apply'); + } + }); + + it('keeps the best-effort loop best-effort for every OTHER child plugin', async () => { + // Scope guard, and the thing most at risk of being broken by the line + // above: exactly one child plugin is exempt from the loop's tolerance. A + // dev stack must still survive any other child failing to init — that + // tolerance is what makes `new DevPlugin()` work with packages missing. + orgBehaviour.mode = 'healthy'; + const failing = { + name: 'com.example.broken', + version: '1.0.0', + init: async () => { throw new Error('BROKEN_CHILD'); }, + } as any; + process.env.OS_TENANCY_POSTURE = 'isolated'; + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn(() => { throw new Error('not found'); }), + getServices: vi.fn(() => new Map()), + registerService: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + await expect( + new DevPlugin({ seedAdminUser: false, extraPlugins: [failing] }).init(ctx), + ).resolves.toBeUndefined(); + const errors = ctx.logger.error.mock.calls.map((c: unknown[]) => String(c[0])); + expect(errors.some((l: string) => l.includes('com.example.broken') && l.includes('BROKEN_CHILD'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts index bc59d1c64f..ce6cff0d37 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts @@ -56,6 +56,7 @@ import { DevPlugin } from './dev-plugin'; const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; const OLD_NODE_ENV = process.env.NODE_ENV; +const OLD_DEGRADED = process.env.OS_ALLOW_DEGRADED_TENANCY; /** Boot DevPlugin under a tenancy configuration and report what it tried. */ const initUnder = async (env: { posture?: string; legacy?: string }) => { @@ -96,6 +97,15 @@ beforeEach(() => { delete process.env.OS_TENANCY_POSTURE; delete process.env.OS_MULTI_ORG_ENABLED; process.env.NODE_ENV = 'development'; + // [#5301] This suite observes BRANCH ENTRY, and it reads that entry off the + // warning the absent-package path emits. Since #5301 that path is ADR-0093 D5 + // fail-fast: a walled posture with the enterprise package absent REFUSES to + // init unless the operator opted in, so without this hatch every walled case + // below would throw before it could be observed. The hatch does not weaken + // what is under test here — the degraded path still names the requested + // posture in the same line, which is the whole signal #5262 pinned. The + // fail-fast itself is pinned next door, in `dev-plugin-tenancy-failfast.test.ts`. + process.env.OS_ALLOW_DEGRADED_TENANCY = '1'; }); afterEach(() => { if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; @@ -104,6 +114,8 @@ afterEach(() => { else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = OLD_NODE_ENV; + if (OLD_DEGRADED === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = OLD_DEGRADED; vi.restoreAllMocks(); }); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index af22323b75..d7ec1107f9 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext } from '@objectstack/core'; -import { resolveAllowDevPlugin, resolveTenancyPosture } from '@objectstack/types'; +import { resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; /** @@ -439,6 +439,13 @@ export class DevPlugin implements Plugin { } } + // [#5301] The enterprise organizations plugin, once constructed — held so + // the child-`init()` loop below can tell ITS refusal apart from every other + // child plugin's. That loop is best-effort by design (a dev stack survives + // an absent service), but "the organization wall failed to come up" is the + // one failure in it that ADR-0093 D5 forbids booting through. + let organizationsPlugin: Plugin | undefined; + // 5. Security Plugin (RBAC, RLS, field-level masking) // OrganizationsPlugin (when multi-org; ENTERPRISE `@objectstack/organizations`, // ADR-0081 D2) MUST register BEFORE SecurityPlugin because @@ -462,16 +469,89 @@ export class DevPlugin implements Plugin { const tenancyPosture = resolveTenancyPosture(); const multiTenant = postureEnforcesWall(tenancyPosture); if (multiTenant) { + // [#5301] ADR-0093 D5 is enforced HERE, not merely reported. This + // branch used to `logger.warn` and boot on, so a dev stack that asked + // for the organization wall and could not get it served traffic with + // no wall and nobody having agreed to that — while `objectstack serve` + // refused to boot on the very same fact. D5 is a property of the + // DEPLOYMENT ("a stack that requested isolation must not serve traffic + // without it"), not of one entrypoint, so the dev assembly path owes + // the same answer. + // + // `throw`, not `process.exit(1)`. serve.ts needs the exit because its + // guard sits inside a broad AuthPlugin `try` that swallows throws; + // DevPlugin is a LIBRARY-shaped assembly plugin with no claim on the + // host process, and its boot chain does not swallow — `kernel.use()` + // only registers, `initPluginWithTimeout` does not catch, `bootstrap()` + // rethrows. So a throw genuinely aborts boot here, exactly as this + // file's own `assertNotProduction()` already relies on. Killing the + // host process from a library would additionally take down embedders + // (tests, scripts, a parent app) that are entitled to catch this. + // + // #4818 — TWO STAGES, TWO FAILURES, TWO DIAGNOSES, mirroring serve.ts. + // `import` and `new OrganizationsPlugin()` shared one `try`, so a + // plugin that CONSTRUCTED and refused was reported as an absent + // package. 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 encode any of the plugin's private + // refusal semantics. + const organizationsPkg = '@objectstack/organizations'; + let orgMod: any; + // ── Stage 1: import. Failure here = the package is ABSENT. ── try { - const organizationsPkg = '@objectstack/organizations'; - const mod: any = await import(/* webpackIgnore: true */ organizationsPkg); - this.childPlugins.push(new mod.OrganizationsPlugin()); - ctx.logger.info(` ✔ Organizations plugin enabled (posture '${tenancyPosture}': organization_id auto-stamp, per-org seed)`); - } catch { + orgMod = await import(/* webpackIgnore: true */ organizationsPkg); + } catch (orgErr: any) { + const cause = orgErr instanceof Error ? orgErr.message : String(orgErr); + if (!resolveAllowDegradedTenancy()) { + throw new Error( + `tenancy posture '${tenancyPosture}' was requested but @objectstack/organizations ` + + '(the enterprise multi-org runtime) could not be loaded, so the organization wall is ' + + 'INACTIVE. Refusing to initialize — a stack that requested multi-organization ' + + 'isolation must not serve traffic without it (ADR-0093 D5). Fix one of: ' + + 'install @objectstack/organizations; or set OS_TENANCY_POSTURE=single (and unset ' + + 'OS_MULTI_ORG_ENABLED) to run single-org; or set OS_ALLOW_DEGRADED_TENANCY=1 to boot ' + + `in an explicitly degraded single-org state. cause: ${cause}`, + ); + } + // Explicitly opted into degraded operation — boot, but brand it. // Names the posture that was actually requested, not one knob's // spelling of it: the old text asserted `OS_MULTI_ORG_ENABLED=true` // at an operator who may well have set only `OS_TENANCY_POSTURE`. - ctx.logger.warn(` ✘ tenancy posture '${tenancyPosture}' requested but @objectstack/organizations (enterprise) not installed — running single-org, organization wall INACTIVE (ADR-0093 D5)`); + ctx.logger.warn(` ✘ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): tenancy posture '${tenancyPosture}' requested but @objectstack/organizations (enterprise) not installed — running single-org, organization wall INACTIVE (ADR-0093 D5)`); + // Degraded boot: `orgMod` stays undefined, so stage 2 is skipped. + // Nothing was loaded, so nothing can be constructed. + } + + // ── Stage 2: construct + register. Failure here = the package IS + // present and the plugin itself declined. Report what it said, + // verbatim, and refuse 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 { + organizationsPlugin = new orgMod.OrganizationsPlugin(); + this.childPlugins.push(organizationsPlugin!); + } catch (mountErr: any) { + // 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 and let them be the authority. + const mountMessage = mountErr instanceof Error ? mountErr.message : String(mountErr); + const mountCode = (mountErr as any)?.code; + throw new Error( + `tenancy posture '${tenancyPosture}' was requested and @objectstack/organizations WAS ` + + 'found and loaded, but its OrganizationsPlugin refused to be constructed, so the ' + + 'organization wall is INACTIVE. Refusing to initialize (ADR-0093 D5). This is NOT a ' + + 'missing-package problem: the runtime is installed and resolvable here. The plugin ' + + 'reported (verbatim — the framework does not interpret it): ' + + (mountCode !== undefined ? `code: ${String(mountCode)} — ` : '') + + `${mountMessage}. OS_ALLOW_DEGRADED_TENANCY does NOT apply to this failure and will ` + + 'not get past it: it covers an ABSENT multi-org runtime the operator accepts doing ' + + 'without, not a present one that declined. (#4818)', + ); + } + ctx.logger.info(` ✔ Organizations plugin enabled (posture '${tenancyPosture}': organization_id auto-stamp, per-org seed)`); } } try { @@ -544,6 +624,28 @@ export class DevPlugin implements Plugin { try { await plugin.init(ctx); } catch (err: any) { + // [#5301] One child's init failure is NOT best-effort: the enterprise + // organizations plugin declining here means the organization wall a + // walled posture asked for is INACTIVE, and ADR-0093 D5 forbids serving + // traffic in that state. This is stage 2's other half — under + // `objectstack serve` the same refusal reaches the kernel, whose Phase-1 + // loop rethrows, so serve needs no equivalent line; DevPlugin's own loop + // would otherwise swallow it into a log entry and boot on unwalled, + // re-opening on `init()` exactly the hole the construct stage closes. + // A PRESENT plugin that refused is never covered by + // OS_ALLOW_DEGRADED_TENANCY (#4818) — the hatch means "the runtime is + // absent and I accept that", so it is deliberately not consulted here. + if (organizationsPlugin !== undefined && plugin === organizationsPlugin) { + throw new Error( + 'the enterprise @objectstack/organizations runtime was loaded but its OrganizationsPlugin ' + + `failed to initialize, so the organization wall requested by tenancy posture ` + + `'${resolveTenancyPosture()}' is INACTIVE. Refusing to initialize — a stack that ` + + 'requested multi-organization isolation must not serve traffic without it (ADR-0093 D5). ' + + 'The plugin reported (verbatim — the framework does not interpret it): ' + + `${err?.message ?? String(err)}. OS_ALLOW_DEGRADED_TENANCY does NOT apply: it covers an ` + + 'ABSENT multi-org runtime, not a present one that declined. (#4818)', + ); + } ctx.logger.error(`Failed to init child plugin ${plugin.name}: ${err.message}`); } }