diff --git a/.changeset/plain-donkeys-repeat.md b/.changeset/plain-donkeys-repeat.md new file mode 100644 index 0000000000..ee8dfe6a85 --- /dev/null +++ b/.changeset/plain-donkeys-repeat.md @@ -0,0 +1,56 @@ +--- +'@objectstack/spec': minor +--- + +Declare the ASSEMBLED manifest stage on the installed-package read API. + +`GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a +package was installed with, and two stages reach that table through declared +doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` += glob patterns), while a `defineStack()` host installs the assembled body +(`manifest.objects` = object definitions). Both response schemas typed every row +at the authoring stage alone, so the shipped `defineStack()` path served a +payload its own declared contract refused. + +Following the #14242 ruling — declare the assembled stage rather than widen the +authoring one — `@objectstack/spec/api` gains two exports: +`AssembledInstalledPackageSchema` (the assembled-stage counterpart of +`InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union +over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` +and `GetInstalledPackageResponseSchema` are bound to the union. + +This is additive at runtime, and the runtime parse is where the gain is: every +payload that parsed before still parses, payloads that were refused for their +manifest stage now parse, and a row belonging to neither stage — an `objects` +array mixing globs with definitions — is still refused. `ManifestSchema` is +unchanged. + +The STATIC gain is one-sided, and smaller than a union normally implies. +`AssembledPackageBodySchema` is annotated `z.ZodType, …>` +in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded +there, and untouched by this change — so the assembled branch carries no field +typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off +one of these two response types now yields `unknown` where it used to yield +`string`; narrowing toward the AUTHORING branch restores the whole of +`ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away +from it yields `Record` — every manifest field `unknown`. In the +assignment direction the assembled branch admits any object at `manifest`, so a +garbage manifest and the mixed-stage row named above both typecheck clean even +though the runtime union refuses both. So: narrow at the point of use for the +authoring stage, and treat an assembled manifest as a record the runtime — not +the compiler — has checked. + +`@objectstack/spec/api` also gains a `browser` export condition. Declaring the +assembled stage makes this entry's module graph reach the datasource +declaration and with it the driver-config validators, whose postgres URL +refinement links `pg-connection-string` — a package whose `parse` statically +resolves `require('fs')`, so a browser bundler that reaches it fails on +`Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a +build with the pg-grammar arm swapped for its dependency-free twin: exactly the +boundary the four entries that already carry the condition use. Node resolution +and the Node bundles are unchanged, byte for byte. For browser consumers the +postgres `url` refinement degrades to the shape-only checks it already performs +before `parse` — the unix-socket short-circuit and the refusal of the +filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters +are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource +publish is a server-side act, so that arm never legitimately ran in a browser. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index a19f88e447..f9e49b459d 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -30,13 +30,107 @@ DELETE /api/v1/packages/:packageId — Uninstall a package ## TypeScript Usage ```typescript -import { GetInstalledPackageRequestSchema, GetInstalledPackageResponseSchema, ListInstalledPackagesRequestSchema, ListInstalledPackagesResponseSchema, PackageApiErrorCode, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; -import type { GetInstalledPackageRequest, GetInstalledPackageResponse, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; +import { AssembledInstalledPackageSchema, GetInstalledPackageRequestSchema, GetInstalledPackageResponseSchema, InstalledPackageAtEitherStageSchema, ListInstalledPackagesRequestSchema, ListInstalledPackagesResponseSchema, PackageApiErrorCode, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; +import type { AssembledInstalledPackage, GetInstalledPackageRequest, GetInstalledPackageResponse, InstalledPackageAtEitherStage, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; // Validate data -const result = GetInstalledPackageRequestSchema.parse(data); +const result = AssembledInstalledPackageSchema.parse(data); ``` +--- + +## AssembledInstalledPackage + +Installed package row whose manifest is the assembled package body + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `AssembledInstalledPackage.manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **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 (semantic versioning) | +| **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 | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets | +| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | +| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | +| **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 | Seed Data / Fixtures for bootstrapping | +| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | +| **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 \| … +8 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. Use the permission declarations, which are enforced. | +| **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) | +| **functions** | `any` | optional | Named handler functions, as they survived the record JSON projection | +| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | +| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | +| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | +| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | +| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | +| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | +| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | +| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | +| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | +| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | +| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | +| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | +| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | +| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | +| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | +| **hooks** | `any` | optional | Object lifecycle hooks, as they survived the record JSON projection | +| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | +| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | +| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | +| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | +| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | + +### Nested Shape: `AssembledInstalledPackage.upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + + --- ## GetInstalledPackageRequest @@ -61,7 +155,7 @@ Get installed package response | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | +| **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more` | ✅ | Installed package details | ### Nested Shape: `GetInstalledPackageResponse.error` @@ -77,7 +171,9 @@ Get installed package response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | -### Nested Shape: `GetInstalledPackageResponse.data` +### Nested Shape: `GetInstalledPackageResponse.data[option 1]` + +Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -94,6 +190,192 @@ Get installed package response | **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | | **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | +### Nested Shape: `GetInstalledPackageResponse.data[option 2]` + +Installed package row whose manifest is the assembled package body + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + + +--- + +## InstalledPackageAtEitherStage + +Installed package row at whichever manifest stage it was installed at + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Installed package with runtime lifecycle state + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `InstalledPackageAtEitherStage[option 1].manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **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 (semantic versioning) | +| **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 | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **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 \| … +8 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. Use the permission declarations, which are enforced. | +| **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: `InstalledPackageAtEitherStage[option 1].upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + +--- + +#### Option 2 + +Installed package row whose manifest is the assembled package body + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | The ASSEMBLED package body this row carries | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | + +### Nested Shape: `InstalledPackageAtEitherStage[option 2].manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **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 (semantic versioning) | +| **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 | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets | +| **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | +| **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | +| **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 | Seed Data / Fixtures for bootstrapping | +| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | +| **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 \| … +8 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. Use the permission declarations, which are enforced. | +| **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) | +| **functions** | `any` | optional | Named handler functions, as they survived the record JSON projection | +| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | +| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | +| **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | +| **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | +| **pages** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Custom Pages | +| **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | +| **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | +| **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | +| **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | +| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | +| **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | +| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | +| **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | +| **agents** | `{ name: string; label: string; avatar?: string; role: string; … }[]` | optional | AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents | +| **tools** | `{ name: string; label: string; description: string; parameters: Record; … }[]` | optional | AI Tool metadata records — optional refinement layer, never required: the default path is skills referencing platform tools or materialised action_`` tools (ADR-0109) | +| **skills** | `{ name: string; label: string; description?: string; surface?: Enum<'ask' \| 'build' \| 'both'>; … }[]` | optional | AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063) | +| **hooks** | `any` | optional | Object lifecycle hooks, as they survived the record JSON projection | +| **mappings** | `{ name: string; label?: string; sourceFormat?: Enum<'csv' \| 'json' \| 'xml' \| 'sql'>; targetObject: string; … }[]` | optional | Data Import/Export Mappings | +| **analyticsCubes** | `{ name: string; title?: string; description?: string; sql: string; … }[]` | optional | Analytics Semantic Layer Cubes | +| **connectors** | `{ name: string; label: string; type: Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>; description?: string; … }[]` | optional | External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set `enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097). | +| **requires** | `string[]` | optional | Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup) | +| **tiers** | `string[]` | optional | Plugin tier presets to enable; overrides --preset | + +### Nested Shape: `InstalledPackageAtEitherStage[option 2].upgradeHistory[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromVersion** | `string` | ✅ | Version before upgrade | +| **toVersion** | `string` | ✅ | Version after upgrade | +| **upgradedAt** | `string` | ✅ | Upgrade timestamp | +| **status** | `Enum<'success' \| 'failed' \| 'rolled_back'>` | ✅ | Upgrade outcome | +| **migrationLog** | `string[]` | optional | Migration step logs | + +--- + --- @@ -124,7 +406,7 @@ List installed packages response | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | +| **data** | `{ packages: (object \| object)[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | ### Nested Shape: `ListInstalledPackagesResponse.error` @@ -144,7 +426,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **packages** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }[]` | ✅ | Installed packages | +| **packages** | `({ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more)[]` | ✅ | Installed packages | | **total** | `integer` | optional | Total matching packages | | **nextCursor** | `string` | optional | Cursor for the next page | | **hasMore** | `boolean` | ✅ | Whether more packages are available | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c1ceafa3ec..2d8ac398a8 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1523 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/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 173 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -33,7 +33,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) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **192** | **1521** | 14 protocol modules | +| **Total** | **192** | **1523** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 438 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 440 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -85,7 +85,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`, `GetInstalledPackageResponse`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | +| [`package-api.zod.ts`](/docs/references/api/package-api) | `AssembledInstalledPackage`, `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `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` | | [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | diff --git a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts index 22e7fa1d2f..c81095de20 100644 --- a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts @@ -30,31 +30,30 @@ * payload. So the parses below are handed `body`, not `body.data`, and a * regression in the envelope reddens here too. * - * ## ⚠️ The list row's remaining gap is the #14242 STAGE mismatch, not F2 + * ## ⚠️ The list row's remaining gap WAS the #14242 STAGE mismatch — closed [#17431] * * F2 was not the only thing standing between `GET /packages` and its declared - * schema, and this file measures the rest rather than declaring past it. - * `ListInstalledPackagesResponseSchema` types each row as + * schema. `ListInstalledPackagesResponseSchema` typed each row as * `InstalledPackageSchema`, whose `manifest` is `ManifestSchema` — the * AUTHORING-stage manifest, where `objects` is an array of GLOB PATTERNS. What - * the registry stores, and therefore what this door serves, is the ASSEMBLED - * body: `ObjectQL.registerApp` is handed `manifest.objects` as object - * DEFINITIONS and `SchemaRegistry.installPackage` records what it was given. + * the registry stores, and therefore what this door serves, is whatever it was + * installed with: `ObjectQL.registerApp` hands `manifest.objects` over as + * object DEFINITIONS, `POST /packages` hands over the authoring manifest its + * own declared request schema describes, and `SchemaRegistry.installPackage` + * records what it was given either way. * * That is the mismatch #14242 identified one layer down, whose maintainer * ruling (2026-09-02, quoted in `stack.zod.ts` at `ArtifactPackageSchema`) was - * to «declare the assembled stage rather than widen the authoring one». No - * assembled-stage counterpart of `InstalledPackageSchema` exists in - * `@objectstack/spec/api` yet, and authoring one is a `packages/spec` change - * this card is explicitly routed away from. + * to «declare the assembled stage rather than widen the authoring one». #17431 + * followed that ruling one layer up: `@objectstack/spec/api` now declares + * `AssembledInstalledPackageSchema`, and both read responses are bound to + * `InstalledPackageAtEitherStageSchema` — a union over the two whole, closed + * stage declarations. ⛔ Neither stage was widened; a row belonging to NEITHER + * is still refused, and that is asserted below rather than assumed. * - * ⇒ The `GET /packages` ledger row is deliberately left WITHOUT a - * `responseSchema`. Writing one would be exactly the "declared but unverified" - * surface the ledger header exists to prevent: it would read as a promise the - * door keeps only for glob-authored packages and breaks for every - * `defineStack()` host, which is the shipped open-core path. The boundary is - * pinned below in BOTH directions, so whoever declares the assembled stage - * gets a red test telling them the row has become fillable. + * ⇒ Both `GET /packages` and `GET /packages/:id` now carry a `responseSchema`, + * legitimate because THIS file drives those handlers and parses what they + * answer on both authoring paths. * * ## The residue on the delete row, PINNED rather than hidden * @@ -78,8 +77,10 @@ import { describe, it, expect, vi } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { ListInstalledPackagesResponseSchema, + GetInstalledPackageResponseSchema, UninstallPackageApiResponseSchema, } from '@objectstack/spec/api'; +import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; const PREFIX = '/api/v1'; @@ -232,23 +233,96 @@ describe('#16781 — GET /packages: the F2 gap is closed on EVERY authoring path }); /** - * ⭐ THE BOUNDARY, and the reason the `GET /packages` ledger row carries no + * ⭐ THE BOUNDARY, and the reason both `/packages` READ rows now carry a * `responseSchema`. * - * This asserts a CURRENT FAILURE on purpose. On the shipped `defineStack()` - * path the served row still does not parse — and the surviving issue is - * `manifest.objects` ALONE, the #14242 authoring-vs-assembled stage - * mismatch, with `data.hasMore` gone from the issue list because this card - * closed it. When someone declares the assembled stage (the ruled remedy), - * this test goes red and tells them the row has become fillable. + * This asserted a CURRENT FAILURE until #17431: on the shipped + * `defineStack()` path the served row did not parse, and the surviving + * issue was `manifest.objects` ALONE — the #14242 authoring-vs-assembled + * stage mismatch — with `data.hasMore` gone from the list because #16781 + * closed it. Declaring the assembled stage is what turned it green, which + * is the pickup path that pin was written to signal. */ - it('the assembled row does NOT yet parse, and the ONLY surviving issue is the #14242 stage mismatch', async () => { + it('the assembled row parses END TO END — the #14242 stage mismatch is closed', async () => { const r = await send('GET', `${PREFIX}/packages`, [CODE_PKG]); const verdict = ListInstalledPackagesResponseSchema.safeParse(r.body); + expect(verdict.error?.issues.map((i) => i.path.join('.')) ?? []).toEqual([]); + expect(verdict.success).toBe(true); + expect(verdict.data!.data.packages).toHaveLength(1); + }); + + /** + * ⛔ ADMITTING BOTH STAGES IS NOT ADMITTING ANYTHING. + * + * `InstalledPackageAtEitherStageSchema` is a union over two whole CLOSED + * declarations, not a widened `objects` key (#14242's rejected road C). A + * row whose `objects` MIXES a glob with a definition belongs to neither + * stage and parses through neither branch — measured here through the real + * door, so «it accepts both» cannot quietly become «it accepts anything». + */ + it('a row at NEITHER stage is still refused by the declared response', async () => { + const MIXED_PKG = { + ...CODE_PKG, id: 'com.acme.mixed', namespace: 'mixed', name: 'Mixed', + objects: ['./src/objects/*.object.yml', { name: 'mixed_lead', fields: { title: { type: 'text' } } }], + }; + const r = await send('GET', `${PREFIX}/packages`, [MIXED_PKG]); + expect(r.status).toBe(200); + + const verdict = ListInstalledPackagesResponseSchema.safeParse(r.body); expect(verdict.success).toBe(false); - expect(verdict.error!.issues.map((i) => i.path.join('.'))) - .toEqual(['data.packages.0.manifest.objects.0']); + // Lit control: the same door, the same parse, one stage-clean row. + expect(ListInstalledPackagesResponseSchema + .safeParse((await send('GET', `${PREFIX}/packages`, [CODE_PKG])).body).success).toBe(true); + }); + + /** + * ⚠️ The declaration is a strict SUBSET of the wire on this row too, and + * the residue is named rather than hidden — the disposition its `DELETE` + * sibling already carries below. + */ + it('the UNDECLARED per-row residue is exactly `writable`', async () => { + const r = await send('GET', `${PREFIX}/packages`, [CODE_PKG]); + const parsed: any = ListInstalledPackagesResponseSchema.parse(r.body); + // `writable` is this door's own computed verdict (#14375), not a + // declared record field, so a declared parse drops it. Deleting it from + // the wire is a payload removal; declaring it is a separate decision + // about what the response promises. + expect(strippedKeys(r.body.data.packages[0], parsed.data.packages[0])).toEqual(['writable']); + }); +}); + +describe('#17431 — GET /packages/:id serves the same row, and parses on BOTH authoring paths', () => { + const get = (pkg: any) => send('GET', `${PREFIX}/packages/${pkg.id}`, [pkg]); + + for (const [label, pkg] of [['glob-authored', GLOB_PKG], ['assembled / defineStack', CODE_PKG]] as const) { + it(`${label}: the served body parses END TO END`, async () => { + const r = await get(pkg); + expect(r.status).toBe(200); + + const verdict = GetInstalledPackageResponseSchema.safeParse(r.body); + expect(verdict.error?.issues.map((i) => i.path.join('.')) ?? []).toEqual([]); + expect(verdict.success).toBe(true); + expect((verdict.data!.data as any).manifest.id).toBe(pkg.id); + }); + } + + it('the assembled row was refused before the stage was declared — the same one issue', async () => { + // Built by DEGRADING the served body to what the authoring-stage + // declaration alone could describe, so this is a statement about the + // fix rather than about a hand-written literal: with the assembled + // branch removed, `objects` is the single surviving reason. + const r = await get(CODE_PKG); + const verdict = InstalledPackageSchema.safeParse(r.body.data); + + expect(verdict.success).toBe(false); + expect(verdict.error!.issues.map((i) => i.path.join('.'))).toEqual(['manifest.objects.0']); + }); + + it('the UNDECLARED residue is exactly `writable` — named, not hidden', async () => { + const r = await get(CODE_PKG); + const parsed: any = GetInstalledPackageResponseSchema.parse(r.body); + expect(strippedKeys(r.body.data, parsed.data)).toEqual(['writable']); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 289494e22d..013030f987 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -374,27 +374,22 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /share-links/:token/messages', domain: '/share-links', disposition: 'public', note: 'unauthenticated shared-conversation messages' }, // ── packages ────────────────────────────────────────────────────────────── - // [#16781] NO `responseSchema`, and the blank is a MEASURED verdict rather - // than an unvisited row. `ListInstalledPackagesResponseSchema` now describes - // the envelope this door serves — the missing `hasMore` (#16628 contract - // review F2) was added — but it types each row as `InstalledPackageSchema`, - // whose `manifest` is the AUTHORING-stage `ManifestSchema` (`objects` = glob - // patterns). This door serves the ASSEMBLED body, where `objects` carries - // object DEFINITIONS: the #14242 stage mismatch, whose maintainer ruling - // (2026-09-02, quoted at `ArtifactPackageSchema` in spec `stack.zod.ts`) was - // to declare the assembled stage rather than widen the authoring one. Until - // an assembled-stage counterpart exists in `@objectstack/spec/api`, a name - // here would promise conformance the door keeps only for glob-authored - // packages and breaks for every `defineStack()` host — the "declared but - // unverified" surface this field's header forbids. Both directions of that - // boundary are pinned in `domains/packages-read-delete-response-conformance.test.ts`, - // so the row becomes fillable against a RED test, never against a guess. - { route: 'GET /packages', domain: '/packages', disposition: 'sdk', client: 'packages.list' }, + // [#17431] The `/packages` READ rows carry a `responseSchema` again: the + // #14242 authoring-vs-assembled manifest stage mismatch that kept `GET + // /packages` blank through #16781 is declared rather than widened, and both + // directions are covered in + // `domains/packages-read-delete-response-conformance.test.ts`. Each row's + // note records what its declaration does NOT carry. + { route: 'GET /packages', domain: '/packages', disposition: 'sdk', client: 'packages.list', + responseSchema: 'ListInstalledPackagesResponseSchema', + note: 'The schema names the WHOLE BODY here, envelope included (`BaseResponseSchema.extend({ data })`), not the `data` alone its lifecycle siblings above declare. This row was blank until now as a MEASURED verdict: an earlier contract review had added `hasMore`, but every row was still typed `InstalledPackageSchema`, whose `manifest` is the AUTHORING-stage `ManifestSchema` (`objects` = glob patterns), while a `defineStack()` host installs the ASSEMBLED body (`objects` = object definitions) — the stage mismatch the comment above names. It is filled by following that ruling one layer up: `@objectstack/spec/api` declares `AssembledInstalledPackageSchema` and binds both read responses to `InstalledPackageAtEitherStageSchema`, a union over the two whole CLOSED stage declarations — neither stage widened, and a row belonging to neither still refused. Fillable because `domains/packages-read-delete-response-conformance.test.ts` drives THIS handler and parses the payload it answers on BOTH authoring paths. ⚠️ The declaration is a strict SUBSET of the wire: each row also carries `writable`, this door\'s own computed verdict and not a declared record field, which a declared parse therefore strips — asserted by name in the same file rather than fixed' }, { route: 'POST /packages', domain: '/packages', disposition: 'sdk', client: 'packages.install' }, - { route: 'GET /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.get' }, + { route: 'GET /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.get', + responseSchema: 'GetInstalledPackageResponseSchema', + note: 'Same schema shape as the list row above (WHOLE BODY, envelope included) over the same projection — `toPackageResponse` + `withWritableVerdict`, one expression serving two doors. Bound to the same two-stage declaration and covered in `domains/packages-read-delete-response-conformance.test.ts`, which drives THIS handler on both authoring paths. ⚠️ Same measured residue: the door also serves `writable`, which the schema does not carry and a declared parse strips — asserted by name' }, { route: 'DELETE /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.uninstall', responseSchema: 'UninstallPackageApiResponseSchema', - note: 'The schema names the WHOLE BODY here, envelope included (`BaseResponseSchema.extend({ data })`), not the `data` alone its lifecycle siblings above declare. Fillable because `domains/packages-read-delete-response-conformance.test.ts` drives THIS handler and parses the payload it answers, on both authoring paths — the row carries no manifest, so the authoring-vs-assembled manifest stage mismatch that keeps `GET /packages` blank cannot reach it. ⚠️ The declaration is a strict SUBSET of the wire: the door also serves `registryRemoved` and `persisted`, which the schema does not carry and a declared parse therefore strips. That residue is asserted by name in the same file rather than fixed — deleting live keys from a published payload is a wire removal, and widening the schema is a `packages/spec` change. See the comment above the `GET /packages` row for the stage mismatch and its ruling' }, + note: 'The schema names the WHOLE BODY here, envelope included (`BaseResponseSchema.extend({ data })`), not the `data` alone its lifecycle siblings above declare. Fillable because `domains/packages-read-delete-response-conformance.test.ts` drives THIS handler and parses the payload it answers, on both authoring paths — the row carries no manifest, so the authoring-vs-assembled manifest stage mismatch that kept `GET /packages` blank never reached it. ⚠️ The declaration is a strict SUBSET of the wire: the door also serves `registryRemoved` and `persisted`, which the schema does not carry and a declared parse therefore strips. That residue is asserted by name in the same file rather than fixed — deleting live keys from a published payload is a wire removal, and widening the schema is a `packages/spec` change. See the `GET /packages` row above for the stage mismatch and its ruling' }, { route: 'PATCH /packages/:id/enable', domain: '/packages', disposition: 'sdk', client: 'packages.enable' }, { route: 'PATCH /packages/:id/disable', domain: '/packages', disposition: 'sdk', client: 'packages.disable' }, { route: 'PATCH /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.update' }, diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 64fcb8267a..13fc77f981 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -79,6 +79,9 @@ "AppDefinitionResponseSchema (const)", "ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema (const)", + "AssembledInstalledPackage (type)", + "AssembledInstalledPackageParsed (type)", + "AssembledInstalledPackageSchema (const)", "AuditMetaItemRequest (type)", "AuditMetaItemRequestSchema (const)", "AuditMetaItemResponse (type)", @@ -570,6 +573,9 @@ "InstallPackageResponse (type)", "InstallPackageResponseSchema (const)", "InstalledPackage (type)", + "InstalledPackageAtEitherStage (type)", + "InstalledPackageAtEitherStageParsed (type)", + "InstalledPackageAtEitherStageSchema (const)", "ListAiConversationsRequest (type)", "ListAiConversationsRequestSchema (const)", "ListAiConversationsResponse (type)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 1d2c8c6c73..6678fc870f 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -23,6 +23,8 @@ "api/ApiTestingUiConfig:path = \"/api-docs\"", "api/ApiTestingUiConfig:syntaxHighlighting = true", "api/ApiTestingUiConfig:theme = \"light\"", + "api/AssembledInstalledPackage:enabled = true", + "api/AssembledInstalledPackage:status = \"installed\"", "api/AuthFeaturesConfig:organization = false", "api/AuthFeaturesConfig:twoFactor = false", "api/AuthProviderInfo:type = \"social\"", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 9c236c9bf9..dc8d03c42e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -192,6 +192,18 @@ "api/ApproveAiPendingActionResponse:error", "api/ApproveAiPendingActionResponse:result", "api/ApproveAiPendingActionResponse:status", + "api/AssembledInstalledPackage:enabled", + "api/AssembledInstalledPackage:errorMessage", + "api/AssembledInstalledPackage:installedAt", + "api/AssembledInstalledPackage:installedVersion", + "api/AssembledInstalledPackage:manifest", + "api/AssembledInstalledPackage:previousVersion", + "api/AssembledInstalledPackage:registeredNamespaces", + "api/AssembledInstalledPackage:settings", + "api/AssembledInstalledPackage:status", + "api/AssembledInstalledPackage:statusChangedAt", + "api/AssembledInstalledPackage:updatedAt", + "api/AssembledInstalledPackage:upgradeHistory", "api/AuditMetaItemRequest:limit", "api/AuditMetaItemRequest:name", "api/AuditMetaItemRequest:organizationId", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index aaca948358..67ac31231c 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -62,6 +62,8 @@ "AppDefinitionResponseSchema": "api/AppDefinitionResponse", "ApproveAiPendingActionResponse": "api/ApproveAiPendingActionResponse", "ApproveAiPendingActionResponseSchema": "api/ApproveAiPendingActionResponse", + "AssembledInstalledPackage": "api/AssembledInstalledPackage", + "AssembledInstalledPackageSchema": "api/AssembledInstalledPackage", "AuditMetaItemRequest": "api/AuditMetaItemRequest", "AuditMetaItemRequestSchema": "api/AuditMetaItemRequest", "AuditMetaItemResponse": "api/AuditMetaItemResponse", @@ -412,6 +414,8 @@ "InitiateChunkedUploadRequestSchema": "api/InitiateChunkedUploadRequest", "InitiateChunkedUploadResponse": "api/InitiateChunkedUploadResponse", "InitiateChunkedUploadResponseSchema": "api/InitiateChunkedUploadResponse", + "InstalledPackageAtEitherStage": "api/InstalledPackageAtEitherStage", + "InstalledPackageAtEitherStageSchema": "api/InstalledPackageAtEitherStage", "ListAiConversationsRequest": "api/ListAiConversationsRequest", "ListAiConversationsRequestSchema": "api/ListAiConversationsRequest", "ListAiConversationsResponse": "api/ListAiConversationsResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 36256fd3d2..9b72f41e7b 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -74,6 +74,9 @@ "AppDefinitionResponseSchema": "src/api/metadata.zod.ts#AppDefinitionResponseSchema (const)", "ApproveAiPendingActionResponse": "src/api/protocol.zod.ts#ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema": "src/api/protocol.zod.ts#ApproveAiPendingActionResponseSchema (const)", + "AssembledInstalledPackage": "src/api/package-api.zod.ts#AssembledInstalledPackage (type)", + "AssembledInstalledPackageParsed": "src/api/package-api.zod.ts#AssembledInstalledPackageParsed (type)", + "AssembledInstalledPackageSchema": "src/api/package-api.zod.ts#AssembledInstalledPackageSchema (const)", "AuditMetaItemRequest": "src/api/protocol.zod.ts#AuditMetaItemRequest (type)", "AuditMetaItemRequestSchema": "src/api/protocol.zod.ts#AuditMetaItemRequestSchema (const)", "AuditMetaItemResponse": "src/api/protocol.zod.ts#AuditMetaItemResponse (type)", @@ -544,6 +547,9 @@ "InstallPackageResponse": "src/kernel/package-registry.zod.ts#InstallPackageResponse (type)", "InstallPackageResponseSchema": "src/kernel/package-registry.zod.ts#InstallPackageResponseSchema (const)", "InstalledPackage": "src/kernel/package-registry.zod.ts#InstalledPackage (type)", + "InstalledPackageAtEitherStage": "src/api/package-api.zod.ts#InstalledPackageAtEitherStage (type)", + "InstalledPackageAtEitherStageParsed": "src/api/package-api.zod.ts#InstalledPackageAtEitherStageParsed (type)", + "InstalledPackageAtEitherStageSchema": "src/api/package-api.zod.ts#InstalledPackageAtEitherStageSchema (const)", "ListAiConversationsRequest": "src/api/protocol.zod.ts#ListAiConversationsRequest (type)", "ListAiConversationsRequestSchema": "src/api/protocol.zod.ts#ListAiConversationsRequestSchema (const)", "ListAiConversationsResponse": "src/api/protocol.zod.ts#ListAiConversationsResponse (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 299e71d049..b7f65df75f 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -33,6 +33,7 @@ "api/ApiTestingUiType", "api/AppDefinitionResponse", "api/ApproveAiPendingActionResponse", + "api/AssembledInstalledPackage", "api/AuditMetaItemRequest", "api/AuditMetaItemResponse", "api/AuthEndpoint", @@ -228,6 +229,7 @@ "api/InitiateChunkedUploadResponse", "api/InstallPackageRequest", "api/InstallPackageResponse", + "api/InstalledPackageAtEitherStage", "api/ListAiConversationsRequest", "api/ListAiConversationsResponse", "api/ListAiPendingActionsRequest", diff --git a/packages/spec/package.json b/packages/spec/package.json index 2b5bd7cd7d..180e392f77 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -108,6 +108,16 @@ } }, "./api": { + "browser": { + "import": { + "types": "./dist/api/index.d.mts", + "default": "./dist/browser/api/index.mjs" + }, + "require": { + "types": "./dist/api/index.d.ts", + "default": "./dist/browser/api/index.js" + } + }, "import": { "types": "./dist/api/index.d.mts", "default": "./dist/api/index.mjs" diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index fd90f2fea0..f44d24f349 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -18,7 +18,12 @@ import { UninstallPackageApiResponseSchema, PackageApiErrorCode, PackageApiContracts, + AssembledInstalledPackageSchema, + InstalledPackageAtEitherStageSchema, } from './package-api.zod'; +import { InstalledPackageSchema } from '../kernel/package-registry.zod'; +import { AssembledPackageBodySchema } from '../stack.zod'; +import { z } from 'zod'; // ========================================== // Path Parameters @@ -476,3 +481,144 @@ describe('package-rollback-response retirement (#12038 3A)', () => { expect(entries.length).toBeGreaterThan(0); }); }); + + +// ========================================== +// Manifest STAGES on the installed-package row (#17431 / #14242 road B) +// ========================================== + +/** + * The two stages, as one row each, differing ONLY in `manifest.objects`. + * + * Everything else is held equal on purpose: what separates them has to be the + * stage, so a failure below can only be about the stage. + */ +const LIFECYCLE = { status: 'installed', enabled: true } as const; +const MANIFEST_BASE = { + id: 'com.acme.stage', namespace: 'stage', version: '1.0.0', type: 'app', scope: 'project', + name: 'Stage Fixture', +} as const; +/** AUTHORING: `objects` are GLOB PATTERNS. */ +const GLOB_ROW = { ...LIFECYCLE, manifest: { ...MANIFEST_BASE, objects: ['./src/objects/*.object.yml'] } }; +/** ASSEMBLED: `objects` are object DEFINITIONS — what `registerApp` iterates. */ +const ASSEMBLED_ROW = { + ...LIFECYCLE, + manifest: { ...MANIFEST_BASE, objects: [{ name: 'stage_lead', fields: { title: { type: 'text' } } }] }, +}; +/** NEITHER stage: one array carrying both spellings — road C's shape. */ +const MIXED_ROW = { + ...LIFECYCLE, + manifest: { + ...MANIFEST_BASE, + objects: ['./src/objects/*.object.yml', { name: 'stage_lead', fields: { title: { type: 'text' } } }], + }, +}; + +describe('the two declared manifest stages are DISTINCT, not two names for one shape', () => { + it('`InstalledPackageSchema` is the AUTHORING stage: globs parse, definitions are refused', () => { + expect(InstalledPackageSchema.safeParse(GLOB_ROW).success).toBe(true); + + const dark = InstalledPackageSchema.safeParse(ASSEMBLED_ROW); + expect(dark.success).toBe(false); + expect(dark.error!.issues.map((i) => i.path.join('.'))).toEqual(['manifest.objects.0']); + }); + + it('`AssembledInstalledPackageSchema` is the ASSEMBLED stage: definitions parse, globs are refused', () => { + expect(AssembledInstalledPackageSchema.safeParse(ASSEMBLED_ROW).success).toBe(true); + + const dark = AssembledInstalledPackageSchema.safeParse(GLOB_ROW); + expect(dark.success).toBe(false); + expect(dark.error!.issues.map((i) => i.path.join('.'))).toEqual(['manifest.objects.0']); + }); + + it('⛔ neither stage was WIDENED to reach the other — each still refuses the other exactly', () => { + // The pair above already shows it; this states the proposition #14242 ruled + // on so a future widening of either declaration reddens by name here. + expect(InstalledPackageSchema.safeParse(ASSEMBLED_ROW).success).toBe(false); + expect(AssembledInstalledPackageSchema.safeParse(GLOB_ROW).success).toBe(false); + }); +}); + +describe('`InstalledPackageAtEitherStageSchema` admits both stages and NOTHING else', () => { + it('parses the authoring row', () => { + expect(InstalledPackageAtEitherStageSchema.safeParse(GLOB_ROW).success).toBe(true); + }); + + it('parses the assembled row', () => { + expect(InstalledPackageAtEitherStageSchema.safeParse(ASSEMBLED_ROW).success).toBe(true); + }); + + it('⛔ REFUSES a row belonging to neither stage — this is not road C', () => { + // Road C would have widened `objects` to `(string | ObjectDef)[]`, which + // accepts exactly this. A union over two whole CLOSED stages does not: the + // mixed array parses through neither branch. + const verdict = InstalledPackageAtEitherStageSchema.safeParse(MIXED_ROW); + expect(verdict.success).toBe(false); + }); + + it('⛔ still refuses an unknown key INSIDE the manifest, on both branches', () => { + // `ManifestSchema` is `strictObject` and the assembled body inherits that + // close, so the union cannot become a hole either stage does not have. + for (const row of [GLOB_ROW, ASSEMBLED_ROW]) { + const typo = { ...row, manifest: { ...row.manifest, namesapce: 'stage' } }; + expect(InstalledPackageAtEitherStageSchema.safeParse(typo).success).toBe(false); + } + }); +}); + +describe('the record-body override set is MEASURED, never hand-picked', () => { + /** Does this schema have a JSON Schema form at all? */ + const emits = (schema: unknown): boolean => { + try { + z.toJSONSchema(schema as never, { io: 'input' } as never); + return true; + } catch { + return false; + } + }; + + it('exactly `functions` and `hooks` have no JSON form on the assembled body', () => { + // The two published response schemas below embed the assembled body. Any + // collection with no JSON form makes them BOTH vanish from + // `json-schema/api/`, which the build's disappearance ratchet refuses — so + // the read-API record body overrides exactly this set, and this pin is what + // keeps the two in step. A new non-serialisable collection reddens HERE, + // naming itself, rather than unpublishing two response schemas. + const shape = (AssembledPackageBodySchema as unknown as { shape: Record }).shape; + const noJsonForm = Object.keys(shape).filter((k) => !emits(shape[k])); + expect(noJsonForm.sort()).toEqual(['functions', 'hooks']); + }); + + it('lit control: the body itself does not emit, the narrowed row does', () => { + // Without both halves this pin could pass while measuring nothing. + expect(emits(AssembledPackageBodySchema)).toBe(false); + expect(emits(ListInstalledPackagesResponseSchema)).toBe(true); + expect(emits(GetInstalledPackageResponseSchema)).toBe(true); + }); +}); + +describe('the read-API responses are declared at both stages (#17431)', () => { + const envelope = (data: unknown) => ({ success: true, data }); + + it('`ListInstalledPackagesResponseSchema` parses a list of either stage', () => { + for (const row of [GLOB_ROW, ASSEMBLED_ROW]) { + const verdict = ListInstalledPackagesResponseSchema.safeParse( + envelope({ packages: [row], total: 1, hasMore: false }), + ); + expect(verdict.success).toBe(true); + } + }); + + it('`GetInstalledPackageResponseSchema` parses either stage', () => { + for (const row of [GLOB_ROW, ASSEMBLED_ROW]) { + expect(GetInstalledPackageResponseSchema.safeParse(envelope(row)).success).toBe(true); + } + }); + + it('both responses still refuse a row that is at NEITHER stage', () => { + expect(ListInstalledPackagesResponseSchema.safeParse( + envelope({ packages: [MIXED_ROW], total: 1, hasMore: false }), + ).success).toBe(false); + expect(GetInstalledPackageResponseSchema.safeParse(envelope(MIXED_ROW)).success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index eb9798a9d3..e4b821324e 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -8,6 +8,7 @@ import { UpgradePlanSchema } from '../kernel/package-upgrade.zod'; import { PackageArtifactSchema } from '../kernel/package-artifact.zod'; import { ManifestSchema } from '../kernel/manifest.zod'; import { ArtifactReferenceSchema } from '../marketplace/marketplace.zod'; +import { AssembledPackageBodySchema } from '../stack.zod'; /** * # Package API Protocol @@ -42,6 +43,142 @@ export const PackagePathParamsSchema = lazySchema(() => z.object({ })); export type PackagePathParams = z.input; +// ========================================== +// Installed Package Rows — the two declared manifest STAGES +// ========================================== + +/** + * One installed-package row whose `manifest` is the ASSEMBLED package body — + * the assembled-stage counterpart of {@link InstalledPackageSchema}. + * + * ## The stage this exists to name + * + * `InstalledPackageSchema.manifest` is `ManifestSchema`, the AUTHORING stage: + * its `objects` is `z.array(z.string())`, GLOB PATTERNS naming files a + * file-based loader should read. What a `defineStack()` host installs is the + * ASSEMBLED body, whose `objects` are object DEFINITIONS — `ObjectQL.registerApp` + * is handed exactly that and iterates it into `registerObject(objDef, …)`, and + * `SchemaRegistry.installPackage` records what it was handed. So the read doors + * serve rows the authoring declaration refuses, with a single surviving reason: + * the manifest stage. + * + * That is the mismatch #14242 identified one layer down, and this declaration + * follows its ruling rather than re-deriving one. The maintainer's decision + * (2026-09-02, road B), quoted at `ArtifactPackageSchema` in `../stack.zod`, + * was to «declare the assembled stage rather than widen the authoring one». + * ⛔ Widening `ManifestSchema.objects` into a union of both spellings was road + * C and was REJECTED by name: a union AT THE KEY makes neither stage checkable, + * which is the tolerate-at-the-consumer shape Prime Directive #12 refuses. So + * `ManifestSchema` is untouched here — still `strictObject`, still globs — and + * the assembled stage gets its own name, built from `AssembledPackageBodySchema` + * (#14242's own declaration) rather than a second transcription of it. + * + * The body half is deliberately typed `Record`; the reason is + * recorded at `AssembledPackageBodySchema` and is not repeated here. The RUNTIME + * schema still carries the manifest's every field plus every collection's full + * declaration, so a wrong-shaped body is refused exactly as it is there — with + * the one measured exception {@link AssembledPackageRecordBodySchema} states + * and pins. + */ +/** + * The assembled package body AS THE REGISTRY RECORDS IT — the same declaration, + * with the two collections that have no JSON form left unchecked. + * + * ## Why this exists at all, measured rather than assumed + * + * `SchemaRegistry.installPackage` does not store the caller's object; it stores + * `toRecordManifest(manifest)`, a structural JSON projection that DROPS + * functions, class instances, `Map`, `Set` and every other exotic value. So the + * row this API serves is JSON by construction, and two of the assembled body's + * 55 collections cannot survive that projection in the shape they declare: + * + * - `functions` — a `z.function()` branch (a named callable); + * - `hooks` — a `z.custom()` branch (a lifecycle handler). + * + * Those same two are the reason `AssembledPackageBodySchema` has NO JSON Schema + * at all: `z.toJSONSchema` refuses a function and a custom type, which is also + * why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish none. + * Embedding the body verbatim in the two published response schemas below made + * BOTH of them disappear from `json-schema/api/`, which the build's own + * disappearance ratchet refuses and whose only other remedy is retiring two + * published defs. `build-schemas.ts` names the remedy taken here instead: + * «make it emit — narrow the unrepresentable member». + * + * ⛔ The override set is NOT hand-picked, and must never become so. It is the + * measured set of shape members with no JSON form, pinned key-by-key in + * `./package-api.test.ts`: a new collection with no JSON form reddens there, + * naming itself, instead of silently unpublishing these responses again. + * + * ⚠️ What `unknown` costs, stated plainly: on THIS surface those two keys are + * accepted without being checked. It is a widening from today, where both are + * refused outright by `ManifestSchema`'s strict close while the door really can + * serve them — so the declaration moves from wrong to incomplete, never from + * checked to tolerant. Every other key, `objects` included, is checked at the + * assembled stage exactly as `AssembledPackageBodySchema` declares it. The + * ARTIFACT surface is untouched and keeps both collections fully declared. + */ +const AssembledPackageRecordBodySchema = lazySchema(() => + (AssembledPackageBodySchema as unknown as z.ZodObject).extend({ + functions: z.unknown().optional() + .describe('Named handler functions, as they survived the record JSON projection'), + hooks: z.unknown().optional() + .describe('Object lifecycle hooks, as they survived the record JSON projection'), + }).describe('One package as assembled, as the registry RECORDS it (JSON only)')); + +export const AssembledInstalledPackageSchema = lazySchema(() => InstalledPackageSchema.extend({ + manifest: AssembledPackageRecordBodySchema.describe('The ASSEMBLED package body this row carries'), +}).describe('Installed package row whose manifest is the assembled package body')); +export type AssembledInstalledPackage = z.input; +/** Post-parse shape of {@link AssembledInstalledPackage} — defaults applied, transforms run (ADR-0122). */ +export type AssembledInstalledPackageParsed = z.infer; + +/** + * One installed-package row at WHICHEVER manifest stage it was installed at — + * the element the read doors (`GET /packages`, `GET /packages/:id`) serve. + * + * ## Why this surface names BOTH stages, where the artifact names one + * + * #14242 bound the artifact's `packages[]` to the assembled stage ALONE, and + * its stated reason is a property of that surface: «a glob in a compiled + * artifact names files nobody will read». The installed-packages table is not + * a compiled artifact. It is the record of what was installed, and BOTH stages + * reach it through DECLARED doors: + * + * - {@link PackageInstallRequestSchema} declares `manifest: ManifestSchema` — + * the AUTHORING stage — and `POST /packages` hands that body straight to + * `SchemaRegistry.installPackage`, which stores a JSON projection of it; + * - a `defineStack()` host reaches the same table through + * `ObjectQL.registerApp`, which installs the ASSEMBLED body. + * + * ⇒ a read contract naming only the assembled stage would refuse a row this + * API's own install contract is declared to produce. Naming only the authoring + * stage is the defect this declaration closes. So the row is declared as what + * it is: one of two stages, each named by its own closed declaration. + * + * ## ⛔ This is a union of two whole STAGES, never a tolerant shape + * + * Road C's defect was a union INSIDE a key: `objects: (string | ObjectDef)[]` + * describes no stage, and admits an array that mixes globs with definitions. + * This union is over two complete, closed declarations, so every parse is a + * FULL parse of one coherent stage and a body belonging to neither — a mixed + * `objects` array among them — is refused by both branches and therefore by + * this schema. That refusal is pinned in + * `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`, + * beside the two doors, so «it accepts both» can never quietly become «it + * accepts anything». + * + * ⛔ Never relax either branch to make a payload fit. A row that parses through + * neither stage is a producer defect, and this is the declaration that has to + * keep saying so. + */ +export const InstalledPackageAtEitherStageSchema = lazySchema(() => z.union([ + InstalledPackageSchema, + AssembledInstalledPackageSchema, +]).describe('Installed package row at whichever manifest stage it was installed at')); +export type InstalledPackageAtEitherStage = z.input; +/** Post-parse shape of {@link InstalledPackageAtEitherStage} — defaults applied, transforms run (ADR-0122). */ +export type InstalledPackageAtEitherStageParsed = z.infer; + // ========================================== // 2. List Packages (GET /api/v1/packages) // ========================================== @@ -72,7 +209,7 @@ export type ListInstalledPackagesRequestParsed = z.infer BaseResponseSchema.extend({ data: z.object({ - packages: z.array(InstalledPackageSchema).describe('Installed packages'), + packages: z.array(InstalledPackageAtEitherStageSchema).describe('Installed packages'), total: z.number().int().optional().describe('Total matching packages'), nextCursor: z.string().optional().describe('Cursor for the next page'), hasMore: z.boolean().describe('Whether more packages are available'), @@ -96,7 +233,7 @@ export type GetInstalledPackageRequest = z.input BaseResponseSchema.extend({ - data: InstalledPackageSchema.describe('Installed package details'), + data: InstalledPackageAtEitherStageSchema.describe('Installed package details'), }).describe('Get installed package response')); export type GetInstalledPackageResponse = z.input; /** Post-parse shape of {@link GetInstalledPackageResponse} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 2ca861c197..44aa0fc44d 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -18,8 +18,7 @@ }, "src/api/package-api.test.ts": { "TS6133: 'GetInstalledPackageRequestSchema' is declared but its value is never read.": 1, - "TS6133: 'UninstallPackageApiRequestSchema' is declared but its value is never read.": 1, - "TS6133: '…' is declared but its value is never read.": 2 + "TS6133: 'UninstallPackageApiRequestSchema' is declared but its value is never read.": 1 }, "src/api/rest-server.test.ts": { "TS6133: 'RestApiConfigType' is declared but its value is never read.": 1 diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 0d7bb9b4de..f15872daec 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -117,6 +117,12 @@ const browserConditionedEntries = [ 'src/data/index.ts', 'src/system/index.ts', 'src/kernel/index.ts', + // `./api` joined the poisoned set when the package read API began declaring + // the assembled manifest stage: its record body reaches the datasource + // declaration, and with it the driver-config validators. Same seam, same + // swap, same degradation the 2026-08-22 ruling accepted — not a second + // mechanism. + 'src/api/index.ts', ]; /**