diff --git a/.changeset/28117-disable-app-module.md b/.changeset/28117-disable-app-module.md new file mode 100644 index 00000000000..ec9c501696c --- /dev/null +++ b/.changeset/28117-disable-app-module.md @@ -0,0 +1,19 @@ +--- +'@objectstack/runtime': patch +'@objectstack/objectql': patch +'@objectstack/trigger-record-change': patch +--- + +feat(runtime): an admin can disable a `type: 'module'` package of an installed app, and a disabled package's navigation contributions and record-change flows stop (#28117) + +Clause-②: yes (widening) — `PATCH /api/v1/packages/:id/disable` now accepts a module of an installed app, which it refused `422 WRITABLE_PACKAGE_REQUIRED` before. Nothing is narrowed. +Metadata-protocol: none + +A suite ships one artifact: its `type: 'app'` package and several `type: 'module'` packages. A customer who does not use a module can now switch it off in their environment, and "disabled" keeps its one meaning: hidden, refused on the data plane, data kept. + +- **The disable door** (`@objectstack/runtime`). An admin with `manage_metadata` may disable a package whose `manifest.type` is `'module'` and whose `manifest.dependencies` directly name an installed package of `manifest.type` `'app'`, unless its own `manifest.scope` is `system` or `cloud`. This holds for a suite booted from code and for one installed from the marketplace: both leave the same row. Every other read-only package answers the disable door `422 WRITABLE_PACKAGE_REQUIRED` as before: the app itself, a plugin, a platform package, a module that declares no app or whose app is not installed. The exemption is for disable only. The module stays read-only for authoring, so edit, publish, revert, delete and overwrite still refuse it, and its served row still says `writable: false`. +- **Navigation** (`@objectstack/objectql`). `SchemaRegistry.applyNavContributions` drops the contributions of a disabled package, so `getApp`, `getAllApps` and the protocol's `app` reads (`GET /api/v1/meta/app`, `GET /api/v1/meta/app/:name`) no longer list a disabled module's menu entries. They come back on enable. A disabled package's contribution into a missing group is not relocated and raises no `nav_contribution_group_missing` diagnostic. +- **Record-change flows** (`@objectstack/trigger-record-change`). A flow bound to an object whose owning package is disabled is not run for a write made in-process (an app hook's `ctx.api.object(...)`, another flow, a service), which the engine still accepts. The record is written; the flow runs again once the package is enabled. Writes through the data plane were already refused. +- **Already in place**, and now pinned on the module path: the data plane's `404 OBJECT_PACKAGE_DISABLED` for the module's objects, and the per-environment state file that replays a disable at boot. + +An app hook that reads a disabled module's object through `ctx.api.object(name)` is served the kept records: the in-process read is not refused, and this release does not change that. The marketplace handbook says so under "Disabling a module". diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 7dbce7f5d3f..4354a0c6f71 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -214,8 +214,12 @@ the detail. `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and `…/purge-sample-data`, both requiring `manage_metadata`. 7. **Enable and disable.** **Setup → Installed Apps** lists the apps installed - from the marketplace. **Disable** is offered only for a writable package: - for a package provided by code or by an installed app, the runtime answers + from the marketplace. An admin with `manage_metadata` may disable a writable + package, and a **module of an installed app**: a `type: 'module'` package + that declares an installed `type: 'app'` package in its + `manifest.dependencies` ([Disabling a module](#disabling-a-module) below). + For every other package provided by code or by an installed app (the app + itself, a plugin, a platform package), the runtime answers `PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`. To take an installed app away, uninstall it. 8. **Upgrading.** You publish a new version; nothing running changes. An @@ -255,6 +259,45 @@ An app ships extras in one of two shapes: composed at build time, which travels inside your App rather than as a listing of its own ([A project is a multi-package artifact](/docs/getting-started/examples#a-project-is-a-multi-package-artifact)). +### Disabling a module + +A customer who does not use one of your modules can switch it off, per +environment, with `PATCH /api/v1/packages/:id/disable`, and back on with +`PATCH /api/v1/packages/:id/enable`. The module qualifies when its manifest +says `type: 'module'` and declares your app in `dependencies`, the way every +module of a suite already does: + +```ts +manifest: { + id: 'app.example.suite.orders', + type: 'module', + namespace: 'suite', + dependencies: { 'app.example.suite': '^1.0.0' }, + navigationContributions: [/* its menu entries in the app's groups */], +}, +``` + +Disabled has one meaning: the module is hidden, refused on the data plane, and +its data is kept. + +- **Data plane.** `/api/v1/data/` and the by-name metadata reads answer + the module's objects `404 OBJECT_PACKAGE_DISABLED`. Records stay in the + database and come back on enable. +- **Navigation.** The module's `navigationContributions` leave the app's menu. +- **Flows.** Record-change flows on the module's objects do not run while it is + disabled, whoever writes the record. +- **Restart.** The choice is kept per environment and survives a restart. +- **Authoring.** A disabled module is still read-only: edit, publish, revert + and delete keep answering `422 WRITABLE_PACKAGE_REQUIRED`. + +**What your app's own code meets.** A hook of your app that reads or writes a +module's object through `ctx.api.object('')` runs inside the server, +not through the data plane, and it is **not** refused: a `find` returns the +module's kept records, and a write lands (no record-change flow runs on it). +There is no `OBJECT_PACKAGE_DISABLED` error to catch there. A browser or API +client reading the module's objects does meet the `404 OBJECT_PACKAGE_DISABLED` +refusal while the module is off. + ## Related - [CLI reference](/docs/deployment/cli) — every `os package publish` and `os package install` flag diff --git a/packages/objectql/src/registry-nav-contribution-disabled-package.test.ts b/packages/objectql/src/registry-nav-contribution-disabled-package.test.ts new file mode 100644 index 00000000000..fd501b03f5c --- /dev/null +++ b/packages/objectql/src/registry-nav-contribution-disabled-package.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry'; + +/** + * [#28117] A DISABLED package's navigation contributions leave the app it + * contributes to, on every read that folds them, and come back on enable. + * + * A suite module (a `type: 'module'` package of an installed app) puts its + * menu entries into the app's groups through `navigationContributions` + * (ADR-0029 D7). Disabling the module must take those entries out of the app: + * "disabled" has one meaning, hidden + refused on the data plane + data kept. + * Before this card the fold (`applyNavContributions`) pushed every registered + * contribution and never asked whether its package was switched off, so the + * module's objects answered 404 `OBJECT_PACKAGE_DISABLED` while the menu kept + * offering them. + * + * The fold is the ONE seam all three readers go through: the registry helpers + * `getApp` / `getAllApps` here, and the protocol's `app` list and by-name reads + * (`getMetaItems` / `getMetaItem` call `registry.applyNavContributions`), which + * the runtime pin `packages/runtime/src/domains/packages-disable-app-module.test.ts` + * drives through the real door. Nothing is cached past the fold: the verdict + * is the package row's state at read time, so a re-enable needs no + * invalidation. + */ +describe('[#28117] navigation contributions of a disabled package', () => { + const APP_PKG = 'com.qa.suite'; + const MODULE_PKG = 'com.qa.suite.orders'; + const OTHER_PKG = 'com.qa.suite.reports'; + + let registry: SchemaRegistry; + + const suiteApp = () => ({ + name: 'qs_app', + label: 'Suite', + navigation: [ + { + id: 'qs_group', + type: 'group', + label: 'Sales', + children: [{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'qs_account' }], + }, + ], + }); + + // Each module declares the app, as a suite's modules do: that is also what + // lets it co-own the app's namespace (ADR-0141 D5). + const manifest = (id: string, type: string) => + ({ id, name: id, version: '1.0.0', type, namespace: 'qs', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any; + + /** The ids the folded app's group holds, read the way `reader` reads it. */ + const groupIds = (app: any) => + (app.navigation.find((n: any) => n.id === 'qs_group')?.children ?? []).map((c: any) => c.id); + const topIds = (app: any) => app.navigation.map((n: any) => n.id); + + const READERS: ReadonlyArray<{ label: string; read: (r: SchemaRegistry) => any }> = [ + { label: 'getApp', read: (r) => r.getApp('qs_app') }, + { label: 'getAllApps', read: (r) => r.getAllApps().find((a: any) => a.name === 'qs_app') }, + { label: 'applyNavContributions (the protocol app read)', read: (r) => r.applyNavContributions(suiteApp()) }, + ]; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + (registry as any).logLevel = 'silent'; + registry.installPackage(manifest(APP_PKG, 'app'), 'code'); + registry.installPackage(manifest(MODULE_PKG, 'module'), 'code'); + registry.installPackage(manifest(OTHER_PKG, 'module'), 'code'); + registry.registerApp(suiteApp(), APP_PKG); + registry.registerAppNavContribution( + { app: 'qs_app', group: 'qs_group', priority: 50, items: [{ id: 'nav_orders', type: 'object', label: 'Orders', objectName: 'qs_order' }] }, + MODULE_PKG, + ); + // A contribution into a group the app does not declare: the fold relocates + // it to the top level (#14553). Disabled, it must not be relocated either. + registry.registerAppNavContribution( + { app: 'qs_app', group: 'qs_missing_group', priority: 60, items: [{ id: 'nav_order_help', type: 'url', label: 'Help', url: '/help' }] }, + MODULE_PKG, + ); + registry.registerAppNavContribution( + { app: 'qs_app', group: 'qs_group', priority: 70, items: [{ id: 'nav_reports', type: 'object', label: 'Reports', objectName: 'qs_report' }] }, + OTHER_PKG, + ); + }); + + for (const { label, read } of READERS) { + it(`${label}: an enabled package's contributions are folded (the control)`, () => { + const app = read(registry); + expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']); + expect(topIds(app)).toContain('nav_order_help'); + }); + + it(`${label}: a disabled package's contributions are dropped, in its group and at the top level, and the others stay`, () => { + expect(registry.disablePackage(MODULE_PKG), 'precondition: disabled').toBeTruthy(); + const app = read(registry); + expect(groupIds(app)).toEqual(['nav_accounts', 'nav_reports']); + expect(topIds(app)).not.toContain('nav_order_help'); + }); + + it(`${label}: re-enabling the package restores its contributions`, () => { + registry.disablePackage(MODULE_PKG); + read(registry); + expect(registry.enablePackage(MODULE_PKG), 'precondition: enabled').toBeTruthy(); + const app = read(registry); + expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']); + expect(topIds(app)).toContain('nav_order_help'); + }); + } + + it('records no missing-group diagnostic for a disabled package (its contribution is not folded at all)', () => { + registry.disablePackage(MODULE_PKG); + registry.getApp('qs_app'); + expect(registry.getAppNavDiagnostics('qs_app').map((d: any) => d.packageId)).not.toContain(MODULE_PKG); + }); + + it('keeps the stored app untouched: disabling never edits the app the module contributes to', () => { + registry.disablePackage(MODULE_PKG); + registry.getApp('qs_app'); + const stored = (registry as any).getItem('app', 'qs_app'); + expect(groupIds(stored)).toEqual(['nav_accounts']); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 407921f4f7b..0d8ee0e6b32 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -6795,9 +6795,10 @@ export class SchemaRegistry { } /** - * Return a copy of `app` with all registered navigation contributions - * merged into its `navigation` tree. The stored app is never mutated, so - * repeated reads stay idempotent. + * Return a copy of `app` with the registered navigation contributions of + * every ENABLED package merged into its `navigation` tree (#28117: a disabled + * package's are dropped). The stored app is never mutated, so repeated reads + * stay idempotent. * * Public so the protocol serving path (`getMetaItems` / `getMetaItem` for * `app`) can merge contributions the same way `getApp` / `getAllApps` do — @@ -6815,6 +6816,12 @@ export class SchemaRegistry { const sorted = [...contributions].sort((a, b) => a.priority - b.priority); for (const c of sorted) { if (!c.items.length) continue; + // [#28117] A disabled package's contributions leave the app, as its + // objects leave the data plane: "disabled" has one meaning (hidden + + // refused + data kept). Asked per read, never cached, so an enable + // restores them with nothing to invalidate; skipped BEFORE the group + // lookup, so a disabled contribution is not relocated or diagnosed either. + if (this.isPackageDisabled(c.packageId)) continue; if (c.group) { const group = this.findNavGroup(nav, c.group); if (group) { diff --git a/packages/runtime/src/domains/packages-disable-app-module.test.ts b/packages/runtime/src/domains/packages-disable-app-module.test.ts new file mode 100644 index 00000000000..711b88a1b34 --- /dev/null +++ b/packages/runtime/src/domains/packages-disable-app-module.test.ts @@ -0,0 +1,409 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28117] An admin may disable a `type: 'module'` package of an installed + * `type: 'app'` package; every other read-only package keeps answering + * `422 WRITABLE_PACKAGE_REQUIRED` on the disable door, and the module stays + * read-only for authoring. + * + * ## The ruling + * + * A suite (HotCRM) ships ONE artifact: its `type: 'app'` package and several + * `type: 'module'` packages (ADR-0019 D3, ADR-0130 D2/D6). A customer who does + * not use a module switches it off in settings (plan A, the platform-level hard + * switch): the customer's own admin switches it, per environment, and + * "disabled" has exactly ONE meaning — hidden, refused on the data plane, data + * kept. Before this card the disable door asked `isWritablePackage` alone, and + * every package of an installed or code-booted artifact is read-only to it, so + * a suite module could never be switched off. + * + * ## The tie, as measured + * + * "Belongs to an installed app" is read off what the registry row carries, and + * on both ways a suite arrives — booted from code (`AppPlugin` → the `manifest` + * service, `source: 'code'`) and installed from the marketplace (the `manifest` + * service with `source: 'marketplace'`) — the row carries the same three keys: + * the module's `manifest.type`, its `manifest.dependencies`, and its + * `manifest.namespace`. The artifact's own package list (`ArtifactInstallScope`) + * is NOT carried: it exists only for the length of the registration call. So + * the tie is the module's DIRECT declared dependency on an installed package + * whose `manifest.type` is `'app'` (ADR-0141 D1: a dependency is what a module + * declares about the app it extends). Both shapes are booted below and asserted + * to answer alike. + * + * ## The rest of "disabled" was already in place, and is pinned here + * + * Once the module is disabled, the data plane answers its objects + * `404 OBJECT_PACKAGE_DISABLED`, its navigation contributions leave the app + * (`SchemaRegistry.applyNavContributions`, this card), and the choice is + * written to the environment's state file and replayed at boot + * (`package-state-store.ts`, `AppPlugin.seedPersistedDisabledPackages`). + * + * ## What an app hook meets (scope addendum 6523684730) + * + * The app package's own code may read a module's object in-process, through a + * hook's `ctx.api.object(name)`. That read reaches the engine directly, not the + * data plane, and is measured and pinned below exactly as it answers: this PR + * changes no answer for any caller. + * + * The negative control (the module case red with the door's exemption ablated) + * is a one-time ablation, cited in the PR, not a permanent case. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { AppPlugin } from '../app-plugin.js'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const ENVIRONMENT_ID = 'env_suite_modules'; +const ADMIN = { + userId: 'u_admin', tenantId: 'org_qa', isSystem: false, + positions: [], permissions: [], systemPermissions: ['manage_metadata'], +}; + +/** The suite's packages: the app, two of its modules, and a plugin it carries. */ +const APP = 'com.qa.suite'; +const MODULE = 'com.qa.suite.orders'; +/** A module that depends on the suite's PLUGIN, not on its app. */ +const MODULE_OF_PLUGIN = 'com.qa.suite.addon'; +const PLUGIN = 'com.qa.suite.tools'; +/** Registered on their own, beside the suite. */ +const ORPHAN_MODULE = 'com.qa.loose'; +const STRAY_MODULE = 'com.qa.stray'; +const PLATFORM_MODULE = 'com.qa.platform.module'; +const PLATFORM_PLUGIN = 'com.qa.platform'; + +const ORDER = 'qs_order'; +const ACCOUNT = 'qs_account'; +const APP_NAME = 'qs_app'; + +const textField = (label: string) => ({ name: label.toLowerCase(), type: 'text' as const, label }); + +/** The suite artifact, composed the way HotCRM composes its four packages. */ +function suiteArtifact() { + const app = defineStack({ + manifest: { id: APP, name: 'QA Suite', namespace: 'qs', version: '1.0.0', type: 'app' }, + objects: [{ name: ACCOUNT, label: 'Account', fields: { name: textField('Name') } }], + apps: [{ + name: APP_NAME, + label: 'QA Suite', + navigation: [{ + id: 'qs_group', type: 'group', label: 'Sales', + children: [{ id: 'nav_accounts', type: 'object', objectName: ACCOUNT, label: 'Accounts' }], + }], + }], + }); + const orders = defineStack({ + manifest: { + id: MODULE, name: 'QA Suite Orders', namespace: 'qs', version: '1.0.0', type: 'module', + dependencies: { [APP]: '^1.0.0' }, + navigationContributions: [{ + app: APP_NAME, group: 'qs_group', priority: 50, + items: [{ id: 'nav_orders', type: 'object', objectName: ORDER, label: 'Orders' }], + }], + }, + objects: [{ name: ORDER, label: 'Order', fields: { name: textField('Name') } }], + }); + const tools = defineStack({ + manifest: { + id: PLUGIN, name: 'QA Suite Tools', namespace: 'qs', version: '1.0.0', type: 'plugin', + dependencies: { [APP]: '^1.0.0' }, + }, + }); + const addon = defineStack({ + manifest: { + id: MODULE_OF_PLUGIN, name: 'QA Suite Addon', namespace: 'qs', version: '1.0.0', type: 'module', + dependencies: { [PLUGIN]: '^1.0.0' }, + }, + }); + return composeStacks([orders, addon, tools, app], { manifest: 'preserve' }); +} + +/** The packages registered beside the suite, each through the `manifest` door on its own. */ +const STANDALONE = [ + // A module that declares no app at all. + { id: ORPHAN_MODULE, name: 'Loose module', namespace: 'ql', version: '1.0.0', type: 'module' }, + // A module whose declared app is not installed. + { id: STRAY_MODULE, name: 'Stray module', namespace: 'qy', version: '1.0.0', type: 'module', dependencies: { 'com.qa.absent': '^1.0.0' } }, + // A platform-delivered module of the installed app: `system` scope keeps it the platform's. + { id: PLATFORM_MODULE, name: 'Platform module', namespace: 'qp', version: '1.0.0', type: 'module', scope: 'system', dependencies: { [APP]: '^1.0.0' } }, + { id: PLATFORM_PLUGIN, name: 'Platform plugin', namespace: 'qz', version: '1.0.0', type: 'plugin', scope: 'system' }, +]; + +interface ManifestDoor { + register(artifact: unknown, options?: { source?: 'code' | 'marketplace' }): Promise | void; +} + +type InstallPath = 'code' | 'marketplace'; + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; +} + +/** + * One kernel, the suite arriving by `path`: + * - `code`: the suite is the `AppPlugin`'s bundle, registered through the + * `manifest` service as a `defineStack()` host boots it; + * - `marketplace`: the `AppPlugin` carries no payload (an empty environment) + * and the suite is registered through the `manifest` service with + * `source: 'marketplace'`, as the install-local door registers it. + * The `AppPlugin` is there on both, with the environment's id, because it is + * what replays the state file at boot. + */ +async function bootKernel(path: InstallPath, root: string): Promise { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any, true); + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.use(new AppPlugin(path === 'code' ? suiteArtifact() : {}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_qa' })); + const host: Plugin = { + name: 'qa.suite-host', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + const door = ctx.getService('manifest'); + if (path === 'marketplace') await door.register(suiteArtifact(), { source: 'marketplace' }); + for (const manifest of STANDALONE) await door.register(manifest); + }, + }; + await kernel.use(host); + await kernel.bootstrap(); + await engine.syncSchemas(); + return { kernel, engine }; +} + +async function shutdown(booted: Booted | undefined): Promise { + if (booted && booted.kernel.getState() === 'running') await booted.kernel.shutdown(); +} + +const codeOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.code : body?.code); + +/** What the runtime dispatcher answers the admin on `method path`, through `dispatch()` and the host's exit for a throw. */ +async function dispatchAs(kernel: ObjectKernel, method: string, path: string, body: Record = {}) { + const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...ADMIN }); + const raw = new Request(`http://localhost/api/v1${path}`, { + method, + headers: { 'content-type': 'application/json' }, + ...(method === 'GET' ? {} : { body: JSON.stringify(body) }), + }); + try { + const out: any = await dispatcher.dispatch(method, path, body, {}, { request: raw, environmentId: ENVIRONMENT_ID } as any, '/api/v1'); + const answer = out.response?.body; + return { status: out.response?.status as number | undefined, code: codeOf(answer), data: answer?.data }; + } catch (e) { + const thrown = resolveThrownHttpError(e); + return { status: thrown.status as number | undefined, code: thrown.code as unknown, data: undefined }; + } +} + +const PACKAGE_DISABLED = { status: 404, code: 'OBJECT_PACKAGE_DISABLED' } as const; +const READ_ONLY = { status: 422, code: 'WRITABLE_PACKAGE_REQUIRED' } as const; + +/** The navigation ids of the app's `qs_group`, as `GET /meta/app/qs_app` serves it. */ +async function servedGroupIds(kernel: ObjectKernel): Promise { + const read = await dispatchAs(kernel, 'GET', `/meta/app/${APP_NAME}`); + expect(read.status, 'precondition: the app reads').toBe(200); + const app = read.data?.item ?? read.data; + const group = (app?.navigation ?? []).find((n: any) => n.id === 'qs_group'); + return (group?.children ?? []).map((c: any) => c.id); +} + +/** The same ids, as the registry's own helper folds them. */ +const registryGroupIds = (engine: ObjectQL): string[] => + ((engine as any).registry.getApp(APP_NAME)?.navigation ?? []) + .find((n: any) => n.id === 'qs_group')?.children?.map((c: any) => c.id) ?? []; + +const enabledOf = (engine: ObjectQL, id: string): unknown => (engine as any).registry.getPackage(id)?.enabled; + +let home: string; +let priorHome: string | undefined; +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-suite-modules-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +for (const path of ['code', 'marketplace'] as const) { + describe(`[#28117] disabling a module of an installed app — the suite installed by ${path}`, () => { + let root: string; + let booted: Booted | undefined; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), `os-suite-${path}-`)); + // Each install path keeps its own state file, so one describe's disable never seeds the other's boot. + writeFileSync(join(root, '.keep'), ''); + process.env.OS_HOME = mkdtempSync(join(home, `${path}-`)); + booted = await bootKernel(path, root); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + rmSync(root, { recursive: true, force: true }); + }, 60_000); + + it('measures the tie the registry row carries: type, dependencies and namespace, on this install path', () => { + const row = (booted!.engine as any).registry.getPackage(MODULE); + expect(row?.source).toBe(path); + expect({ type: row?.manifest?.type, dependencies: row?.manifest?.dependencies, namespace: row?.manifest?.namespace }) + .toEqual({ type: 'module', dependencies: { [APP]: '^1.0.0' }, namespace: 'qs' }); + expect((booted!.engine as any).registry.getPackage(APP)?.manifest?.type).toBe('app'); + // Read-only for authoring on both paths: booted into `engine.manifests`. + expect((booted!.engine as any).manifests.has(MODULE)).toBe(true); + }); + + it('serves the module while it is enabled (the control)', async () => { + const created = await dispatchAs(booted!.kernel, 'POST', `/data/${ORDER}`, { name: 'kept' }); + expect(created.status, 'precondition: a record of the module object is written').toBe(201); + expect((await dispatchAs(booted!.kernel, 'GET', `/data/${ORDER}`)).status).toBe(200); + expect(await servedGroupIds(booted!.kernel)).toEqual(['nav_accounts', 'nav_orders']); + expect(registryGroupIds(booted!.engine)).toEqual(['nav_accounts', 'nav_orders']); + }); + + it('disables the module (200): its objects answer 404 OBJECT_PACKAGE_DISABLED and its nav items leave the app', async () => { + const disabled = await dispatchAs(booted!.kernel, 'PATCH', `/packages/${MODULE}/disable`); + expect(disabled.status).toBe(200); + expect(disabled.data?.enabled).toBe(false); + expect(enabledOf(booted!.engine, MODULE)).toBe(false); + + const read = await dispatchAs(booted!.kernel, 'GET', `/data/${ORDER}`); + expect({ status: read.status, code: read.code }).toEqual(PACKAGE_DISABLED); + expect(await servedGroupIds(booted!.kernel)).toEqual(['nav_accounts']); + expect(registryGroupIds(booted!.engine)).toEqual(['nav_accounts']); + // The app itself is untouched. + expect((await dispatchAs(booted!.kernel, 'GET', `/data/${ACCOUNT}`)).status).toBe(200); + }); + + it('keeps the module read-only for authoring while it may be disabled: edit, publish and delete still 422', async () => { + const edit = await dispatchAs(booted!.kernel, 'PATCH', `/packages/${MODULE}`, { name: 'Renamed' }); + expect({ status: edit.status, code: edit.code }).toEqual(READ_ONLY); + const publish = await dispatchAs(booted!.kernel, 'POST', `/packages/${MODULE}/publish`); + expect({ status: publish.status, code: publish.code }).toEqual(READ_ONLY); + const del = await dispatchAs(booted!.kernel, 'DELETE', `/packages/${MODULE}`); + expect({ status: del.status, code: del.code }).toEqual(READ_ONLY); + expect((booted!.engine as any).registry.getPackage(MODULE)?.manifest?.name).toBe('QA Suite Orders'); + }); + + it('re-enables the module (200): its objects and its nav items come back, with the kept record', async () => { + const enabled = await dispatchAs(booted!.kernel, 'PATCH', `/packages/${MODULE}/enable`); + expect(enabled.status).toBe(200); + const read = await dispatchAs(booted!.kernel, 'GET', `/data/${ORDER}`); + expect(read.status).toBe(200); + expect(JSON.stringify(read.data)).toContain('kept'); + expect(await servedGroupIds(booted!.kernel)).toEqual(['nav_accounts', 'nav_orders']); + expect(registryGroupIds(booted!.engine)).toEqual(['nav_accounts', 'nav_orders']); + }); + + const REFUSED: ReadonlyArray<{ label: string; id: string }> = [ + { label: 'the app package itself', id: APP }, + { label: 'a plugin the suite carries', id: PLUGIN }, + { label: 'a module that depends on a plugin, not on an app', id: MODULE_OF_PLUGIN }, + { label: 'an orphan module that declares no app', id: ORPHAN_MODULE }, + { label: 'a module whose declared app is not installed', id: STRAY_MODULE }, + { label: 'a platform (system-scoped) module of the installed app', id: PLATFORM_MODULE }, + { label: 'a platform (system-scoped) plugin', id: PLATFORM_PLUGIN }, + ]; + for (const { label, id } of REFUSED) { + it(`still refuses to disable ${label}: 422 WRITABLE_PACKAGE_REQUIRED, and it stays enabled`, async () => { + expect((booted!.engine as any).registry.getPackage(id), `precondition: ${id} is registered`).toBeDefined(); + const answer = await dispatchAs(booted!.kernel, 'PATCH', `/packages/${id}/disable`); + expect({ status: answer.status, code: answer.code }).toEqual(READ_ONLY); + expect(enabledOf(booted!.engine, id)).toBe(true); + }); + } + }); +} + +describe('[#28117] a disabled module stays disabled across a restart (the environment\'s state file)', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-suite-restart-')); + process.env.OS_HOME = mkdtempSync(join(home, 'restart-')); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it('boots the module disabled after a disable through the door, and enabled again after an enable', async () => { + const first = await bootKernel('code', root); + try { + expect((await dispatchAs(first.kernel, 'PATCH', `/packages/${MODULE}/disable`)).status).toBe(200); + } finally { + await shutdown(first); + } + + const second = await bootKernel('code', root); + try { + expect(enabledOf(second.engine, MODULE)).toBe(false); + const read = await dispatchAs(second.kernel, 'GET', `/data/${ORDER}`); + expect({ status: read.status, code: read.code }).toEqual(PACKAGE_DISABLED); + expect(registryGroupIds(second.engine)).toEqual(['nav_accounts']); + expect((await dispatchAs(second.kernel, 'PATCH', `/packages/${MODULE}/enable`)).status).toBe(200); + } finally { + await shutdown(second); + } + + const third = await bootKernel('code', root); + try { + expect(enabledOf(third.engine, MODULE)).toBe(true); + expect(registryGroupIds(third.engine)).toEqual(['nav_accounts', 'nav_orders']); + } finally { + await shutdown(third); + } + }, 120_000); +}); + +describe('[#28117 addendum 6523684730] what an app hook meets reading a disabled module\'s object through ctx.api.object()', () => { + let root: string; + let booted: Booted | undefined; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'os-suite-hook-')); + process.env.OS_HOME = mkdtempSync(join(home, 'hook-')); + booted = await bootKernel('code', root); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + rmSync(root, { recursive: true, force: true }); + }, 60_000); + + it('measures the in-process read: the engine serves it, the data plane refuses the same object', async () => { + expect((await dispatchAs(booted!.kernel, 'POST', `/data/${ORDER}`, { name: 'kept' })).status).toBe(201); + expect((await dispatchAs(booted!.kernel, 'PATCH', `/packages/${MODULE}/disable`)).status).toBe(200); + + // The app package's hook, on the app's own object, reading the module's object. + const seen: Array<{ rows?: number; error?: { code?: unknown; status?: unknown; name?: unknown } }> = []; + booted!.engine.registerHook('afterInsert', async (ctx: any) => { + try { + const rows = await ctx.api.object(ORDER).find({}); + seen.push({ rows: Array.isArray(rows) ? rows.length : -1 }); + } catch (e) { + const err = e as { code?: unknown; status?: unknown; name?: unknown }; + seen.push({ error: { code: err.code, status: err.status, name: err.name } }); + } + }, { object: ACCOUNT, packageId: APP }); + + expect((await dispatchAs(booted!.kernel, 'POST', `/data/${ACCOUNT}`, { name: 'acme' })).status).toBe(201); + expect(seen).toEqual([{ rows: 1 }]); + + // The same object, on the data plane, in the same disabled window. + const read = await dispatchAs(booted!.kernel, 'GET', `/data/${ORDER}`); + expect({ status: read.status, code: read.code }).toEqual(PACKAGE_DISABLED); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 6c83a625c0a..53825564f8b 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -56,7 +56,10 @@ import { // (`saveMetaItem` → `WRITABLE_PACKAGE_REQUIRED`). Imported, never re-spelled: // this defect existed precisely because the lifecycle routes had no copy of it, // and a second copy would be the next place it drifts. -import { isWritablePackage } from '@objectstack/metadata-protocol'; +import { isWritablePackage, READ_ONLY_PACKAGE_SCOPES } from '@objectstack/metadata-protocol'; +// [#28117] The one reader of a manifest's declared dependency ids (ADR-0141), +// shared with the registry's co-owner gate. +import { declaredDependencyIds } from '@objectstack/core'; // [#9960] The uninstall seam's DECLARED shapes, from the same producer and for // the same reason as the predicate above: this door reached `deletePackage` // through `protocol` and routinely sent two keys — `organizationId` @@ -591,6 +594,64 @@ function requireWritablePackage( }; } +/** + * [#28117] Whether `id` is a **module of an installed app** — the one kind of + * read-only package an admin may DISABLE (and only disable) on this server. + * + * ## The ruling + * + * A suite ships one artifact: its `type: 'app'` package and several + * `type: 'module'` packages (ADR-0019 D3, ADR-0130 D2/D6). The maintainer + * ruled plan A for "enable modules in settings": the customer's own admin + * switches a module off, per environment, and "disabled" has exactly ONE + * meaning — hidden, refused on the data plane, data kept. Every package of a + * booted or installed artifact is read-only to {@link isWritablePackage}, so + * without this reading the disable door refused every suite module. + * + * ## The tie + * + * Read off the registry ROW, because the row is what both install paths leave + * behind: a code boot (`AppPlugin` → `manifest.register`) and a marketplace + * install (`manifest.register(…, { source: 'marketplace' })`) both record the + * module's `manifest.type` and `manifest.dependencies`, and neither keeps the + * artifact's own package list past the registration call. So a package is a + * module of an installed app when: + * + * • its `manifest.type` is `'module'`; + * • it DIRECTLY declares (`manifest.dependencies`, ADR-0141 D1) a package + * that is installed here and whose `manifest.type` is `'app'`; + * • its own `manifest.scope` is not a platform scope + * ({@link READ_ONLY_PACKAGE_SCOPES}): a module the platform delivers stays + * the platform's, as every platform package does. + * + * Everything else answers as before: the app itself, a plugin, a driver, a + * module that declares no app, or one whose app is not installed. + * + * ⛔ A reading at the disable door, never a change to {@link isWritablePackage}: + * `writable` keeps meaning "may author into", and stays `false` for a module, + * so edit, publish, revert, delete and overwrite still refuse it + * (`WRITABLE_PACKAGE_REQUIRED`, ADR-0070 D2). The served row does not carry + * this verdict yet: it needs a key in `package-api-assembled.zod.ts`'s + * `SERVED_ROW_VERDICTS`, a spec-lane edit. + */ +function isDisableableAppModule(engine: unknown, id: string): boolean { + const registry = (engine as PackageRowRegistryHost | null | undefined)?.registry; + if (typeof registry?.getPackage !== 'function') return false; + const manifest = registry.getPackage(id)?.manifest; + if (manifest?.type !== 'module') return false; + if (typeof manifest.scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(manifest.scope)) return false; + return declaredDependencyIds(manifest).some( + (dependency) => dependency !== id && registry.getPackage!(dependency)?.manifest?.type === 'app', + ); +} + +/** The registry slice {@link isDisableableAppModule} reads: one row, by id. */ +interface PackageRowRegistryHost { + registry?: { + getPackage?(id: string): { manifest?: { type?: unknown; scope?: unknown; dependencies?: unknown } } | undefined; + }; +} + /** * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. * @@ -1720,7 +1781,15 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // switch off. BEFORE `disablePackage`, and before // `setPackageDisabled` writes the choice to disk — a disable that // lands is not undone by a restart, it is REPLAYED by one. - const readOnly = requireWritablePackage(deps, qlService, id, 'disable'); if (readOnly) return readOnly; + // [#28117] One exemption, for THIS verb only: a module of an + // installed app is the customer admin's to switch off + // ({@link isDisableableAppModule}). Every other door below still + // asks `requireWritablePackage` alone, so the module stays + // read-only for authoring. + const readOnly = isDisableableAppModule(qlService, id) + ? null + : requireWritablePackage(deps, qlService, id, 'disable'); + if (readOnly) return readOnly; const pkg = registry.disablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; try { diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index efe3cf3b992..5cb0687dd26 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -896,3 +896,80 @@ describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', expect(findOneSpy).toHaveBeenCalledWith('gate_calc', expect.objectContaining({ where: { id } })); }, 15000); }); + +/** + * [#28117] A record-change flow bound to an object of a DISABLED package does + * not fire while the package is disabled, and fires again once it is enabled. + * + * "Disabled" has one meaning (hidden + refused on the data plane + data kept), + * and the data plane's refusal stops only the writes that come through it. A + * write made IN-PROCESS — an app hook's `ctx.api.object(...)`, another flow's + * data node, a service — reaches the engine directly, and the engine fires the + * object's lifecycle hooks whatever the package's state. Measured on the base + * tree with this rig: the flow below stamped `done` on a record of the + * disabled package's object. The trigger now asks the registry whether the + * written object's OWNING package is disabled (`isObjectPackageDisabled`, the + * question the data plane's refusal asks) before it dispatches; the write + * itself is untouched, so the data is kept. + */ +describe('[#28117] record-change flows on an object of a disabled package', () => { + const PKG = 'com.qa.trigger.module'; + const OTHER_PKG = 'com.qa.trigger.app'; + + /** The registry's package-lifecycle members this block drives — public on the concrete registry, outside the slot's contract. */ + type PackageLifecycleRegistry = { + installPackage(manifest: Record, source: 'code'): unknown; + disablePackage(id: string): unknown; + enablePackage(id: string): unknown; + }; + + async function boot() { + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + + const objectql = kernel.getService('objectql'); + const data = kernel.getService('data'); + const automation = kernel.getService('automation'); + const lifecycle = objectql.registry as unknown as PackageLifecycleRegistry; + + await attachSqlite(objectql); + lifecycle.installPackage({ id: PKG, name: PKG, version: '1.0.0', type: 'module', namespace: 'qdm' }, 'code'); + lifecycle.installPackage({ id: OTHER_PKG, name: OTHER_PKG, version: '1.0.0', type: 'app', namespace: 'qda' }, 'code'); + objectql.registry.registerObject(objectDef('qdm_order'), PKG, 'qdm'); + objectql.registry.registerObject(objectDef('qda_account'), OTHER_PKG, 'qda'); + await objectql.syncSchemas(); + await installDefaultOrganization(objectql as never); + automation.registerFlow('qdm_order_stamp', stampFlow('qdm_order_stamp', 'qdm_order') as any); + automation.registerFlow('qda_account_stamp', stampFlow('qda_account_stamp', 'qda_account') as any); + + /** Insert one record of `object` in-process, as a user, and read back its `stamp` once the flow had time to run. */ + const stampAfterInsert = async (object: string): Promise => { + const created: any = await data.insert(object, { status: 'new' }, { context: { userId: 'u_trigger' } }); + const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; + await sleep(200); + const row: any = await data.findOne(object, { where: { id } }); + expect(row, 'the record itself is written: only the flow dispatch is withheld').toBeTruthy(); + return row?.stamp ?? null; + }; + return { kernel, lifecycle, stampAfterInsert }; + } + + it('fires while the package is enabled (the control)', async () => { + const { stampAfterInsert } = await boot(); + expect(await stampAfterInsert('qdm_order')).toBe('done'); + }, 15000); + + it('does not fire while the package is disabled, and fires again once it is re-enabled', async () => { + const { lifecycle, stampAfterInsert } = await boot(); + expect(lifecycle.disablePackage(PKG), 'precondition: disabled').toBeTruthy(); + expect(await stampAfterInsert('qdm_order'), 'a flow on a disabled package\'s object fired').toBeNull(); + // Another package's object, written in the same disabled window: its flow still fires. + expect(await stampAfterInsert('qda_account')).toBe('done'); + + expect(lifecycle.enablePackage(PKG), 'precondition: enabled').toBeTruthy(); + expect(await stampAfterInsert('qdm_order')).toBe('done'); + }, 15000); +}); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index 04e3ca86825..9ce4a4d903d 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -87,6 +87,16 @@ export interface RecordChangeDataEngine { object: string, options: { where?: Record; fields?: string[]; context?: unknown }, ): Promise | null | undefined>; + /** + * [#28117] Optional schema-registry accessor (the ObjectQL engine's + * `registry`, whose `isObjectPackageDisabled` is the question the data + * plane's `OBJECT_PACKAGE_DISABLED` refusal asks). When present, + * {@link RecordChangeTrigger} does not dispatch a flow for a write to an + * object whose OWNING package is disabled. Typed structurally so this + * plugin keeps its zero build-time dependency on objectql; an engine + * without it dispatches as before. + */ + registry?: { isObjectPackageDisabled?(name: string): boolean }; } /** Minimal logger surface (matches core's `ctx.logger`). */ @@ -291,6 +301,17 @@ export class RecordChangeTrigger implements FlowTrigger { if ((ctx.session as { skipTriggers?: boolean } | undefined)?.skipTriggers) { return; } + // [#28117] A disabled package's object: "disabled" has one + // meaning (hidden + refused on the data plane + data kept), and + // a write that reaches the engine in-process (an app hook's + // `ctx.api.object(...)`, another flow, a service) still fires the + // object's lifecycle hooks. The write itself is untouched, so the + // data is kept; only the flow dispatch is withheld, asked per + // write, so an enable resumes it with nothing to re-bind. + const written = binding.object ?? ctx.object; + if (written && this.engine.registry?.isObjectPackageDisabled?.(written) === true) { + return; + } const automationCtx = await this.buildContext(binding, ctx); await callback(automationCtx); } catch (err) {