Skip to content

Commit 33e33f7

Browse files
committed
fix(cli): resolve @objectstack/organizations from the host app (cloud#1013)
`objectstack serve` loaded the enterprise multi-org runtime with a bare `import('@objectstack/organizations')`. Node ESM resolves a bare specifier against the importer's own realpath — the CLI's, inside the framework workspace it is linked out of — while that package is cloud-private and lives in the served app's node_modules. The import could therefore never succeed: every self-hosted deployment requesting a walled tenancy posture (`group` / `isolated`) hit the ADR-0093 D5 fail-fast and exited 1, and the only way past it was OS_ALLOW_DEGRADED_TENANCY=1 — the unwalled state D5 exists to prevent. The file already had the right resolver (`importFromHost`), but it was declared AFTER the auth block that contains this load, so the organizations site could not use it. Extracted to `src/utils/import-from-host.ts` (`createHostRequire` / `createHostImporter`), hoisted above the auth block, and used at the organizations site. Two adjacent corrections: - the fallback now applies only when the host cannot RESOLVE the package. A host-resolved package that throws while it evaluates propagates its real error instead of being re-imported bare and reported as MODULE_NOT_FOUND, which every caller classifies as "not installed". - the D5 fatal names the app as where the package must be installed. Regression: `test/serve-organizations-host-resolution.e2e.test.ts` spawns the REAL `os serve` against a temp app that carries the package in its own node_modules. Every existing multi-org test bypasses this path (cloud's dogfood suites pass `extraPlugins: [new OrganizationsPlugin()]`; the verify harness posture test mocks the module), which is why the defect survived. The new e2e fails against the bare import with the exact issue message and passes with the fix; a second case pins that the D5 fail-fast still fires when the app genuinely lacks the package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu
1 parent 742cebb commit 33e33f7

5 files changed

Lines changed: 476 additions & 19 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `objectstack serve` resolves the enterprise multi-org runtime from the app, not from the framework (cloud#1013)
6+
7+
Any self-hosted deployment that requested a walled tenancy posture
8+
(`OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=1`) refused
9+
to boot:
10+
11+
```
12+
✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations
13+
could not be loaded, so the organization wall is INACTIVE. Refusing to boot.
14+
cause: Cannot find package '@objectstack/organizations' imported from …/packages/cli/src/commands/serve.ts
15+
```
16+
17+
…however the package was installed. `serve` loaded it with a **bare**
18+
`import('@objectstack/organizations')`, and Node ESM resolves a bare specifier
19+
against the **importer's own realpath** — the CLI's, inside the framework
20+
workspace it is linked out of. `@objectstack/organizations` ships in the cloud
21+
distribution and lives in the *served app's* `node_modules`, so that import
22+
could never succeed and declaring the dependency in the app changed nothing. The
23+
only way past the ADR-0093 D5 fail-fast was `OS_ALLOW_DEGRADED_TENANCY=1`, i.e.
24+
booting with the organization wall inactive — exactly the state D5 exists to
25+
prevent.
26+
27+
The load now goes through the same host-app resolver `serve` already used for
28+
the AI service packages (`createHostImporter`, extracted to
29+
`src/utils/import-from-host.ts`): resolve from the host app's root, import the
30+
resolved path, and fall back to the CLI's own resolution only for the
31+
framework-owned packages the CLI itself depends on. **Declare
32+
`@objectstack/organizations` in your app's `package.json`** and a walled posture
33+
boots.
34+
35+
Two smaller changes ride along:
36+
37+
- A package the host resolves but that **throws while it loads** now propagates
38+
its real error instead of being re-imported bare and reported as
39+
`MODULE_NOT_FOUND` — a broken package used to be misreported as a missing one
40+
(silently skipped for optional services, or a fatal telling the operator to
41+
install what was already installed).
42+
- The D5 fatal now names *the app* as the place the package has to go.

packages/cli/src/commands/serve.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ 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';
2122
import {
2223
printHeader,
2324
printKV,
@@ -1528,6 +1529,28 @@ export default class Serve extends Command {
15281529
}
15291530
}
15301531

1532+
// Host-app package resolution — shared by every optional / enterprise
1533+
// package loaded from here down.
1534+
//
1535+
// Node ESM resolves a bare `import(pkg)` against the IMPORTER's own
1536+
// realpath. The CLI is reached through a workspace/`link:` dependency, so
1537+
// that realpath is inside the FRAMEWORK workspace: a bare import can only
1538+
// see what the framework itself installed. A package supplied by the app
1539+
// being served — a cloud-private one such as `@objectstack/organizations`,
1540+
// or anything a customer installs into their own project — is invisible
1541+
// to it no matter what the host app declares. Resolve from the host root
1542+
// instead; the CLI's own resolution stays as the fallback for the
1543+
// framework-owned packages the CLI depends on.
1544+
//
1545+
// Defined HERE, above the auth block, because the enterprise organizations
1546+
// load inside it needs it: this helper used to be declared *after* that
1547+
// block, so the organizations load fell back to a bare import, resolved in
1548+
// the framework workspace, never found the cloud-private package, and every
1549+
// walled-posture deployment hit the ADR-0093 D5 fail-fast and exited 1
1550+
// (cloud#1013).
1551+
const hostRequire = createHostRequire();
1552+
const importFromHost = createHostImporter(hostRequire);
1553+
15311554
// 5d. Auto-register AuthPlugin (and paired Security/Audit) when the
15321555
// 'auth' tier is enabled and no auth plugin is already configured.
15331556
// The Console expects /api/v1/auth/* to be served by better-auth via
@@ -1725,7 +1748,16 @@ export default class Serve extends Command {
17251748
if (multiTenant) {
17261749
try {
17271750
const organizationsPkg = '@objectstack/organizations';
1728-
const mod: any = await import(/* webpackIgnore: true */ organizationsPkg);
1751+
// Resolve from the HOST APP (cloud#1013). This package is
1752+
// cloud-private: it is installed in the served app's
1753+
// node_modules, never in the framework workspace the CLI's own
1754+
// realpath points at, so a bare import here could never find it
1755+
// — `objectstack serve` failed the fail-fast below on EVERY
1756+
// self-hosted walled-posture deployment, and the only way past
1757+
// it was OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled
1758+
// state D5 exists to prevent. The host app declares the package;
1759+
// this resolves it from there.
1760+
const mod: any = await importFromHost(organizationsPkg);
17291761
await kernel.use(new mod.OrganizationsPlugin());
17301762
trackPlugin('Organizations');
17311763
} catch (orgErr) {
@@ -1750,7 +1782,9 @@ export default class Serve extends Command {
17501782
' so the organization wall is INACTIVE. Refusing to boot — a deployment that requested\n' +
17511783
' multi-organization isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
17521784
' Fix one of:\n' +
1753-
' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' +
1785+
' • add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP\n' +
1786+
" — declare it in the app's package.json and install; the CLI resolves it from the\n" +
1787+
' app, not from the framework it is linked out of — or\n' +
17541788
" • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or\n" +
17551789
' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' +
17561790
` cause: ${cause}\n`,
@@ -1918,23 +1952,11 @@ export default class Serve extends Command {
19181952
(p: any) => p.name === 'com.objectstack.service-ai'
19191953
|| p.constructor?.name === 'AIServicePlugin'
19201954
);
1921-
// Resolve optional plugin packages from the HOST APP's context (the app
1922-
// being served declares them as deps — including private packages like
1955+
// `importFromHost` (declared above, before the auth block) resolves
1956+
// optional plugin packages from the HOST APP's context — the app being
1957+
// served declares them as deps, including private packages like
19231958
// @objectstack/service-ai-studio that the framework CLI itself does not
1924-
// depend on). A bare import would resolve relative to the CLI's location
1925-
// and miss a package linked into the app's node_modules. Falls back to a
1926-
// bare import for framework-owned packages.
1927-
const { createRequire: _createRequire } = await import('node:module');
1928-
const { pathToFileURL: _pathToFileURL } = await import('node:url');
1929-
const _nodePath = await import('node:path');
1930-
const _hostRequire = _createRequire(_nodePath.join(process.cwd(), 'package.json'));
1931-
const importFromHost = async (pkg: string): Promise<any> => {
1932-
try {
1933-
return await import(_pathToFileURL(_hostRequire.resolve(pkg)).href);
1934-
} catch {
1935-
return import(/* webpackIgnore: true */ pkg);
1936-
}
1937-
};
1959+
// depend on.
19381960
// [CE AI opt-in] Auto-register the headless AI service ONLY when the host
19391961
// app DECLARES the AI service (or the cloud AI Studio that builds on it).
19401962
// Declaration is the edition boundary: a Community-Edition app that omits
@@ -1947,7 +1969,7 @@ export default class Serve extends Command {
19471969
const hostDeclaresDependency = (pkg: string): boolean => {
19481970
try {
19491971
const hostPkg = JSON.parse(
1950-
_fs.readFileSync(_hostRequire.resolve('./package.json'), 'utf8'),
1972+
_fs.readFileSync(hostRequire.resolve('./package.json'), 'utf8'),
19511973
) as Record<string, Record<string, string> | undefined>;
19521974
return Boolean(
19531975
hostPkg.dependencies?.[pkg] ?? hostPkg.devDependencies?.[pkg]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* cloud#1013 — resolving a host-app package from the CLI.
5+
*
6+
* The defect: `serve` loaded `@objectstack/organizations` with a BARE
7+
* `import()`. Node ESM resolves that against the importer's own realpath — the
8+
* CLI's, inside the framework workspace — while the package is cloud-private
9+
* and only ever exists in the served app's `node_modules`. It could therefore
10+
* never resolve, and every walled tenancy posture died on the ADR-0093 D5
11+
* fail-fast.
12+
*
13+
* These cases run against a REAL fixture app on disk (a real `node_modules`,
14+
* real resolution, nothing mocked): the first two are the issue's own repro,
15+
* one half per case — the CLI's resolution cannot see the package, the host
16+
* app's can.
17+
*/
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
21+
import { tmpdir } from 'node:os';
22+
import { dirname, join, resolve } from 'node:path';
23+
import { fileURLToPath } from 'node:url';
24+
import { createHostImporter, createHostRequire } from './import-from-host.js';
25+
26+
/** The cloud-private package at the heart of cloud#1013. */
27+
const ORGANIZATIONS = '@objectstack/organizations';
28+
/** A package that fails while it EVALUATES — not while it resolves. */
29+
const BROKEN = '@fixture/throws-on-load';
30+
31+
/** `packages/cli` — what a bare `import()` inside the CLI resolves against. */
32+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
33+
34+
let hostRoot: string;
35+
36+
function writeFixturePackage(root: string, name: string, indexJs: string): void {
37+
const dir = join(root, 'node_modules', ...name.split('/'));
38+
mkdirSync(dir, { recursive: true });
39+
writeFileSync(
40+
join(dir, 'package.json'),
41+
JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', main: 'index.js' }),
42+
'utf8',
43+
);
44+
writeFileSync(join(dir, 'index.js'), indexJs, 'utf8');
45+
}
46+
47+
beforeAll(() => {
48+
// A host app exactly as the fix expects one: it DECLARES the enterprise
49+
// package and has it installed in its own node_modules. The framework
50+
// workspace the CLI lives in has neither.
51+
hostRoot = mkdtempSync(join(tmpdir(), 'os-import-from-host-'));
52+
writeFileSync(
53+
join(hostRoot, 'package.json'),
54+
JSON.stringify({
55+
name: 'host-app-fixture',
56+
type: 'module',
57+
dependencies: { [ORGANIZATIONS]: '*' },
58+
}),
59+
'utf8',
60+
);
61+
writeFixturePackage(
62+
hostRoot,
63+
ORGANIZATIONS,
64+
'export class OrganizationsPlugin { name = "com.objectstack.organizations"; }\n',
65+
);
66+
writeFixturePackage(hostRoot, BROKEN, 'throw new Error("fixture package exploded on import");\n');
67+
});
68+
69+
afterAll(() => {
70+
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
71+
});
72+
73+
describe('host-app package resolution (cloud#1013)', () => {
74+
it('the CLI\'s own resolution cannot see a host-only package — the defect', () => {
75+
// Literally the issue's repro, from `packages/cli`:
76+
// node -e "require.resolve('@objectstack/organizations')" -> MODULE_NOT_FOUND
77+
// A bare `import()` in serve.ts resolved from exactly here, which is why
78+
// declaring the dependency in the app changed nothing.
79+
expect(() => createHostRequire(CLI_ROOT).resolve(ORGANIZATIONS)).toThrow(
80+
/Cannot find module/,
81+
);
82+
});
83+
84+
it('resolves a package that exists ONLY in the host app', async () => {
85+
const importFromHost = createHostImporter(createHostRequire(hostRoot));
86+
const mod = await importFromHost(ORGANIZATIONS);
87+
// The export `serve` constructs: `new mod.OrganizationsPlugin()`.
88+
expect(typeof mod.OrganizationsPlugin).toBe('function');
89+
expect(new mod.OrganizationsPlugin().name).toBe('com.objectstack.organizations');
90+
});
91+
92+
it('falls back to the CLI\'s own resolution when the host cannot resolve', async () => {
93+
// Whatever the host app cannot see must still load from the CLI's own
94+
// dependencies — that fallback is what keeps every framework-owned load in
95+
// `serve` (plugin-auth, plugin-security, service-i18n, …) working exactly
96+
// as before. Modelled with a host `require` that resolves nothing, because
97+
// a real one cannot: vitest exports NODE_PATH into the test process, so
98+
// every package in the workspace store resolves from any directory.
99+
const blindHostRequire = {
100+
resolve(pkg: string): string {
101+
throw Object.assign(new Error(`Cannot find module '${pkg}'`), { code: 'MODULE_NOT_FOUND' });
102+
},
103+
} as unknown as NodeRequire;
104+
const mod = await createHostImporter(blindHostRequire)('chalk');
105+
expect(typeof mod.default.green).toBe('function');
106+
});
107+
108+
it('reports a package that neither can resolve as module-not-found', async () => {
109+
const importFromHost = createHostImporter(createHostRequire(hostRoot));
110+
// Callers classify "missing vs crashed" off this error (Serve.
111+
// isModuleNotFoundError), so the absent case must stay recognisable.
112+
await expect(importFromHost('@fixture/nowhere-at-all')).rejects.toThrow(
113+
/Cannot find (module|package)|Failed to (load|resolve)/,
114+
);
115+
});
116+
117+
it('propagates an evaluation crash instead of masking it as module-not-found', async () => {
118+
// A host-resolved package that THROWS while loading is a broken package,
119+
// not a missing one. Re-importing it bare (the shape this helper replaced)
120+
// would swap the real cause for a MODULE_NOT_FOUND, which every caller
121+
// reads as "not installed" — a crash silently downgraded to a skip, or a
122+
// fatal telling the operator to install what is already installed.
123+
const importFromHost = createHostImporter(createHostRequire(hostRoot));
124+
await expect(importFromHost(BROKEN)).rejects.toThrow(/fixture package exploded on import/);
125+
});
126+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Resolve optional packages from the **host app**, not from the CLI.
5+
*
6+
* Node ESM resolves a bare `import('pkg')` against the **importer's own
7+
* realpath**. The CLI is reached through a `link:`/workspace dependency, so its
8+
* realpath is inside the *framework* workspace — a bare import from
9+
* `packages/cli` can only ever see packages installed in the framework's own
10+
* `node_modules`. Every package that lives OUTSIDE that workspace and is
11+
* supplied by the app being served — a cloud-private package such as
12+
* `@objectstack/organizations` or `@objectstack/service-ai-studio`, or anything
13+
* a customer installs into their own project — is therefore invisible to a bare
14+
* import, no matter what the host app declares in its `package.json`
15+
* (cloud#1013: `objectstack serve` could never load the enterprise multi-org
16+
* runtime, so every self-hosted walled-posture deployment hit the ADR-0093 D5
17+
* fail-fast and exited 1).
18+
*
19+
* The fix is to resolve from the host app's root and import the resolved
20+
* absolute path. The CLI's own resolution stays as the fallback, for the
21+
* framework-owned packages the CLI itself depends on and the host does not
22+
* declare.
23+
*
24+
* Resolution failure is the ONLY thing that falls back. A package the host
25+
* resolves but that throws while it evaluates is a genuine crash and propagates
26+
* unchanged: re-importing it bare would replace the real cause with a
27+
* `MODULE_NOT_FOUND`, which every caller here classifies as "not installed" —
28+
* turning a broken package into a silent skip (or, on the organizations path,
29+
* into a fatal message telling the operator to install what is already there).
30+
*/
31+
32+
import { createRequire } from 'node:module';
33+
import { join } from 'node:path';
34+
import { pathToFileURL } from 'node:url';
35+
36+
/**
37+
* Imports a package as the host app would see it.
38+
*
39+
* `any` is the module namespace of a package this repo does not compile against
40+
* (it is not a dependency of the CLI at all) — every call site reads an export
41+
* off it dynamically, exactly as the bare `import()` it replaces did.
42+
*/
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
export type HostImporter = (pkg: string) => Promise<any>;
45+
46+
/**
47+
* A `require` anchored at the **host app's** `package.json` — i.e. the project
48+
* `objectstack serve` was invoked in, whose `node_modules` carries the packages
49+
* it declares.
50+
*
51+
* @param hostRoot Directory holding the host app's `package.json` (default: the
52+
* process CWD, which is where the CLI reads `objectstack.config.ts` from too).
53+
*/
54+
export function createHostRequire(hostRoot: string = process.cwd()): NodeRequire {
55+
return createRequire(join(hostRoot, 'package.json'));
56+
}
57+
58+
/**
59+
* Build an importer that resolves from the host app first, then falls back to
60+
* the CLI's own resolution.
61+
*
62+
* @param hostRequire Reuse an existing host `require` (callers usually also need
63+
* it to read the host `package.json`); defaults to one anchored at the CWD.
64+
*/
65+
export function createHostImporter(
66+
hostRequire: NodeRequire = createHostRequire(),
67+
): HostImporter {
68+
return async (pkg: string): Promise<any> => {
69+
let resolved: string;
70+
try {
71+
resolved = hostRequire.resolve(pkg);
72+
} catch {
73+
// Invisible to the host app — try the CLI's own dependencies. A package
74+
// neither can see throws MODULE_NOT_FOUND from here, which is what the
75+
// callers' "missing vs crashed" classification expects.
76+
return import(/* webpackIgnore: true */ pkg);
77+
}
78+
return import(pathToFileURL(resolved).href);
79+
};
80+
}

0 commit comments

Comments
 (0)