diff --git a/.changeset/31067-runtime-export-disable-dependents.md b/.changeset/31067-runtime-export-disable-dependents.md new file mode 100644 index 00000000000..c5674429c82 --- /dev/null +++ b/.changeset/31067-runtime-export-disable-dependents.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +feat(runtime): `@objectstack/runtime` exports the package disable door's dependent-rule verdict, `readPackageDisableDependents`, and its sentence, `formatPackageDisableDependents`, with the `DisableDependentRule` type + +Clause-②: yes +Metadata-protocol: none + +`readPackageDisableDependents(registry, packageId)` answers the validation rules a disable of the package would leave reading a field it adds through `objectExtensions`: each as `{ packageId, object, rule, fields }`, from any enabled package (declared dependent or not, an app's `.custom` pair included), ordered by package, object and rule. It reads the registry and writes nothing, and answers an empty list for a package that withholds nothing. `formatPackageDisableDependents(lead, dependents)` spells the refusal's message: `lead`, every rule as `package · object · rule (reads FIELD, …)`, then the remedy. + +They are the same two functions `PATCH /packages/:id/disable` and `POST /packages` with `enableOnInstall: false` ask, now on the package entry, so a host door that takes a package out of service another way (a control plane's per-install disable, a soft-disable uninstall) asks the same scan before its write and answers the same `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (the code is in the `@objectstack/spec` error-code ledger) instead of keeping a second copy. Nothing existing changes: every door answers exactly as before. diff --git a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts index 8d12ecd6931..4bfd2bfba4b 100644 --- a/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts +++ b/packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts @@ -58,6 +58,8 @@ import { resolveThrownHttpError } from '@objectstack/types'; import { AppPlugin } from '../app-plugin.js'; import { HttpDispatcher } from '../http-dispatcher.js'; import { loadDisabledPackageIds, setPackageDisabled } from '../package-state-store.js'; +import * as runtime from '../index.js'; +import { disableDependentRulesMessage, readDisableDependentRules } from './packages.js'; const ENVIRONMENT_ID = 'env_suite_extension_fields'; const ADMIN = { @@ -1132,3 +1134,101 @@ describe('[#30756] the enable door judges the rules on an object the package own expect(registry.getPackage(OWNER)?.enabled).toBe(true); }); }); + +describe('[#31067] the verdict the disable door asks is the package entry\'s export', () => { + const OWNER = 'com.qa.verdict.owner'; + const READER = 'com.qa.verdict.reader'; + const EXTENDER = 'com.qa.verdict.flags'; + const THING = 'qv_thing'; + const FLAG = 'qv_flag'; + const RULE = 'qv_flag_required'; + const RULE_OFF = 'qv_flag_switched_off'; + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + const NAMESPACES: Record = { [OWNER]: 'qv', [READER]: 'qw', [EXTENDER]: 'qx' }; + const manifestOf = (id: string) => ({ id, name: id, namespace: NAMESPACES[id], version: '1.0.0', type: 'module' }); + + let previousHome: string | undefined; + beforeAll(() => { + previousHome = process.env.OS_HOME; + process.env.OS_HOME = mkdtempSync(join(home, 'disable-verdict-')); + }); + afterAll(() => { + process.env.OS_HOME = previousHome; + }); + + /** + * The owner's object, the extender's field on it, and an ENABLED reader's two rules on it reading that field, + * one switched off: the extender's disable would withhold the field from the one active rule. + */ + async function door() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' } as any); + (registry as any).logLevel = 'silent'; + const kernel: any = { + getService: (name: string) => (name === 'objectql' ? Promise.resolve({ registry }) : null), + context: { getService: () => null }, + }; + const dispatcher = new HttpDispatcher(kernel); + const context = { + request: {}, + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + } as any; + for (const id of [OWNER, READER, EXTENDER]) { + const installed = await dispatcher.handlePackages('', 'POST', { manifest: manifestOf(id) }, {}, context); + expect(installed.response?.status, `precondition: ${id} installs`).toBe(201); + } + registry.registerObject({ name: THING, label: 'Thing', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } as any, OWNER); + registry.registerObject({ name: THING, fields: { [FLAG]: { name: FLAG, type: 'boolean', label: 'Flag' } } } as any, EXTENDER, undefined, 'extend'); + registry.registerObject({ + name: THING, + validations: [ + { name: RULE, type: 'script', severity: 'error', message: 'Flag it', condition: `record.${FLAG} == null` }, + { name: RULE_OFF, type: 'script', severity: 'error', message: 'Off', condition: `record.${FLAG} == true`, active: false }, + ], + } as any, READER, undefined, 'extend'); + const disable = (id: string) => dispatcher.handlePackages(`/${id}/disable`, 'PATCH', {}, {}, context); + return { registry, disable }; + } + + it('is the one function the door asks, exported on the package entry with its type beside it', () => { + expect(runtime.readPackageDisableDependents).toBe(readDisableDependentRules); + expect(runtime.formatPackageDisableDependents).toBe(disableDependentRulesMessage); + }); + + it('answers the active rules a disable would leave reading a withheld field, and writes nothing', async () => { + const { registry } = await door(); + const dependents: runtime.DisableDependentRule[] = runtime.readPackageDisableDependents(registry, EXTENDER); + expect(dependents).toEqual([{ packageId: READER, object: THING, rule: RULE, fields: [FLAG] }]); + // Pure: the package stays enabled and the field stays served. + expect(registry.getPackage(EXTENDER)?.enabled).not.toBe(false); + expect(Object.keys(registry.getObject(THING)?.fields ?? {})).toContain(FLAG); + // A package that withholds nothing has no dependents; nor does a registry the verdict cannot read. + expect(runtime.readPackageDisableDependents(registry, READER)).toEqual([]); + expect(runtime.readPackageDisableDependents({}, EXTENDER)).toEqual([]); + }); + + it('the door answers 409 with exactly the sentence the exported builder spells over the exported verdict', async () => { + const { registry, disable } = await door(); + const expected = runtime.formatPackageDisableDependents( + `Package '${EXTENDER}' cannot be disabled`, + runtime.readPackageDisableDependents(registry, EXTENDER), + ); + expect(expected).toBe( + `Package '${EXTENDER}' cannot be disabled: an active validation rule reads a field it adds through objectExtensions, ` + + `which the disable would withhold — ${READER} · ${THING} · ${RULE} (reads ${FLAG}). Switch off or change that rule first, ` + + 'or keep the package enabled.', + ); + const result: any = await disable(EXTENDER); + expect({ status: result.response?.status, code: result.response?.body?.error?.code }).toEqual({ status: 409, code: CODE }); + expect(String(result.response?.body?.error?.message)).toBe(expected); + expect(registry.getPackage(EXTENDER)?.enabled).not.toBe(false); + }); + + it('control: once the reading package is disabled the verdict is empty, and the door proceeds', async () => { + const { registry, disable } = await door(); + expect((await disable(READER)).response?.status).toBe(200); + expect(runtime.readPackageDisableDependents(registry, EXTENDER)).toEqual([]); + const result: any = await disable(EXTENDER); + expect(result.response?.status, String(result.response?.body?.error?.message)).toBe(200); + expect(registry.getPackage(EXTENDER)?.enabled).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index d796baff1bd..5b02216a7d7 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1051,8 +1051,18 @@ function declaredRulesOf(body: unknown): Array<{ name?: unknown; active?: unknow * * Ordered by package, object and rule. Empty for a package already disabled * (the disable withholds nothing more) and for one that extends nothing. + * + * ## On the package entry + * + * Exported as `readPackageDisableDependents` from `@objectstack/runtime`, with + * {@link disableDependentRulesMessage} as `formatPackageDisableDependents`, so + * a host door that takes a package out of service another way (a control + * plane's per-install disable, a soft-disable uninstall) asks the scan this + * door asks and answers the sentence it answers, instead of a second copy of + * either. Pure over `registry`: it reads the registry and writes nothing, so + * a host asks it BEFORE its own write, as this door does. */ -function readDisableDependentRules(registry: any, packageId: string): DisableDependentRule[] { +export function readDisableDependentRules(registry: any, packageId: string): DisableDependentRule[] { if (typeof registry?.getObjectContributors !== 'function' || typeof registry?.getAllObjects !== 'function') return []; const disabled = (id: unknown): boolean => typeof registry.isPackageDisabled === 'function' && registry.isPackageDisabled(id); @@ -1178,8 +1188,11 @@ function ruleLayerLabel(layer: { packageId?: unknown; ownership?: unknown }): st /** * The sentence a dependent-rule refusal carries: `lead` (what was refused), * then every rule as `package · object · rule (reads FIELD, …)`, then the remedy. + * Exported as `formatPackageDisableDependents` from `@objectstack/runtime` + * beside {@link readDisableDependentRules}; a host door answers it with `409` + * `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, as this door does. */ -function disableDependentRulesMessage(lead: string, dependents: readonly DisableDependentRule[]): string { +export function disableDependentRulesMessage(lead: string, dependents: readonly DisableDependentRule[]): string { const one = dependents.length === 1; const listed = dependents .map((d) => `${d.packageId} · ${d.object} · ${d.rule} (reads ${d.fields.join(', ')})`) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5a14a4cf8c2..eb76649fd33 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -249,6 +249,17 @@ export type { KernelResolver } from './http-dispatcher.js'; export { DomainHandlerRegistry } from './domain-handler-registry.js'; export type { DomainRoute, DomainHandler, DomainRequest, DomainHandlerDeps } from './domain-handler-registry.js'; export { MiddlewareManager } from './middleware.js'; +// The package disable door's dependent-rule verdict and its sentence, for a +// host door that takes a package out of service another way: the validation +// rules a disable would leave reading a withheld `objectExtensions` field, and +// the `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` message naming them. +// The same two functions `PATCH /packages/:id/disable` asks: one scan, one +// sentence. ⛔ Never re-spell either in a host. +export { + readDisableDependentRules as readPackageDisableDependents, + disableDependentRulesMessage as formatPackageDisableDependents, +} from './domains/packages.js'; +export type { DisableDependentRule } from './domains/packages.js'; // ── Security primitives ─────────────────────────────────────────────── // Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…) and