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
46 changes: 46 additions & 0 deletions .changeset/plugin-dev-tenancy-d5-failfast.md
Original file line number Diff line number Diff line change
@@ -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。
200 changes: 200 additions & 0 deletions packages/plugins/plugin-dev/src/dev-plugin-tenancy-failfast.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>();
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();
});
});
Loading
Loading