From 33e33f7466566cc79f4716c60f69587d77c00231 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:59:25 +0000 Subject: [PATCH 1/2] fix(cli): resolve @objectstack/organizations from the host app (cloud#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu --- .../serve-organizations-host-resolution.md | 42 ++++ packages/cli/src/commands/serve.ts | 60 ++++-- .../cli/src/utils/import-from-host.test.ts | 126 ++++++++++++ packages/cli/src/utils/import-from-host.ts | 80 ++++++++ ...-organizations-host-resolution.e2e.test.ts | 187 ++++++++++++++++++ 5 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 .changeset/serve-organizations-host-resolution.md create mode 100644 packages/cli/src/utils/import-from-host.test.ts create mode 100644 packages/cli/src/utils/import-from-host.ts create mode 100644 packages/cli/test/serve-organizations-host-resolution.e2e.test.ts diff --git a/.changeset/serve-organizations-host-resolution.md b/.changeset/serve-organizations-host-resolution.md new file mode 100644 index 0000000000..2b05e84158 --- /dev/null +++ b/.changeset/serve-organizations-host-resolution.md @@ -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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 170bccd206..81ebf047a8 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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, @@ -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 @@ -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) { @@ -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`, @@ -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 => { - 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 @@ -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 | undefined>; return Boolean( hostPkg.dependencies?.[pkg] ?? hostPkg.devDependencies?.[pkg] diff --git a/packages/cli/src/utils/import-from-host.test.ts b/packages/cli/src/utils/import-from-host.test.ts new file mode 100644 index 0000000000..d28ea47c85 --- /dev/null +++ b/packages/cli/src/utils/import-from-host.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/src/utils/import-from-host.ts b/packages/cli/src/utils/import-from-host.ts new file mode 100644 index 0000000000..cbadbcf6a5 --- /dev/null +++ b/packages/cli/src/utils/import-from-host.ts @@ -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; + +/** + * 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 => { + 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); + }; +} diff --git a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts new file mode 100644 index 0000000000..d4c6a2eb6e --- /dev/null +++ b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cloud#1013 — `os serve` must load the enterprise multi-org runtime from the + * HOST APP, over the REAL CLI process. + * + * The defect: the organizations load used a bare `import()`, which Node ESM + * resolves against the importer's own realpath — the CLI's, inside the + * framework workspace. `@objectstack/organizations` is cloud-private and only + * ever lives in the served app's `node_modules`, so the import could never + * succeed: EVERY self-hosted deployment with `OS_TENANCY_POSTURE=group` or + * `isolated` hit the ADR-0093 D5 fail-fast and exited 1, and the only way past + * it was `OS_ALLOW_DEGRADED_TENANCY=1` — i.e. the unwalled state D5 exists to + * prevent. + * + * WHY THIS FILE SPAWNS THE CLI. The defect survived because every test of the + * walled postures hands the plugin in as `extraPlugins: [new + * OrganizationsPlugin()]` (the cloud showcase dogfood suites) or mocks the + * module (`packages/verify`'s posture test). Both bypass the CLI's own + * resolution — the one thing that was broken. Only a test that runs `serve` + * against a real app directory, with a real package in a real `node_modules` + * and nothing mocked, exercises it. + * + * The fixture stands in for the enterprise package (it is not installable in + * this workspace — that is the whole point), registering the same `org-scoping` + * service and posture entitlement the real one does. What is under test here is + * RESOLUTION, not the enterprise semantics: proof that the real plugin walls + * tenants lives in cloud's security-enterprise multi-org integration test. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runServe, randomPort } from './helpers/serve-process.js'; + +const CONFIG = ` +export default { + manifest: { + id: 'com.example.orghost', + namespace: 'orghost', + version: '1.0.0', + type: 'app', + name: 'Organizations Host-Resolution Fixture', + }, + objects: [{ + name: 'orghost_task', + label: 'Task', + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + }, + }], +}; +`; + +/** + * Stand-in for `@objectstack/organizations`. Mirrors the real plugin's + * open-core-visible contract: the `org-scoping` service name plugin-security + * probes to keep (vs strip) the wildcard `organization_id` RLS policies, and + * the ADR-0105 D12 posture entitlement open core reads off that service. + */ +const FAKE_ORGANIZATIONS = ` +export class OrganizationsPlugin { + name = 'com.objectstack.organizations'; + type = 'standard'; + version = '0.0.0-fixture'; + supportedPostures = ['group', 'isolated']; + async init(ctx) { + ctx.registerService('org-scoping', this); + } +} +`; + +/** A host app with the enterprise package installed — the supported shape. */ +let appWithPackage: string; +/** The same app WITHOUT it — the fail-fast must still fire. */ +let appWithoutPackage: string; + +function writeApp(prefix: string, opts: { withOrganizations: boolean }): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'orghost-fixture', + private: true, + type: 'module', + ...(opts.withOrganizations + ? { dependencies: { '@objectstack/organizations': '*' } } + : {}), + }, + null, + 2, + ), + 'utf8', + ); + if (opts.withOrganizations) { + const pkgDir = join(dir, 'node_modules', '@objectstack', 'organizations'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ + name: '@objectstack/organizations', + version: '0.0.0-fixture', + type: 'module', + main: 'index.js', + }), + 'utf8', + ); + writeFileSync(join(pkgDir, 'index.js'), FAKE_ORGANIZATIONS, 'utf8'); + } + return dir; +} + +beforeAll(() => { + appWithPackage = writeApp('os-org-host-ok-', { withOrganizations: true }); + appWithoutPackage = writeApp('os-org-host-missing-', { withOrganizations: false }); +}); + +afterAll(() => { + for (const dir of [appWithPackage, appWithoutPackage]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Auth must be wired for the organizations block to be reached at all. */ +const SERVE_ENV = { + OS_AUTH_SECRET: 'org-host-resolution-e2e-secret', + OS_TENANCY_POSTURE: 'isolated', +}; + +describe('os serve — enterprise organizations resolution (cloud#1013)', () => { + it( + 'boots a walled posture with the package installed in the APP, not the framework', + async () => { + const port = randomPort(); + const { stdout, stderr } = await runServe(appWithPackage, ['--port', port], { + waitFor: /Press Ctrl\+C to stop/, + env: { ...SERVE_ENV }, + timeoutMs: 240_000, + }); + + const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; + + // The load-bearing assertion. Before the fix, the bare import resolved + // from the CLI's realpath in the framework workspace, threw + // MODULE_NOT_FOUND, and this boot died on the D5 fail-fast instead of + // reaching the banner at all. + expect(stderr, `the D5 fail-fast fired — the app-installed package was not found${seen}`) + .not.toMatch(/could not be loaded/); + expect(stdout, `serve never reached its banner${seen}`).toContain('Press Ctrl+C to stop'); + // …and it is the APP's package that got mounted: `Organizations` is + // tracked only on the path that actually registered the plugin. + expect(stdout, `OrganizationsPlugin was not registered${seen}`).toContain('Organizations'); + }, + 300_000, + ); + + it( + 'still refuses to boot a walled posture when the app does not ship the package', + async () => { + // The other half of the contract: the fix must not turn the ADR-0093 D5 + // fail-fast into a lenient skip. An app that requests isolation without + // the enterprise runtime must still die rather than serve traffic with + // the organization wall inactive. + const port = randomPort(); + const { stdout, stderr } = await runServe(appWithoutPackage, ['--port', port], { + waitFor: /Press Ctrl\+C to stop/, + env: { ...SERVE_ENV }, + timeoutMs: 240_000, + }); + + const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; + expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch( + /FATAL: tenancy posture 'isolated' was requested/, + ); + // The remedy names the app, because that is where the package has to go. + expect(stderr).toMatch(/to THIS APP/); + expect(stdout, `serve served traffic without the wall${seen}`).not.toContain( + 'Press Ctrl+C to stop', + ); + }, + 300_000, + ); +}); From 0f223592f195dfaa7ec1d198e0acbe4c96ae46b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:04:22 +0000 Subject: [PATCH 2/2] docs(deployment): name the app as where @objectstack/organizations must be installed (cloud#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D5 boot-guard remediation list said "install @objectstack/organizations", which was un-actionable while the CLI resolved the package against its own realpath — installing it ANYWHERE did not lift the guard. Now that `serve` resolves from the host app, the location is exact and load-bearing, so the bullet says it: declare it in the served app's package.json. Same wording as the fatal message the CLI prints, so the doc and the terminal agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu --- content/docs/deployment/tenancy-modes.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 1e5a197f51..44214b1e05 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -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.