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
42 changes: 42 additions & 0 deletions .changeset/serve-organizations-host-resolution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/cli": patch
---

fix(cli): `objectstack serve` resolves the enterprise multi-org runtime from the app, not from the framework (cloud#1013)

Any self-hosted deployment that requested a walled tenancy posture
(`OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=1`) refused
to boot:

```
✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations
could not be loaded, so the organization wall is INACTIVE. Refusing to boot.
cause: Cannot find package '@objectstack/organizations' imported from …/packages/cli/src/commands/serve.ts
```

…however the package was installed. `serve` loaded it with a **bare**
`import('@objectstack/organizations')`, and Node ESM resolves a bare specifier
against the **importer's own realpath** — the CLI's, inside the framework
workspace it is linked out of. `@objectstack/organizations` ships in the cloud
distribution and lives in the *served app's* `node_modules`, so that import
could never succeed and declaring the dependency in the app changed nothing. The
only way past the ADR-0093 D5 fail-fast was `OS_ALLOW_DEGRADED_TENANCY=1`, i.e.
booting with the organization wall inactive — exactly the state D5 exists to
prevent.

The load now goes through the same host-app resolver `serve` already used for
the AI service packages (`createHostImporter`, extracted to
`src/utils/import-from-host.ts`): resolve from the host app's root, import the
resolved path, and fall back to the CLI's own resolution only for the
framework-owned packages the CLI itself depends on. **Declare
`@objectstack/organizations` in your app's `package.json`** and a walled posture
boots.

Two smaller changes ride along:

- A package the host resolves but that **throws while it loads** now propagates
its real error instead of being re-imported bare and reported as
`MODULE_NOT_FOUND` — a broken package used to be misreported as a missing one
(silently skipped for optional services, or a fatal telling the operator to
install what was already installed).
- The D5 fatal now names *the app* as the place the package has to go.
5 changes: 4 additions & 1 deletion content/docs/deployment/tenancy-modes.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,10 @@ The platform **refuses to boot** in this state:

Resolve it one of three ways:

- **install `@objectstack/organizations`** (the enterprise multi-org runtime); or
- **add `@objectstack/organizations`** (the enterprise multi-org runtime) **to the
app you are serving** — declare it in that app's `package.json` and install it
there. The CLI resolves the package from the served app, not from the framework
it is linked out of, so installing it anywhere else does not lift the guard; or
- **unset `OS_MULTI_ORG_ENABLED`** to run single-org; or
- **set `OS_ALLOW_DEGRADED_TENANCY=1`** to boot anyway in an explicitly degraded
single-org state.
Expand Down
60 changes: 41 additions & 19 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
import { createHostRequire, createHostImporter } from '../utils/import-from-host.js';
import {
printHeader,
printKV,
Expand DownExpand Up@@ -1528,6 +1529,28 @@ export default class Serve extends Command {
}
}

// Host-app package resolution — shared by every optional / enterprise
// package loaded from here down.
//
// Node ESM resolves a bare `import(pkg)` against the IMPORTER's own
// realpath. The CLI is reached through a workspace/`link:` dependency, so
// that realpath is inside the FRAMEWORK workspace: a bare import can only
// see what the framework itself installed. A package supplied by the app
// being served — a cloud-private one such as `@objectstack/organizations`,
// or anything a customer installs into their own project — is invisible
// to it no matter what the host app declares. Resolve from the host root
// instead; the CLI's own resolution stays as the fallback for the
// framework-owned packages the CLI depends on.
//
// Defined HERE, above the auth block, because the enterprise organizations
// load inside it needs it: this helper used to be declared *after* that
// block, so the organizations load fell back to a bare import, resolved in
// the framework workspace, never found the cloud-private package, and every
// walled-posture deployment hit the ADR-0093 D5 fail-fast and exited 1
// (cloud#1013).
const hostRequire = createHostRequire();
const importFromHost = createHostImporter(hostRequire);

// 5d. Auto-register AuthPlugin (and paired Security/Audit) when the
// 'auth' tier is enabled and no auth plugin is already configured.
// The Console expects /api/v1/auth/* to be served by better-auth via
Expand DownExpand Up@@ -1725,7 +1748,16 @@ export default class Serve extends Command {
if (multiTenant) {
try {
const organizationsPkg = '@objectstack/organizations';
const mod: any = await import(/* webpackIgnore: true */ organizationsPkg);
// Resolve from the HOST APP (cloud#1013). This package is
// cloud-private: it is installed in the served app's
// node_modules, never in the framework workspace the CLI's own
// realpath points at, so a bare import here could never find it
// — `objectstack serve` failed the fail-fast below on EVERY
// self-hosted walled-posture deployment, and the only way past
// it was OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled
// state D5 exists to prevent. The host app declares the package;
// this resolves it from there.
const mod: any = await importFromHost(organizationsPkg);
await kernel.use(new mod.OrganizationsPlugin());
trackPlugin('Organizations');
} catch (orgErr) {
Expand All@@ -1750,7 +1782,9 @@ export default class Serve extends Command {
' so the organization wall is INACTIVE. Refusing to boot — a deployment that requested\n' +
' multi-organization isolation must not serve traffic without it (ADR-0093 D5).\n\n' +
' Fix one of:\n' +
' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' +
' • add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP\n' +
" — declare it in the app's package.json and install; the CLI resolves it from the\n" +
' app, not from the framework it is linked out of — or\n' +
" • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or\n" +
' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' +
` cause: ${cause}\n`,
Expand DownExpand Up@@ -1918,23 +1952,11 @@ export default class Serve extends Command {
(p: any) => p.name === 'com.objectstack.service-ai'
|| p.constructor?.name === 'AIServicePlugin'
);
// Resolve optional plugin packages from the HOST APP's context (the app
// being served declares them as deps — including private packages like
// `importFromHost` (declared above, before the auth block) resolves
// optional plugin packages from the HOST APP's context — the app being
// served declares them as deps, including private packages like
// @objectstack/service-ai-studio that the framework CLI itself does not
// depend on). A bare import would resolve relative to the CLI's location
// and miss a package linked into the app's node_modules. Falls back to a
// bare import for framework-owned packages.
const { createRequire: _createRequire } = await import('node:module');
const { pathToFileURL: _pathToFileURL } = await import('node:url');
const _nodePath = await import('node:path');
const _hostRequire = _createRequire(_nodePath.join(process.cwd(), 'package.json'));
const importFromHost = async (pkg: string): Promise<any> => {
try {
return await import(_pathToFileURL(_hostRequire.resolve(pkg)).href);
} catch {
return import(/* webpackIgnore: true */ pkg);
}
};
// depend on.
// [CE AI opt-in] Auto-register the headless AI service ONLY when the host
// app DECLARES the AI service (or the cloud AI Studio that builds on it).
// Declaration is the edition boundary: a Community-Edition app that omits
Expand All@@ -1947,7 +1969,7 @@ export default class Serve extends Command {
const hostDeclaresDependency = (pkg: string): boolean => {
try {
const hostPkg = JSON.parse(
_fs.readFileSync(_hostRequire.resolve('./package.json'), 'utf8'),
_fs.readFileSync(hostRequire.resolve('./package.json'), 'utf8'),
) as Record<string, Record<string, string> | undefined>;
return Boolean(
hostPkg.dependencies?.[pkg] ?? hostPkg.devDependencies?.[pkg]
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/utils/import-from-host.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* cloud#1013 — resolving a host-app package from the CLI.
*
* The defect: `serve` loaded `@objectstack/organizations` with a BARE
* `import()`. Node ESM resolves that against the importer's own realpath — the
* CLI's, inside the framework workspace — while the package is cloud-private
* and only ever exists in the served app's `node_modules`. It could therefore
* never resolve, and every walled tenancy posture died on the ADR-0093 D5
* fail-fast.
*
* These cases run against a REAL fixture app on disk (a real `node_modules`,
* real resolution, nothing mocked): the first two are the issue's own repro,
* one half per case — the CLI's resolution cannot see the package, the host
* app's can.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHostImporter, createHostRequire } from './import-from-host.js';

/** The cloud-private package at the heart of cloud#1013. */
const ORGANIZATIONS = '@objectstack/organizations';
/** A package that fails while it EVALUATES — not while it resolves. */
const BROKEN = '@fixture/throws-on-load';

/** `packages/cli` — what a bare `import()` inside the CLI resolves against. */
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

let hostRoot: string;

function writeFixturePackage(root: string, name: string, indexJs: string): void {
const dir = join(root, 'node_modules', ...name.split('/'));
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', main: 'index.js' }),
'utf8',
);
writeFileSync(join(dir, 'index.js'), indexJs, 'utf8');
}

beforeAll(() => {
// A host app exactly as the fix expects one: it DECLARES the enterprise
// package and has it installed in its own node_modules. The framework
// workspace the CLI lives in has neither.
hostRoot = mkdtempSync(join(tmpdir(), 'os-import-from-host-'));
writeFileSync(
join(hostRoot, 'package.json'),
JSON.stringify({
name: 'host-app-fixture',
type: 'module',
dependencies: { [ORGANIZATIONS]: '*' },
}),
'utf8',
);
writeFixturePackage(
hostRoot,
ORGANIZATIONS,
'export class OrganizationsPlugin { name = "com.objectstack.organizations"; }\n',
);
writeFixturePackage(hostRoot, BROKEN, 'throw new Error("fixture package exploded on import");\n');
});

afterAll(() => {
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
});

describe('host-app package resolution (cloud#1013)', () => {
it('the CLI\'s own resolution cannot see a host-only package — the defect', () => {
// Literally the issue's repro, from `packages/cli`:
// node -e "require.resolve('@objectstack/organizations')" -> MODULE_NOT_FOUND
// A bare `import()` in serve.ts resolved from exactly here, which is why
// declaring the dependency in the app changed nothing.
expect(() => createHostRequire(CLI_ROOT).resolve(ORGANIZATIONS)).toThrow(
/Cannot find module/,
);
});

it('resolves a package that exists ONLY in the host app', async () => {
const importFromHost = createHostImporter(createHostRequire(hostRoot));
const mod = await importFromHost(ORGANIZATIONS);
// The export `serve` constructs: `new mod.OrganizationsPlugin()`.
expect(typeof mod.OrganizationsPlugin).toBe('function');
expect(new mod.OrganizationsPlugin().name).toBe('com.objectstack.organizations');
});

it('falls back to the CLI\'s own resolution when the host cannot resolve', async () => {
// Whatever the host app cannot see must still load from the CLI's own
// dependencies — that fallback is what keeps every framework-owned load in
// `serve` (plugin-auth, plugin-security, service-i18n, …) working exactly
// as before. Modelled with a host `require` that resolves nothing, because
// a real one cannot: vitest exports NODE_PATH into the test process, so
// every package in the workspace store resolves from any directory.
const blindHostRequire = {
resolve(pkg: string): string {
throw Object.assign(new Error(`Cannot find module '${pkg}'`), { code: 'MODULE_NOT_FOUND' });
},
} as unknown as NodeRequire;
const mod = await createHostImporter(blindHostRequire)('chalk');
expect(typeof mod.default.green).toBe('function');
});

it('reports a package that neither can resolve as module-not-found', async () => {
const importFromHost = createHostImporter(createHostRequire(hostRoot));
// Callers classify "missing vs crashed" off this error (Serve.
// isModuleNotFoundError), so the absent case must stay recognisable.
await expect(importFromHost('@fixture/nowhere-at-all')).rejects.toThrow(
/Cannot find (module|package)|Failed to (load|resolve)/,
);
});

it('propagates an evaluation crash instead of masking it as module-not-found', async () => {
// A host-resolved package that THROWS while loading is a broken package,
// not a missing one. Re-importing it bare (the shape this helper replaced)
// would swap the real cause for a MODULE_NOT_FOUND, which every caller
// reads as "not installed" — a crash silently downgraded to a skip, or a
// fatal telling the operator to install what is already installed.
const importFromHost = createHostImporter(createHostRequire(hostRoot));
await expect(importFromHost(BROKEN)).rejects.toThrow(/fixture package exploded on import/);
});
});
80 changes: 80 additions & 0 deletions packages/cli/src/utils/import-from-host.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Resolve optional packages from the **host app**, not from the CLI.
*
* Node ESM resolves a bare `import('pkg')` against the **importer's own
* realpath**. The CLI is reached through a `link:`/workspace dependency, so its
* realpath is inside the *framework* workspace — a bare import from
* `packages/cli` can only ever see packages installed in the framework's own
* `node_modules`. Every package that lives OUTSIDE that workspace and is
* supplied by the app being served — a cloud-private package such as
* `@objectstack/organizations` or `@objectstack/service-ai-studio`, or anything
* a customer installs into their own project — is therefore invisible to a bare
* import, no matter what the host app declares in its `package.json`
* (cloud#1013: `objectstack serve` could never load the enterprise multi-org
* runtime, so every self-hosted walled-posture deployment hit the ADR-0093 D5
* fail-fast and exited 1).
*
* The fix is to resolve from the host app's root and import the resolved
* absolute path. The CLI's own resolution stays as the fallback, for the
* framework-owned packages the CLI itself depends on and the host does not
* declare.
*
* Resolution failure is the ONLY thing that falls back. A package the host
* resolves but that throws while it evaluates is a genuine crash and propagates
* unchanged: re-importing it bare would replace the real cause with a
* `MODULE_NOT_FOUND`, which every caller here classifies as "not installed" —
* turning a broken package into a silent skip (or, on the organizations path,
* into a fatal message telling the operator to install what is already there).
*/

import { createRequire } from 'node:module';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

/**
* Imports a package as the host app would see it.
*
* `any` is the module namespace of a package this repo does not compile against
* (it is not a dependency of the CLI at all) — every call site reads an export
* off it dynamically, exactly as the bare `import()` it replaces did.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HostImporter = (pkg: string) => Promise<any>;

/**
* A `require` anchored at the **host app's** `package.json` — i.e. the project
* `objectstack serve` was invoked in, whose `node_modules` carries the packages
* it declares.
*
* @param hostRoot Directory holding the host app's `package.json` (default: the
* process CWD, which is where the CLI reads `objectstack.config.ts` from too).
*/
export function createHostRequire(hostRoot: string = process.cwd()): NodeRequire {
return createRequire(join(hostRoot, 'package.json'));
}

/**
* Build an importer that resolves from the host app first, then falls back to
* the CLI's own resolution.
*
* @param hostRequire Reuse an existing host `require` (callers usually also need
* it to read the host `package.json`); defaults to one anchored at the CWD.
*/
export function createHostImporter(
hostRequire: NodeRequire = createHostRequire(),
): HostImporter {
return async (pkg: string): Promise<any> => {
let resolved: string;
try {
resolved = hostRequire.resolve(pkg);
} catch {
// Invisible to the host app — try the CLI's own dependencies. A package
// neither can see throws MODULE_NOT_FOUND from here, which is what the
// callers' "missing vs crashed" classification expects.
return import(/* webpackIgnore: true */ pkg);
}
return import(pathToFileURL(resolved).href);
};
}
Loading
Loading