diff --git a/.changeset/host-app-resolver-shared.md b/.changeset/host-app-resolver-shared.md new file mode 100644 index 0000000000..f9d76f9a69 --- /dev/null +++ b/.changeset/host-app-resolver-shared.md @@ -0,0 +1,48 @@ +--- +"@objectstack/types": minor +"@objectstack/verify": minor +"@objectstack/cli": patch +--- + +fix(verify): resolve the enterprise organizations package from the HOST APP (#4700) + +`bootStack(app, { multiTenant: true })` — and therefore `objectstack verify +--multi-tenant` — could never load `@objectstack/organizations`. Node ESM +resolves a bare `import()` against the **importer's own realpath**, which for +`packages/verify` is inside the framework workspace, while the enterprise +package is cloud-private and only ever lives in the verified app's +`node_modules`. Every real host app fell into the catch and was told to +"Install/link it in this workspace" — about a package it had already installed. +Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed +that one call site and this issue tracked the two the sweep left behind. + +**New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` / +`createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts` +— where `@objectstack/verify` and the dogfood suite could not import it without +inverting the dependency direction — into a **node-only subpath export** of +`@objectstack/types`. One behaviour, one source; the CLI now consumes it and its +private copy is deleted. + +It is a subpath and **not** the root export because `@objectstack/types` is a +dependency of `@objectstack/hono` ("edge-compatible REST API server for +Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel` +boots on Workers. The root entry reaches zero `node:` builtins, and a Workers +bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two +entries as separate self-contained bundles (`splitting: false`), and a test +walks the root's import graph and fails on the first reachable `node:` +specifier, so the isolation is enforced rather than merely intended. Same +arrangement `@objectstack/metadata` already ships for its `./node` subpath. + +**New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names +the app whose `node_modules` supplies those optional packages — for a harness +booting an app that is not the working directory. + +**The dogfood multi-org gates had never run.** Two suites probed availability +with the same bare `import()` and so were **constant-false** — not "false +because absent" but false by construction, in every environment including the +cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and +the attachments cross-tenant isolation block had therefore never executed while +the suite reported green (Prime Directive #10, test-suite edition). They now +resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a +run is expected to ship the package — turning a silent skip into a loud failure, +so a run can no longer pass by quietly not running the gates it exists for. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 81ebf047a8..e81723799b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -18,7 +18,9 @@ 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'; +// Shared with @objectstack/verify and the dogfood multi-org probes (#4700) — +// node-only, hence the `/node` subpath rather than the edge-safe root export. +import { createHostRequire, createHostImporter } from '@objectstack/types/node'; import { printHeader, printKV, diff --git a/packages/cli/src/utils/import-from-host.ts b/packages/cli/src/utils/import-from-host.ts deleted file mode 100644 index cbadbcf6a5..0000000000 --- a/packages/cli/src/utils/import-from-host.ts +++ /dev/null @@ -1,80 +0,0 @@ -// 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/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 643c209f71..d6a18827d7 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -16,17 +16,18 @@ "@objectstack/example-showcase": "workspace:*", "@objectstack/mcp": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-email": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", - "@objectstack/platform-objects": "workspace:*", "@objectstack/service-analytics": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "@objectstack/verify": "workspace:*" }, "devDependencies": { diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index e88b0a467f..c6435d06a6 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -25,6 +25,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js'; +import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js'; const SYS = { isSystem: true } as const; const DAY_MS = 86_400_000; @@ -525,13 +526,11 @@ describe('attachments permission matrix (#2755)', () => { }); // ── (g) tenant isolation — enterprise multi-org boot ───────────────────── -const organizationsAvailable = await import(/* webpackIgnore: true */ '@objectstack/organizations') - .then(() => true) - .catch(() => false); -if (!organizationsAvailable) { - // eslint-disable-next-line no-console - console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the attachments multi-tenant block'); -} +// #4700: this probe was a bare `import()` resolved against this file's realpath +// inside the framework workspace, so it was constant-false and block (g) had +// never executed. Shared host-app resolution + a declarative switch now decide +// it; see `enterprise-organizations.ts`. +warnIfUnavailable('attachments multi-tenant block'); describe.skipIf(!organizationsAvailable)('attachments cross-tenant isolation (g)', () => { let stack: VerifyStack; diff --git a/packages/qa/dogfood/test/enterprise-organizations.test.ts b/packages/qa/dogfood/test/enterprise-organizations.test.ts new file mode 100644 index 0000000000..f533bc5ba0 --- /dev/null +++ b/packages/qa/dogfood/test/enterprise-organizations.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4700 — proof that the multi-org availability probe is no longer constant. + * + * The old probe answered "unavailable" in every environment because it resolved + * a cloud-private package against the framework workspace. Nothing detected that + * — a `describe.skipIf` that always skips leaves no trace beyond a warning line, + * and the suite stays green. The only way to know a capability probe works is to + * make it say BOTH things, on demand. + * + * So these cases build real host roots on disk (real `node_modules`, a real + * stand-in package, nothing mocked) and pin all three verdicts: available, + * unavailable, and declared-but-missing. + */ + +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 { probeOrganizations, MULTI_ORG_ENV, ORGANIZATIONS_PKG } from './enterprise-organizations.js'; + +let hostWithPkg: string; +let hostWithoutPkg: string; + +function writeHost(prefix: string, withPkg: boolean): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + name: 'dogfood-host-fixture', + private: true, + type: 'module', + ...(withPkg ? { dependencies: { [ORGANIZATIONS_PKG]: '*' } } : {}), + }), + 'utf8', + ); + if (withPkg) { + const pkgDir = join(dir, 'node_modules', ...ORGANIZATIONS_PKG.split('/')); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ + name: ORGANIZATIONS_PKG, + version: '0.0.0-fixture', + type: 'module', + main: 'index.js', + }), + 'utf8', + ); + writeFileSync( + join(pkgDir, 'index.js'), + 'export class OrganizationsPlugin { name = "com.objectstack.organizations"; }\n', + 'utf8', + ); + } + return dir; +} + +beforeAll(() => { + hostWithPkg = writeHost('os-dogfood-org-ok-', true); + hostWithoutPkg = writeHost('os-dogfood-org-missing-', false); +}); + +afterAll(() => { + for (const dir of [hostWithPkg, hostWithoutPkg]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('enterprise multi-org probe (#4700)', () => { + it('reports AVAILABLE when the package is installed in the host app', async () => { + // The verdict the old probe could never reach, no matter what any app or CI + // had installed. This is what makes `describe.skipIf(!organizationsAvailable)` + // a real gate rather than an unconditional skip. + const probe = await probeOrganizations(hostWithPkg, false); + expect(probe.available).toBe(true); + expect(probe.reason).toBeUndefined(); + }); + + it('reports UNAVAILABLE, with an actionable reason, when the app lacks it', async () => { + const probe = await probeOrganizations(hostWithoutPkg, false); + expect(probe.available).toBe(false); + // The reason has to name the switch, or the skip stays folklore. + expect(probe.reason).toContain(MULTI_ORG_ENV); + expect(probe.reason).toContain(hostWithoutPkg); + }); + + it('THROWS when the run declares the package but it is missing', async () => { + // The half that converts "silently green over gates that never ran" into a + // failure a CI operator cannot miss (Prime Directive #10 / "absence must be + // loud"). Without this, a cloud run that lost the package would look exactly + // like a cloud run that has it. + await expect(probeOrganizations(hostWithoutPkg, true)).rejects.toThrow( + new RegExp(`${MULTI_ORG_ENV}=1 declares`), + ); + }); + + it('does not throw when the run declares the package AND it is there', async () => { + await expect(probeOrganizations(hostWithPkg, true)).resolves.toEqual({ available: true }); + }); +}); diff --git a/packages/qa/dogfood/test/enterprise-organizations.ts b/packages/qa/dogfood/test/enterprise-organizations.ts new file mode 100644 index 0000000000..7b95b5764c --- /dev/null +++ b/packages/qa/dogfood/test/enterprise-organizations.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4700 — availability of the enterprise `@objectstack/organizations` package + * (ADR-0081 D2), for the dogfood gates that can only run multi-org. + * + * ── The defect this replaces ───────────────────────────────────────────────── + * + * Two dogfood files each carried their own copy of: + * + * const organizationsAvailable = await import('@objectstack/organizations') + * .then(() => true).catch(() => false); + * describe.skipIf(!organizationsAvailable)(...) + * + * Node ESM resolves a bare specifier against the IMPORTER's own realpath — + * `packages/qa/dogfood`, inside the framework workspace — while the package is + * cloud-private and lives in the host app's `node_modules`. The probe was + * therefore **constant-false**: not "false because the package is absent", but + * false by construction, in every environment, including the enterprise/cloud CI + * whose comment claimed it "runs this". Two whole multi-org gates — the #1994 + * cross-tenant RLS proof and the attachments cross-tenant isolation block — had + * never executed, and the suite was green the entire time. That is Prime + * Directive #10's "declared ≠ enforced" in its test-suite form, and a constant- + * false capability probe is strictly worse than no probe: it manufactures the + * appearance of coverage. + * + * ── What replaces it ───────────────────────────────────────────────────────── + * + * 1. **Resolve like production does** — through the shared host-app resolver + * (`@objectstack/types/node`), the same one `objectstack serve` (cloud#1013) + * and `bootStack` use. The probe can now be true, which it previously could + * not. + * + * 2. **Make the skip falsifiable** — `OS_TEST_MULTI_ORG_ENABLED=1` declares that + * this run is *supposed* to have the package. Declared-and-missing is then a + * hard, loud failure instead of a silent skip, so the run that intends to + * exercise these gates can no longer pass by quietly not running them + * ("Absence must be loud"). Undeclared-and-missing still skips, but the + * warning names the switch, so the skip is discoverable rather than folklore. + * + * The honest state after this change, stated plainly: in the FRAMEWORK repo the + * package genuinely is not installed, so these gates still skip here — that is + * correct and unavoidable for a cloud-private package. What changed is that the + * skip is now a fact about the environment rather than an artefact of the + * resolver, and a cloud/enterprise run that ships the package will actually + * execute the blocks (and will fail loudly if it thinks it ships the package but + * does not). + */ + +import { createHostImporter, createHostRequire } from '@objectstack/types/node'; + +/** The cloud-private enterprise package (ADR-0081 D2). */ +export const ORGANIZATIONS_PKG = '@objectstack/organizations'; + +/** + * Env switch declaring the enterprise package IS expected in this run. + * `OS_TEST_*` per the Prime Directive #9 test/CI-only shape, `_ENABLED` because + * it is a boolean opt-in. + */ +export const MULTI_ORG_ENV = 'OS_TEST_MULTI_ORG_ENABLED'; + +export interface OrganizationsProbe { + available: boolean; + /** Human-readable reason to log when unavailable. */ + reason?: string; +} + +/** + * Resolve the enterprise package the way the runtime does. + * + * @param hostRoot Root of the app under test (default: CWD, which is where the + * dogfood suite runs and where a linked enterprise package would be declared). + * @param declared Whether the run asserts the package is present; defaults to + * reading {@link MULTI_ORG_ENV}. When true, absence THROWS instead of skipping. + */ +export async function probeOrganizations( + hostRoot?: string, + declared: boolean = process.env[MULTI_ORG_ENV] === '1', +): Promise { + const importFromHost = createHostImporter(createHostRequire(hostRoot)); + try { + await importFromHost(ORGANIZATIONS_PKG); + return { available: true }; + } catch (e) { + const detail = (e as Error).message; + if (declared) { + throw new Error( + `${MULTI_ORG_ENV}=1 declares that ${ORGANIZATIONS_PKG} (enterprise, ADR-0081 D2) is ` + + `installed for this run, but it could not be resolved from ${hostRoot ?? process.cwd()}. ` + + 'Refusing to skip the multi-org dogfood gates silently: a run that believes it is ' + + 'exercising cross-tenant isolation and is not would report green over gates that ' + + `never executed. Install/link ${ORGANIZATIONS_PKG} into the app under test, or unset ` + + `${MULTI_ORG_ENV} to accept the skip. (${detail})`, + ); + } + return { + available: false, + reason: + `${ORGANIZATIONS_PKG} (enterprise) is not resolvable from ${hostRoot ?? process.cwd()} — ` + + `skipping the multi-org gate. Set ${MULTI_ORG_ENV}=1 in a run that ships the package to ` + + 'turn this skip into a failure. ' + + `(${detail})`, + }; + } +} + +/** + * Module-level verdict shared by the multi-org dogfood files. Throws at import + * time when {@link MULTI_ORG_ENV} is declared but the package is missing — the + * loud half of the contract. + */ +const probe = await probeOrganizations(); + +export const organizationsAvailable: boolean = probe.available; + +/** Log the skip once, naming the switch that makes it fail instead. */ +export function warnIfUnavailable(gate: string): void { + if (probe.available) return; + // eslint-disable-next-line no-console + console.warn(`[dogfood] ${gate}: ${probe.reason}`); +} diff --git a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts index c51c45c95b..0a451b1ffe 100644 --- a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts @@ -33,14 +33,16 @@ import { runRlsProofs, formatRlsReport, type RlsReport } from '@objectstack/veri // The multi-org runtime moved to the ENTERPRISE `@objectstack/organizations` // package (ADR-0081 D2) — not part of this open workspace. Skip (loudly) when // it isn't linked in; enterprise/cloud CI, which ships the package, runs this. -const organizationsPkg = '@objectstack/organizations'; -const organizationsAvailable = await import(/* webpackIgnore: true */ organizationsPkg) - .then(() => true) - .catch(() => false); -if (!organizationsAvailable) { - // eslint-disable-next-line no-console - console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the multi-org RLS gate'); -} +// +// #4700: the probe used to be a bare `import()`, which Node ESM resolves against +// this file's own realpath in the framework workspace — so it answered "not +// installed" unconditionally, everywhere, and this gate had never once run while +// the suite reported green. It now resolves from the host app like the runtime +// does, and `OS_TEST_MULTI_ORG_ENABLED=1` turns an unexpected skip into a +// failure. +import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js'; + +warnIfUnavailable('multi-org RLS gate'); describe.skipIf(!organizationsAvailable)('objectstack verify RLS: CRM multi-tenant (#1994 org-scoped)', () => { let stack: VerifyStack; diff --git a/packages/types/package.json b/packages/types/package.json index 326d52cffb..8eb712fde7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -10,10 +10,15 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.mjs", + "require": "./dist/node.js" } }, "scripts": { - "build": "tsup --config ../../tsup.config.ts", + "build": "tsup", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/packages/types/src/node-isolation.test.ts b/packages/types/src/node-isolation.test.ts new file mode 100644 index 0000000000..523969f990 --- /dev/null +++ b/packages/types/src/node-isolation.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4700 — the ROOT entry of `@objectstack/types` must stay free of `node:` + * builtins. + * + * `@objectstack/types` is a dependency of `@objectstack/hono` ("edge-compatible + * REST API server for Cloudflare Workers, Deno, Bun, and Node") and of the + * plugin/service layer a `LiteKernel` boots on Workers. Moving the host-app + * resolver (`node:module` / `node:url`) into this package is only safe because + * it sits behind the `./node` subpath export, which those consumers never + * import. + * + * That safety is an invariant, and an invariant nobody checks is a comment. The + * failure it guards against is silent and delayed: someone adds a `readFileSync` + * to a root-reachable file, every test here passes, every framework consumer + * passes, and the break surfaces as a Workers bundle failure in a downstream + * repo — the "declared ≠ enforced" shape of Prime Directive #10, one layer under + * the packaging. + * + * So this walks the real import graph from `src/index.ts` and fails on the first + * `node:` specifier it can reach. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * This package is CJS-typed (no `"type": "module"` — it publishes `dist/index.js` + * as CommonJS), so `module: NodeNext` forbids `import.meta` here. Walk up from + * the CWD to the package root instead, which works wherever vitest is invoked + * from. + */ +function findPackageRoot(): string { + let dir = process.cwd(); + for (;;) { + const manifest = join(dir, 'package.json'); + if (existsSync(manifest)) { + const { name } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string }; + if (name === '@objectstack/types') return dir; + } + const parent = dirname(dir); + if (parent === dir) throw new Error('could not locate the @objectstack/types package root'); + dir = parent; + } +} + +const PKG = findPackageRoot(); +const SRC = join(PKG, 'src'); + +/** `import ... from 'x'` / `export ... from 'x'` / `await import('x')`. */ +const SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g; + +function specifiersOf(file: string): string[] { + const src = readFileSync(file, 'utf8'); + const out: string[] = []; + for (const m of src.matchAll(SPECIFIER)) out.push(m[1]!); + return out; +} + +/** Resolve a relative TS import (`./x.js` → `src/x.ts`). */ +function resolveRelative(fromFile: string, spec: string): string | undefined { + const base = join(dirname(fromFile), spec); + for (const cand of [base.replace(/\.js$/, '.ts'), `${base}.ts`, join(base, 'index.ts')]) { + if (existsSync(cand)) return cand; + } + return undefined; +} + +/** Every source file reachable from an entry, following relative imports. */ +function reachableFrom(entry: string): Map { + const seen = new Map(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.shift()!; + if (seen.has(file)) continue; + const specs = specifiersOf(file); + seen.set(file, specs); + for (const spec of specs) { + if (!spec.startsWith('.')) continue; + const next = resolveRelative(file, spec); + if (next) queue.push(next); + } + } + return seen; +} + +describe('@objectstack/types — node-only code stays behind the ./node subpath (#4700)', () => { + it('nothing reachable from the root entry imports a `node:` builtin', () => { + const graph = reachableFrom(join(SRC, 'index.ts')); + const offenders: string[] = []; + for (const [file, specs] of graph) { + for (const spec of specs) { + if (spec.startsWith('node:')) offenders.push(`${file.slice(PKG.length + 1)} -> ${spec}`); + } + } + expect( + offenders, + 'The root export must stay edge/browser-safe — @objectstack/hono bundles it for ' + + 'Cloudflare Workers, Deno and Bun, where a `node:` builtin breaks the bundle even ' + + 'if it is never called. Put node-only code in src/node.ts (exported as ' + + '"@objectstack/types/node") instead.', + ).toEqual([]); + }); + + it('the root entry does not reach src/node.ts at all', () => { + const graph = reachableFrom(join(SRC, 'index.ts')); + expect( + [...graph.keys()].map((f) => f.slice(PKG.length + 1)), + 're-exporting the node slice from the root would defeat the subpath split', + ).not.toContain('src/node.ts'); + }); + + it('src/node.ts really is the node-only slice — otherwise this suite proves nothing', () => { + // Guards against the vacuous pass: if the resolver ever stopped using node + // builtins, the two cases above would go green for the wrong reason and the + // subpath would look justified when it no longer was. + const specs = specifiersOf(join(SRC, 'node.ts')); + expect(specs.filter((s) => s.startsWith('node:')).sort()).toEqual([ + 'node:module', + 'node:path', + 'node:url', + ]); + }); + + it('package.json publishes the ./node subpath', () => { + const pkg = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8')) as { + exports: Record>; + }; + expect(Object.keys(pkg.exports)).toContain('./node'); + expect(pkg.exports['./node']).toEqual({ + types: './dist/node.d.ts', + import: './dist/node.mjs', + require: './dist/node.js', + }); + }); +}); diff --git a/packages/cli/src/utils/import-from-host.test.ts b/packages/types/src/node.test.ts similarity index 60% rename from packages/cli/src/utils/import-from-host.test.ts rename to packages/types/src/node.test.ts index d28ea47c85..6f25b23c83 100644 --- a/packages/cli/src/utils/import-from-host.test.ts +++ b/packages/types/src/node.test.ts @@ -1,35 +1,43 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * cloud#1013 — resolving a host-app package from the CLI. + * cloud#1013 / #4700 — resolving a host-app package from a framework package. * * 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. + * framework package's, inside the framework workspace — while the package is + * cloud-private and only ever exists in the host app's `node_modules`. It could + * therefore never resolve, and every walled tenancy posture died on the ADR-0093 + * D5 fail-fast. #4700 found the same bare import in two more framework packages + * (`@objectstack/verify`'s `bootStack`, the dogfood multi-org probes), which is + * why the resolver moved here from `packages/cli/src/utils/import-from-host.ts`: + * one behaviour, one source. * * 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. + * one half per case — the framework package'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'; +import { join } from 'node:path'; +import { createHostImporter, createHostRequire } from './node.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)), '../..'); +/** + * A directory inside the framework workspace — what a bare `import()` from a + * framework package resolves against. (`import.meta` is unavailable here: this + * package is CJS-typed, and `module: NodeNext` forbids it. The CWD is the + * package root under vitest, and the assertion holds for any framework + * directory anyway — none of them can see a cloud-private package.) + */ +const PACKAGE_ROOT = process.cwd(); let hostRoot: string; @@ -47,7 +55,7 @@ function writeFixturePackage(root: string, name: string, indexJs: string): void 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. + // workspace this package lives in has neither. hostRoot = mkdtempSync(join(tmpdir(), 'os-import-from-host-')); writeFileSync( join(hostRoot, 'package.json'), @@ -70,13 +78,13 @@ 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`: +describe('host-app package resolution (cloud#1013, #4700)', () => { + it("the framework package's own resolution cannot see a host-only package — the defect", () => { + // Literally the issue's repro, from a framework package: // 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( + // A bare `import()` in serve.ts / harness.ts resolved from exactly here, + // which is why declaring the dependency in the app changed nothing. + expect(() => createHostRequire(PACKAGE_ROOT).resolve(ORGANIZATIONS)).toThrow( /Cannot find module/, ); }); @@ -84,25 +92,26 @@ describe('host-app package resolution (cloud#1013)', () => { 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()`. + // The export `serve` and `bootStack` construct: `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. + it("falls back to the importing package's own resolution when the host cannot resolve", async () => { + // Whatever the host app cannot see must still load from the framework + // package's own dependencies — that fallback is what keeps every + // framework-owned load in `serve` (plugin-auth, plugin-security, + // service-i18n, …) and in `bootStack` 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'); + const mod = await createHostImporter(blindHostRequire)('@objectstack/spec'); + expect(mod).toBeTypeOf('object'); }); it('reports a package that neither can resolve as module-not-found', async () => { diff --git a/packages/types/src/node.ts b/packages/types/src/node.ts new file mode 100644 index 0000000000..4f68d88f50 --- /dev/null +++ b/packages/types/src/node.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/types/node` — the **node-only** slice of the shared utilities. + * + * WHY A SUBPATH AND NOT THE ROOT EXPORT. `@objectstack/types` is a dependency of + * `@objectstack/hono`, whose whole reason to exist is "edge-compatible REST API + * server for Cloudflare Workers, Deno, Bun, and Node" — and of the plugin/service + * layer a `LiteKernel` boots on Workers. The root entry (`src/index.ts`) reaches + * **zero** `node:` builtins today, and that is a property those consumers depend + * on: a Workers bundle that pulls in `node:module` fails to build (or dies at + * first call) even when nothing ever invokes it. Everything here needs + * `node:module` / `node:url` by definition — it exists to drive Node's own + * resolver — so it lives behind its own entry point instead. + * + * The isolation is structural, not conventional: `tsup` builds `src/index.ts` and + * `src/node.ts` as separate entries with `splitting: false`, so the root bundle + * contains no reference to this file, and `node-isolation.test.ts` fails the + * build if anything reachable from the root ever imports a `node:` builtin. Same + * arrangement `@objectstack/metadata` already ships for `./node`. + * + * ── What lives here ────────────────────────────────────────────────────────── + * + * Resolving optional packages from the **host app**, not from the framework + * package doing the importing. + * + * Node ESM resolves a bare `import('pkg')` against the **importer's own + * realpath**. Framework packages (the CLI, `@objectstack/verify`, + * `@objectstack/dogfood`) are reached through `link:`/workspace dependencies, so + * their realpath is inside the *framework* workspace — a bare import from any of + * them 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, verified or tested — 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; framework#4700: `bootStack({ multiTenant: true })` told + * apps to install a package they had already installed, and the dogfood + * multi-org probes were constant-false). + * + * The fix is to resolve from the host app's root and import the resolved + * absolute path. The importing package's own resolution stays as the fallback, + * for the framework-owned packages it 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 importing package 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, or the app `bootStack` is verifying, 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 importing package'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 importing package'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/types/tsup.config.ts b/packages/types/tsup.config.ts new file mode 100644 index 0000000000..e2c6cde48b --- /dev/null +++ b/packages/types/tsup.config.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'tsup'; + +/** + * Two entries, deliberately. `src/index.ts` is the edge/browser-safe root that + * `@objectstack/hono` (Cloudflare Workers, Deno, Bun) and the Worker-bootable + * plugin layer consume; `src/node.ts` is the node-only slice + * (`node:module` / `node:url`) behind the `./node` subpath export. + * + * `splitting: false` is what makes the isolation real rather than nominal: each + * entry is emitted as a self-contained bundle, so nothing the root pulls in can + * drag a `node:` builtin along through a shared chunk. `node-isolation.test.ts` + * pins the source-level half of the same invariant. + * + * (Identical shape to `packages/metadata/tsup.config.ts`, which already ships a + * `./node` subpath this way. The only reason this file exists at all — rather + * than the shared `../../tsup.config.ts` — is the second entry.) + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/node.ts'], + splitting: false, + sourcemap: true, + clean: true, + dts: !process.env.OS_SKIP_DTS, + format: ['esm', 'cjs'], + target: 'es2020', +}); diff --git a/packages/verify/package.json b/packages/verify/package.json index 83f28e8f53..7e0b29cc86 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -33,7 +33,8 @@ "@objectstack/service-automation": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-settings": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/packages/verify/src/harness.host-resolution.test.ts b/packages/verify/src/harness.host-resolution.test.ts new file mode 100644 index 0000000000..8a38492a7d --- /dev/null +++ b/packages/verify/src/harness.host-resolution.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4700 — `bootStack({ multiTenant: true })` must load the enterprise multi-org + * runtime from the HOST APP. + * + * The defect: the organizations load used a bare `import()`, which Node ESM + * resolves against the importer's own realpath — `packages/verify`'s, inside the + * framework workspace. `@objectstack/organizations` is cloud-private and only + * ever lives in the verified app's `node_modules`, so the import could never + * succeed: `objectstack verify --multi-tenant` (and every programmatic + * `bootStack(app, { multiTenant: true })`) fell into the catch and told the + * operator to "Install/link it in this workspace" — about a package the app had + * already installed. Same defect class as cloud#1013, one package over. + * + * WHY THIS FILE EXISTS ALONGSIDE `harness.posture.test.ts`. That file proves the + * POSTURE semantics and reaches the plugin through `vi.mock`, which substitutes + * the module registry and therefore bypasses resolution entirely — the one thing + * that was broken. A mocked import cannot fail the way the real one did, so the + * defect was invisible to it (exactly how it survived #4699's sweep of `serve`). + * These cases use a real temp app directory with a real `node_modules` and a + * real stand-in package on disk, and mock nothing. + * + * 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. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootStack } from './harness'; + +/** + * 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); + } +} +`; + +const app = { + manifest: { + id: 'com.example.hostres', + namespace: 'hostres', + version: '0.0.1', + type: 'app', + name: 'Host Resolution Fixture', + }, + objects: [], +}; + +interface TenancyShape { + posture: string; + requestedPosture: string; + isolationActive: boolean; +} + +/** A host app with the enterprise package installed — the supported shape. */ +let appWithPackage: string; +/** The same app WITHOUT it — the hard error must still fire. */ +let appWithoutPackage: string; + +function writeApp(prefix: string, opts: { withOrganizations: boolean }): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'hostres-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-verify-org-host-ok-', { withOrganizations: true }); + appWithoutPackage = writeApp('os-verify-org-host-missing-', { withOrganizations: false }); +}); + +afterAll(() => { + for (const dir of [appWithPackage, appWithoutPackage]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +afterEach(() => { + delete process.env.OS_TENANCY_POSTURE; +}); + +// Each case boots the full in-process stack — well beyond the 5s default. +const BOOT_TIMEOUT = 120_000; + +describe('bootStack multiTenant — host-app package resolution (#4700)', () => { + it( + 'mounts the enterprise plugin installed in the APP, not in the framework workspace', + async () => { + // Before the fix this rejected with "requires the enterprise + // @objectstack/organizations package … Install/link it in this + // workspace" — for an app that plainly has it installed. + const stack = await bootStack(app as never, { + multiTenant: true, + hostRoot: appWithPackage, + }); + try { + // The plugin really mounted: `org-scoping` is registered ONLY by the + // app-supplied package, and the walled posture it entitles is active. + await expect(stack.kernel.getServiceAsync('org-scoping')).resolves.toBeDefined(); + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); + + it( + 'still fails hard when the app does not ship the package', + async () => { + // The other half of the contract: the fix must not turn the explicit + // opt-in into a lenient single-tenant downgrade. An app that asks for + // multi-tenant without the enterprise runtime must still throw rather + // than boot with every tenant policy stripped — which is what a fixture + // would then assert its authorization model against. + await expect( + bootStack(app as never, { multiTenant: true, hostRoot: appWithoutPackage }), + ).rejects.toThrow(/requires the enterprise @objectstack\/organizations/); + // The posture env is restored even on the failure path. + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); + + it( + 'names the app directory in the remedy, because that is where the package has to go', + async () => { + // The old message said "Install/link it in this workspace", which pointed + // at the framework checkout — the one place installing it would NOT have + // helped. An operator who followed it verbatim could not succeed. + await expect( + bootStack(app as never, { multiTenant: true, hostRoot: appWithoutPackage }), + ).rejects.toThrow(new RegExp(`Install/link it in THIS APP \\(${appWithoutPackage}\\)`)); + }, + BOOT_TIMEOUT, + ); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 7ba38d0c14..efe9b98fb4 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -30,6 +30,10 @@ import { SharingServicePlugin } from '@objectstack/plugin-sharing'; import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +// Node-only subpath (#4700). Optional packages supplied by the app under +// verification — `@objectstack/organizations` above all — must be resolved from +// THAT app, not from `packages/verify`'s own realpath inside this workspace. +import { createHostImporter, createHostRequire } from '@objectstack/types/node'; /** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ interface InjectableApp { @@ -100,6 +104,22 @@ export interface BootOptions { * entitles a walled posture but no longer activates one by itself. */ multiTenant?: boolean; + /** + * Root directory of the **host app** being verified — the one whose + * `node_modules` carries the optional packages it declares (currently the + * enterprise `@objectstack/organizations` that `multiTenant` needs). + * + * Defaults to `process.cwd()`, which is where `objectstack verify` already + * reads `objectstack.config.ts` from. Set it when booting an app that is not + * the current working directory — a programmatic harness verifying several + * apps in one process, or a test fixture on a temp path. + * + * Exists because Node ESM resolves a bare `import()` against the importer's + * own realpath: without a host anchor, `packages/verify` can only ever see the + * framework's own `node_modules`, so an app-installed package was invisible no + * matter what the app declared (#4700, same defect class as cloud#1013). + */ + hostRoot?: string; /** * Register `@objectstack/service-automation` so authored flows execute against * the real stack. The plugin seeds the built-in node executors and, at start(), @@ -267,15 +287,23 @@ export async function bootStack( // so a missing package is a hard, actionable error — not a silent // single-org downgrade that would flip the fixture's RLS posture. if (opts.multiTenant) { + // #4700: this used a bare `import()`, which Node ESM resolves against the + // IMPORTER's realpath — `packages/verify`, inside the framework workspace. + // `@objectstack/organizations` is cloud-private and only ever lives in the + // host app's `node_modules`, so the import could never succeed and the + // message below fired at apps that had already installed the package, + // telling them to install it again. Resolve from the host app (the project + // `objectstack verify` runs in) and fall back to this package's own + // resolution — the same helper `objectstack serve` uses (cloud#1013). const organizationsPkg = '@objectstack/organizations'; let mod: any; try { - mod = await import(/* webpackIgnore: true */ organizationsPkg); + mod = await createHostImporter(createHostRequire(opts.hostRoot))(organizationsPkg); } catch (e) { restoreTenancyPosture(); throw new Error( 'verify: multiTenant=true requires the enterprise @objectstack/organizations package (migrated from plugin-org-scoping, ADR-0081 D2). ' + - `Install/link it in this workspace to run multi-org fixtures. (${(e as Error).message})`, + `Install/link it in THIS APP (${opts.hostRoot ?? process.cwd()}) to run multi-org fixtures. (${(e as Error).message})`, ); } await kernel.use(new mod.OrganizationsPlugin()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c37d5ab33..dae7bbe9ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1683,6 +1683,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types '@objectstack/verify': specifier: workspace:* version: link:../../verify @@ -2424,6 +2427,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types devDependencies: '@types/node': specifier: ^26.1.2