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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/24062-authored-install-scope-project.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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). -->

**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.
125 changes: 116 additions & 9 deletions content/docs/references/api/package-api.mdx

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand All @@ -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. |
Expand All @@ -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 |

---

Expand Down Expand Up @@ -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.

Expand All @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [string, (m: Record<string, unknown>) => unknown, Record<string, unknown>?]> = [
['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);
});
});
15 changes: 11 additions & 4 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -860,7 +864,10 @@ interface InstallBodyIssue {
*/
function installBodyRefusal(body: unknown, unionIssues: ReadonlyArray<InstallBodyIssue>): 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.
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,9 @@
"PackageInstallBody (type)",
"PackageInstallBodyParsed (type)",
"PackageInstallBodySchema (const)",
"PackageInstallManifest (type)",
"PackageInstallManifestParsed (type)",
"PackageInstallManifestSchema (const)",
"PackageInstallRequest (type)",
"PackageInstallRequestParsed (type)",
"PackageInstallRequestSchema (const)",
Expand Down Expand Up @@ -1102,6 +1105,7 @@
"isSubscribableChannel (function)",
"makeApiErrorSchema (function)",
"normalizeEndpointPath (function)",
"packageInstallScopeRefusal (function)",
"readAuthFamilies (function)",
"readChannelRoute (function)",
"readServiceSelfInfo (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/authorable-defaults/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/declaration-map/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading