Skip to content

Commit 7e791e5

Browse files
qq9340100claude
andauthored
fix(plugin-dev): 请求组织墙而企业包不可用时拒绝 init (ADR-0093 D5) (#5301) (#6053)
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 拒绝——故对这一个 子插件单独例外,其余子插件容错不变。 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent dca5bd3 commit 7e791e5

5 files changed

Lines changed: 572 additions & 7 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/plugin-dev": patch
3+
---
4+
5+
fix(plugin-dev): 请求了组织墙而企业包不可用时拒绝 init,不再只 warn 就无墙跑 (#5301)
6+
7+
`DevPlugin` 请求了有墙 tenancy posture(`isolated` / `group`)却加载不到企业
8+
`@objectstack/organizations` 时,只打一条 `logger.warn` 就继续 boot。于是同一台机器上,
9+
**同一个事实**有两个相反的答案:
10+
11+
| 入口 | 请求 `isolated`、企业包缺失 | 结果 |
12+
|---|---|---|
13+
| `objectstack serve` | 拒绝启动(除非显式 `OS_ALLOW_DEGRADED_TENANCY=1`) | 安全 |
14+
| `DevPlugin`(改前) | warn 后继续 | **无墙服务流量**,且没人显式同意过 |
15+
16+
ADR-0093 D5「请求了隔离就不得在没有隔离的情况下服务流量」是**部署**的性质,不是某一个
17+
入口的性质,所以 dev 装配路径欠同一个答案。#5262 让这条更容易被触发而不是更难:在它之前,
18+
只设 `OS_TENANCY_POSTURE` 的 dev 栈根本不进这个分支(那是 #5262 本身的缺陷),修好读数之后
19+
它会进分支、会加载失败,然后正好走这条 fail-open 的路。
20+
21+
**改为 `throw`,不是 `process.exit(1)`** `serve.ts` 必须 `process.exit`,因为它那道闸
22+
嵌在会吞异常的 AuthPlugin `try` 里;`DevPlugin`**库形态**的装配插件,对宿主进程没有处置权,
23+
嵌入方(测试、脚本、父应用)有权 catch 它。而且它的 boot 链不吞异常——`kernel.use()` 只登记、
24+
`initPluginWithTimeout` 不 catch、`bootstrap()` 会 rethrow——所以 `throw` 能真的中止 boot,
25+
与同文件 `assertNotProduction()` 的既有依据一致。
26+
27+
**#4818 分两阶段,两种失败两种诊断:**
28+
29+
- **阶段 1(import 失败 = 包缺失)**:`OS_ALLOW_DEGRADED_TENANCY` 生效。未设则拒绝 init,
30+
报文里点名被请求的 posture 和全部出路;设了则照旧 warn 后降级继续,而且这条 warn 仍然
31+
如实说明墙是 INACTIVE。判定用的是 `resolveAllowDegradedTenancy()`——和 `serve.ts`
32+
同一个 resolver,所以两个入口对「显式同意」的定义不可能漂移。
33+
- **阶段 2(construct / init 失败 = 包在、插件自己拒绝)**:hatch **不覆盖**,一律中止。
34+
该 hatch 的含义始终是「这个能力**缺席**,我接受降级」,而不是「替我越过插件正在执行的闸」;
35+
让它放行会把插件的许可证/前置条件检查降格成一个环境变量。报文原样转述插件自己的说法,
36+
框架不解释,并明说这**不是**缺包问题,省掉一轮「去查安装」的排查。
37+
38+
阶段 2 在 `DevPlugin` 里比 `serve.ts` 多一处落点:`serve` 把插件交给 `kernel.use()`,
39+
其 Phase-1 循环会 rethrow init 失败;而 `DevPlugin` 自己 init 子插件,那个循环刻意是
40+
best-effort(记一条 error 继续,dev 栈才能在缺包时照常起)。对这一个子插件,best-effort
41+
默认就是同一个 fail-open,所以它现在单独例外——其余子插件的容错**完全不变**
42+
43+
**迁移。** 只影响「请求了有墙 posture 且企业包不可用」的 dev 栈——此前它静默降级,现在会
44+
拒绝启动。若确实要在无墙状态下继续跑,显式设 `OS_ALLOW_DEGRADED_TENANCY=1`,与
45+
`objectstack serve` 的做法一致。单组织(`single` posture,即默认)栈完全不受影响,
46+
不进这个分支,也不需要这个 hatch。
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #5301 — DevPlugin enforces ADR-0093 D5: a stack that REQUESTED the
4+
// organization wall must not serve traffic without it.
5+
//
6+
// Before this, `objectstack serve` and `DevPlugin` gave OPPOSITE answers to one
7+
// fact on one machine. Walled posture requested, enterprise
8+
// `@objectstack/organizations` absent:
9+
//
10+
// objectstack serve → refuses to boot (unless OS_ALLOW_DEGRADED_TENANCY=1)
11+
// DevPlugin → one logger.warn, then boots and serves UNWALLED,
12+
// with nobody having consented to the degradation
13+
//
14+
// D5 is a property of the DEPLOYMENT, not of one entrypoint, so the dev
15+
// assembly path owes the same answer. #5262 made this MORE reachable, not less:
16+
// before it, a dev stack setting only `OS_TENANCY_POSTURE` never entered the
17+
// branch at all, so the warn-only path was dead code for the documented
18+
// configuration. After it, that stack enters, fails to load, and took the
19+
// fail-open path — which is what this file now forbids.
20+
//
21+
// ── What is observed, and why it is honest ──────────────────────────────────
22+
// `@objectstack/organizations` is a cloud-private enterprise package genuinely
23+
// absent from this workspace, so the dynamic import genuinely fails and the
24+
// real stage-1 catch runs — no stubbing of the thing under test. That makes
25+
// this file the faithful witness for the ABSENT-package half of #4818's split.
26+
// The PRESENT-but-refusing half needs the package to resolve, so it lives in
27+
// `dev-plugin-tenancy-mount-refusal.test.ts`, which mocks it.
28+
29+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
30+
31+
// #3060 — same treatment as the sibling suites: init() dynamically imports ~10
32+
// real workspace packages, whose vite transforms alone can blow the test
33+
// timeout under a parallel `pnpm test`. Each factory throws the shape an absent
34+
// package produces, so the graceful-degradation branches run for real with zero
35+
// module resolution on the hot path. `@objectstack/organizations` is
36+
// deliberately NOT listed: it is really absent, and its real failure is the
37+
// signal this file reads.
38+
vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
39+
vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
40+
vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
41+
vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
42+
vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
43+
vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
44+
vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
45+
vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
46+
vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
47+
vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
48+
vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
49+
vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); });
50+
51+
import { DevPlugin } from './dev-plugin';
52+
53+
const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
54+
const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED;
55+
const OLD_NODE_ENV = process.env.NODE_ENV;
56+
const OLD_DEGRADED = process.env.OS_ALLOW_DEGRADED_TENANCY;
57+
58+
const makeCtx = () => {
59+
const registered = new Map<string, unknown>();
60+
return {
61+
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
62+
getService: vi.fn((name: string) => {
63+
if (registered.has(name)) return registered.get(name);
64+
throw new Error('not found');
65+
}),
66+
getServices: vi.fn(() => new Map()),
67+
registerService: vi.fn((name: string, svc: unknown) => registered.set(name, svc)),
68+
hook: vi.fn(),
69+
trigger: vi.fn(),
70+
getKernel: vi.fn(),
71+
} as any;
72+
};
73+
74+
/** Boot DevPlugin under a tenancy configuration; never swallows. */
75+
const init = async (env: { posture?: string; legacy?: string; degraded?: string }) => {
76+
if (env.posture === undefined) delete process.env.OS_TENANCY_POSTURE;
77+
else process.env.OS_TENANCY_POSTURE = env.posture;
78+
if (env.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
79+
else process.env.OS_MULTI_ORG_ENABLED = env.legacy;
80+
if (env.degraded === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY;
81+
else process.env.OS_ALLOW_DEGRADED_TENANCY = env.degraded;
82+
83+
const ctx = makeCtx();
84+
await new DevPlugin({ seedAdminUser: false }).init(ctx);
85+
const lines = [
86+
...ctx.logger.warn.mock.calls,
87+
...ctx.logger.info.mock.calls,
88+
...ctx.logger.error.mock.calls,
89+
].map((c: unknown[]) => String(c[0]));
90+
return { ctx, lines };
91+
};
92+
93+
beforeEach(() => {
94+
delete process.env.OS_TENANCY_POSTURE;
95+
delete process.env.OS_MULTI_ORG_ENABLED;
96+
delete process.env.OS_ALLOW_DEGRADED_TENANCY;
97+
process.env.NODE_ENV = 'development';
98+
});
99+
afterEach(() => {
100+
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
101+
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
102+
if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
103+
else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY;
104+
if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV;
105+
else process.env.NODE_ENV = OLD_NODE_ENV;
106+
if (OLD_DEGRADED === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY;
107+
else process.env.OS_ALLOW_DEGRADED_TENANCY = OLD_DEGRADED;
108+
vi.restoreAllMocks();
109+
});
110+
111+
describe('#5301 — stage 1 (package ABSENT): D5 fail-fast unless the operator opted in', () => {
112+
it('walled posture + absent enterprise package + no hatch → REFUSES to init', async () => {
113+
// THE regression. This exact configuration used to emit one warning and
114+
// boot on, serving traffic with the organization wall inactive.
115+
await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/);
116+
});
117+
118+
it('`group` is walled too — it refuses on the same terms as `isolated`', async () => {
119+
// `group` has no legacy-boolean spelling at all, so it is the posture most
120+
// likely to reach here by the documented configuration alone.
121+
await expect(init({ posture: 'group' })).rejects.toThrow(/ADR-0093 D5/);
122+
});
123+
124+
it('the legacy boolean requests the wall too, and is refused the same way', async () => {
125+
await expect(init({ legacy: 'true' })).rejects.toThrow(/ADR-0093 D5/);
126+
});
127+
128+
it('the refusal names the requested posture and every way out', async () => {
129+
// A refusal that does not say how to get past it just moves the operator's
130+
// problem from "no wall" to "no boot and no idea why".
131+
const err = await init({ posture: 'isolated' }).catch((e: Error) => e);
132+
const msg = (err as Error).message;
133+
expect(msg).toContain("posture 'isolated'");
134+
expect(msg).toContain('@objectstack/organizations');
135+
expect(msg).toContain('OS_TENANCY_POSTURE=single');
136+
expect(msg).toContain('OS_ALLOW_DEGRADED_TENANCY=1');
137+
// The framework's own words about WHY, not just what: D5 is the authority.
138+
expect(msg).toContain('must not serve traffic without it');
139+
});
140+
141+
it('throws rather than exiting the process — DevPlugin is a library', async () => {
142+
// The distinction #5301 turns on. serve.ts must `process.exit(1)` because
143+
// its guard sits inside a broad AuthPlugin `try` that swallows throws.
144+
// DevPlugin has no claim on the host process: embedders (tests, scripts, a
145+
// parent app) are entitled to catch this, and the boot chain does not
146+
// swallow it — `kernel.use()` only registers, `initPluginWithTimeout` does
147+
// not catch, `bootstrap()` rethrows. Consistent with the same file's
148+
// `assertNotProduction()`. That this assertion can run AT ALL is the proof:
149+
// a `process.exit(1)` would take the test runner down with it.
150+
const exit = vi.spyOn(process, 'exit').mockImplementation(((): never => {
151+
throw new Error('process.exit must not be called from a library plugin');
152+
}) as any);
153+
await expect(init({ posture: 'isolated' })).rejects.toThrow(/ADR-0093 D5/);
154+
expect(exit).not.toHaveBeenCalled();
155+
});
156+
157+
it('with OS_ALLOW_DEGRADED_TENANCY=1 it boots degraded, and says so', async () => {
158+
// The hatch's whole meaning: "the capability is ABSENT and I accept the
159+
// degradation". Boot continues — but branded, never silent.
160+
const run = await init({ posture: 'isolated', degraded: '1' });
161+
const warning = run.lines.find((l) => l.includes('@objectstack/organizations'));
162+
expect(warning).toBeDefined();
163+
expect(warning).toContain('DEGRADED TENANCY');
164+
expect(warning).toContain("posture 'isolated'");
165+
// The line must stay honest about what is NOT being enforced.
166+
expect(warning).toContain('organization wall INACTIVE');
167+
expect(warning).toContain('ADR-0093 D5');
168+
});
169+
170+
it('the hatch shares the OS_ALLOW_* family truthiness, exactly as serve.ts reads it', async () => {
171+
// `resolveAllowDegradedTenancy()` — the SAME resolver serve.ts calls, so the
172+
// two entrypoints can never drift on what "opted in" means. A hand-rolled
173+
// `=== '1'` here would have made `true`/`on`/`yes` work for serve and fail
174+
// for dev, on one machine, from one .env file.
175+
for (const truthy of ['1', 'true', 'on', 'yes', 'YES', ' True ']) {
176+
// Resolving AT ALL is the assertion: it means init() ran to completion
177+
// instead of refusing. (The helper resolves with its captured log lines.)
178+
await expect(init({ posture: 'isolated', degraded: truthy })).resolves.toBeTruthy();
179+
}
180+
for (const falsy of ['0', 'false', 'off', 'no', '']) {
181+
await expect(init({ posture: 'isolated', degraded: falsy })).rejects.toThrow(/ADR-0093 D5/);
182+
}
183+
});
184+
});
185+
186+
describe('#5301 — unwalled postures are untouched', () => {
187+
it('single-org dev stacks never enter the branch, and never refuse', async () => {
188+
// The guard must not become a tax on the default configuration: a stack
189+
// that never asked for the wall is not degraded by not having one.
190+
for (const env of [{ posture: 'single' }, { legacy: 'false' }, {}]) {
191+
const run = await init(env);
192+
expect(run.lines.some((l) => l.includes('@objectstack/organizations'))).toBe(false);
193+
expect(run.lines.some((l) => l.includes('ADR-0093 D5'))).toBe(false);
194+
}
195+
});
196+
197+
it('a single posture does not need the hatch to boot', async () => {
198+
await expect(init({ posture: 'single' })).resolves.toBeTruthy();
199+
});
200+
});

0 commit comments

Comments
 (0)