Skip to content

Commit eda6f14

Browse files
feat(spec)!: an install through POST /api/v1/packages declares manifest.scope project only, so an authored install cannot lock itself (#24160)
Fixes #24062 Clause-②: no (narrowing) An install through `POST /api/v1/packages` may now declare `manifest.scope` `project` only, or leave it out. `cloud` and `system` are refused at parse time with `400` / `VALIDATION_ERROR`, on both body forms and on either overwrite spelling, and the refusal says what to write instead. So an authored install can no longer lock itself. `ManifestSchema` is unchanged for every other producer. Session: `session_015p3xNTnaiNHYkAHoQ4y5Cg` (the `os-dev` cloud run dispatched by the `domain:spec#2` seat, claim `6230547268`). Base `9f024d8a2c`, head `b78d15a5`. ## What changed - `packages/spec/src/api/package-api.zod.ts` - New export `PackageInstallManifestSchema` (plus the types `PackageInstallManifest` and `PackageInstallManifestParsed`). It is `ManifestSchema.extend({ scope })` with `scope: z.enum(['project'], { error }).default('project')`. Every other key, the strict close, its unknown-key prescription and the retired-key tombstones ride the `.extend()` unchanged; the authorable-surface shard lists the same key set, tombstones included. - New export `packageInstallScopeRefusal(input)`: the one refusal sentence. It names the value sent, why `cloud` / `system` lock the package, and the remedy: "Remove `scope` (it defaults to `project`), or write `scope: 'project'`." - `PackageInstallRequestSchema.manifest` and the bare branch of `PackageInstallBodySchema` both now use `PackageInstallManifestSchema`. The docblocks that named `ManifestSchema` for those two positions now name the new schema. - The `overwrite` describe gains: "an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED". I re-read PR #24055's door on `main`: `requireWritablePackage` runs after the 409, for both overwrite spellings, at `packages.ts` (the `readOnly` leg). - `packages/runtime/src/domains/packages.ts`: **one line plus its comment and import, a deviation from the dispatch's "no runtime source change" expectation, measured and reported below.** `installBodyRefusal` re-parses the bare form through `PackageInstallManifestSchema`, the union's own bare branch, instead of `ManifestSchema`. - Tests: spec pins in `package-api.test.ts`, and the door pins as §9 of `packages-install-body-contract.test.ts`. - Generated outputs, regenerated by the generators only: the `api` shards of `api-surface`, `authorable-surface`, `authorable-defaults`, `json-schema.manifest`, `declaration-map` and `export-origins`; `content/docs/references/api/package-api.mdx` and `references/index.mdx`. Plus one hand-edited ledger, `dropped-refinements.baseline.json`: the build printed the entry for the new published schema `api/PackageInstallManifest` (the same `navigationContributions` site `api/PackageInstallRequest` already declares), and the header totals 235 to 236 and 766 to 767, which `scripts/dropped-refinements.test.ts` asserts. Delta vs `main`: 3 exports added, 0 removed. - `.changeset/24062-authored-install-scope-project.md`: `@objectstack/spec` `minor` (BREAKING narrowing, launch-window convention, PR #24055's `62f959dd62` precedent) and `@objectstack/runtime` `patch`. It carries FROM / TO, the `Clause-②` line, and the ADR-0087 disposition `not-required (no-migration-prescription)`. The only conversion that could exist (rewrite `cloud` / `system` to `project`) is the silent rewrite this card refuses. ## Accept set, before and after | Body | Before | After | |---|---|---| | wrapped `{ manifest }`, `scope: 'cloud'` or `'system'` | `201`, authored row stored with that scope | `400` `VALIDATION_ERROR`, nothing installed; issue `invalid_value` at `manifest.scope` | | bare manifest, `scope: 'cloud'` or `'system'` | `201` | `400` `VALIDATION_ERROR`, nothing installed; issue at `scope` | | either form with `overwrite: true` or `?overwrite=true`, platform scope | `201` on a fresh id; over an authored project row it re-locked that row | `400`, ahead of the 409 and the read-only gate; an existing row keeps its manifest | | `scope: 'project'`, either form | `201` | `201` | | no `scope`, either form | `201`, stored as sent (no `scope` key) | `201`, stored as sent: the door still stores what it was sent, so absence stays absent | | `ManifestSchema.safeParse` with `cloud` / `system` / `project` | accepted | accepted, unchanged | ## Measured: the door needed one line, and why I made it rather than stopping A2 assumed narrowing the declaration makes the door refuse with no runtime change. That holds for the **verdict** and fails for the **sentence**. Probe on the built `dist`, before the runtime line: ```text cloud wrapped false bare false union bare issues: [["invalid_union",[],"Invalid input"]] ManifestSchema re-parse: true | narrowed re-parse: manifest.scope "cloud" is not a scope a package installed through POST /api/v1/packages ma… wrapped re-parse: [["invalid_value",["manifest","scope"]]] ``` zod reports the failed union as one `invalid_union`, and `installBodyRefusal` locates the failure by re-parsing the form the caller wrote. For the bare form it re-parsed with `ManifestSchema`, which admits `cloud`. That re-parse passed, so the door fell back to the union's issues, and the bare-form refusal would have read `(body): Invalid input`: a `400` that does not say what to write. The door's own header says the re-read schemas are "the declarations `@objectstack/spec` publishes for this door; nothing here restates them", and `ManifestSchema` is no longer that branch. So the fix re-reads the branch the union actually carries. It adds no door-side verdict: which bodies are refused is still the declaration's decision alone. The ablation below shows the line is load-bearing. If the seat would rather hold the runtime file out, the alternative is the bare-form refusal without a prescription. ## Producers measured (A3) `git grep` on `9f024d8a2c` for every sender of `POST /api/v1/packages` and every parser of `PackageInstallRequestSchema` / `PackageInstallBodySchema` outside `packages/spec`: - `@objectstack/client` `packages.install(manifest, options)` forwards the caller's manifest, and sets no `scope` of its own. - `packages/runtime/src/domains/packages.ts` is the door itself. - `packages/client/src/readme-package-install-example.test.ts` and `packages/runtime/src/domains/packages-install-body-contract.test.ts` are the two test parsers. Neither fixture carries a platform scope; both stay green unchanged. - The CLI (`packages/cli/src`): no sender of this door. - `metadata-protocol` `duplicatePackage` calls `installPackage` in-process, not over this door, and **strips** `scope` from the copy already ("the copy is scope-less"): the same rule this card declares. - The platform's `scope: 'system'` / `'cloud'` manifests (plugin-auth, plugin-security, organizations, the service plugins, the apps) reach the registry by code boot (`registerApp` / `registry.installPackage(…, 'code')`), never through these schemas. - **Studio (`../objectui`): NOT MEASURED.** The sibling checkout is not in this container and `packages/console/dist` is absent. Studio creates bases through this door. If it ever sent a platform scope, the new `400` names the fix. No first-party producer sends `cloud` / `system` through these schemas, so the narrowing breaks nothing first-party. ## Fixtures triaged (A9) None needed re-spelling. Every test that installs a `scope: 'system'` / `'cloud'` package seeds the registry directly and never passes through the door or the narrowed schemas: `packages-readonly-gate`, `packages-writable-verdict`, `packages-serializable-response`, `packages-install-overwrite-readonly` (its authored cloud-scoped row is exactly the already-locked shape, still meaningful), and `metadata-protocol`'s `get-meta-items-package-writable`. §8's existing `scope: 'tenant'` row still answers `400` naming `manifest.scope`. ## Rows that already locked themselves (A7): a documented operator delete, no code remedy - **Measured:** `service-package` `start()` re-installs every `sys_packages` row as `authored` with its stored manifest (`packages/services/service-package/src/index.ts`, the hydration loop's `registry.installPackage(…, 'authored')`). `isWritablePackage` reads `manifest.scope` off the registry row. So a row stored with `cloud` / `system` before this PR is still read-only after it and after every restart. `DELETE`, `PATCH …/disable` and the overwrite door keep refusing it `422`; this PR changes nothing for such a row. - **Recommended remedy: the documented operator delete**, written into the changeset: `DELETE FROM sys_packages WHERE id = 'PACKAGE_ID'` on the environment's database, then restart. No API door can do it, by ADR-0070 D2's own design: there is no `isSystem` escape hatch. - **Why not a startup re-scope:** it is a stored-row rewrite at boot, the silent-rewrite class the card's filing and its review both advise against. It would also be a code remedy outside this card's file surface (a stop-and-report). And no evidence of a real affected row exists: the defect was found by a deleted probe, and no first-party producer sends the scope. If the seat or the maintainer wants a code remedy, it is a separate card on `service-package` hydration. - Not measured: what happens to `sys_metadata` items such a package owned once its row is deleted. ## Tests and evidence (head `b78d15a5`) - Spec build: `VERDICT command-exit 0`. - Spec vitest `--project local --maxWorkers=2`, full: 690/692 files on the first run. The 2 red were generated-ledger freshness (`dropped-refinements` totals; `export-origins` missing the two new exports), both fixed by regeneration; then `scripts/dropped-refinements.test.ts` + `scripts/export-origins.test.ts`: 51/51 passed. `src/api/package-api.test.ts` + `package-install-one-authority.test.ts`: 105/105. - `check:generated`: "All 14 generated artifacts are up to date". - Spec typecheck and runtime typecheck: `VERDICT command-exit 0`. - Runtime, all 28 `src/domains/packages*.test.ts`: 28 files, 559 tests passed. - Client `readme-package-install-example`, `client.test`, `return-type-precision`: 3 files, 220 tests passed. - **Ablation 1 (runtime line)**, via `scripts/ablation-replace.mjs`: the bare-branch re-parse was put back to `ManifestSchema`. Result: exactly the 4 BARE §9 cases red ("4 failed / 40 passed"), all WRAPPED cases green. Restore: blob equals HEAD `3168f2e89ab3`, `git diff HEAD` empty. - **Ablation 2 (spec narrowing, reverse verification through `dist`)**: the enum was widened back to `cloud` / `system` / `project` with a marker value. `ablation-dist-preflight`: "marker present in 10 built files". Runtime contract: 11 failed / 33 passed (every §9 refusal, both forms, both overwrite spellings, the existing-row overwrite); spec: 2 failed / 90 passed. Restored, rebuilt, `--absent` preflight exit 0, "working tree clean against HEAD". - `check-adr-0087-registration`: one declared-breaking changeset, `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, exit 0. `check-changeset-no-major`: "This diff introduces no `major` bump", exit 0. - `node scripts/pm/check-governed-merges.mjs --branch claude/issue-24062-authored-install-scope`: "0 of 14 path(s) hit the register … NOT governed"; 412 changed lines. No `skills/**` path moved. - Derived gate families (`dispatch-gates --commands`, 110): all run; 108 exit 0. Two exit 3, PREREQUISITE NOT MET, read as NOT MEASURED and left to CI: `check:skill-examples` (`packages/client-react/dist` not built here) and `check:dual-build-cjs-loads` (dists outside this closure). `--ran` reconciliation: "110 derived famil(ies) accounted for — 108 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". `check-plugin-teardown-shape --self-test` first refused on the shallow clone; after fetching its pinned fixture commit: "48 cases pass". - `main` moved 3 commits past the base (`135e19e20`, `1fee13d4c`, `c40506aac`). None touches a path in this diff, so I did not merge them; CI judges the merge ref. ## Acceptance notes - #24086 (`domain:engine`) is not addressed here. If it makes the protocol's `installPackage` parse what the door parses, it inherits this narrowing only by parsing `PackageInstallManifestSchema` (or the body union), not `ManifestSchema`. Noted for that card, not filed. - The refusal sentence is pinned only on its subjects (`scope`, the value sent, `scope: 'project'`) beside `code` and `status`, never in full. --- _Generated by [Claude Code](https://claude.ai/code/session_015p3xNTnaiNHYkAHoQ4y5Cg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ac0616 commit eda6f14

14 files changed

Lines changed: 384 additions & 28 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
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
7+
8+
`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.
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: the refused value is one a caller sends in a request body, and the only conversion that could exist for it (rewrite `cloud` / `system` to `project`) is the silent rewrite this change refuses on purpose, so there is nothing for `objectstack migrate meta` to rewrite. Stored `sys_packages` rows are untouched. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); the narrowed declaration is a Zod schema in packages/spec (not runtime-interface-only or type-surface-only); and nothing exported is removed (not runtime-export-retired). -->
13+
14+
**BREAKING** (a published request declaration's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
15+
16+
- **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.
17+
- **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).
18+
- **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`.
19+
- **The fix for an integrator:** remove `scope` from the manifest you install (it defaults to `project`), or write `scope: 'project'`.
20+
- **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.

‎content/docs/references/api/package-api.mdx‎

Lines changed: 116 additions & 9 deletions
Large diffs are not rendered by default.

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1538 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ 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
2121
| Module | Pages | Schemas | Description |
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
24-
| [API Protocol](/docs/references/api) | 32 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
24+
| [API Protocol](/docs/references/api) | 32 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. |
2525
| [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [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
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **197** | **1538** | 14 protocol modules |
37+
| **Total** | **197** | **1539** | 14 protocol modules |
3838

3939
---
4040

@@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
6363

6464
## API Protocol
6565

66-
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 436 schemas**
66+
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 437 schemas**
6767

6868
REST contracts, endpoints, routing, realtime, batch, discovery.
6969

@@ -87,7 +87,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
8787
| [`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` |
8888
| [`misc`](/docs/references/api/misc) *(no single source file)* | `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` |
8989
| [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` |
90-
| [`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` |
90+
| [`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` |
9191
| [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` |
9292
| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` |
9393
| [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` |

‎packages/runtime/src/domains/packages-install-body-contract.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,74 @@ describe('§8 every other declared constraint is refused, naming the path', () =
526526
});
527527
}
528528
});
529+
530+
// ═══════════════════════════════════════════════════════════════════════
531+
// §9 — a platform scope on an authored install: refused, so it cannot self-lock
532+
// ═══════════════════════════════════════════════════════════════════════
533+
534+
/**
535+
* An install through this door AUTHORS a project package, and the read-only
536+
* gate judges a row by its stored `manifest.scope`: `cloud` and `system` mark
537+
* a package the platform ships. A manifest that declared one answered `201`
538+
* and produced an authored row that `DELETE`, `PATCH …/disable` and
539+
* `?overwrite=true` then refused `422` `WRITABLE_PACKAGE_REQUIRED`. The
540+
* declaration now narrows the install manifest's `scope` to `project`
541+
* (`PackageInstallManifestSchema`), and this door answers what it declares:
542+
* `400` / `VALIDATION_ERROR`, nothing installed, on both body forms and on
543+
* either overwrite spelling. The refusal says what to write instead, so its
544+
* first sentence's subject — `scope: 'project'` — is asserted beside the code.
545+
*/
546+
describe('§9 an authored install declaring `scope: cloud | system` is refused, on every form', () => {
547+
for (const scope of ['cloud', 'system'] as const) {
548+
const forms: ReadonlyArray<readonly [string, (m: Record<string, unknown>) => unknown, Record<string, unknown>?]> = [
549+
['WRAPPED', (m) => ({ manifest: m })],
550+
['BARE', (m) => m],
551+
['WRAPPED with `overwrite: true`', (m) => ({ manifest: m, overwrite: true })],
552+
['WRAPPED with `?overwrite=true`', (m) => ({ manifest: m }), { overwrite: 'true' }],
553+
['BARE with `?overwrite=true`', (m) => m, { overwrite: 'true' }],
554+
];
555+
for (const [label, wrap, query] of forms) {
556+
it(`\`scope: '${scope}'\`, ${label} → 400 VALIDATION_ERROR, nothing installed, the remedy named`, async () => {
557+
const m = { ...manifest(`scope.${scope}`), scope };
558+
const body = wrap(m);
559+
expect(PackageInstallBodySchema.safeParse(body).success, 'off-declaration by the declaration itself').toBe(false);
560+
expect(
561+
PackageInstallBodySchema.safeParse(wrap({ ...m, scope: 'project' })).success,
562+
'the control differs by the scope alone',
563+
).toBe(true);
564+
565+
const { registry, install } = door();
566+
const r = await install(body, query);
567+
568+
expectRefused(r);
569+
expect(registry.getPackage(m.id)).toBeUndefined();
570+
expect(messageOf(r)).toContain('scope');
571+
expect(messageOf(r)).toContain(`"${scope}"`);
572+
expect(messageOf(r)).toContain("scope: 'project'");
573+
});
574+
}
575+
}
576+
577+
it('an overwrite of an AUTHORED project row that declares `scope: cloud` → 400, and the row keeps its scope', async () => {
578+
const { registry, install } = door();
579+
const m = manifest('scope.overwrite.existing');
580+
expect((await install({ manifest: m })).response?.status).toBe(201);
581+
582+
const r = await install({ manifest: { ...m, name: 'Relocked', scope: 'cloud' }, overwrite: true });
583+
584+
expectRefused(r);
585+
expect(registry.getPackage(m.id)?.manifest).toEqual(m);
586+
});
587+
588+
it('controls — `scope: project` and an absent scope install 201, stored as sent', async () => {
589+
const { registry, install } = door();
590+
const explicit = { ...manifest('scope.project'), scope: 'project' };
591+
const absent = manifest('scope.absent');
592+
593+
expect((await install({ manifest: explicit })).response?.status).toBe(201);
594+
expect((await install(absent)).response?.status).toBe(201);
595+
expect(registry.getPackage(explicit.id)?.manifest?.scope).toBe('project');
596+
// The parse is a gate, not a normaliser: the default is not stored.
597+
expect(registry.getPackage(absent.id)?.manifest).toEqual(absent);
598+
});
599+
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,16 @@ import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatch
125125
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
126126
// [#19328] The install door's DECLARED body contract, asked whole at
127127
// `POST /api/v1/packages` — the union of the wrapped request and a bare
128-
// manifest — plus the wrapped branch by name, which the refusal re-reads to
129-
// locate a failure on the form the caller wrote. Both are the declarations
128+
// manifest — plus each branch by name, which the refusal re-reads to locate a
129+
// failure on the form the caller wrote. All three are the declarations
130130
// `@objectstack/spec` publishes for this door; nothing here restates them.
131131
// Its own line on purpose: the `@objectstack/spec/kernel` import above carries
132132
// the id/version legs' names and is not this import's to share.
133-
import { PackageInstallBodySchema, PackageInstallRequestSchema } from '@objectstack/spec/api';
133+
import {
134+
PackageInstallBodySchema,
135+
PackageInstallManifestSchema,
136+
PackageInstallRequestSchema,
137+
} from '@objectstack/spec/api';
134138

135139
/**
136140
* [#13598] The `protocol` service slot **as this domain reaches it** — one
@@ -860,7 +864,10 @@ interface InstallBodyIssue {
860864
*/
861865
function installBodyRefusal(body: unknown, unionIssues: ReadonlyArray<InstallBodyIssue>): string {
862866
const wrapped = body !== null && typeof body === 'object' && 'manifest' in body;
863-
const branch = (wrapped ? PackageInstallRequestSchema : ManifestSchema).safeParse(body);
867+
// The bare branch is the union's own, `PackageInstallManifestSchema` — ⛔ not
868+
// `ManifestSchema`, which admits the platform scopes this door refuses, so a
869+
// re-parse through it passed and the bare-form refusal lost its sentence.
870+
const branch = (wrapped ? PackageInstallRequestSchema : PackageInstallManifestSchema).safeParse(body);
864871
// By the disjointness above the branch cannot pass where the union failed;
865872
// if a future union ever made it, the union's own issues still say what
866873
// was refused rather than an empty sentence.

‎packages/spec/api-surface/api.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@
748748
"PackageInstallBody (type)",
749749
"PackageInstallBodyParsed (type)",
750750
"PackageInstallBodySchema (const)",
751+
"PackageInstallManifest (type)",
752+
"PackageInstallManifestParsed (type)",
753+
"PackageInstallManifestSchema (const)",
751754
"PackageInstallRequest (type)",
752755
"PackageInstallRequestParsed (type)",
753756
"PackageInstallRequestSchema (const)",
@@ -1102,6 +1105,7 @@
11021105
"isSubscribableChannel (function)",
11031106
"makeApiErrorSchema (function)",
11041107
"normalizeEndpointPath (function)",
1108+
"packageInstallScopeRefusal (function)",
11051109
"readAuthFamilies (function)",
11061110
"readChannelRoute (function)",
11071111
"readServiceSelfInfo (function)",

‎packages/spec/authorable-defaults/api.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@
123123
"api/OpenApiGenerationConfig:version = \"3.0.3\"",
124124
"api/OpenApiSpec:openapi = \"3.0.0\"",
125125
"api/OpenApiSpec:servers = []",
126+
"api/PackageInstallManifest:defaultDatasource = \"default\"",
127+
"api/PackageInstallManifest:scope = \"project\"",
126128
"api/PackageRollbackRequest:rollbackCustomizations = true",
127129
"api/PackageUpgradeRequest:createSnapshot = true",
128130
"api/PackageUpgradeRequest:dryRun = false",

‎packages/spec/authorable-surface/api.json‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,32 @@
12551255
"api/PackageExportManifest:label",
12561256
"api/PackageExportManifest:name",
12571257
"api/PackageExportManifest:version",
1258+
"api/PackageInstallManifest:capabilities [RETIRED]",
1259+
"api/PackageInstallManifest:configuration [RETIRED]",
1260+
"api/PackageInstallManifest:contributes",
1261+
"api/PackageInstallManifest:data",
1262+
"api/PackageInstallManifest:datasources",
1263+
"api/PackageInstallManifest:defaultDatasource",
1264+
"api/PackageInstallManifest:dependencies",
1265+
"api/PackageInstallManifest:description",
1266+
"api/PackageInstallManifest:engine",
1267+
"api/PackageInstallManifest:engines",
1268+
"api/PackageInstallManifest:extensions [RETIRED]",
1269+
"api/PackageInstallManifest:id",
1270+
"api/PackageInstallManifest:installModes",
1271+
"api/PackageInstallManifest:integrity",
1272+
"api/PackageInstallManifest:loading [RETIRED]",
1273+
"api/PackageInstallManifest:main",
1274+
"api/PackageInstallManifest:name",
1275+
"api/PackageInstallManifest:namespace",
1276+
"api/PackageInstallManifest:navigationContributions",
1277+
"api/PackageInstallManifest:objects",
1278+
"api/PackageInstallManifest:packaging",
1279+
"api/PackageInstallManifest:permissions",
1280+
"api/PackageInstallManifest:runtime",
1281+
"api/PackageInstallManifest:scope",
1282+
"api/PackageInstallManifest:type",
1283+
"api/PackageInstallManifest:version",
12581284
"api/PackageInstallRequest:artifactRef",
12591285
"api/PackageInstallRequest:enableOnInstall",
12601286
"api/PackageInstallRequest:manifest",

‎packages/spec/declaration-map/api.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,8 @@
547547
"PackageExportManifestSchema": "api/PackageExportManifest",
548548
"PackageInstallBody": "api/PackageInstallBody",
549549
"PackageInstallBodySchema": "api/PackageInstallBody",
550+
"PackageInstallManifest": "api/PackageInstallManifest",
551+
"PackageInstallManifestSchema": "api/PackageInstallManifest",
550552
"PackageInstallRequest": "api/PackageInstallRequest",
551553
"PackageInstallRequestSchema": "api/PackageInstallRequest",
552554
"PackageInstallResponse": "api/PackageInstallResponse",

0 commit comments

Comments
 (0)