Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/31067-runtime-export-disable-dependents.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string, string> = { [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);
});
});
17 changes: 15 additions & 2 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(', ')})`)
Expand Down
11 changes: 11 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading