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/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. 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, + ); +});