diff --git a/.changeset/24062-authored-install-scope-project.md b/.changeset/24062-authored-install-scope-project.md new file mode 100644 index 00000000000..df579a6b251 --- /dev/null +++ b/.changeset/24062-authored-install-scope-project.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +--- + +feat(spec)!: an install through `POST /api/v1/packages` may declare `manifest.scope` `project` only; `cloud` and `system` are refused, so an authored install can no longer lock itself + +`cloud` and `system` are the scopes of packages the platform ships, and the ADR-0070 D2 read-only rule (`isWritablePackage`) judges a registered package by its stored `manifest.scope`. An install through `POST /api/v1/packages` authors the package, yet the install declaration admitted all three scopes. So a manifest that copied `scope: 'cloud'` or `'system'` from a shipped package answered `201` and produced an authored package that `DELETE /packages/:id`, `PATCH /packages/:id/disable` and `POST /packages?overwrite=true` then refused with `422 WRITABLE_PACKAGE_REQUIRED`, and the stored row came back on every restart. The install declaration now narrows the install manifest's `scope` to `project` (new export `PackageInstallManifestSchema`, `ManifestSchema` with that one key narrowed), on both body forms of `PackageInstallBodySchema` and on `PackageInstallRequestSchema.manifest`. `ManifestSchema` itself is unchanged: platform packages still declare all three scopes, and they reach the registry by code boot, not over this door. The door already parses its body through that declaration, so it answers the refusal with no rule of its own; its bare-form refusal sentence now re-reads the union's own bare branch, so it carries the prescription too. + +Clause-②: no (narrowing) + + + +**BREAKING** (a published request declaration's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM:** `POST /api/v1/packages` with a manifest declaring `scope: 'cloud'` or `scope: 'system'`, in the wrapped body `{ manifest }` or as a bare manifest, with or without `overwrite`, answered `201`; `PackageInstallBodySchema` and `PackageInstallRequestSchema` parsed it green. +- **TO:** the same request answers `400` with `error.code: 'VALIDATION_ERROR'`, names `scope` and the value sent, and installs nothing; both schemas refuse it with an `invalid_value` issue at `manifest.scope` (wrapped) or `scope` (bare). +- **Unchanged:** a manifest with `scope: 'project'` or no `scope` installs as before (`201`), and the door still stores the manifest as sent, so an absent `scope` stays absent. `ManifestSchema` and every other producer of it keep `cloud` / `system`. +- **The fix for an integrator:** remove `scope` from the manifest you install (it defaults to `project`), or write `scope: 'project'`. +- **A package that already locked itself this way** (an authored `sys_packages` row whose manifest declares `cloud` or `system`) stays read-only after this change, because the read-only rule reads the stored scope. No API door removes it; an operator deletes its row from the environment's database (`DELETE FROM sys_packages WHERE id = 'PACKAGE_ID'`) and restarts the server. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 4bd03bc6a4c..518793d23d7 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -52,8 +52,8 @@ A declaration that needs the assembled body goes in the sibling file. ## TypeScript Usage ```typescript -import { GetInstalledPackageRequestSchema, ListInstalledPackagesRequestSchema, PackageApiErrorCode, PackageInstallBodySchema, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; -import type { GetInstalledPackageRequest, ListInstalledPackagesRequest, PackageApiErrorCode, PackageInstallBody, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; +import { GetInstalledPackageRequestSchema, ListInstalledPackagesRequestSchema, PackageApiErrorCode, PackageInstallBodySchema, PackageInstallManifestSchema, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; +import type { GetInstalledPackageRequest, ListInstalledPackagesRequest, PackageApiErrorCode, PackageInstallBody, PackageInstallManifest, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; // Validate data const result = GetInstalledPackageRequestSchema.parse(data); @@ -129,10 +129,10 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns; `scope` is `project`) | | **settings** | `Record` | optional | User-provided settings at install time | | **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — honoured at POST /api/v1/packages: `true` enables the installed row, `false` disables it, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) | -| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict | +| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict; an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | | **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation | @@ -145,7 +145,7 @@ Install package request | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **scope** | `Enum<'project'>` | optional (default: `"project"`) | Deployment scope — an authored install is always `project`; `cloud` / `system` are refused (platform-shipped, read-only scopes) | | **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | @@ -181,6 +181,8 @@ Install package request #### Option 2 +Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope is project) + ### Properties | Property | Type | Required | Description | @@ -190,7 +192,7 @@ Install package request | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **scope** | `Enum<'project'>` | optional (default: `"project"`) | Deployment scope — an authored install is always `project`; `cloud` / `system` are refused (platform-shipped, read-only scopes) | | **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | @@ -282,6 +284,111 @@ A navigation contribution: a package injecting nav items into an app it does not --- +--- + +## PackageInstallManifest + +Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope is project) + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package | +| **scope** | `Enum<'project'>` | optional (default: `"project"`) | Deployment scope — an authored install is always `project`; `cloud` / `system` are refused (platform-shipped, read-only scopes) | +| **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | +| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | +| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | +| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | + +### Nested Shape: `PackageInstallManifest.permissions` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") | +| **hooks** | `string[]` | optional | Lifecycle hooks the plugin may register (e.g. "record.beforeInsert") | +| **network** | `string[]` | optional | Network hosts the plugin may reach (e.g. "api.acme.com") | +| **fs** | `string[]` | optional | Filesystem paths the plugin may access | + +### Nested Shape: `PackageInstallManifest.contributes` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kinds** | `{ id: string; description?: string }[]` | optional | Metadata kind identifiers this package registers | +| **events** | `never` | optional | [REMOVED] `manifest.contributes.events` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the list: its only in-repo author already subscribed imperatively in plugin code, so the declaration was decorative. Delete the key. Subscribe to system events in the plugin itself — `ctx.hook('kernel:ready', …)` (or the events service) from `init`/`start` is the enforced channel; record lifecycle hooks register on the data engine. | +| **menus** | `never` | optional | [REMOVED] `manifest.contributes.menus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no renderer ever read it; two alias maps already redirected this spelling to `navigation`. Delete the key. Declare navigation in the app's `navigation` tree, or inject items into another package's app via `manifest.navigationContributions` (ADR-0029 D7), which the engine registers. | +| **themes** | `never` | optional | [REMOVED] `manifest.contributes.themes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: theme registration reaches the registry only through the stack-level `themes` collection (a `ThemeSchema` surface, unrelated to this `{ id, label, path }` shape), never through `contributes.themes`. Delete the key; declare themes in the stack `themes` collection instead. | +| **translations** | `never` | optional | [REMOVED] `manifest.contributes.translations` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, path }` entries; authoring them registered no translations. Delete the key. Declare translations as `translation` metadata: `defineTranslationBundle({ … })` in the stack's `translations` collection (`defineStack({ translations: […] })`), which the engine registers and the i18n pipeline serves. | +| **actions** | `never` | optional | [REMOVED] `manifest.contributes.actions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; actions declared here were never invocable. Delete the key. Declare actions in the stack `actions` collection (registered by the engine) or register imperatively via `engine.registerAction`. | +| **drivers** | `never` | optional | [REMOVED] `manifest.contributes.drivers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: a storage driver is wired by registering a kernel SERVICE named `driver.*` (the objectql plugin picks it up and calls `registerDriver`), and its only in-repo author was registered that way, not by this declaration. Delete the key. | +| **fieldTypes** | `never` | optional | [REMOVED] `manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — there is no `registerFieldType` seam anywhere: the declaration advertised an extension point the platform does not have, so authoring it configured nothing. Delete the key. The field-type vocabulary is the spec `FieldType` enum; extending it is a spec change, not a manifest declaration. | +| **functions** | `never` | optional | [REMOVED] `manifest.contributes.functions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; ObjectQL functions declared here were never registered. Delete the key. Declare functions on the stack (`defineStack({ functions: […] })`), which the hook binder registers via `engine.registerFunction`. | +| **routes** | `never` | optional | [REMOVED] `manifest.contributes.routes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the HttpDispatcher never registered a prefix from the declaration, so an entry here parsed cleanly and served nothing while published material kept recommending it. Delete the key. An app does not mount routes on `http.server` — that service is the platform's own door-building primitive, and a code-mounted route gets no caller identity, auth gate, rate limit, namespace carve-out or OpenAPI entry. Declare the endpoint under `defineStack({ apis })` (`type: 'object_operation'` or `'flow'`, ADR-0121), compute in a flow, and register code as a stack function (`defineStack({ functions })`) or a connector. | +| **commands** | `never` | optional | [REMOVED] `manifest.contributes.commands` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the CLI never resolved commands from this declaration: commands are auto-discovered through oclif's native plugin system (the plugin package declares an `oclif` section in its own `package.json`; see `cli-extension.zod.ts`), and the `objectstack.config.ts` plugins array no longer determines CLI commands. Delete the key. | + +### Nested Shape: `PackageInstallManifest.data[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Target Object Name | +| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy | +| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | +| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn | +| **records** | `Record[]` | ✅ | Data records | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | + +### Nested Shape: `PackageInstallManifest.navigationContributions[number]` + +A navigation contribution: a package injecting nav items into an app it does not own (ADR-0029 D7) + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | +| **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | +| **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | ✅ | Navigation items contributed into the target app/group | + +### Nested Shape: `PackageInstallManifest.engine` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectstack** | `string` | ✅ | ObjectStack platform version requirement (SemVer range, e.g. ">=3.0.0") | + +### Nested Shape: `PackageInstallManifest.engines` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **platform** | `string` | optional | ObjectStack platform release range (SemVer, e.g. ">=4.0 <5") | +| **protocol** | `string` | optional | Runtime/metadata protocol range, checked first (ADR §3.10 #3) | + + --- ## PackageInstallRequest @@ -292,10 +399,10 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns; `scope` is `project`) | | **settings** | `Record` | optional | User-provided settings at install time | | **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — honoured at POST /api/v1/packages: `true` enables the installed row, `false` disables it, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) | -| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict | +| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict; an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | | **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation | @@ -308,7 +415,7 @@ Install package request | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1, 1.0.0+20230101) | | **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | -| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **scope** | `Enum<'project'>` | optional (default: `"project"`) | Deployment scope — an authored install is always `project`; `cloud` / `system` are refused (platform-shipped, read-only scopes) | | **installModes** | `Enum<'managed' \| 'unmanaged'>[]` | optional | Install modes this package permits (ADR-0131 D6): managed \| unmanaged. Absent means ['managed']. | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 56b5643955b..9589c7a954e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1538 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1538** | 14 protocol modules | +| **Total** | **197** | **1539** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 436 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 437 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -87,7 +87,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`metadata.zod.ts`](/docs/references/api/metadata) | `AppDefinitionResponse`, `ConceptListResponse`, `MetadataBulkRegisterRequest`, `MetadataBulkResponse`, `MetadataBulkUnregisterRequest`, `MetadataDeleteResponse`, `MetadataDependenciesResponse`, `MetadataDependentsResponse`, `MetadataExistsResponse`, `MetadataExportRequest`, `MetadataExportResponse`, `MetadataImportRequest`, `MetadataImportResponse`, `MetadataItemResponse`, `MetadataListResponse`, `MetadataNamesResponse`, `MetadataQueryRequest`, `MetadataQueryResponse`, `MetadataRegisterRequest`, `MetadataTypeInfoResponse`, `MetadataTypesResponse`, `MetadataValidateRequest`, `MetadataValidateResponse`, `ObjectDefinitionResponse` | | [`misc`](/docs/references/api/misc) *(no single source file)* | `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` | | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | -| [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `ListInstalledPackagesRequest`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | +| [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `ListInstalledPackagesRequest`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallManifest`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | diff --git a/packages/runtime/src/domains/packages-install-body-contract.test.ts b/packages/runtime/src/domains/packages-install-body-contract.test.ts index 697ffe1664e..27264a939a8 100644 --- a/packages/runtime/src/domains/packages-install-body-contract.test.ts +++ b/packages/runtime/src/domains/packages-install-body-contract.test.ts @@ -526,3 +526,74 @@ describe('§8 every other declared constraint is refused, naming the path', () = }); } }); + +// ═══════════════════════════════════════════════════════════════════════ +// §9 — a platform scope on an authored install: refused, so it cannot self-lock +// ═══════════════════════════════════════════════════════════════════════ + +/** + * An install through this door AUTHORS a project package, and the read-only + * gate judges a row by its stored `manifest.scope`: `cloud` and `system` mark + * a package the platform ships. A manifest that declared one answered `201` + * and produced an authored row that `DELETE`, `PATCH …/disable` and + * `?overwrite=true` then refused `422` `WRITABLE_PACKAGE_REQUIRED`. The + * declaration now narrows the install manifest's `scope` to `project` + * (`PackageInstallManifestSchema`), and this door answers what it declares: + * `400` / `VALIDATION_ERROR`, nothing installed, on both body forms and on + * either overwrite spelling. The refusal says what to write instead, so its + * first sentence's subject — `scope: 'project'` — is asserted beside the code. + */ +describe('§9 an authored install declaring `scope: cloud | system` is refused, on every form', () => { + for (const scope of ['cloud', 'system'] as const) { + const forms: ReadonlyArray) => unknown, Record?]> = [ + ['WRAPPED', (m) => ({ manifest: m })], + ['BARE', (m) => m], + ['WRAPPED with `overwrite: true`', (m) => ({ manifest: m, overwrite: true })], + ['WRAPPED with `?overwrite=true`', (m) => ({ manifest: m }), { overwrite: 'true' }], + ['BARE with `?overwrite=true`', (m) => m, { overwrite: 'true' }], + ]; + for (const [label, wrap, query] of forms) { + it(`\`scope: '${scope}'\`, ${label} → 400 VALIDATION_ERROR, nothing installed, the remedy named`, async () => { + const m = { ...manifest(`scope.${scope}`), scope }; + const body = wrap(m); + expect(PackageInstallBodySchema.safeParse(body).success, 'off-declaration by the declaration itself').toBe(false); + expect( + PackageInstallBodySchema.safeParse(wrap({ ...m, scope: 'project' })).success, + 'the control differs by the scope alone', + ).toBe(true); + + const { registry, install } = door(); + const r = await install(body, query); + + expectRefused(r); + expect(registry.getPackage(m.id)).toBeUndefined(); + expect(messageOf(r)).toContain('scope'); + expect(messageOf(r)).toContain(`"${scope}"`); + expect(messageOf(r)).toContain("scope: 'project'"); + }); + } + } + + it('an overwrite of an AUTHORED project row that declares `scope: cloud` → 400, and the row keeps its scope', async () => { + const { registry, install } = door(); + const m = manifest('scope.overwrite.existing'); + expect((await install({ manifest: m })).response?.status).toBe(201); + + const r = await install({ manifest: { ...m, name: 'Relocked', scope: 'cloud' }, overwrite: true }); + + expectRefused(r); + expect(registry.getPackage(m.id)?.manifest).toEqual(m); + }); + + it('controls — `scope: project` and an absent scope install 201, stored as sent', async () => { + const { registry, install } = door(); + const explicit = { ...manifest('scope.project'), scope: 'project' }; + const absent = manifest('scope.absent'); + + expect((await install({ manifest: explicit })).response?.status).toBe(201); + expect((await install(absent)).response?.status).toBe(201); + expect(registry.getPackage(explicit.id)?.manifest?.scope).toBe('project'); + // The parse is a gate, not a normaliser: the default is not stored. + expect(registry.getPackage(absent.id)?.manifest).toEqual(absent); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index b0bb67c4429..3168f2e89ab 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -125,12 +125,16 @@ import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatch import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; // [#19328] The install door's DECLARED body contract, asked whole at // `POST /api/v1/packages` — the union of the wrapped request and a bare -// manifest — plus the wrapped branch by name, which the refusal re-reads to -// locate a failure on the form the caller wrote. Both are the declarations +// manifest — plus each branch by name, which the refusal re-reads to locate a +// failure on the form the caller wrote. All three are the declarations // `@objectstack/spec` publishes for this door; nothing here restates them. // Its own line on purpose: the `@objectstack/spec/kernel` import above carries // the id/version legs' names and is not this import's to share. -import { PackageInstallBodySchema, PackageInstallRequestSchema } from '@objectstack/spec/api'; +import { + PackageInstallBodySchema, + PackageInstallManifestSchema, + PackageInstallRequestSchema, +} from '@objectstack/spec/api'; /** * [#13598] The `protocol` service slot **as this domain reaches it** — one @@ -860,7 +864,10 @@ interface InstallBodyIssue { */ function installBodyRefusal(body: unknown, unionIssues: ReadonlyArray): string { const wrapped = body !== null && typeof body === 'object' && 'manifest' in body; - const branch = (wrapped ? PackageInstallRequestSchema : ManifestSchema).safeParse(body); + // The bare branch is the union's own, `PackageInstallManifestSchema` — ⛔ not + // `ManifestSchema`, which admits the platform scopes this door refuses, so a + // re-parse through it passed and the bare-form refusal lost its sentence. + const branch = (wrapped ? PackageInstallRequestSchema : PackageInstallManifestSchema).safeParse(body); // By the disjointness above the branch cannot pass where the union failed; // if a future union ever made it, the union's own issues still say what // was refused rather than an empty sentence. diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 09a5349ce4a..82d567ef207 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -748,6 +748,9 @@ "PackageInstallBody (type)", "PackageInstallBodyParsed (type)", "PackageInstallBodySchema (const)", + "PackageInstallManifest (type)", + "PackageInstallManifestParsed (type)", + "PackageInstallManifestSchema (const)", "PackageInstallRequest (type)", "PackageInstallRequestParsed (type)", "PackageInstallRequestSchema (const)", @@ -1102,6 +1105,7 @@ "isSubscribableChannel (function)", "makeApiErrorSchema (function)", "normalizeEndpointPath (function)", + "packageInstallScopeRefusal (function)", "readAuthFamilies (function)", "readChannelRoute (function)", "readServiceSelfInfo (function)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index db27d77140f..42d60883399 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -123,6 +123,8 @@ "api/OpenApiGenerationConfig:version = \"3.0.3\"", "api/OpenApiSpec:openapi = \"3.0.0\"", "api/OpenApiSpec:servers = []", + "api/PackageInstallManifest:defaultDatasource = \"default\"", + "api/PackageInstallManifest:scope = \"project\"", "api/PackageRollbackRequest:rollbackCustomizations = true", "api/PackageUpgradeRequest:createSnapshot = true", "api/PackageUpgradeRequest:dryRun = false", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5c3e9ec1940..2eb81d92a48 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1255,6 +1255,32 @@ "api/PackageExportManifest:label", "api/PackageExportManifest:name", "api/PackageExportManifest:version", + "api/PackageInstallManifest:capabilities [RETIRED]", + "api/PackageInstallManifest:configuration [RETIRED]", + "api/PackageInstallManifest:contributes", + "api/PackageInstallManifest:data", + "api/PackageInstallManifest:datasources", + "api/PackageInstallManifest:defaultDatasource", + "api/PackageInstallManifest:dependencies", + "api/PackageInstallManifest:description", + "api/PackageInstallManifest:engine", + "api/PackageInstallManifest:engines", + "api/PackageInstallManifest:extensions [RETIRED]", + "api/PackageInstallManifest:id", + "api/PackageInstallManifest:installModes", + "api/PackageInstallManifest:integrity", + "api/PackageInstallManifest:loading [RETIRED]", + "api/PackageInstallManifest:main", + "api/PackageInstallManifest:name", + "api/PackageInstallManifest:namespace", + "api/PackageInstallManifest:navigationContributions", + "api/PackageInstallManifest:objects", + "api/PackageInstallManifest:packaging", + "api/PackageInstallManifest:permissions", + "api/PackageInstallManifest:runtime", + "api/PackageInstallManifest:scope", + "api/PackageInstallManifest:type", + "api/PackageInstallManifest:version", "api/PackageInstallRequest:artifactRef", "api/PackageInstallRequest:enableOnInstall", "api/PackageInstallRequest:manifest", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index aa83acb2a39..c0bff12d876 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -547,6 +547,8 @@ "PackageExportManifestSchema": "api/PackageExportManifest", "PackageInstallBody": "api/PackageInstallBody", "PackageInstallBodySchema": "api/PackageInstallBody", + "PackageInstallManifest": "api/PackageInstallManifest", + "PackageInstallManifestSchema": "api/PackageInstallManifest", "PackageInstallRequest": "api/PackageInstallRequest", "PackageInstallRequestSchema": "api/PackageInstallRequest", "PackageInstallResponse": "api/PackageInstallResponse", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 8c68fe528a2..04f4e68c4c5 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 235, - "droppedRefinementSites": 766, + "publishedSchemasWithDroppedRefinements": 236, + "droppedRefinementSites": 767, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -447,6 +447,11 @@ "options[1].navigationContributions.element.items.element.lazy.options[0]" ] }, + "api/PackageInstallManifest": { + "sites": [ + "navigationContributions.element.items.element.lazy.options[0]" + ] + }, "api/PackageInstallRequest": { "sites": [ "manifest.navigationContributions.element.items.element.lazy.options[0]" diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 5f27a330a1a..1d3a90d157f 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -719,6 +719,9 @@ "PackageInstallBody": "src/api/package-api.zod.ts#PackageInstallBody (type)", "PackageInstallBodyParsed": "src/api/package-api.zod.ts#PackageInstallBodyParsed (type)", "PackageInstallBodySchema": "src/api/package-api.zod.ts#PackageInstallBodySchema (const)", + "PackageInstallManifest": "src/api/package-api.zod.ts#PackageInstallManifest (type)", + "PackageInstallManifestParsed": "src/api/package-api.zod.ts#PackageInstallManifestParsed (type)", + "PackageInstallManifestSchema": "src/api/package-api.zod.ts#PackageInstallManifestSchema (const)", "PackageInstallRequest": "src/api/package-api.zod.ts#PackageInstallRequest (type)", "PackageInstallRequestParsed": "src/api/package-api.zod.ts#PackageInstallRequestParsed (type)", "PackageInstallRequestSchema": "src/api/package-api.zod.ts#PackageInstallRequestSchema (const)", @@ -1055,6 +1058,7 @@ "isSubscribableChannel": "src/api/discovery.zod.ts#isSubscribableChannel (function)", "makeApiErrorSchema": "src/api/contract.zod.ts#makeApiErrorSchema (function)", "normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)", + "packageInstallScopeRefusal": "src/api/package-api.zod.ts#packageInstallScopeRefusal (function)", "readAuthFamilies": "src/api/discovery.zod.ts#readAuthFamilies (function)", "readChannelRoute": "src/api/discovery.zod.ts#readChannelRoute (function)", "readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index e2e055f68ee..92e1ce3bf34 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -300,6 +300,7 @@ "api/PackageApiErrorCode", "api/PackageExportManifest", "api/PackageInstallBody", + "api/PackageInstallManifest", "api/PackageInstallRequest", "api/PackageInstallResponse", "api/PackagePathParams", diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index abaa47ce6f7..a7807cc9bc9 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -5,6 +5,7 @@ import { GetInstalledPackageRequestSchema, PackageInstallRequestSchema, PackageInstallBodySchema, + PackageInstallManifestSchema, PackageInstallResponseSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, @@ -1213,6 +1214,60 @@ describe('install contract bound to the live door', () => { }); }); + /** + * An install through this door AUTHORS a project package. `cloud` and + * `system` are the scopes of packages the platform ships, which the + * read-only gate refuses on every API exit, so an authored install that + * declared one locked itself. The install manifest narrows `scope` to + * `project`; `ManifestSchema` itself keeps all three, for platform packages. + */ + describe('`manifest.scope` on an authored install admits `project` alone', () => { + const PLATFORM_SCOPES = ['cloud', 'system'] as const; + + for (const scope of PLATFORM_SCOPES) { + it(`\`scope: '${scope}'\` is refused on BOTH body forms and with \`overwrite\`, the remedy named`, () => { + const m = { ...SDK_MANIFEST, scope }; + for (const body of [{ manifest: m }, m, { manifest: m, overwrite: true }]) { + expect(PackageInstallBodySchema.safeParse(body).success, JSON.stringify(body)).toBe(false); + } + // The located issue — what the install door surfaces — on each branch alone. + const wrapped = PackageInstallRequestSchema.safeParse({ manifest: m }); + const bare = PackageInstallManifestSchema.safeParse(m); + for (const [verdict, path] of [[wrapped, ['manifest', 'scope']], [bare, ['scope']]] as const) { + expect(verdict.success).toBe(false); + const issues = verdict.error?.issues ?? []; + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('invalid_value'); + expect(issues[0]?.path).toEqual(path); + expect(issues[0]?.message).toContain(`"${scope}"`); + expect(issues[0]?.message).toContain("scope: 'project'"); + } + }); + } + + it('`scope: project` and an absent scope parse green on both forms, the parsed scope `project`', () => { + for (const m of [SDK_MANIFEST, { ...SDK_MANIFEST, scope: 'project' }]) { + const wrapped = PackageInstallBodySchema.safeParse({ manifest: m }); + const bare = PackageInstallBodySchema.safeParse(m); + expect(wrapped.success && bare.success).toBe(true); + expect((wrapped.data as { manifest: { scope: string } }).manifest.scope).toBe('project'); + expect((bare.data as { scope: string }).scope).toBe('project'); + } + }); + + it('`ManifestSchema` is NOT narrowed — platform packages still declare all three scopes', () => { + for (const scope of ['cloud', 'system', 'project']) { + expect(ManifestSchema.safeParse({ ...SDK_MANIFEST, scope }).success, scope).toBe(true); + } + }); + + it('the install manifest is ManifestSchema in every other key — the same closed key set', () => { + expect(Object.keys(PackageInstallManifestSchema.shape).sort()) + .toEqual(Object.keys(ManifestSchema.shape).sort()); + expect(PackageInstallManifestSchema.safeParse({ ...SDK_MANIFEST, bogus: 1 }).success).toBe(false); + }); + }); + it('the manifest slot names ONE stage — the authoring one this wire carries', () => { // The assembled stage reaches the packages table through // `ObjectQL.registerApp`, never over this door, so the write contract names diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index 3ef18faa2e7..0b783e592de 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -186,6 +186,56 @@ export type GetInstalledPackageRequest = z.input ManifestSchema.extend({ + scope: z.enum(['project'], { error: (iss) => packageInstallScopeRefusal(iss.input) }) + .default('project') + .describe('Deployment scope — an authored install is always `project`; `cloud` / `system` are refused (platform-shipped, read-only scopes)'), +}).describe('Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope is project)')); +export type PackageInstallManifest = z.input; +/** Post-parse shape of {@link PackageInstallManifest} — defaults applied, transforms run (ADR-0122). */ +export type PackageInstallManifestParsed = z.infer; + /** * Request body for installing a package, in its WRAPPED form. * @@ -205,7 +255,8 @@ export type GetInstalledPackageRequest = z.input strictObject({ + 'key, or spell it as the declared install option it meant; the declared keys are ' + 'enumerated by `PackageInstallRequestSchema` (@objectstack/spec, api/package-api.zod.ts).', }, { - /** Package manifest to install — the AUTHORING stage */ - manifest: ManifestSchema.describe('Package manifest to install (AUTHORING stage: `objects` are glob patterns)'), + /** Package manifest to install — the AUTHORING stage, `scope` narrowed to `project` ({@link PackageInstallManifestSchema}) */ + manifest: PackageInstallManifestSchema.describe('Package manifest to install (AUTHORING stage: `objects` are glob patterns; `scope` is `project`)'), /** User-provided settings at install time */ settings: z.record(z.string(), z.unknown()).optional() @@ -334,7 +385,7 @@ export const PackageInstallRequestSchema = lazySchema(() => strictObject({ * ⛔ Never remove this declaration while the handler still reads the key. */ overwrite: z.boolean().optional() - .describe('Overwrite an already-installed package id instead of answering 409 Conflict'), + .describe('Overwrite an already-installed package id instead of answering 409 Conflict; an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED'), /** Current platform version for compatibility verification */ platformVersion: z.string().optional() @@ -382,13 +433,14 @@ export type PackageInstallRequestParsed = z.infer z.union([ PackageInstallRequestSchema, - ManifestSchema, + PackageInstallManifestSchema, ]).describe('Install package request body, wrapped or as a bare manifest')); export type PackageInstallBody = z.input; /** Post-parse shape of {@link PackageInstallBody} — defaults applied, transforms run (ADR-0122). */