From bf2947b9daf1bf97e631565be5cdb8f95d0e140e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 03:55:51 +0000 Subject: [PATCH 1/2] wip: interpolate ORGANIZATIONS_RUNTIME_PKG into serve's operator-facing prose --- ...rve-organizations-message-spelling.test.ts | 202 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 183 +++++++++++----- 2 files changed, 334 insertions(+), 51 deletions(-) create mode 100644 packages/cli/src/commands/serve-organizations-message-spelling.test.ts diff --git a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts new file mode 100644 index 0000000000..802832abdd --- /dev/null +++ b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The operator-facing prose that names the multi-org runtime spells it from the + * ONE declaration, and these pins read what it RENDERS (#12151). + * + * ── What this closes ───────────────────────────────────────────────────── + * + * #11614 moved the package name `serve` RESOLVES onto + * `Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the + * spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster + * (`serve-capability-vocabulary.test.ts`). It deliberately stopped there. The + * sentences an operator actually reads still carried their own copies of the + * literal, under no check at all — so a roster-key rename would leave the + * install remedy, the ADR-0093 D5 fatal refusal, the degraded-boot warning, the + * stage-2 mount refusal and the posture description naming a package that no + * longer exists, while boot reached for the new one. Every gate stays green + * through that: the roster pin only ever sees the declaration. + * + * ── Why it reads the RENDERED text, not the source ─────────────────────── + * + * "No bare literal outside the declaration" is the tempting stronger form and + * is deliberately NOT built here: a source scan has to exclude comments — three + * comments in `serve.ts` legitimately name the package — and that shape is easy + * to get wrong. What matters is what reaches the operator, so each pin renders + * a message and compares the affected LINE, whitespace included, against text + * built from the constant. That is also the half that makes the interpolation + * safe: two of these five sit on a fatal path an operator reads at the worst + * possible moment, where a stray space or a lost backtick is a real regression, + * and a `toBe` on the rendered line is what turns "eyeballed" into "checked". + * + * The expectations are built from `Serve.ORGANIZATIONS_RUNTIME_PKG` rather than + * from a literal on purpose. Rename the roster key and these keep passing — + * the prose moved with it. Re-spell it inline in any of these messages and the + * rendered line stops matching the declaration, which is the exact drift this + * file exists to catch. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { HostDeclaration } from '@objectstack/types/node'; + +import Serve, { + formatDegradedTenancyWarning, + formatOrganizationsAbsentFatal, + formatOrganizationsInstallRemedy, + formatOrganizationsMountFatal, + resolveTenancyPostureOrRefusal, +} from './serve.js'; + +/** The one declaration. Every expectation below is built from THIS, never from a literal. */ +const PKG = Serve.ORGANIZATIONS_RUNTIME_PKG; + +/** + * `chalk` may or may not emit SGR codes depending on TTY detection. + * + * The escape is written as `\x1b`, never as the byte itself: one raw control + * character makes grep treat the whole file as binary, and a test file nobody's + * `git grep` can find is a test file that stops being maintained (#4890/#5157). + */ +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); +const lines = (s: string) => plain(s).split('\n'); + +/** + * Every `@scope/name` the RENDERED text puts in front of an operator. + * + * Reading the rendering, not the source, is the whole point — a comment naming + * the package is invisible here, which is why this can be a total sweep instead + * of a scan with exclusions to get wrong. + */ +const scopedNamesIn = (rendered: string): string[] => + plain(rendered).match(/@[a-z0-9-]+\/[a-z0-9._-]+/g) ?? []; + +const DECLARED: HostDeclaration = { + packageName: PKG, + hostRoot: '/srv/app', + declared: true, + field: 'dependencies', + specifier: '^1.2.3', +}; +const UNDECLARED: HostDeclaration = { packageName: PKG, hostRoot: '/srv/app', declared: false }; + +const remedyDeclared = () => formatOrganizationsInstallRemedy('declared-unresolvable', DECLARED, '/srv/app'); +const remedyUndeclared = () => formatOrganizationsInstallRemedy('undeclared', UNDECLARED, '/srv/app'); + +describe('serve — the multi-org runtime name an operator READS comes from the declaration (#12151)', () => { + it('site 1 — the "install is broken" remedy names it, with the spacing intact', () => { + // The `declared-unresolvable` branch: the app's package.json IS correct and + // the install is what broke (#4719). One of the two an operator ACTS on. + expect(lines(remedyDeclared())[0]).toBe( + ` • this app DECLARES ${PKG} (dependencies: "^1.2.3") — the`, + ); + }); + + it('site 2 — the "add it to THIS APP" remedy names it, with the spacing intact', () => { + // The other instruction an operator acts on: the app never declared it. + expect(lines(remedyUndeclared())[0]).toBe( + ` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`, + ); + }); + + it('site 3 — the ADR-0093 D5 fatal refusal names it', () => { + const fatal = formatOrganizationsAbsentFatal('isolated', remedyUndeclared(), 'Cannot find package'); + // A leading blank line separates the refusal from whatever boot last printed. + expect(lines(fatal)[0]).toBe(''); + expect(lines(fatal)[1]).toBe( + ` ✖ FATAL: tenancy posture 'isolated' was requested but ${PKG} could not be loaded,`, + ); + }); + + it('site 4 — the degraded-boot warning names it', () => { + expect(plain(formatDegradedTenancyWarning('isolated'))).toBe( + ` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture 'isolated' requested but ` + + `${PKG} is unavailable — booting with the organization wall INACTIVE. ` + + 'Organization boundaries are NOT enforced. (ADR-0093 D5)', + ); + }); + + it('site 5 — the stage-2 mount refusal names it', () => { + const fatal = formatOrganizationsMountFatal('isolated', 'seat count exceeded', 'ORG_SEATS'); + expect(lines(fatal)[1]).toBe( + ` ✖ FATAL: tenancy posture 'isolated' was requested and ${PKG} WAS found and loaded,`, + ); + // …and it stays the "present but declined" diagnosis, not an absence. + expect(plain(fatal)).toContain('This is NOT a missing-package problem'); + }); + + it('no message an operator reads names any OTHER scoped package', () => { + // The sweep the excluded source-scan form was reaching for, done over the + // rendering instead — where comments cannot reach and no exclusion list is + // needed. `mountMessage` is the plugin's own words, so it is fed something + // neutral here; the framework never interprets it. + for (const [label, rendered] of [ + ['remedy (declared)', remedyDeclared()], + ['remedy (undeclared)', remedyUndeclared()], + ['stage-1 fatal', formatOrganizationsAbsentFatal('group', remedyUndeclared(), 'ERR_MODULE_NOT_FOUND')], + ['degraded warning', formatDegradedTenancyWarning('group')], + ['stage-2 fatal', formatOrganizationsMountFatal('group', 'refused', undefined)], + ] as const) { + const names = scopedNamesIn(rendered); + expect(names.length, `${label} names no package at all — it stopped telling operators which one`) + .toBeGreaterThan(0); + for (const name of names) { + expect(name, `${label} names '${name}', which is not the runtime serve resolves`).toBe(PKG); + } + } + }); +}); + +describe('serve — the posture description an operator reads names the declaration (#12151)', () => { + const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const; + let saved: Record = {}; + + beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; + }); + + afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('site 6 — the `isolated` fix-list bullet names it, through the real gate', () => { + // Rendered through `resolveTenancyPostureOrRefusal` rather than by reading + // the hint table: the bullet's assembly (`• set OS_TENANCY_POSTURE=

— `) + // is part of what the operator sees, so it is part of what is pinned. + process.env.OS_TENANCY_POSTURE = 'not-a-posture'; + const verdict = resolveTenancyPostureOrRefusal(); + expect(verdict.ok, 'the gate accepted a value that is not a posture').toBe(false); + if (verdict.ok) return; + expect(lines(verdict.fatal)).toContain( + ` • set OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime ` + + "(the legacy spelling 'multi' is accepted and normalizes to this)", + ); + }); +}); + +describe('#12151 CONTROL — these pins can say no', () => { + it('a lost space between the package name and what follows it fails the comparison', () => { + // The exact regression the card names: interpolating into a template is + // where a stray space or a lost backtick hides. If this instrument could + // not tell the two apart, every assertion above would be decorative. + const expected = ` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`; + expect(` • add ${PKG}(the enterprise multi-org runtime) to THIS APP`).not.toBe(expected); + expect(` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`).not.toBe(expected); + expect(lines(remedyUndeclared())[0]).toBe(expected); + }); + + it('the scoped-name reader returns a positive on a foreign package and empty on none', () => { + // Anchored on a term that is NOT a substring of the one under test: a + // zero-hit sweep is not a reading until the same instrument answers yes. + expect(scopedNamesIn('prose naming @objectstack/legacy-orgs and @acme/thing')).toEqual([ + '@objectstack/legacy-orgs', + '@acme/thing', + ]); + expect(scopedNamesIn('prose naming no scoped package at all')).toEqual([]); + expect(scopedNamesIn(`prose naming ${PKG}`)).toEqual([PKG]); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5477242edf..3b93962e6b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -60,7 +60,9 @@ import { isDeclaredByHost, packageNameFromSpecifier, readHostDeclaration, + type HostDeclaration, type HostImporter, + type HostImportFailureKind, } from '@objectstack/types/node'; import { printHeader, @@ -2860,46 +2862,20 @@ export default class Serve extends Command { // file that was already correct. The importer now says which // one it is, so this text can too. const declaration = readHostDeclaration(organizationsPkg, hostRoot); - const remedy = - hostImportFailureKind(orgErr) === 'declared-unresolvable' - ? ' • this app DECLARES @objectstack/organizations ' + - `(${declaration.field}: ${JSON.stringify(declaration.specifier)}) — the\n` + - ' declaration is NOT the problem and re-reading package.json will not help.\n' + - ` Repair the INSTALL in ${hostRoot}: run \`pnpm install\`, check that a\n` + - ' production prune did not drop it, and that its dist is actually built — 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. Being merely reachable\n' + - ' through NODE_PATH / a hoisted workspace store is deliberately not enough\n' + - ' (#4719) — that made this wall depend on how the process was launched.\n' + - ' NOTE: this runtime is closed-source and is NOT on the public npm registry —\n' + - ' it is distributed with an enterprise / cloud subscription. Without one this\n' + - ' bullet is not followable, and one of the two below is your path — or\n'; + const remedy = formatOrganizationsInstallRemedy( + hostImportFailureKind(orgErr), + declaration, + hostRoot, + ); console.error( - chalk.red( - `\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested but ` + - '@objectstack/organizations could not be loaded,\n' + - ' 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' + - remedy + - " • 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`, - ), + chalk.red(formatOrganizationsAbsentFatal(tenancyPosture, remedy, cause)), ); process.exit(1); } // Explicitly opted into degraded operation — boot, but brand it // loudly. The `tenancy` service also reports `degraded: true` to // /auth/config and the Setup dashboard so it stays visible. - console.warn( - chalk.yellow( - ` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture '${tenancyPosture}' requested but ` + - '@objectstack/organizations is unavailable — booting with the organization wall INACTIVE. ' + - 'Organization boundaries are NOT enforced. (ADR-0093 D5)', - ), - ); + console.warn(chalk.yellow(formatDegradedTenancyWarning(tenancyPosture))); // Degraded boot: `orgMod` stays undefined, so stage 2 below is // skipped. Nothing was loaded, so nothing can be mounted. } @@ -2925,23 +2901,7 @@ export default class Serve extends Command { // be caught and boot would continue with the wall inactive. console.error( chalk.red( - `\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested and ` + - '@objectstack/organizations WAS found and loaded,\n' + - ' but its OrganizationsPlugin refused to mount, so the organization wall is INACTIVE.\n' + - ' Refusing to boot — a deployment that requested multi-organization isolation must not\n' + - ' serve traffic without it (ADR-0093 D5).\n\n' + - ' This is NOT a missing-package problem: the runtime is installed and resolvable here,\n' + - ' so module resolution / NODE_PATH / dependency pruning are not the place to look.\n\n' + - ' The plugin reported (verbatim — the framework does not interpret it):\n' + - (mountCode !== undefined ? ` code: ${String(mountCode)}\n` : '') + - ` ${mountMessage}\n\n` + - ' Fix one of:\n' + - ' • resolve what the plugin reported above — its message is the authority on the\n' + - ' remedy; this CLI has no further detail to add, or\n' + - " • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org.\n\n" + - ' OS_ALLOW_DEGRADED_TENANCY does NOT apply to this failure and will not get past it:\n' + - ' it covers an ABSENT multi-org runtime the operator accepts doing without, not a\n' + - ' present one that declined to mount. (#4818)\n', + formatOrganizationsMountFatal(tenancyPosture, mountMessage, mountCode), ), ); process.exit(1); @@ -4314,7 +4274,7 @@ const TENANCY_POSTURE_FIX_HINTS: Readonly> = { single: 'one organization, no organization wall — the default', group: 'organization wall enforced by the open engine, one shared database', isolated: - 'organization wall + the enterprise @objectstack/organizations runtime ' + `organization wall + the enterprise ${Serve.ORGANIZATIONS_RUNTIME_PKG} runtime ` + "(the legacy spelling 'multi' is accepted and normalizes to this)", }; @@ -4375,6 +4335,127 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict { } } + +/** + * The operator-facing prose that names the multi-org runtime, as SEAMS rather + * than string expressions buried inside `run()` — the same reason + * {@link resolveTenancyPostureOrRefusal} above is one (#5359). + * + * #11614 single-sourced the name `serve` RESOLVES onto + * {@link Serve.ORGANIZATIONS_RUNTIME_PKG} and stopped there, deliberately: the + * five sentences below are what an operator reads at the worst possible moment, + * and churning them for a provable no-op is how a whitespace slip lands in the + * one text that has to be right. This is the other half, done the way that + * makes it checkable rather than eyeballed — the name is interpolated from the + * same declaration the roster pins, and + * `serve-organizations-message-spelling.test.ts` asserts what these RENDER, not + * that the constant appears in the source. Rename the roster key and the prose + * moves with it; re-spell it inline and the rendered text stops matching. + * + * Deliberately NOT a source scan for "no bare literal outside the declaration": + * that shape has to exclude comments (three of them here legitimately name the + * package) and is easy to get wrong. + * + * Each function returns PLAIN text. The `chalk` wrapper and the `process.exit` + * stay at the call site, where the control flow is readable. + */ + +/** + * The install remedy bullet for a multi-org runtime that would not load — two + * absences, two remedies (#4719). + * + * `declared-unresolvable` means the app's `package.json` DOES name the package + * and the install is what is broken; anything else means the app never declared + * it. Telling the first operator to re-read a file that is already correct is + * the defect this branch exists to avoid. + */ +export function formatOrganizationsInstallRemedy( + kind: HostImportFailureKind | undefined, + declaration: HostDeclaration, + hostRoot: string, +): string { + const pkg = Serve.ORGANIZATIONS_RUNTIME_PKG; + return kind === 'declared-unresolvable' + ? ` • this app DECLARES ${pkg} ` + + `(${declaration.field}: ${JSON.stringify(declaration.specifier)}) — the\n` + + ' declaration is NOT the problem and re-reading package.json will not help.\n' + + ` Repair the INSTALL in ${hostRoot}: run \`pnpm install\`, check that a\n` + + ' production prune did not drop it, and that its dist is actually built — or\n' + : ` • add ${pkg} (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. Being merely reachable\n' + + ' through NODE_PATH / a hoisted workspace store is deliberately not enough\n' + + ' (#4719) — that made this wall depend on how the process was launched.\n' + + ' NOTE: this runtime is closed-source and is NOT on the public npm registry —\n' + + ' it is distributed with an enterprise / cloud subscription. Without one this\n' + + ' bullet is not followable, and one of the two below is your path — or\n'; +} + +/** + * Stage 1 refusal: a walled posture was requested and the multi-org runtime is + * ABSENT, so the organization wall would be inactive (ADR-0093 D5). + */ +export function formatOrganizationsAbsentFatal( + posture: TenancyPosture, + remedy: string, + cause: string, +): string { + return ( + `\n ✖ FATAL: tenancy posture '${posture}' was requested but ` + + `${Serve.ORGANIZATIONS_RUNTIME_PKG} could not be loaded,\n` + + ' 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' + + remedy + + " • 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` + ); +} + +/** + * The degraded-boot brand: the operator explicitly accepted an absent multi-org + * runtime via `OS_ALLOW_DEGRADED_TENANCY=1`, so boot continues — loudly. + */ +export function formatDegradedTenancyWarning(posture: TenancyPosture): string { + return ( + ` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture '${posture}' requested but ` + + `${Serve.ORGANIZATIONS_RUNTIME_PKG} is unavailable — booting with the organization wall INACTIVE. ` + + 'Organization boundaries are NOT enforced. (ADR-0093 D5)' + ); +} + +/** + * Stage 2 refusal: the runtime IS present and its plugin declined to mount + * (#4818). A different fact with a different remedy, and one + * `OS_ALLOW_DEGRADED_TENANCY` deliberately does not cover. + */ +export function formatOrganizationsMountFatal( + posture: TenancyPosture, + mountMessage: string, + mountCode: unknown, +): string { + return ( + `\n ✖ FATAL: tenancy posture '${posture}' was requested and ` + + `${Serve.ORGANIZATIONS_RUNTIME_PKG} WAS found and loaded,\n` + + ' but its OrganizationsPlugin refused to mount, so the organization wall is INACTIVE.\n' + + ' Refusing to boot — a deployment that requested multi-organization isolation must not\n' + + ' serve traffic without it (ADR-0093 D5).\n\n' + + ' This is NOT a missing-package problem: the runtime is installed and resolvable here,\n' + + ' so module resolution / NODE_PATH / dependency pruning are not the place to look.\n\n' + + ' The plugin reported (verbatim — the framework does not interpret it):\n' + + (mountCode !== undefined ? ` code: ${String(mountCode)}\n` : '') + + ` ${mountMessage}\n\n` + + ' Fix one of:\n' + + ' • resolve what the plugin reported above — its message is the authority on the\n' + + ' remedy; this CLI has no further detail to add, or\n' + + " • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org.\n\n" + + ' OS_ALLOW_DEGRADED_TENANCY does NOT apply to this failure and will not get past it:\n' + + ' it covers an ABSENT multi-org runtime the operator accepts doing without, not a\n' + + ' present one that declined to mount. (#4818)\n' + ); +} + /** * Which env var supplied the auth base URL, in the precedence order `serve` * reads them. `null` in {@link AuthBaseUrlResolution.source} means no variable From 78b79c54e5ea5882034db8153ab8b566056868ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:03:31 +0000 Subject: [PATCH 2/2] chore(cli): changeset for the serve organizations prose single-sourcing --- ...erve-organizations-prose-single-sourced.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/serve-organizations-prose-single-sourced.md diff --git a/.changeset/serve-organizations-prose-single-sourced.md b/.changeset/serve-organizations-prose-single-sourced.md new file mode 100644 index 0000000000..fa76511ec9 --- /dev/null +++ b/.changeset/serve-organizations-prose-single-sourced.md @@ -0,0 +1,33 @@ +--- +"@objectstack/cli": patch +--- + +chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151) + +`serve` prints `@objectstack/organizations` at operators in five places: the +two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy +posture cannot load the runtime, the degraded-boot warning, the stage-2 mount +refusal, and the `isolated` posture description in the tenancy-posture fix list. +#11614 single-sourced the name `serve` RESOLVES onto +`Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the +spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their +own copies — so a roster-key rename would leave operator instructions naming a +package that no longer exists while boot reached for the new one, with every gate +green (the roster pin only sees the declaration). + +All five now interpolate the constant, and a new test asserts what they RENDER +rather than that the constant appears in the source — the affected line compared +whitespace-included against text built from the same declaration. The message +bodies moved into pure formatters (`formatOrganizationsInstallRemedy`, +`formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`, +`formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal` +in the same file already uses, so the rendering is reachable without spawning a +boot; `chalk` and the `process.exit` stay at the call site. + +**No behaviour change.** The declared value is byte-identical to the literal it +replaces, and the rendered output was verified byte-for-byte against the +pre-change expressions across every branch (both remedy kinds, both walled +postures, `mountCode` present and absent). The three comments in `serve.ts` that +legitimately name the package are untouched — the tempting "no bare literal +outside the declaration" source scan is deliberately not built, since it would +have to exclude comments and that shape is easy to get wrong.