Skip to content

Commit b25a116

Browse files
os-zhuangzhuangjianguoclaude
authored
fix(verify): resolve the enterprise organizations package from the host app (#4700) (#4857)
Node ESM resolves a bare `import()` against the importer's own realpath. `packages/verify`'s realpath is inside the framework workspace, while `@objectstack/organizations` is cloud-private and only ever lives in the verified app's `node_modules` — so `bootStack({ multiTenant: true })` fell into its catch on every real host app and told operators to install a package they had already installed. Same defect class as cloud#1013 (#4699 fixed `serve`). Move the host-app resolver out of the CLI, where verify and the dogfood suite could not import it without inverting the dependency direction, into a node-only subpath export of @objectstack/types: - `@objectstack/types/node` — new subpath, NOT the root export: the root is a dependency of the edge-targeted `@objectstack/hono` and reaches zero `node:` builtins. tsup emits both entries self-contained (`splitting: false`), and node-isolation.test.ts walks the root import graph and fails on the first reachable `node:` specifier, so the split is enforced, not just intended. - CLI consumes it; its private copy and that copy's tests are deleted. - `BootOptions.hostRoot` (optional, defaults to cwd) names the app supplying the optional packages. - Dogfood: both multi-org probes were CONSTANT-FALSE by construction, so the #1994 cross-tenant RLS gate and the attachments isolation block had never executed while the suite was green. They now resolve like the runtime does, and OS_TEST_MULTI_ORG_ENABLED=1 turns an unexpected skip into a hard failure. Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny Co-authored-by: Claude <zhuangjianguo@steedos.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d40f43a commit b25a116

17 files changed

Lines changed: 827 additions & 130 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/verify": minor
4+
"@objectstack/cli": patch
5+
---
6+
7+
fix(verify): resolve the enterprise organizations package from the HOST APP (#4700)
8+
9+
`bootStack(app, { multiTenant: true })` — and therefore `objectstack verify
10+
--multi-tenant` — could never load `@objectstack/organizations`. Node ESM
11+
resolves a bare `import()` against the **importer's own realpath**, which for
12+
`packages/verify` is inside the framework workspace, while the enterprise
13+
package is cloud-private and only ever lives in the verified app's
14+
`node_modules`. Every real host app fell into the catch and was told to
15+
"Install/link it in this workspace" — about a package it had already installed.
16+
Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed
17+
that one call site and this issue tracked the two the sweep left behind.
18+
19+
**New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` /
20+
`createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts`
21+
— where `@objectstack/verify` and the dogfood suite could not import it without
22+
inverting the dependency direction — into a **node-only subpath export** of
23+
`@objectstack/types`. One behaviour, one source; the CLI now consumes it and its
24+
private copy is deleted.
25+
26+
It is a subpath and **not** the root export because `@objectstack/types` is a
27+
dependency of `@objectstack/hono` ("edge-compatible REST API server for
28+
Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel`
29+
boots on Workers. The root entry reaches zero `node:` builtins, and a Workers
30+
bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two
31+
entries as separate self-contained bundles (`splitting: false`), and a test
32+
walks the root's import graph and fails on the first reachable `node:`
33+
specifier, so the isolation is enforced rather than merely intended. Same
34+
arrangement `@objectstack/metadata` already ships for its `./node` subpath.
35+
36+
**New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names
37+
the app whose `node_modules` supplies those optional packages — for a harness
38+
booting an app that is not the working directory.
39+
40+
**The dogfood multi-org gates had never run.** Two suites probed availability
41+
with the same bare `import()` and so were **constant-false** — not "false
42+
because absent" but false by construction, in every environment including the
43+
cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and
44+
the attachments cross-tenant isolation block had therefore never executed while
45+
the suite reported green (Prime Directive #10, test-suite edition). They now
46+
resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a
47+
run is expected to ship the package — turning a silent skip into a loud failure,
48+
so a run can no longer pass by quietly not running the gates it exists for.

packages/cli/src/commands/serve.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level
1818
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
1919
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
2020
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
21-
import { createHostRequire, createHostImporter } from '../utils/import-from-host.js';
21+
// Shared with @objectstack/verify and the dogfood multi-org probes (#4700) —
22+
// node-only, hence the `/node` subpath rather than the edge-safe root export.
23+
import { createHostRequire, createHostImporter } from '@objectstack/types/node';
2224
import {
2325
printHeader,
2426
printKV,

packages/cli/src/utils/import-from-host.ts

Lines changed: 0 additions & 80 deletions
This file was deleted.

packages/qa/dogfood/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@
1616
"@objectstack/example-showcase": "workspace:*",
1717
"@objectstack/mcp": "workspace:*",
1818
"@objectstack/objectql": "workspace:*",
19+
"@objectstack/platform-objects": "workspace:*",
1920
"@objectstack/plugin-audit": "workspace:*",
2021
"@objectstack/plugin-auth": "workspace:*",
2122
"@objectstack/plugin-email": "workspace:*",
2223
"@objectstack/plugin-security": "workspace:*",
2324
"@objectstack/plugin-sharing": "workspace:*",
2425
"@objectstack/plugin-webhooks": "workspace:*",
25-
"@objectstack/platform-objects": "workspace:*",
2626
"@objectstack/service-analytics": "workspace:*",
2727
"@objectstack/service-messaging": "workspace:*",
2828
"@objectstack/service-storage": "workspace:*",
2929
"@objectstack/spec": "workspace:*",
30+
"@objectstack/types": "workspace:*",
3031
"@objectstack/verify": "workspace:*"
3132
},
3233
"devDependencies": {

packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
2525
import { StorageServicePlugin } from '@objectstack/service-storage';
2626
import { AuditPlugin } from '@objectstack/plugin-audit';
2727
import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js';
28+
import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js';
2829

2930
const SYS = { isSystem: true } as const;
3031
const DAY_MS = 86_400_000;
@@ -525,13 +526,11 @@ describe('attachments permission matrix (#2755)', () => {
525526
});
526527

527528
// ── (g) tenant isolation — enterprise multi-org boot ─────────────────────
528-
const organizationsAvailable = await import(/* webpackIgnore: true */ '@objectstack/organizations')
529-
.then(() => true)
530-
.catch(() => false);
531-
if (!organizationsAvailable) {
532-
// eslint-disable-next-line no-console
533-
console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the attachments multi-tenant block');
534-
}
529+
// #4700: this probe was a bare `import()` resolved against this file's realpath
530+
// inside the framework workspace, so it was constant-false and block (g) had
531+
// never executed. Shared host-app resolution + a declarative switch now decide
532+
// it; see `enterprise-organizations.ts`.
533+
warnIfUnavailable('attachments multi-tenant block');
535534

536535
describe.skipIf(!organizationsAvailable)('attachments cross-tenant isolation (g)', () => {
537536
let stack: VerifyStack;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4700 — proof that the multi-org availability probe is no longer constant.
5+
*
6+
* The old probe answered "unavailable" in every environment because it resolved
7+
* a cloud-private package against the framework workspace. Nothing detected that
8+
* — a `describe.skipIf` that always skips leaves no trace beyond a warning line,
9+
* and the suite stays green. The only way to know a capability probe works is to
10+
* make it say BOTH things, on demand.
11+
*
12+
* So these cases build real host roots on disk (real `node_modules`, a real
13+
* stand-in package, nothing mocked) and pin all three verdicts: available,
14+
* unavailable, and declared-but-missing.
15+
*/
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
19+
import { tmpdir } from 'node:os';
20+
import { join } from 'node:path';
21+
import { probeOrganizations, MULTI_ORG_ENV, ORGANIZATIONS_PKG } from './enterprise-organizations.js';
22+
23+
let hostWithPkg: string;
24+
let hostWithoutPkg: string;
25+
26+
function writeHost(prefix: string, withPkg: boolean): string {
27+
const dir = mkdtempSync(join(tmpdir(), prefix));
28+
writeFileSync(
29+
join(dir, 'package.json'),
30+
JSON.stringify({
31+
name: 'dogfood-host-fixture',
32+
private: true,
33+
type: 'module',
34+
...(withPkg ? { dependencies: { [ORGANIZATIONS_PKG]: '*' } } : {}),
35+
}),
36+
'utf8',
37+
);
38+
if (withPkg) {
39+
const pkgDir = join(dir, 'node_modules', ...ORGANIZATIONS_PKG.split('/'));
40+
mkdirSync(pkgDir, { recursive: true });
41+
writeFileSync(
42+
join(pkgDir, 'package.json'),
43+
JSON.stringify({
44+
name: ORGANIZATIONS_PKG,
45+
version: '0.0.0-fixture',
46+
type: 'module',
47+
main: 'index.js',
48+
}),
49+
'utf8',
50+
);
51+
writeFileSync(
52+
join(pkgDir, 'index.js'),
53+
'export class OrganizationsPlugin { name = "com.objectstack.organizations"; }\n',
54+
'utf8',
55+
);
56+
}
57+
return dir;
58+
}
59+
60+
beforeAll(() => {
61+
hostWithPkg = writeHost('os-dogfood-org-ok-', true);
62+
hostWithoutPkg = writeHost('os-dogfood-org-missing-', false);
63+
});
64+
65+
afterAll(() => {
66+
for (const dir of [hostWithPkg, hostWithoutPkg]) {
67+
if (dir) rmSync(dir, { recursive: true, force: true });
68+
}
69+
});
70+
71+
describe('enterprise multi-org probe (#4700)', () => {
72+
it('reports AVAILABLE when the package is installed in the host app', async () => {
73+
// The verdict the old probe could never reach, no matter what any app or CI
74+
// had installed. This is what makes `describe.skipIf(!organizationsAvailable)`
75+
// a real gate rather than an unconditional skip.
76+
const probe = await probeOrganizations(hostWithPkg, false);
77+
expect(probe.available).toBe(true);
78+
expect(probe.reason).toBeUndefined();
79+
});
80+
81+
it('reports UNAVAILABLE, with an actionable reason, when the app lacks it', async () => {
82+
const probe = await probeOrganizations(hostWithoutPkg, false);
83+
expect(probe.available).toBe(false);
84+
// The reason has to name the switch, or the skip stays folklore.
85+
expect(probe.reason).toContain(MULTI_ORG_ENV);
86+
expect(probe.reason).toContain(hostWithoutPkg);
87+
});
88+
89+
it('THROWS when the run declares the package but it is missing', async () => {
90+
// The half that converts "silently green over gates that never ran" into a
91+
// failure a CI operator cannot miss (Prime Directive #10 / "absence must be
92+
// loud"). Without this, a cloud run that lost the package would look exactly
93+
// like a cloud run that has it.
94+
await expect(probeOrganizations(hostWithoutPkg, true)).rejects.toThrow(
95+
new RegExp(`${MULTI_ORG_ENV}=1 declares`),
96+
);
97+
});
98+
99+
it('does not throw when the run declares the package AND it is there', async () => {
100+
await expect(probeOrganizations(hostWithPkg, true)).resolves.toEqual({ available: true });
101+
});
102+
});

0 commit comments

Comments
 (0)