diff --git a/.changeset/serve-organizations-pkg-roster-key.md b/.changeset/serve-organizations-pkg-roster-key.md new file mode 100644 index 0000000000..b8c1928962 --- /dev/null +++ b/.changeset/serve-organizations-pkg-roster-key.md @@ -0,0 +1,35 @@ +--- +'@objectstack/cli': patch +--- + +refactor(cli): `serve` resolves `@objectstack/organizations` through one declaration the spec roster pins (#11614) + +`PLATFORM_PLUGIN_WIRED_RUNTIMES` (`packages/spec`) is the provenance roster for +`plugins[]`-wired out-of-repo runtimes, keyed by npm package name — the single +machine-readable answer to "is this `@objectstack/*` package real, and where +does it ship from?". It exists because a **fabricated** `@objectstack/framework` +sat next to the real `@objectstack/organizations` in published docs for months, +indistinguishable by inspection (#10921). + +`serve` is the only runtime that prints one of those names AT OPERATORS — the +install remedy, the fatal refusal when a walled tenancy posture cannot load the +multi-org runtime, and the degraded-boot warning. It spelled the package as a +bare literal at the resolution site, under no pin at all, so the roster and the +name `serve` actually resolves could diverge in silence: rename the roster key, +or mistype the literal, and every gate stays green while boot reaches for a +package that does not exist and the fatal message tells the operator to install +it. + +The name is now declared once, as `Serve.ORGANIZATIONS_RUNTIME_PKG`, and both +resolution-path uses read it — `importFromHost(…)` and the `readHostDeclaration(…)` +that decides which of the two absence remedies to print. A drift pin in +`serve-capability-vocabulary.test.ts` (the suite that already holds the two +rosters to each other) asserts that value is a roster **key** and that its row is +the `enterprise` edition. + +**Load semantics are untouched.** Which postures load the runtime, the two-stage +import/mount failure classification, and what `OS_ALLOW_DEGRADED_TENANCY` does +and does not cover are all exactly as they were; the roster is deliberately not a +resolution registry and is not consulted at boot. This single-sources the +spelling and nothing else, so there is no behaviour or output change — the +declared value is byte-identical to the literal it replaces. diff --git a/packages/cli/src/commands/serve-cluster-host-resolution.test.ts b/packages/cli/src/commands/serve-cluster-host-resolution.test.ts index 97569d212a..8309ed424c 100644 --- a/packages/cli/src/commands/serve-cluster-host-resolution.test.ts +++ b/packages/cli/src/commands/serve-cluster-host-resolution.test.ts @@ -204,16 +204,39 @@ function packageNameOf(specifier: string): string | undefined { } /** - * Resolve one level of `const X = ''` — the idiom `serve.ts` uses - * everywhere to keep `tsc` from statically resolving an optional package - * (`const i18nPkg = '@objectstack/service-i18n'`). Without this the sweep would - * see only an identifier and classify every optional load as unknowable. + * Resolve `const X = ''` — the idiom `serve.ts` uses everywhere to keep + * `tsc` from statically resolving an optional package + * (`const i18nPkg = '@objectstack/service-i18n'`) — and one further hop, + * `const X = Serve.MEMBER`, where `MEMBER` is a `static readonly` string on the + * command class in this same file. Without this the sweep sees only an + * identifier and classifies the load as unknowable. + * + * The second hop is not a convenience. #11614 single-sourced the + * `@objectstack/organizations` spelling onto `Serve.ORGANIZATIONS_RUNTIME_PKG` + * so the spec-owned provenance roster could pin it, and a resolver that stops + * one hop short turns that load from "app-declarable, host-anchored, checked" + * into "unknowable" — SILENTLY, because an unresolved specifier drops OUT of + * `APP_DECLARABLE_LOADS` rather than into it. The named half of the vacuity + * guard below is what caught that, and is why it names packages instead of only + * counting them. Resolving one hop further strictly WIDENS what the sweep + * judges; it can never excuse a load. */ function resolveIdentifier(code: string, name: string): string | undefined { - const m = code.match( + const direct = code.match( new RegExp(`\\bconst\\s+${name}\\s*(?::\\s*string\\s*)?=\\s*(['"\`])([^'"\`]*)\\1`), ); - return m?.[2]; + if (direct) return direct[2]; + + // `const organizationsPkg = Serve.ORGANIZATIONS_RUNTIME_PKG;` (#11614) + const viaStatic = code.match( + new RegExp(`\\bconst\\s+${name}\\s*(?::\\s*string\\s*)?=\\s*Serve\\.([A-Za-z_$][\\w$]*)\\s*;`), + ); + if (!viaStatic) return undefined; + + const member = code.match( + new RegExp(`\\bstatic\\s+readonly\\s+${viaStatic[1]}\\s*(?::\\s*string\\s*)?=\\s*(['"\`])([^'"\`]*)\\1`), + ); + return member?.[2]; } /** Every dynamic load in `serve.ts`, bare or host-anchored. */ @@ -453,14 +476,18 @@ describe('os serve → every app-declarable optional load is host-anchored', () "packages/cli's manifest read as empty — every package would look app-declarable", ).toBeGreaterThan(20); - // Named, not just counted: this proves the resolver still handles all three - // spellings serve.ts uses — a `const` binding, a template prefix, and the - // manifest cross-check that decides app-declarable at all. + // Named, not just counted: this proves the resolver still handles every + // spelling serve.ts uses — a `const` binding, a template prefix, a `const` + // bound to a class static, and the manifest cross-check that decides + // app-declarable at all. Naming them is what caught #11614: the + // organizations spelling moved onto a static, the resolver stopped one hop + // short, and that load dropped OUT of the swept population — which the + // count-only floor of >20 absorbed without a word. const found = new Set(APP_DECLARABLE_LOADS.map((s) => s.packageName)); for (const pkg of [ '@objectstack/service-cluster', // const binding (#10645) '@objectstack/service-cluster-', // template prefix (#10645, the driver) - '@objectstack/organizations', // const binding (cloud#1013) + '@objectstack/organizations', // const <- static (cloud#1013, #11614) '@objectstack/service-i18n', // const binding (#10769) ]) { expect(found, `the sweep no longer sees the ${pkg} load`).toContain(pkg); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0ea76ca0eb..5477242edf 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -491,6 +491,33 @@ export default class Serve extends Command { */ static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = PLATFORM_ALWAYS_ON_CAPABILITIES; + /** + * The `plugins[]`-wired multi-org runtime this command loads when the + * resolved tenancy posture is walled (ADR-0105 D12) — declared ONCE, here. + * + * `serve` is the only runtime that prints this package name AT OPERATORS (the + * install remedy, the fatal refusal, the degraded-boot warning), and the + * spec-owned provenance roster `PLATFORM_PLUGIN_WIRED_RUNTIMES` is what makes + * the name answerable as real rather than fabricated — #10921 is the case + * where a fabricated `@objectstack/framework` sat next to the real + * `@objectstack/organizations` in published docs for months, indistinguishable. + * Spelled inline at the resolution site, the name was a second copy under no + * check at all: the roster's own header calls that "a second description + * nobody checks", and a roster cannot pin a spelling it cannot see. Read from + * here, it is one declaration with a drift pin over it — + * `serve-capability-vocabulary.test.ts` asserts this value is a roster KEY + * (#11614), so a rename on either side fails a test instead of silently + * pointing operators at a package that does not exist. + * + * Exposed as a static for the same reason ALWAYS_ON_CAPABILITIES above is: + * one declaration, two readers — the boot path and the pin. + * + * This single-sources the SPELLING and nothing else. Which postures load the + * runtime, and what each of the two failure stages means, stay exactly where + * they are; the roster is deliberately not a resolution registry. + */ + static readonly ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations'; + /** * Auto-registered plugin tiers. Plugins explicitly listed in * `config.plugins` are always loaded — tiers only gate the optional @@ -2794,7 +2821,9 @@ export default class Serve extends Command { // instances: `instanceof` and named `code` checks are both // fragile here. Stage is the only classifier that needs to know // nothing about the plugin's internals. - const organizationsPkg = '@objectstack/organizations'; + // #11614 — the spelling comes from the class-level declaration the + // spec roster pins, not from a fresh literal at the resolution site. + const organizationsPkg = Serve.ORGANIZATIONS_RUNTIME_PKG; let orgMod: any; // ── Stage 1: import. Failure here = the package is ABSENT. ── try { @@ -2830,7 +2859,7 @@ export default class Serve extends Command { // and whose install was pruned, that sent them to re-read a // file that was already correct. The importer now says which // one it is, so this text can too. - const declaration = readHostDeclaration('@objectstack/organizations', hostRoot); + const declaration = readHostDeclaration(organizationsPkg, hostRoot); const remedy = hostImportFailureKind(orgErr) === 'declared-unresolvable' ? ' • this app DECLARES @objectstack/organizations ' + diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index b9a35b03d6..859c69711e 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -92,6 +92,29 @@ describe('PLATFORM_PLUGIN_WIRED_RUNTIMES vs providers + serve resolver (#11263)' expect(row.edition).toBe('enterprise'); }); + // #11614 — the other half of the pin above, and the half that was missing. + // That one reads the roster with a literal spelled HERE, so it proves the + // roster has a row; it says nothing about the name `serve` actually resolves. + // Until this pin, that name was a bare literal in serve.ts under no check at + // all: rename the roster key, or mistype the literal, and both sides stay + // green while boot reaches for a package that does not exist and the fatal + // message tells operators to install it. + it('the package name serve resolves IS a roster key — not a second, unchecked copy of it (#11614)', () => { + expect( + Object.keys(PLATFORM_PLUGIN_WIRED_RUNTIMES), + `serve loads '${Serve.ORGANIZATIONS_RUNTIME_PKG}', which PLATFORM_PLUGIN_WIRED_RUNTIMES does not declare. ` + + 'The roster is the single source for whether an out-of-repo @objectstack/* package is real and where ' + + 'it ships from (#10921); a runtime that prints a package name at operators must name a row in it.', + ).toContain(Serve.ORGANIZATIONS_RUNTIME_PKG); + + // Provenance, read through serve's own spelling rather than a literal: the + // row this command's remedy text describes is the enterprise one. + const row = PLATFORM_PLUGIN_WIRED_RUNTIMES[Serve.ORGANIZATIONS_RUNTIME_PKG]; + expect(row.edition, `edition drift for the runtime serve loads ('${Serve.ORGANIZATIONS_RUNTIME_PKG}')`).toBe( + 'enterprise', + ); + }); + it('every enterprise-edition provider package has a roster row — enterprise means plugins[]-wired, by definition', () => { // CapabilityEdition's own definition: `enterprise` = "a separately-licensed // enterprise package the app installs and wires in via `plugins[]`". So an