diff --git a/.changeset/25792-pre-install-dependency-check.md b/.changeset/25792-pre-install-dependency-check.md new file mode 100644 index 00000000000..31554117eb3 --- /dev/null +++ b/.changeset/25792-pre-install-dependency-check.md @@ -0,0 +1,24 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +'@objectstack/runtime': patch +'@objectstack/cloud-connection': patch +'@objectstack/spec': patch +--- + +feat(install): installing a package checks its declared `dependencies` first — every entry must name a package installed at a version inside its range, or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING` (ADR-0141 D2) + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** (an install-acceptance narrowing) for a package whose manifest declares a `dependencies` entry the target environment does not satisfy. The three install doors — `POST /api/v1/packages` (both its protocol arm and its no-protocol fallback), the protocol's `installPackage` called in-process, and `POST /api/v1/marketplace/install-local` — used to install such a package and let the gap surface at its first cross-app save. They now refuse the whole install, before anything is registered, written or synced, with `422 DEPENDENCY_MISSING`. + +An entry `(id, range)` is satisfied when `id` is installed at a `manifest.version` inside `range`, or is delivered by the same artifact (presence only: the packages of one artifact are one tested unit). Ranges are read at exact-version granularity in npm style: `^`, `~`, `>=`, `>`, `<=`, `<`, `=`, an exact version, `x` / `*` wildcards, hyphen ranges, space- or comma-joined intersections and `||` unions. A range outside that grammar (`latest` included) or an installed version that is not `MAJOR.MINOR.PATCH` does not satisfy — the check fails closed. + +The refusal names every unmet entry in one answer: `error.details.issues` carries one `AppCompatibilityCheck` issue per entry (`severity: 'error'`, `category: 'dependency_missing'`), and `error.details.unmet` the facts per entry — `packageId`, `dependency`, `range`, `found` (`absent`, `out-of-range`, `unreadable-range`, `unreadable-version` or `unreadable-declaration`) and the `installedVersion` when one is installed. + +Unchanged: a package that declares no dependencies installs exactly as before; a boot never runs the check (what an install admitted stays admitted); `PATCH /api/v1/packages/:id` still applies a `dependencies` edit as given. + +**What changes for you.** Install a package's dependencies, at versions inside its declared ranges, before the package itself; the refusal says which and at what range. `@objectstack/metadata-core` exports the check (`checkDeclaredDependencies`, `assertDeclaredDependencies`), its error (`DependencyMissingError`, `isDependencyMissingError`, `dependencyMissingAnswer`) and the range reader (`rangeAdmitsVersion`, `readExactVersion`, `DEPENDENCY_RANGE_GRAMMAR`). `DEPENDENCY_MISSING` is registered in the error-code ledger under `@objectstack/metadata-core`. diff --git a/content/docs/deployment/app-dependencies.mdx b/content/docs/deployment/app-dependencies.mdx new file mode 100644 index 00000000000..673beb9aaa6 --- /dev/null +++ b/content/docs/deployment/app-dependencies.mdx @@ -0,0 +1,155 @@ +--- +title: App dependencies — declare one, and what install checks +navTitle: App Dependencies +description: Declare that an app depends on another installed app in manifest.dependencies, the version-range grammar install reads, what the DEPENDENCY_MISSING refusal means, and what build-time validation cannot see. +--- + +An installable app may depend on another installed app and reference its +objects ([ADR-0141](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0141-inter-app-dependency-declared-checked-before-install.md)). +Nothing new is authored for it: the declaration is the `dependencies` key every +manifest already has, and installing the app checks it once. + +## Declare a dependency + +`dependencies` maps the depended-on package's id to a version range: + +```ts +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: 'com.acme.psa', + name: 'PSA', + version: '1.0.0', + type: 'app', + namespace: 'psa', + dependencies: { + 'com.acme.crm': '6.x', // "install CRM 6.x first" + }, + }, + // … +}); +``` + +The declaration does two things: + +- **It opens the reference.** The runtime save door judges a list view against + your package's own objects and those of the packages your manifest declares, + transitively; a view bound to an object outside that set is refused there, + `422`, and the fix it prints is this declaration. A dependency opens the + reference, not the data: users still see the other app's records under that + app's own permission sets and sharing rules. +- **It is checked before install** — below. + +Every entry is required; there is no optional arm. Declare only the packages +your app actually references. + +## The version-range grammar + +Install reads each range at exact-version granularity, npm-style: + +| You write | It admits | +|:---|:---| +| `6.x`, `6.*`, `6` | any `6.y.z` | +| `6.1.x`, `6.1` | any `6.1.z` | +| `^6.2.0` | `>=6.2.0 <7.0.0` (`^0.2.3` is `>=0.2.3 <0.3.0`) | +| `~6.2.0` | `>=6.2.0 <6.3.0` | +| `>=6.2.0`, `>6.2.0`, `<=6`, `<7` | the single bound (`<=6` is `<7.0.0`; `>6` is `>=7.0.0`) | +| `6.2.0`, `=6.2.0` | exactly `6.2.0` | +| `6.1.0 - 6.3` | `>=6.1.0 <6.4.0` | +| `>=6.2.0 <7` (or `>=6.2.0, <7`) | both bounds — an intersection | +| `^5.4.0 \|\| ^6.1.0` | either alternative | +| `*`, `x` | any version | + +Anything else — `latest` or another dist-tag, a malformed version — is **not +readable, and an unreadable range never satisfies**: the install is refused +naming the range. The installed side is read as `MAJOR.MINOR.PATCH` (a leading +`v` and `+build` metadata are accepted); a prerelease version such as +`6.1.0-beta.1` is outside the grammar, and a range is never judged satisfied by +one. + +## What install checks + +When you install a package — `POST /api/v1/packages`, `os package install` +through `POST /api/v1/marketplace/install-local`, or an in-process call to the +protocol's `installPackage` — every entry of **that package's own** +`dependencies` must be one of: + +- **installed** in the target environment at a `manifest.version` inside the + declared range; or +- **delivered by the same artifact.** The packages of one release artifact are + one tested unit, so a sibling package the artifact carries counts as present + whatever its version; an in-artifact range mismatch is a build-time concern, + not an install refusal. + +Otherwise the whole install is refused before anything is registered, written +or synced: + +```json +{ + "success": false, + "error": { + "code": "DEPENDENCY_MISSING", + "httpStatus": 422, + "message": "Install refused: a declared dependency is not satisfied in this environment, and nothing was installed. …", + "details": { + "compatible": false, + "issues": [ + { "severity": "error", "category": "dependency_missing", "message": "Package 'com.acme.psa' depends on 'com.acme.crm' 6.x, which is not installed. …" } + ], + "unmet": [ + { "packageId": "com.acme.psa", "dependency": "com.acme.crm", "range": "6.x", "found": "absent" } + ] + } + } +} +``` + +Every unmet entry is reported in that one answer. `found` says what was wrong +with each: + +| `found` | Meaning | Fix | +|:---|:---|:---| +| `absent` | The dependency is not installed | Install it, at a version inside the range, first | +| `out-of-range` | Installed at `installedVersion`, outside the range | Install a version inside the range, or widen the range and republish | +| `unreadable-range` | The declared range is outside the grammar above | Rewrite the range and republish | +| `unreadable-version` | The installed version is not `MAJOR.MINOR.PATCH` | Install a release version of the dependency | +| `unreadable-declaration` | `dependencies` is not a `{ "PACKAGE_ID": "RANGE" }` record | Rewrite the declaration and republish | + +Only the package's own entries are judged: a dependency's dependencies were +judged when it was installed. Whether the dependency is enabled is not judged. + +## What install does not check + +The check runs at those install acts only. It does not run: + +- **at boot.** A restart re-registers what an install already admitted, while + the registry is still filling; it never refuses a package for a dependency it + has not seen yet. +- **at `PATCH /api/v1/packages/:id`.** Editing an authored package's + `dependencies` after install is applied as given; an entry added there is + judged when the package is next installed. +- **at uninstall or upgrade of the dependency.** Removing or upgrading the app + others depend on is not refused today; the refusal at install is the one gate. + +## Sharing a namespace with the app you depend on + +Each package's namespace must still be its own across artifacts: a package that +uses the namespace of the app it depends on is refused at install with +`NAMESPACE_CONFLICT`, exactly as before. Inside one artifact, packages may share +a namespace ([ADR-0130](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0130-release-artifact-as-co-ownership-boundary.md)). +Dependency-declared namespace co-ownership across artifacts (ADR-0141 D5) is +not delivered yet. + +## Build-time validation cannot see a dependency's objects + +`os validate`, `os build` and `os lint` judge one stack: they cannot see the +objects of an app your manifest depends on, so a binding to one is neither +admitted nor refused there. The runtime save door is where a cross-app +reference is judged, against the declared dependencies. + +## Related + +- [Install modes](/docs/deployment/install-modes) — managed or an unmanaged copy +- [Publish and preview](/docs/deployment/publish-and-preview) — how a package version reaches an environment +- [Plugin manifest spec](/docs/protocol/kernel/plugin-spec) — the manifest's keys diff --git a/content/docs/deployment/meta.json b/content/docs/deployment/meta.json index 83f927e2ea2..cb2ca24f83d 100644 --- a/content/docs/deployment/meta.json +++ b/content/docs/deployment/meta.json @@ -9,6 +9,7 @@ "single-project-mode", "publish-and-preview", "install-modes", + "app-dependencies", "cloud-connection", "validating-metadata", "cli", diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 376673f84ca..83dbde93b14 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -468,17 +468,21 @@ Use for large dependencies (React, Vue) that should be shared across plugins. ### Version Constraints -**The kernel does not check these range strings at plugin load.** `manifest.dependencies` -has exactly two consumers today and both read `Object.keys(...)` only — the ids, never the -ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) hands the -ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling package in -the same artifact orders ahead of it and an id naming anything else is skipped; and -`resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids -transitively for the write-scope gate. `SemanticVersionManager` and `DependencyResolver` -below are exported from `@objectstack/core` but have no call site outside their own unit -test. So a range here is a **declaration for the installer and for human readers** — a -stale or malformed one fails nothing at load. The forms below are the ones the parser in -`dependency-resolver.ts` accepts, not a set the kernel enforces on you. +**The kernel does not check these range strings at plugin load — installing a package +does.** `manifest.dependencies` has three consumers. Two read `Object.keys(...)` only — the +ids, never the ranges: `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts`) +hands the ids to `resolvePluginOrder` as `optionalDependencies`, so an id naming a sibling +package in the same artifact orders ahead of it and an id naming anything else is skipped; +and `resolveWritePackageScope` (`packages/metadata-protocol/src/protocol.ts`) walks the ids +transitively for the write-scope gate. The third is the pre-install dependency check every +install door runs (ADR-0141 D2): each entry must name a package installed inside its range, +or one the same artifact delivers, or the install is refused `422 DEPENDENCY_MISSING`. That +check reads ranges with `rangeAdmitsVersion` (`packages/metadata-core/src/protocol-handshake.ts`), +whose grammar is on [App dependencies](/docs/deployment/app-dependencies) — not with +`SemanticVersionManager`. `SemanticVersionManager` and `DependencyResolver` below are +exported from `@objectstack/core` but have no call site outside their own unit test; the +forms below are the ones the parser in `dependency-resolver.ts` accepts, not the grammar an +install enforces. ObjectStack uses **semantic versioning (semver)**. `SemanticVersionManager.satisfies()` diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2c9a40096cc..7a8d5fa41e9 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -463,6 +463,7 @@ const result = AutomationApiErrorCode.parse(data); * `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE` * `DELIVERY_NEVER_SENT` * `DELIVERY_NOT_ELIGIBLE` +* `DEPENDENCY_MISSING` * `DESTRUCTIVE_CHANGE` * `DEVICE_CODE_FAILED` * `DOMAIN_VERIFICATION_DISABLED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index c0d1d505b8d..c1d6f119dfd 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +344 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -144,6 +144,7 @@ const result = ApiErrorSchema.parse(data); * `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE` * `DELIVERY_NEVER_SENT` * `DELIVERY_NOT_ELIGIBLE` +* `DEPENDENCY_MISSING` * `DESTRUCTIVE_CHANGE` * `DEVICE_CODE_FAILED` * `DOMAIN_VERIFICATION_DISABLED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 35335308779..806298c7c96 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -311,6 +311,7 @@ const result = ErrorCode.parse(data); * `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE` * `DELIVERY_NEVER_SENT` * `DELIVERY_NOT_ELIGIBLE` +* `DEPENDENCY_MISSING` * `DESTRUCTIVE_CHANGE` * `DEVICE_CODE_FAILED` * `DOMAIN_VERIFICATION_DISABLED` diff --git a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts new file mode 100644 index 00000000000..70f33af4582 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D2 at `POST /api/v1/marketplace/install-local`: an install whose + * delivered packages declare `dependencies` this runtime does not satisfy is + * refused `422 DEPENDENCY_MISSING`, with the answer the other package-install + * doors give, before the manifest service sees the artifact. + * + * ## What these cases pin + * + * - the refusal on both install branches (inline manifest, cloud snapshot): + * the declared envelope, the code and status, and the closed details shape + * (`compatible`, `issues`, `unmet`); + * - nothing is registered, no ledger file is written, no schema is synced; + * - a dependency installed inside its range is admitted (the lit control); + * - a multi-package artifact whose module declares its app — delivered by + * the same artifact — installs, judged for presence only, while an entry + * naming a package outside the artifact is still judged; + * - the `kernel:ready` rehydrate does NOT run the check: a ledger entry whose + * dependency is absent at boot still re-registers (ADR-0141 D2, "where it + * does not run"). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +// An install binds the package's handlers through `@objectstack/runtime` (a +// lazy `import()` inside the plugin); its first load is paid here, at module +// top, never inside a clocked `it`. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; + +type Handler = (c: any) => Promise; + +const ROUTE = '/api/v1/marketplace/install-local'; + +/** The dependent: 「请先安装 Sales 6.x」. */ +const PSA = { + id: 'com.example.psa', name: 'PSA', version: '1.0.0', scope: 'project', type: 'app', + dependencies: { 'com.example.sales': '6.x' }, +}; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** + * Mount the plugin through its real lifecycle (`start()` + `kernel:ready`), + * over a registry that already holds `installed` (id → version). + */ +async function mount(dir: string, installed: Record = {}, opts: { controlPlaneUrl?: string } = {}) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const registered: any[] = []; + const register = vi.fn((m: any) => { registered.push({ manifest: m }); }); + const syncSchemas = vi.fn(async () => undefined); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const registry = { + getAllPackages: () => registered, + getPackage: (id: string) => (id in installed ? { manifest: { id, version: installed[id] } } : undefined), + }; + const services: Record = { + manifest: { register }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas, registry }), + }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: opts.controlPlaneUrl ?? 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + const registeredAtBoot = register.mock.calls.length; + const syncedAtBoot = syncSchemas.mock.calls.length; + return { + install: (body: unknown) => rawApp.routes.get(`POST ${ROUTE}`)!(makeC(body)), + installRegistrations: () => register.mock.calls.slice(registeredAtBoot).map((call) => call[0]), + installSyncs: () => syncSchemas.mock.calls.length - syncedAtBoot, + bootRegistrations: () => register.mock.calls.slice(0, registeredAtBoot).map((call) => call[0]), + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request(`http://localhost:3000${ROUTE}`), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +function expectDependencyRefusal(res: { payload: any; status: number }, unmet: unknown[]) { + expect(res.status, JSON.stringify(res.payload)).toBe(422); + expect(BaseResponseSchema.safeParse(res.payload).success).toBe(true); + expect(envelopeViolations(res.payload)).toEqual([]); + expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true); + const { error } = res.payload; + expect(error.code).toBe('DEPENDENCY_MISSING'); + expect(Object.keys(error.details).sort()).toEqual(['compatible', 'issues', 'unmet']); + expect(error.details.compatible).toBe(false); + expect(error.details.unmet).toEqual(unmet); + expect(error.details.issues.map((i: any) => i.category)).toEqual(unmet.map(() => 'dependency_missing')); +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-dependency-')); }); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function expectNothingWritten(h: { installRegistrations: () => unknown[]; installSyncs: () => number }) { + expect(h.installRegistrations()).toEqual([]); + expect(h.installSyncs()).toBe(0); + expect(readdirSync(dir)).toEqual([]); +} + +describe('POST install-local: ADR-0141 D2 refuses an unsatisfied dependency, and installs nothing', () => { + it('refuses an absent dependency (inline manifest)', async () => { + const h = await mount(dir); + expectDependencyRefusal(await h.install({ manifest: PSA }), [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' }, + ]); + expectNothingWritten(h); + }); + + it('refuses a dependency installed outside the range', async () => { + const h = await mount(dir, { 'com.example.sales': '5.4.0' }); + expectDependencyRefusal(await h.install({ manifest: PSA }), [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'out-of-range', installedVersion: '5.4.0' }, + ]); + expectNothingWritten(h); + }); + + it('refuses a cloud snapshot the same way: 422, not the 502 of an upstream fault', async () => { + vi.stubEnv('OS_MARKETPLACE_PUBLIC_BASE_URL', 'off'); + vi.stubEnv('OS_CLOUD_API_KEY', 'svc-test-key'); + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ data: { manifest: PSA, version: '1.0.0', version_id: 'ver_1' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )); + vi.stubGlobal('fetch', fetchMock); + const h = await mount(dir, {}, { controlPlaneUrl: 'http://cloud.test' }); + const res = await h.install({ packageId: 'pkg_psa' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expectDependencyRefusal(res, [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' }, + ]); + expectNothingWritten(h); + }); + + it('lit control: installs once the dependency is installed inside the range', async () => { + const h = await mount(dir, { 'com.example.sales': '6.2.0' }); + const res = await h.install({ manifest: PSA }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(h.installRegistrations()).toHaveLength(1); + }); +}); + +describe('POST install-local: a multi-package artifact judges each entry, presence-only inside the artifact', () => { + const artifact = (ordersDeps: Record) => ({ + id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app', + packages: [ + { manifest: { id: 'com.example.multi.orders', name: 'Orders', version: '1.0.0', type: 'module', dependencies: ordersDeps } }, + { manifest: { id: 'com.example.multi.core', name: 'Core', version: '2.0.0', type: 'app' } }, + ], + }); + + it('admits a module that declares its app delivered beside it, whatever the range says', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: artifact({ 'com.example.multi.core': '^1.0.0' }) }); + expect(res.status, JSON.stringify(res.payload)).not.toBe(422); + expect(res.payload?.error?.code).not.toBe('DEPENDENCY_MISSING'); + }); + + it('still judges an entry naming a package outside the artifact', async () => { + const h = await mount(dir); + expectDependencyRefusal( + await h.install({ manifest: artifact({ 'com.example.multi.core': '^1.0.0', 'com.example.sales': '^6' }) }), + [{ packageId: 'com.example.multi.orders', dependency: 'com.example.sales', range: '^6', found: 'absent' }], + ); + expectNothingWritten(h); + }); +}); + +describe('kernel:ready rehydrate: the check does not run at re-registration', () => { + it('re-registers a ledger entry whose dependency is absent at boot', async () => { + new LocalManifestSource(dir).write({ + packageId: PSA.id, versionId: PSA.version, manifestId: PSA.id, version: PSA.version, + manifest: PSA, installedAt: '2026-01-01T00:00:00.000Z', installedBy: 'admin', withSampleData: false, + }); + const h = await mount(dir); + expect(h.bootRegistrations().map((m: any) => m.id)).toContain(PSA.id); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index d5ba73aef27..72271f58175 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -182,6 +182,11 @@ import { import { assertProtocolCompat, checkProtocolCompat, + // [ADR-0141 D2] The pre-install dependency check: the one predicate and + // the one answer every package-install door gives its refusal. + checkDeclaredDependencies, + DependencyMissingError, + dependencyMissingAnswer, isProtocolIncompatibleError, protocolIncompatibleAnswer, ProtocolIncompatibleError, @@ -1536,6 +1541,45 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, refusal.status); } + // 1c'. [ADR-0141 D2] ⭐ THE PRE-INSTALL DEPENDENCY CHECK, beside the + // handshake and as early — before the manifest service sees the + // artifact, so a refused install registers, writes and syncs + // nothing. Every package this act delivers is judged on its OWN + // `manifest.dependencies`: the entries of `packages[]` for a + // multi-package artifact, the manifest itself otherwise. A + // dependency the same artifact delivers (an entry, or the + // artifact's own identity) is judged for presence only; any other + // must be installed in this runtime's registry at a version inside + // its range. The refusal is the other install doors' answer, from + // the one shared helper, 422 on both branches: a dependency this + // environment lacks is the environment's state against a body as + // delivered, never an upstream fault. + // + // ⛔ Not at the `kernel:ready` rehydrate, which re-registers what an + // install already admitted while the boot is still filling the + // registry (AGENTS.md, *Startup registry reads*). + { + const entries = Array.isArray(manifest.packages) + ? manifest.packages + .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined)) + .filter((m: unknown): m is Record => m !== undefined) + : [manifest]; + let registry: { getPackage?(id: string): any } | undefined; + try { registry = ctx.getService('objectql')?.registry; } catch { /* no data engine — nothing installed */ } + const unmet = checkDeclaredDependencies({ + delivered: entries, + deliveredIds: [manifestId], + getInstalled: (id) => registry?.getPackage?.(id), + }); + if (unmet.length > 0) { + const refusal = dependencyMissingAnswer(new DependencyMissingError(unmet)); + return c.json({ + success: false, + error: { code: refusal.code, message: refusal.message, details: refusal.details }, + }, refusal.status); + } + } + // 1e. [ADR-0131 D6] ⭐ THE INSTALL MODE — after the payload, id, D3 // walled-catalog and handshake checks and before every other // judgement of the package and any write, because nothing an diff --git a/packages/metadata-core/src/protocol-handshake-dependencies.test.ts b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts new file mode 100644 index 00000000000..b2ef58fe512 --- /dev/null +++ b/packages/metadata-core/src/protocol-handshake-dependencies.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D2 — the range reader beside the protocol handshake, and the one + * pre-install dependency predicate every install door calls. + * + * The reader answers at EXACT-VERSION granularity (`^6.2.0` refuses an + * installed `6.1.0`, which `rangeAdmitsMajor` admits), over the grammar the + * handshake family already reads. The predicate judges each declared entry + * of the delivered packages: present (installed, or delivered by the same + * act) and, for an installed record, in range. An unreadable range or version + * does not satisfy. + */ + +import { describe, expect, it } from 'vitest'; +import { + assertDeclaredDependencies, + checkDeclaredDependencies, + dependencyMissingAnswer, + DependencyMissingError, + isDependencyMissingError, + rangeAdmitsMajor, + rangeAdmitsVersion, + readExactVersion, +} from './protocol-handshake.js'; + +describe('rangeAdmitsVersion — exact-version granularity over the handshake grammar', () => { + it('caret: the ruling\'s own 「HotCRM 6.x」 spellings, and the minor/patch floor the major reader cannot see', () => { + expect(rangeAdmitsVersion('^6.2.0', '6.2.0')).toBe(true); + expect(rangeAdmitsVersion('^6.2.0', '6.9.1')).toBe(true); + expect(rangeAdmitsVersion('^6.2.0', '7.0.0')).toBe(false); + // The reason this reader exists: the major reader admits it, D2 cannot. + expect(rangeAdmitsMajor('^6.2.0', 6)).toBe(true); + expect(rangeAdmitsVersion('^6.2.0', '6.1.0')).toBe(false); + expect(rangeAdmitsVersion('^6', '6.0.0')).toBe(true); + expect(rangeAdmitsVersion('^6', '5.9.9')).toBe(false); + // npm's zero-major caret narrows to the first non-zero part. + expect(rangeAdmitsVersion('^0.2.3', '0.2.9')).toBe(true); + expect(rangeAdmitsVersion('^0.2.3', '0.3.0')).toBe(false); + expect(rangeAdmitsVersion('^0.0.3', '0.0.4')).toBe(false); + expect(rangeAdmitsVersion('^0.0', '0.0.9')).toBe(true); + expect(rangeAdmitsVersion('^0.0', '0.1.0')).toBe(false); + }); + + it('tilde pins the minor when one is written, the major otherwise', () => { + expect(rangeAdmitsVersion('~6.1.2', '6.1.9')).toBe(true); + expect(rangeAdmitsVersion('~6.1.2', '6.1.1')).toBe(false); + expect(rangeAdmitsVersion('~6.1.2', '6.2.0')).toBe(false); + expect(rangeAdmitsVersion('~6', '6.9.0')).toBe(true); + expect(rangeAdmitsVersion('~6', '7.0.0')).toBe(false); + }); + + it('x and * wildcards, bare partials, and the full wildcard', () => { + expect(rangeAdmitsVersion('6.x', '6.4.2')).toBe(true); + expect(rangeAdmitsVersion('6.x', '7.0.0')).toBe(false); + expect(rangeAdmitsVersion('6.1.*', '6.1.7')).toBe(true); + expect(rangeAdmitsVersion('6.1.*', '6.2.0')).toBe(false); + expect(rangeAdmitsVersion('6', '6.3.0')).toBe(true); + expect(rangeAdmitsVersion('6.1', '6.2.0')).toBe(false); + expect(rangeAdmitsVersion('*', '0.0.1')).toBe(true); + expect(rangeAdmitsVersion('x', '12.0.0')).toBe(true); + }); + + it('an exact version admits that version only', () => { + expect(rangeAdmitsVersion('6.1.2', '6.1.2')).toBe(true); + expect(rangeAdmitsVersion('=6.1.2', '6.1.2')).toBe(true); + expect(rangeAdmitsVersion('6.1.2', '6.1.3')).toBe(false); + }); + + it('single comparators, with npm\'s partial-version desugaring', () => { + expect(rangeAdmitsVersion('>=6.2.0', '6.2.0')).toBe(true); + expect(rangeAdmitsVersion('>=6.2.0', '6.1.9')).toBe(false); + expect(rangeAdmitsVersion('>6.2.0', '6.2.0')).toBe(false); + expect(rangeAdmitsVersion('>6.2.0', '6.2.1')).toBe(true); + // `>6` is `>=7.0.0`; `<=6` is `<7.0.0`; `<6.2` is `<6.2.0`. + expect(rangeAdmitsVersion('>6', '6.9.9')).toBe(false); + expect(rangeAdmitsVersion('>6', '7.0.0')).toBe(true); + expect(rangeAdmitsVersion('<=6', '6.9.9')).toBe(true); + expect(rangeAdmitsVersion('<=6', '7.0.0')).toBe(false); + expect(rangeAdmitsVersion('<6.2', '6.1.9')).toBe(true); + expect(rangeAdmitsVersion('<6.2', '6.2.0')).toBe(false); + // An operator separated from its version by a space reads the same. + expect(rangeAdmitsVersion('>= 6.2.0', '6.2.0')).toBe(true); + }); + + it('space- and comma-joined intersections, hyphen ranges and || unions', () => { + expect(rangeAdmitsVersion('>=6.2.0 <7.0.0', '6.5.0')).toBe(true); + expect(rangeAdmitsVersion('>=6.2.0 <7.0.0', '7.0.0')).toBe(false); + expect(rangeAdmitsVersion('>=6.2.0, <7', '6.1.0')).toBe(false); + expect(rangeAdmitsVersion('6.1.0 - 6.3', '6.3.9')).toBe(true); + expect(rangeAdmitsVersion('6.1.0 - 6.3', '6.4.0')).toBe(false); + expect(rangeAdmitsVersion('6.1.0 - 6.3.0', '6.3.0')).toBe(true); + expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '6.0.0')).toBe(false); + expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '5.9.0')).toBe(true); + expect(rangeAdmitsVersion('^5.4.0 || ^6.1.0', '6.2.0')).toBe(true); + }); + + it('fails closed: a range it cannot read answers null, never true', () => { + for (const range of ['', ' ', 'latest', 'next', '|| ^5', '6.x.foo', '>=', '~>6.0', '^6.0.0.1', 'a'.repeat(200)]) { + expect(rangeAdmitsVersion(range, '6.0.0'), range).toBeNull(); + } + // One unreadable alternative makes a union unreadable unless another admits. + expect(rangeAdmitsVersion('^6 || nonsense', '6.1.0')).toBe(true); + expect(rangeAdmitsVersion('^6 || nonsense', '7.0.0')).toBeNull(); + // An empty alternative is an unreadable one. + expect(rangeAdmitsVersion('^6 ||', '7.0.0')).toBeNull(); + }); + + it('answers null for a version outside MAJOR.MINOR.PATCH (a prerelease included)', () => { + expect(rangeAdmitsVersion('^6', '6')).toBeNull(); + expect(rangeAdmitsVersion('^6', '6.1')).toBeNull(); + expect(rangeAdmitsVersion('^6', '6.1.0-beta.1')).toBeNull(); + expect(rangeAdmitsVersion('^6', 'banana')).toBeNull(); + // A leading `v` and build metadata are read. + expect(readExactVersion('v6.1.0+build.7')).toEqual([6, 1, 0]); + expect(readExactVersion(6)).toBeNull(); + }); +}); + +const installed = (records: Record) => (id: string) => + id in records ? { manifest: { version: records[id] } } : undefined; + +describe('checkDeclaredDependencies — ADR-0141 D2\'s one predicate', () => { + const psa = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0' } }; + + it('admits a dependency installed at a version inside its range', () => { + expect(checkDeclaredDependencies({ delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '6.3.1' }) })).toEqual([]); + }); + + it('reports an absent dependency and one installed outside its range, every entry in one answer', () => { + const pkg = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' } }; + expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({ 'com.hotcrm.sales': '5.9.0' }) })).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'out-of-range', installedVersion: '5.9.0' }, + { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '>=2.1.0', found: 'absent' }, + ]); + }); + + it('fails closed on an unreadable range and on an unreadable installed version, naming which side', () => { + const pkg = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': 'latest', 'com.acme.billing': '^2' } }; + expect(checkDeclaredDependencies({ + delivered: [pkg], + getInstalled: installed({ 'com.hotcrm.sales': '6.0.0', 'com.acme.billing': '2.0.0-rc.1' }), + })).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range', installedVersion: '6.0.0' }, + { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '^2', found: 'unreadable-version', installedVersion: '2.0.0-rc.1' }, + ]); + // An unreadable range is reported as such even when the dependency is + // absent: installing it would not make the entry satisfiable, so the + // range is the fact the author has to act on. + expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({}) })[0]).toEqual( + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range' }, + ); + }); + + it('judges a dependency the same act delivers for presence only, whatever its version', () => { + // ADR-0141 D2 arm 2: inside one artifact the packages are one tested unit. + const core = { id: 'com.example.multi.core', version: '2.0.0' }; + const orders = { id: 'com.example.multi.orders', dependencies: { 'com.example.multi.core': '^1.0.0' } }; + expect(checkDeclaredDependencies({ delivered: [orders, core], getInstalled: installed({}) })).toEqual([]); + // An id delivered by the act without being judged itself (an artifact's own identity). + expect(checkDeclaredDependencies({ + delivered: [orders], deliveredIds: ['com.example.multi.core'], getInstalled: installed({}), + })).toEqual([]); + // Control: delivered by nobody, the same entry is absent. + expect(checkDeclaredDependencies({ delivered: [orders], getInstalled: installed({}) })).toHaveLength(1); + }); + + it('reads a declaration that is not a record as unreadable, never as "no dependencies"', () => { + const pkg = { id: 'com.acme.psa', dependencies: [{ packageId: 'com.hotcrm.sales', versionRange: '^6' }] }; + expect(checkDeclaredDependencies({ delivered: [pkg], getInstalled: installed({}) })).toEqual([ + { packageId: 'com.acme.psa', found: 'unreadable-declaration' }, + ]); + // Absent and empty declarations judge nothing. + expect(checkDeclaredDependencies({ delivered: [{ id: 'a.b.c' }, { id: 'a.b.d', dependencies: {} }], getInstalled: installed({}) })).toEqual([]); + }); +}); + +describe('assertDeclaredDependencies / DependencyMissingError — the ADR-0112 envelope and the one door answer', () => { + const psa = { id: 'com.acme.psa', dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' } }; + + it('returns silently when every entry is satisfied', () => { + expect(() => assertDeclaredDependencies({ + delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '6.0.0', 'com.acme.billing': '2.1.0' }), + })).not.toThrow(); + }); + + it('throws 422 DEPENDENCY_MISSING carrying one dependency_missing issue per unmet entry', () => { + let thrown: unknown; + try { + assertDeclaredDependencies({ delivered: [psa], getInstalled: installed({ 'com.hotcrm.sales': '5.2.0' }) }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(DependencyMissingError); + const err = thrown as DependencyMissingError; + expect(err.code).toBe('DEPENDENCY_MISSING'); + expect(err.status).toBe(422); + expect(err.statusCode).toBe(422); + expect(err.issues).toHaveLength(2); + for (const issue of err.issues) { + expect(issue).toEqual({ severity: 'error', category: 'dependency_missing', message: expect.any(String) }); + } + // Each issue names the id, the range and what was found. + expect(err.issues[0]!.message).toContain("'com.hotcrm.sales'"); + expect(err.issues[0]!.message).toContain('^6.0.0'); + expect(err.issues[0]!.message).toContain('5.2.0'); + expect(err.issues[1]!.message).toContain("'com.acme.billing'"); + expect(err.issues[1]!.message).toContain('not installed'); + expect(isDependencyMissingError(err)).toBe(true); + }); + + it('dependencyMissingAnswer: status, code, the error\'s own message, and a closed details shape', () => { + const err = new DependencyMissingError([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'absent' }, + ]); + const answer = dependencyMissingAnswer(err); + expect(answer.status).toBe(422); + expect(answer.code).toBe('DEPENDENCY_MISSING'); + expect(answer.message).toBe(err.message); + expect(Object.keys(answer.details).sort()).toEqual(['compatible', 'issues', 'unmet']); + expect(answer.details.compatible).toBe(false); + expect(answer.details.issues).toEqual(err.issues); + expect(answer.details.unmet).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'absent' }, + ]); + }); + + it('recognises the brand, not the field names', () => { + const lookalike = Object.assign(new Error('x'), { code: 'DEPENDENCY_MISSING', status: 422, unmet: [] }); + expect(isDependencyMissingError(lookalike)).toBe(false); + expect(isDependencyMissingError(null)).toBe(false); + }); +}); diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts index 314243f3efa..e822aa3ddc5 100644 --- a/packages/metadata-core/src/protocol-handshake.ts +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -20,6 +20,19 @@ * determination that the declared range excludes the runtime major. Absent and * unrecognized ranges are admitted (the former grandfathered, the latter a * parser-coverage gap) so the handshake can never cause a false rejection. + * + * ## The dependency check beside it (ADR-0141 D2) + * + * The same module hosts the pre-install dependency check: one predicate + * ({@link checkDeclaredDependencies}) that every install door runs right after + * {@link assertProtocolCompat}, and the range reader it needs + * ({@link rangeAdmitsVersion}). The reader is a SIBLING of the handshake's + * {@link rangeAdmitsMajor}, sharing its alternative split, its length bound and + * its comparator-operator peel, and differing in the two ways D2 requires: it + * answers at exact-version granularity, and its caller fails CLOSED on what it + * cannot read. The handshake's own verdicts do not move. ⛔ No fourth range + * reader: `parseRangeFloor` and `SemanticVersionManager` are the other two, + * and neither has both the grammar and the granularity. */ import { PROTOCOL_VERSION } from '@objectstack/spec/kernel'; @@ -228,6 +241,45 @@ function rangeAlternatives(range: string): string[] { return range.split('||').map((alternative) => alternative.trim()); } +/** + * The longest range either reader reads. A real SemVer range is short, and a + * manifest's range strings are externally authored, so bounding the length + * defuses any pathological input before it reaches a split or a regex: an + * overlong string is simply unrecognized, never a slow scan. + */ +const MAX_RANGE_LENGTH = 128; + +/** The intersection separator both readers accept: whitespace or a comma. */ +const COMPARATOR_SEPARATOR = /[\s,]+/; + +/** + * A hyphen range's two ends (`'11.0.0 - 12.0.0'`), or `null` when `r` is not + * one. Split on the whitespace-delimited hyphen (a fixed anchor between the two + * `\s+` runs — linear, no `.`-vs-`\s` backtracking) rather than a lazy + * `(.+?)…(.+)` match. + */ +function hyphenRangeEnds(r: string): [string, string] | null { + const ends = r.split(/\s+-\s+/); + return ends.length === 2 ? [ends[0]!, ends[1]!] : null; +} + +type ComparatorOperator = '>=' | '<=' | '>' | '<'; + +/** + * Peel a comparator's operator off by fixed prefix rather than a `\s*(.+)` + * match, whose whitespace/any-char overlap is a polynomial-ReDoS shape on + * untrusted input. `null` when `comparator` opens with no comparison operator. + */ +function peelComparatorOperator(comparator: string): { op: ComparatorOperator; rest: string } | null { + if (comparator.startsWith('>=') || comparator.startsWith('<=')) { + return { op: comparator.slice(0, 2) as ComparatorOperator, rest: comparator.slice(2) }; + } + if (comparator.startsWith('>') || comparator.startsWith('<')) { + return { op: comparator.slice(0, 1) as ComparatorOperator, rest: comparator.slice(1) }; + } + return null; +} + /** * Decide whether a SemVer-ish range admits `runtimeMajor`. * @@ -245,12 +297,9 @@ function rangeAlternatives(range: string): string[] { */ export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean | null { const r = range.trim(); - // A real SemVer range is short. Bounding the length here defuses any - // pathological input before it reaches a split or a regex (a manifest's - // `engines` string is externally authored, so treat it as untrusted): an - // overlong string is simply unrecognized (admit-with-warning), never a slow - // scan. - if (r.length > 128) return null; + // Bounded before any split or regex ({@link MAX_RANGE_LENGTH}): an overlong + // string is simply unrecognized (admit-with-warning), never a slow scan. + if (r.length > MAX_RANGE_LENGTH) return null; let verdict: boolean | null = false; for (const alternative of rangeAlternatives(r)) { const admits = alternativeAdmitsMajor(alternative, runtimeMajor); @@ -266,7 +315,7 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null if (r === '*' || r === 'x' || r === 'latest') return true; // Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined). - const parts = r.split(/[\s,]+/).filter(Boolean); + const parts = r.split(COMPARATOR_SEPARATOR).filter(Boolean); if (parts.length > 1 && parts.every((p) => /^[<>]=?/.test(p))) { let ok = true; for (const p of parts) { @@ -277,13 +326,11 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null return ok; } - // Hyphen range: "11.0.0 - 12.0.0". Split on the whitespace-delimited hyphen - // (a fixed anchor between the two `\s+` runs — linear, no `.`-vs-`\s` - // backtracking) rather than a lazy `(.+?)…(.+)` match. - const hyphenParts = r.split(/\s+-\s+/); - if (hyphenParts.length === 2) { - const lo = leadingMajor(hyphenParts[0]!); - const hi = leadingMajor(hyphenParts[1]!); + // Hyphen range: "11.0.0 - 12.0.0". + const hyphenParts = hyphenRangeEnds(r); + if (hyphenParts) { + const lo = leadingMajor(hyphenParts[0]); + const hi = leadingMajor(hyphenParts[1]); if (lo === null || hi === null) return null; return runtimeMajor >= lo && runtimeMajor <= hi; } @@ -333,19 +380,9 @@ function declaredFloorMajor(range: string): number | null { } function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null { - // Peel the operator off by fixed prefix rather than a `\s*(.+)` match, whose - // whitespace/any-char overlap is a polynomial-ReDoS shape on untrusted input. - let op: string; - let rest: string; - if (comparator.startsWith('>=') || comparator.startsWith('<=')) { - op = comparator.slice(0, 2); - rest = comparator.slice(2); - } else if (comparator.startsWith('>') || comparator.startsWith('<')) { - op = comparator.slice(0, 1); - rest = comparator.slice(1); - } else { - return null; - } + const peeled = peelComparatorOperator(comparator); + if (peeled === null) return null; + const { op, rest } = peeled; const bound = rest.trim().replace(/^v/i, ''); if (bound === '') return null; const parts = bound.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); @@ -468,3 +505,444 @@ export function assertProtocolCompat( throw new ProtocolIncompatibleError(result.diagnostic); } } + +// ========================================================================== +// ADR-0141 D2 — the range reader at exact-version granularity +// ========================================================================== + +/** A full version, `[major, minor, patch]`. */ +export type ExactVersion = readonly [number, number, number]; + +/** + * The version grammar the dependency check reads for an INSTALLED package: + * `MAJOR.MINOR.PATCH`, an optional leading `v`, an optional `+build` suffix + * (ignored, as SemVer orders it). `null` for anything else — a partial + * version (`6`, `6.1`), a prerelease (`6.1.0-beta.1`: outside the stated + * grammar, so the check fails closed on it), a non-string. + */ +export function readExactVersion(version: unknown): ExactVersion | null { + if (typeof version !== 'string' || version.length > MAX_RANGE_LENGTH) return null; + const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/); + if (!m) return null; + return [Number.parseInt(m[1]!, 10), Number.parseInt(m[2]!, 10), Number.parseInt(m[3]!, 10)]; +} + +/** + * The range grammar {@link rangeAdmitsVersion} reads, as a refusal states it. + * The handshake family's grammar minus `latest` (a dist-tag, not a bound), at + * npm's exact-version semantics. + */ +export const DEPENDENCY_RANGE_GRAMMAR = + '`^`, `~`, `>=`, `>`, `<=`, `<` or `=` before a version; an exact version (`6.1.0`); ' + + 'an `x` / `*` wildcard (`6.x`, `6.1.*`, `*`); a hyphen range (`6.1.0 - 6.3`); ' + + 'comparators joined by a space or a comma (an intersection: `>=6.1.0 <7`); and `||` between alternatives'; + +/** A version token as written: up to three numeric parts, stopping at the first `x` / `X` / `*`. */ +function readPartialVersion(token: string): number[] | null { + const m = token.match(/^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/); + if (!m) return null; + const parts: number[] = []; + for (const part of [m[1], m[2], m[3]]) { + if (part === undefined || !/^\d+$/.test(part)) break; + parts.push(Number.parseInt(part, 10)); + } + return parts; +} + +/** `parts` padded with zeros to a full version. */ +function floorOf(parts: readonly number[]): ExactVersion { + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** The first version past every version `parts` names (`6` → `7.0.0`, `6.1` → `6.2.0`). */ +function ceilingOf(parts: readonly number[]): ExactVersion { + return parts.length === 1 ? [parts[0]! + 1, 0, 0] : [parts[0]!, parts[1]! + 1, 0]; +} + +function compareVersions(a: ExactVersion, b: ExactVersion): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** One bound of a desugared comparator; a comparator is the AND of its bounds. */ +interface VersionBound { op: ComparatorOperator; v: ExactVersion } + +/** A bound nothing satisfies — `>*`, `<*`, `<0.0.0`. */ +const NOTHING: VersionBound = { op: '<', v: [0, 0, 0] }; + +/** + * One comparator of an intersection, desugared to bounds the way npm does + * (`^1.2` → `>=1.2.0 <2.0.0`, `>6` → `>=7.0.0`, `<=6.1` → `<6.2.0`). `null` + * when the token is outside {@link DEPENDENCY_RANGE_GRAMMAR}; an empty list + * admits every version. + */ +function comparatorBounds(token: string): VersionBound[] | null { + const peeled = peelComparatorOperator(token); + let op: ComparatorOperator | '=' | '^' | '~' | '' = ''; + let rest = token; + if (peeled) { + ({ op, rest } = peeled); + } else if (token.startsWith('=') || token.startsWith('^') || token.startsWith('~')) { + op = token[0] as '=' | '^' | '~'; + rest = token.slice(1); + } + const parts = readPartialVersion(rest.replace(/^v/i, '')); + if (parts === null) return null; + const full = parts.length === 3; + const any = parts.length === 0; + const floor = floorOf(parts); + switch (op) { + case '': + case '=': + if (full) return [{ op: '>=', v: floor }, { op: '<=', v: floor }]; + return any ? [] : [{ op: '>=', v: floor }, { op: '<', v: ceilingOf(parts) }]; + case '>=': + return any ? [] : [{ op: '>=', v: floor }]; + case '>': + if (any) return [NOTHING]; + return full ? [{ op: '>', v: floor }] : [{ op: '>=', v: ceilingOf(parts) }]; + case '<=': + if (any) return []; + return full ? [{ op: '<=', v: floor }] : [{ op: '<', v: ceilingOf(parts) }]; + case '<': + return any ? [NOTHING] : [{ op: '<', v: floor }]; + case '~': + if (any) return []; + return [{ op: '>=', v: floor }, { op: '<', v: ceilingOf(parts.slice(0, Math.min(parts.length, 2))) }]; + case '^': { + if (any) return []; + // The first non-zero part is the one that may not move; a part left + // unwritten counts as the one that moves (`^0.x` → `<1.0.0`). + const upper: ExactVersion = parts[0]! > 0 || parts.length === 1 + ? [parts[0]! + 1, 0, 0] + : parts[1]! > 0 || parts.length === 2 + ? [0, parts[1]! + 1, 0] + : [0, 0, parts[2]! + 1]; + return [{ op: '>=', v: floor }, { op: '<', v: upper }]; + } + } +} + +function boundAdmits(bound: VersionBound, version: ExactVersion): boolean { + const c = compareVersions(version, bound.v); + switch (bound.op) { + case '>=': return c >= 0; + case '>': return c > 0; + case '<=': return c <= 0; + case '<': return c < 0; + } +} + +/** One `||` alternative at exact-version granularity: the AND of its comparators' bounds. */ +function alternativeAdmitsVersion(r: string, version: ExactVersion): boolean | null { + if (r === '') return null; + // An operator separated from its version by spaces (`>= 6.1.0`) reads as + // one token. The class is a fixed literal set, so the replace is linear. + const joined = r.replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1'); + let bounds: VersionBound[] = []; + const hyphen = hyphenRangeEnds(joined); + if (hyphen) { + const lo = readPartialVersion(hyphen[0].replace(/^v/i, '')); + const hi = readPartialVersion(hyphen[1].replace(/^v/i, '')); + if (lo === null || hi === null) return null; + if (lo.length > 0) bounds.push({ op: '>=', v: floorOf(lo) }); + if (hi.length === 3) bounds.push({ op: '<=', v: floorOf(hi) }); + else if (hi.length > 0) bounds.push({ op: '<', v: ceilingOf(hi) }); + } else { + for (const token of joined.split(COMPARATOR_SEPARATOR).filter(Boolean)) { + const tokenBounds = comparatorBounds(token); + if (tokenBounds === null) return null; + bounds = bounds.concat(tokenBounds); + } + } + return bounds.every((bound) => boundAdmits(bound, version)); +} + +/** + * Decide whether a range admits one exact version — the reader ADR-0141 D2's + * dependency check needs, a sibling of {@link rangeAdmitsMajor}. + * + * Returns `true` / `false` on a positive determination, or `null` when the + * range is outside {@link DEPENDENCY_RANGE_GRAMMAR} or `version` is outside + * {@link readExactVersion}'s grammar. A `||` union admits when ANY alternative + * admits; when none does and any alternative is unreadable, the union is + * unreadable too (`null`), exactly as {@link rangeAdmitsMajor} combines them. + * + * ⛔ The handshake reads `null` as "admit with a warning"; the dependency check + * reads it as "not satisfied". Each caller owns that policy, so the two + * readers can share their parsing without sharing a verdict. + */ +export function rangeAdmitsVersion(range: string, version: string): boolean | null { + const exact = readExactVersion(version); + if (exact === null) return null; + const r = range.trim(); + if (r.length > MAX_RANGE_LENGTH) return null; + let verdict: boolean | null = false; + for (const alternative of rangeAlternatives(r)) { + const admits = alternativeAdmitsVersion(alternative, exact); + if (admits === true) return true; + if (admits === null) verdict = null; + } + return verdict; +} + +// ========================================================================== +// ADR-0141 D2 — the pre-install dependency check +// ========================================================================== + +/** The manifest slice the dependency check reads. */ +export interface DependencyDeclaringManifest { + id?: unknown; + name?: unknown; + /** `ManifestSchema.dependencies`: `{ [packageId]: versionRange }`. */ + dependencies?: unknown; +} + +/** What the check needs of an installed package: the registry row's manifest version. */ +export interface InstalledDependencyRecord { + manifest?: { version?: unknown }; +} + +/** One declared entry the check found unsatisfied, as the refusal carries it. */ +export type UnmetDependency = + | { + /** The delivered package that declares the entry. */ + packageId: string; + /** The declared dependency id. */ + dependency: string; + /** The declared range, as written. */ + range: string; + /** + * `absent` — not installed and not delivered by the same act; + * `out-of-range` — installed at `installedVersion`, outside `range`; + * `unreadable-range` — `range` is outside {@link DEPENDENCY_RANGE_GRAMMAR}; + * `unreadable-version` — installed at a version outside {@link readExactVersion}'s grammar. + */ + found: 'absent' | 'out-of-range' | 'unreadable-range' | 'unreadable-version'; + /** The installed version, as stored, when there is an installed record. */ + installedVersion?: string; + } + | { + packageId: string; + /** `dependencies` is present but is not a `{ packageId: versionRange }` record. */ + found: 'unreadable-declaration'; + }; + +/** What one install act hands {@link checkDeclaredDependencies}. */ +export interface DeclaredDependencyCheck { + /** + * The packages this act installs, each judged on its OWN entries (never a + * dependency's: those were judged when it was installed). One manifest for a + * single-package door; every package of an artifact for an artifact door. + */ + delivered: readonly DependencyDeclaringManifest[]; + /** + * Further ids the same act delivers without being judged itself (an + * artifact's own identity). Every `delivered` id counts already. + */ + deliveredIds?: readonly string[]; + /** The installed package record for an id (`SchemaRegistry.getPackage`), or `undefined`. */ + getInstalled: (id: string) => InstalledDependencyRecord | undefined; +} + +/** The id a delivered manifest is installed under — `id || name`, as the registry keys it. */ +function deliveredId(manifest: DependencyDeclaringManifest): string | undefined { + const id = manifest.id || manifest.name; + return typeof id === 'string' && id !== '' ? id : undefined; +} + +/** + * ADR-0141 D2's one predicate: every declared entry of the delivered packages + * that the environment does not satisfy, in declaration order — empty when the + * install may proceed. + * + * An entry `(id, range)` is satisfied when `id` is delivered by the same act + * (presence only: the packages of one artifact are one tested unit, and an + * in-artifact range mismatch is the author tooling's to report, not the + * install's), or when `id` is installed at a `manifest.version` inside `range`. + * Fail closed: an unreadable range or installed version does not satisfy, and + * a `dependencies` that is present but not a record is reported, never read as + * "no dependencies". Every record entry is required; the record has no + * optional arm. Enablement is not judged. + * + * Pure: no throwing, no I/O. Doors call {@link assertDeclaredDependencies}. + */ +export function checkDeclaredDependencies(check: DeclaredDependencyCheck): UnmetDependency[] { + const delivered = new Set(check.deliveredIds ?? []); + for (const manifest of check.delivered) { + const id = deliveredId(manifest); + if (id !== undefined) delivered.add(id); + } + const unmet: UnmetDependency[] = []; + for (const manifest of check.delivered) { + const declared = manifest.dependencies; + if (declared === undefined || declared === null) continue; + const packageId = deliveredId(manifest) ?? ''; + if (typeof declared !== 'object' || Array.isArray(declared)) { + unmet.push({ packageId, found: 'unreadable-declaration' }); + continue; + } + for (const [dependency, declaredRange] of Object.entries(declared as Record)) { + if (delivered.has(dependency)) continue; + const range = typeof declaredRange === 'string' ? declaredRange : String(declaredRange); + const record = check.getInstalled(dependency); + const stored = record?.manifest?.version; + const installedVersion = record === undefined || stored === undefined || stored === null + ? undefined + : String(stored); + const withVersion = installedVersion === undefined ? {} : { installedVersion }; + // Range first: an unreadable range cannot be satisfied by installing + // anything, so it is the fact the author acts on, present or not. + const rangeReadable = typeof declaredRange === 'string' + && rangeAdmitsVersion(declaredRange, '0.0.0') !== null; + if (!rangeReadable) { + unmet.push({ packageId, dependency, range, found: 'unreadable-range', ...withVersion }); + continue; + } + if (record === undefined) { + unmet.push({ packageId, dependency, range, found: 'absent' }); + continue; + } + const admits = typeof stored === 'string' ? rangeAdmitsVersion(range, stored) : null; + if (admits === null) { + unmet.push({ packageId, dependency, range, found: 'unreadable-version', ...withVersion }); + } else if (!admits) { + unmet.push({ packageId, dependency, range, found: 'out-of-range', ...withVersion }); + } + } + } + return unmet; +} + +/** One `AppCompatibilityCheck` issue (`@objectstack/spec/system`) — the shape the refusal carries per unmet entry. */ +export interface DependencyMissingIssue { + severity: 'error'; + category: 'dependency_missing'; + message: string; +} + +function unmetIssueMessage(entry: UnmetDependency): string { + if (entry.found === 'unreadable-declaration') { + return `Package '${entry.packageId}' declares \`dependencies\` in a shape that is not a ` + + '{ "PACKAGE_ID": "VERSION_RANGE" } record, so what it depends on cannot be read.'; + } + const head = `Package '${entry.packageId}' depends on '${entry.dependency}' ${entry.range}`; + switch (entry.found) { + case 'absent': + return `${head}, which is not installed. Install '${entry.dependency}' at a version inside ${entry.range} first.`; + case 'out-of-range': + return `${head}, which is installed at version ${entry.installedVersion}, outside that range. ` + + `Install a version of '${entry.dependency}' inside ${entry.range} first.`; + case 'unreadable-range': + return `Package '${entry.packageId}' depends on '${entry.dependency}' with the range '${entry.range}', ` + + `which is not a readable version range, so no installed version can satisfy it. Supported: ${DEPENDENCY_RANGE_GRAMMAR}.`; + case 'unreadable-version': + return `${head}, which is installed with ${entry.installedVersion === undefined ? 'no version' : `the version '${entry.installedVersion}'`}` + + ' — not a readable MAJOR.MINOR.PATCH version (a prerelease is outside the grammar), so it cannot be judged against the range.'; + } +} + +/** + * Brand for {@link DependencyMissingError}; `Symbol.for`, for the reason + * {@link PROTOCOL_INCOMPATIBLE_BRAND} gives. + */ +const DEPENDENCY_MISSING_BRAND = Symbol.for('objectstack.metadata-core.dependencyMissingError'); + +/** + * ADR-0141 D2's refusal: an install whose delivered packages declare + * dependencies the environment does not satisfy. Nothing was registered, + * written or persisted. + * + * ## The status it declares: 422 + * + * The status the sibling refusals at the same gate answer for a delivered + * manifest judged against the environment ahead of registration + * (`NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME`); ADR-0141 merge + * question 5. `code` is redeclared as a literal so + * `check:error-status-conformance` derives this class as the producer of + * `DEPENDENCY_MISSING` at 422, and both status spellings are declared because + * doors read either one. + * + * ## What it carries + * + * `issues` — one `AppCompatibilityCheck` issue per unmet entry, the shape + * `AppCompatibilityCheckSchema` reserves the `dependency_missing` category in. + * The shared resolver (`resolveThrownHttpError`) passes a thrown `.issues` + * through into `error.details`, so any door answers them. `unmet` — the facts + * per entry (the dependent, the dependency, the range, what was found), which + * the resolver's closed `details` list drops; a door that recognises the error + * ({@link isDependencyMissingError}) answers them through + * {@link dependencyMissingAnswer}, and a Console renders its text from them. + */ +export class DependencyMissingError extends MetadataError { + readonly code = 'DEPENDENCY_MISSING' as const; + readonly status = 422; + readonly statusCode = 422; + readonly issues: readonly DependencyMissingIssue[]; + + constructor(public readonly unmet: readonly UnmetDependency[]) { + const issues = unmet.map((entry): DependencyMissingIssue => ({ + severity: 'error', + category: 'dependency_missing', + message: unmetIssueMessage(entry), + })); + super( + 'DEPENDENCY_MISSING', + `Install refused: ${unmet.length === 1 ? 'a declared dependency is' : `${unmet.length} declared dependencies are`} ` + + `not satisfied in this environment, and nothing was installed. ${issues.map((i) => i.message).join(' ')}` + + ' A package installs only after every package its manifest declares in `dependencies` is installed' + + ' at a version inside the declared range (ADR-0141 D2).', + ); + this.issues = issues; + (this as Record)[DEPENDENCY_MISSING_BRAND] = true; + } +} + +/** Recognition predicate for {@link DependencyMissingError}, by brand — a lookalike is not recognised. */ +export function isDependencyMissingError(e: unknown): e is DependencyMissingError { + return typeof e === 'object' && e !== null + && (e as Record)[DEPENDENCY_MISSING_BRAND] === true; +} + +/** + * Run ADR-0141 D2's check at an install door: returns silently when every + * declared entry is satisfied, throws {@link DependencyMissingError} naming + * EVERY unmet entry otherwise. Doors call it right after + * {@link assertProtocolCompat}, before anything registers. + */ +export function assertDeclaredDependencies(check: DeclaredDependencyCheck): void { + const unmet = checkDeclaredDependencies(check); + if (unmet.length > 0) throw new DependencyMissingError(unmet); +} + +/** What an HTTP door answers for a {@link DependencyMissingError}. */ +export interface DependencyMissingAnswer { + status: DependencyMissingError['status']; + code: DependencyMissingError['code']; + /** The error's own prose, unchanged. */ + message: string; + /** + * A CLOSED shape: the `AppCompatibilityCheck` verdict (`compatible` and its + * `issues`) and the per-entry facts, named one by one — ⛔ not a spread of + * the error, so a member later added to it does not reach the wire without + * a decision here. + */ + details: { + compatible: false; + issues: readonly DependencyMissingIssue[]; + unmet: readonly UnmetDependency[]; + }; +} + +/** + * The ONE answer every HTTP install door gives ADR-0141 D2's refusal: a door + * asks {@link isDependencyMissingError} ahead of its generic catch, then + * answers through this. ⛔ No door restates the shape. + */ +export function dependencyMissingAnswer(err: DependencyMissingError): DependencyMissingAnswer { + return { + status: err.status, + code: err.code, + message: err.message, + details: { compatible: false, issues: err.issues, unmet: err.unmet }, + }; +} diff --git a/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts b/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts new file mode 100644 index 00000000000..349b6a68559 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-package-install-dependencies.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D2 at the protocol install primitive: `installPackage` refuses a + * package whose declared `dependencies` the environment does not satisfy, + * before it registers or persists anything. + * + * Before this check the primitive installed such a package and the gap + * surfaced later, at the dependent's first cross-app save. The refusal is the + * ADR-0112 envelope — `code` + `status` — with one `dependency_missing` issue + * per unmet entry, every entry in one answer. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DependencyMissingError } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './index.js'; + +function makeImpl(installed: Record = {}) { + const registryCalls: Array<{ manifest: any }> = []; + const engine = { + registry: { + getPackage: (id: string) => (id in installed ? { manifest: { id, ...installed[id] } } : undefined), + installPackage: (manifest: any) => { + registryCalls.push({ manifest }); + return { manifest, status: 'installed', enabled: true }; + }, + }, + find: async () => [], + }; + const publish = vi.fn(async () => ({ success: true })); + const services = new Map([['package', { publish }]]); + const impl = new ObjectStackProtocolImplementation(engine as any, () => services); + return { impl, registryCalls, publish }; +} + +const PSA = { + id: 'com.acme.psa', name: 'PSA', type: 'app', version: '1.0.0', + dependencies: { 'com.hotcrm.sales': '^6.0.0', 'com.acme.billing': '>=2.1.0' }, +}; + +async function refusal(impl: ObjectStackProtocolImplementation, manifest: unknown): Promise { + try { + await (impl as any).installPackage({ manifest }); + } catch (e) { + return e as DependencyMissingError; + } + throw new Error('expected installPackage to refuse'); +} + +describe('installPackage — ADR-0141 D2 pre-install dependency check', () => { + it('refuses a package whose dependencies are absent or out of range: 422 DEPENDENCY_MISSING, every entry, nothing written', async () => { + const { impl, registryCalls, publish } = makeImpl({ 'com.hotcrm.sales': { version: '5.4.0' } }); + const err = await refusal(impl, PSA); + expect(err).toBeInstanceOf(DependencyMissingError); + expect(err.code).toBe('DEPENDENCY_MISSING'); + expect(err.status).toBe(422); + expect(err.unmet).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: '^6.0.0', found: 'out-of-range', installedVersion: '5.4.0' }, + { packageId: 'com.acme.psa', dependency: 'com.acme.billing', range: '>=2.1.0', found: 'absent' }, + ]); + expect(err.issues.map((i) => i.category)).toEqual(['dependency_missing', 'dependency_missing']); + // Refused BEFORE any registry write or durable persist. + expect(registryCalls).toHaveLength(0); + expect(publish).not.toHaveBeenCalled(); + }); + + it('installs once every declared dependency is installed inside its range', async () => { + const { impl, registryCalls, publish } = makeImpl({ + 'com.hotcrm.sales': { version: '6.2.0' }, + 'com.acme.billing': { version: '2.1.0' }, + }); + const res: any = await (impl as any).installPackage({ manifest: PSA }); + expect(res.package.status).toBe('installed'); + expect(registryCalls).toHaveLength(1); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('fails closed on a range outside the stated grammar', async () => { + const { impl, registryCalls } = makeImpl({ 'com.hotcrm.sales': { version: '6.2.0' } }); + const err = await refusal(impl, { ...PSA, dependencies: { 'com.hotcrm.sales': 'latest' } }); + expect(err.code).toBe('DEPENDENCY_MISSING'); + expect(err.unmet).toEqual([ + { packageId: 'com.acme.psa', dependency: 'com.hotcrm.sales', range: 'latest', found: 'unreadable-range', installedVersion: '6.2.0' }, + ]); + expect(registryCalls).toHaveLength(0); + }); + + it('leaves a package that declares nothing exactly as it installed before', async () => { + const { impl, registryCalls } = makeImpl(); + const res: any = await (impl as any).installPackage({ + manifest: { id: 'com.acme.solo', name: 'Solo', type: 'app', version: '1.0.0' }, + }); + expect(res.package.status).toBe('installed'); + expect(registryCalls).toHaveLength(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol-package-install.ts b/packages/metadata-protocol/src/protocol-package-install.ts index f4612b590be..a0c718cb6a6 100644 --- a/packages/metadata-protocol/src/protocol-package-install.ts +++ b/packages/metadata-protocol/src/protocol-package-install.ts @@ -45,7 +45,7 @@ import type { ObjectStackProtocolImplementation, PackagePersistService } from './protocol.js'; import { packagePersistFailureError } from './protocol.js'; -import { assertProtocolCompat } from '@objectstack/metadata-core'; +import { assertDeclaredDependencies, assertProtocolCompat } from '@objectstack/metadata-core'; // [#24086] The install door's own manifest declaration — `ManifestSchema` with // `scope` narrowed to `project` — judged whole at the top of `installPackage`. import { PackageInstallManifestSchema } from '@objectstack/spec/api'; @@ -258,6 +258,19 @@ export async function installPackage( // only); an unparsed range never causes a false rejection. assertProtocolCompat(manifest); + // ADR-0141 D2 — the pre-install dependency check, beside the handshake and + // as early: every `manifest.dependencies` entry must name a package + // installed at a version inside its range (this primitive delivers one + // package, so nothing else is co-delivered), or the install is refused + // `422 DEPENDENCY_MISSING` naming every unmet entry, before the registry + // write and the persist below. One predicate for every install door + // (`@objectstack/metadata-core`); ⛔ it does not run at the manifest edit + // (`updatePackage`) or at the boot rehydrate, which are not install acts. + assertDeclaredDependencies({ + delivered: [manifest], + getInstalled: (id) => this.engine.registry.getPackage?.(id), + }); + // [#21243] What the registry holds for this id BEFORE the write, so a // failed persist below can put exactly that back. Read here because the // install REPLACES the row object (and may register the namespace). diff --git a/packages/runtime/src/domains/packages-install-dependency-missing.test.ts b/packages/runtime/src/domains/packages-install-dependency-missing.test.ts new file mode 100644 index 00000000000..93235be32c0 --- /dev/null +++ b/packages/runtime/src/domains/packages-install-dependency-missing.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0141 D2 at `POST /api/v1/packages`: a package whose declared + * `dependencies` the environment does not satisfy is refused `422 + * DEPENDENCY_MISSING` with its facts, on both arms of the door, and nothing is + * installed. + * + * ## The harness + * + * The protocol primitive and the registry are REAL + * (`ObjectStackProtocolImplementation` over a `SchemaRegistry`), so the + * refusal under test is the one production throws, the dependency is + * installed through the same door the dependent is, and "not installed" is + * read back through the door's `GET`. Each refusal asserts the ADR-0112 + * envelope (`code` and `status`), never a bare throw. `OS_HOME` is redirected + * because the lit controls really install. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ApiErrorSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +let home: string; +let priorHome: string | undefined; + +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-install-dependency-')); + 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 }); +}); + +const ADMIN = () => ({ + request: {}, + environmentId: 'pkg-install-dependency-test', + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +/** The depended-on app, at a version the dependent's range refuses and one it admits. */ +const SALES_5 = { id: 'com.example.sales', name: 'Sales', version: '5.4.0', scope: 'project', type: 'app' }; +const SALES_6 = { ...SALES_5, version: '6.2.0' }; +/** The dependent: 「请先安装 Sales 6.x」. */ +const PSA = { + id: 'com.example.psa', name: 'PSA', version: '1.0.0', scope: 'project', type: 'app', + dependencies: { 'com.example.sales': '6.x' }, +}; + +function silentRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as unknown as { logLevel: string }).logLevel = 'silent'; + return registry; +} + +/** The composed door; `protocol: null` takes the door's no-protocol fallback arm. */ +function door(options: { protocol?: null } = {}) { + const registry = silentRegistry(); + const engine: any = { registry, find: async () => [], manifests: new Map() }; + const protocol = options.protocol === null ? null : new ObjectStackProtocolImplementation(engine, () => new Map()); + const ql: any = { registry, manifests: new Map() }; + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null; + if (name === 'objectql') return Promise.resolve(ql); + return null; + }, + context: { getService: (name: string) => (name === 'objectql' ? ql : null) }, + }; + return { dispatcher: new HttpDispatcher(kernel), registry }; +} + +const install = (d: ReturnType, manifest: unknown) => + d.dispatcher.handlePackages('', 'POST', { manifest }, {}, ADMIN()); +const read = (d: ReturnType, id: string) => + d.dispatcher.handlePackages(`/${id}`, 'GET', undefined, {}, ADMIN()); + +function expectDependencyRefusal(r: any, unmet: unknown[]) { + expect(r.handled).toBe(true); + expect(r.response.status, JSON.stringify(r.response.body)).toBe(422); + const error = r.response.body.error; + expect(r.response.body.success).toBe(false); + expect(error.code).toBe('DEPENDENCY_MISSING'); + expect(error.httpStatus).toBe(422); + expect(ApiErrorSchema.safeParse(error).success).toBe(true); + // The closed details shape: the compatibility verdict and the facts. + expect(Object.keys(error.details).sort()).toEqual(['compatible', 'issues', 'unmet']); + expect(error.details.compatible).toBe(false); + expect(error.details.unmet).toEqual(unmet); + expect(error.details.issues).toHaveLength(unmet.length); + for (const issue of error.details.issues) { + expect(issue).toEqual({ severity: 'error', category: 'dependency_missing', message: expect.any(String) }); + expect(error.message).toContain(issue.message); + } +} + +for (const arm of ['composed', 'fallback'] as const) { + const make = () => (arm === 'composed' ? door() : door({ protocol: null })); + + describe(`POST /api/v1/packages (${arm} arm): ADR-0141 D2 refuses an unsatisfied dependency`, () => { + it('refuses when the dependency is not installed: 422 with the facts, nothing installed', async () => { + const d = make(); + expectDependencyRefusal(await install(d, PSA), [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' }, + ]); + expect(d.registry.getPackage(PSA.id)).toBeUndefined(); + expect((await read(d, PSA.id) as any).response.status).toBe(404); + }); + + it('refuses when the dependency is installed outside the range, naming the version found', async () => { + const d = make(); + expect((await install(d, SALES_5) as any).response.status).toBe(201); + expectDependencyRefusal(await install(d, PSA), [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'out-of-range', installedVersion: '5.4.0' }, + ]); + expect(d.registry.getPackage(PSA.id)).toBeUndefined(); + }); + + it('lit control: installs once the dependency is installed inside the range', async () => { + const d = make(); + expect((await install(d, SALES_6) as any).response.status).toBe(201); + const r: any = await install(d, PSA); + expect(r.response.status, JSON.stringify(r.response.body)).toBe(201); + expect((await read(d, PSA.id) as any).response.status).toBe(200); + }); + }); +} diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 7d8498c3c98..6311687ba31 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -41,7 +41,15 @@ import { readEffectiveTenancyPosture } from './activation-gate.js'; // and its no-protocol fallback runs the same handshake. // [#21762] The refusal's answer is the shared one, which the install-local door // (`@objectstack/cloud-connection`) gives too: imported, never restated. -import { assertProtocolCompat, isProtocolIncompatibleError, protocolIncompatibleAnswer } from '@objectstack/metadata-core'; +import { + assertDeclaredDependencies, + assertProtocolCompat, + dependencyMissingAnswer, + isDependencyMissingError, + isProtocolIncompatibleError, + protocolIncompatibleAnswer, + type InstalledDependencyRecord, +} from '@objectstack/metadata-core'; // [#7560] ADR-0070's read-only-package rule — the SAME predicate the metadata // authoring path asks before refusing a write INTO a platform package // (`saveMetaItem` → `WRITABLE_PACKAGE_REQUIRED`). Imported, never re-spelled: @@ -1462,6 +1470,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // the primitive's, so no-range and unparsed ranges warn the // same way on both arms. assertProtocolCompat(manifest); + // ADR-0141 D2 — and the SAME pre-install dependency check, + // through the one predicate, before the registry write: + // this arm is an install act exactly as the primitive is. + assertDeclaredDependencies({ + delivered: [manifest], + getInstalled: (id) => registry.getPackage(id) as InstalledDependencyRecord | undefined, + }); // [#23972] The same origin the protocol primitive stamps: // a package installed through this door is authored. pkg = registry.installPackage(manifest, 'authored', settings); @@ -1486,6 +1501,18 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...refusal.details }), }; } + // ADR-0141 D2's refusal, recognised the same way and for the + // same reason: the shared resolver would pass its `issues` + // through but drop the per-entry facts (`unmet`) a Console + // renders its text from. Both arms above reach it — the + // primitive throws it, and the fallback runs the same check. + if (isDependencyMissingError(err)) { + const refusal = dependencyMissingAnswer(err); + return { + handled: true, + response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...refusal.details }), + }; + } throw err; } // [#18058 → #18877] HONOUR `enableOnInstall`, which this door declared diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 3ddf8644369..d49f4f9e4c2 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -721,6 +721,20 @@ export const ERROR_CODE_LEDGER = { 'WRITABLE_PACKAGE_REQUIRED', ], '@objectstack/metadata-core': [ + // [ADR-0141 D2] The pre-install dependency check refused an install whose + // delivered package declares a `manifest.dependencies` entry the + // environment does not satisfy: not installed (nor delivered by the same + // act), installed outside the declared range, or a range or installed + // version outside the stated grammar (`DependencyMissingError`, + // `protocol-handshake.ts`). Answered 422, the status the sibling refusals + // at the same gate answer for a delivered manifest judged against the + // environment ahead of registration (`NAMESPACE_CONFLICT`). The three + // install doors — the protocol install primitive, the no-protocol fallback + // of `POST /api/v1/packages` and `POST /api/v1/marketplace/install-local` + // — answer it through the shared `dependencyMissingAnswer`, carrying one + // `dependency_missing` `AppCompatibilityCheck` issue and one fact row per + // unmet entry in `error.details`. + 'DEPENDENCY_MISSING', // [ADR-0131 D6/D7, #23429] `readLegacyOrganizationRows` // (`legacy-organization-row.ts`, `LegacyOrganizationRowsUnreadable`) could // not tell a `sys_metadata`-family table's legacy organization rows from diff --git a/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json b/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json new file mode 100644 index 00000000000..d567b340b34 --- /dev/null +++ b/scripts/adr-anchors/packages__metadata-core__src__protocol-handshake.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/metadata-core/src/protocol-handshake.ts", + "adrs": [ + "ADR-0141", + "ADR-0087" + ], + "invariant": "ADR-0141 D2's pre-install dependency check is ONE predicate (`checkDeclaredDependencies`) every install door runs right after the ADR-0087 D1 handshake and before anything registers: the protocol `installPackage` primitive, the no-protocol fallback of `POST /api/v1/packages`, and `POST /api/v1/marketplace/install-local` — never a boot rehydrate and never `PATCH /api/v1/packages/:id`. An entry is satisfied when its id is delivered by the same act (presence only) or installed at a version inside its range; an unreadable range or installed version does NOT satisfy (fail closed), the opposite of the handshake beside it, which admits an unrecognised range with a warning. `rangeAdmitsVersion` is a sibling of `rangeAdmitsMajor` sharing its alternative split, length bound and operator peel; ⛔ no fourth range reader, and the handshake's verdicts do not move." +} diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 8114abce703..a9a2e16b8fc 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -90,6 +90,7 @@ "content/docs/data-modeling/seed-data.mdx", "content/docs/data-modeling/validation-rules.mdx", "content/docs/data-modeling/validation.mdx", + "content/docs/deployment/app-dependencies.mdx", "content/docs/deployment/backup-restore.mdx", "content/docs/deployment/cli.mdx", "content/docs/deployment/cloud-connection.mdx",