|
| 1 | +--- |
| 2 | +'@objectstack/runtime': minor |
| 3 | +--- |
| 4 | + |
| 5 | +fix(runtime): `POST /api/v1/packages` parses the whole body through `PackageInstallBodySchema` instead of reading it key by key (#19328) |
| 6 | + |
| 7 | +Clause-②: no (narrowing) |
| 8 | + |
| 9 | +**BREAKING for callers of the install door.** The door now enforces the |
| 10 | +WHOLE install-body declaration. A body that `PackageInstallBodySchema` refuses |
| 11 | +is refused with `400` / `VALIDATION_ERROR` and installs nothing, whatever the |
| 12 | +reason. Every shape below used to answer `201`, and two of the install options |
| 13 | +used to be honoured. The list names the shapes a caller is most likely to have |
| 14 | +sent, and ends with one bullet for everything else the declaration states. For |
| 15 | +each one, change what you send FROM the refused shape TO the declared one: |
| 16 | + |
| 17 | +- **A manifest with no `type`, in either body form.** FROM |
| 18 | + `{ "manifest": { "id": …, "name": …, "version": … } }` (or the same manifest |
| 19 | + sent bare) TO the same manifest with a declared `type`: one of `app`, |
| 20 | + `plugin`, `ui`, `driver`, `server`, `theme`, `agent`, `objectql`, `module`, |
| 21 | + `gateway` or `adapter`, e.g. `"type": "app"`. It used to install anyway. |
| 22 | +- **A manifest with no `name`, in either body form.** `name` is the manifest's |
| 23 | + other required key besides `id`, `version` and `type`. FROM |
| 24 | + `{ "manifest": { "id": …, "version": …, "type": … } }` (or the same manifest |
| 25 | + sent bare) TO the same manifest with `"name": "…"`, the human-readable |
| 26 | + package name. It used to install anyway, because the door parsed only the |
| 27 | + `id` and `version` legs. |
| 28 | +- **An unknown key inside the manifest, or on a bare body.** Example: a |
| 29 | + transposed `namesapce`. FROM a manifest carrying the undeclared key TO the |
| 30 | + manifest with that key removed, or spelled as the declared key it was meant |
| 31 | + to be (`namespace`). The refusal names the key. The key used to be STORED |
| 32 | + with the package. |
| 33 | +- **A string-typed `enableOnInstall` or `overwrite`.** FROM |
| 34 | + `"enableOnInstall": "false"` / `"overwrite": "true"` TO JSON booleans, |
| 35 | + `"enableOnInstall": false` / `"overwrite": true`. `'false'` used to install a |
| 36 | + fresh package ENABLED, which is the opposite of what the caller asked for. |
| 37 | + `'true'` for `overwrite` was read as absent, which answered `409` on an |
| 38 | + installed id. |
| 39 | +- **Install options spelled on the BARE form (`enableOnInstall`, `overwrite`, |
| 40 | + `settings`).** FROM `{ "id": …, …, "overwrite": true }` TO the wrapped form, |
| 41 | + `{ "manifest": { "id": …, … }, "enableOnInstall": …, "overwrite": …, |
| 42 | + "settings": … }`. `overwrite` may also go on the query string instead |
| 43 | + (`?overwrite=true`), which a bare body may keep using. Before this change the |
| 44 | + door handled these key by key: `enableOnInstall` was ignored, but |
| 45 | + **`overwrite: true` and `settings` were HONOURED** (an installed id was |
| 46 | + overwritten, and the settings reached the install). All three were also |
| 47 | + stored as manifest keys. They are refused now. This is the part of the |
| 48 | + change that removes behaviour a caller could have been relying on. |
| 49 | +- **Any other constraint `ManifestSchema` declares.** Each one is now enforced |
| 50 | + at this door. Before, only the `id` and `version` legs were, and the manifest |
| 51 | + was stored as sent. The constraints include: |
| 52 | + - the `namespace` grammar: 2–20 characters, starting with a lowercase |
| 53 | + letter, with only lowercase letters, digits and underscores; |
| 54 | + - the closed value sets of `scope` (`cloud`, `system`, `project`), |
| 55 | + `runtime` and `packaging`; |
| 56 | + - the declared type of a key (for example a non-string `description`, or a |
| 57 | + non-string version in `dependencies`); |
| 58 | + - the retired manifest keys `configuration`, `capabilities`, `extensions` and |
| 59 | + `loading`, which the declaration keeps only as named refusals; |
| 60 | + - unknown keys inside the nested blocks the declaration closes. These are |
| 61 | + `contributes` (and its `kinds[]`), `data[]` (each entry is a `SeedSchema` |
| 62 | + seed), `navigationContributions[]` (and their items), `engine`, `engines`, |
| 63 | + and the structured form of `permissions`. |
| 64 | + |
| 65 | + FROM the off-declaration value TO the value the declaration states. The |
| 66 | + refusal names the path. |
| 67 | + |
| 68 | +The accept set only shrinks back to what the published declaration has always |
| 69 | +said. `PackageInstallBodySchema` in `@objectstack/spec` is a union of two |
| 70 | +branches: the wrapped request, or a bare manifest as the whole body. |
| 71 | +`ManifestSchema` declares the manifest's required keys (`id`, `name`, |
| 72 | +`version`, `type`) and the grammar or value set of the rest. It closes the |
| 73 | +manifest, and the nested blocks listed above, against unknown keys. |
| 74 | +The wrapped request types `enableOnInstall` and `overwrite` as booleans. Its |
| 75 | +docblock says a bare manifest carries no install options: «a caller that needs |
| 76 | +an option sends the wrapped form». Until now, the door parsed only two legs of |
| 77 | +the manifest (`id` and `version`) and read every other key positionally off |
| 78 | +the raw body. That is «declared ≠ enforced» on a published API contract. Now |
| 79 | +the door parses the body once through the declared union and reads |
| 80 | +`overwrite`, `settings` and `enableOnInstall` off the parsed request. Nothing |
| 81 | +in `@objectstack/spec` moves, and neither branch of the union is relaxed. |
| 82 | + |
| 83 | +**What is not affected.** |
| 84 | + |
| 85 | +- A well-formed body installs exactly as before, in both forms and on both |
| 86 | + install limbs (the protocol primitive and the bare-registry fallback). |
| 87 | +- The first-party SDK (`client.packages.install(m, { enableOnInstall, |
| 88 | + overwrite, settings })`) already sends the wrapped form with JSON booleans. |
| 89 | + Studio's create-package dialog sends `{ manifest }` with a declared `type`. |
| 90 | +- The manifest is stored as sent, with no parse-time defaults added. |
| 91 | +- The refusals for a missing or malformed `id` and `version` keep their own |
| 92 | + sentences and still come first. |
| 93 | +- Every request-shape refusal is still answered ahead of the duplicate-id |
| 94 | + `409`. |
| 95 | +- Boot-time and in-process installs reach `SchemaRegistry.installPackage` / |
| 96 | + `ObjectQL.registerApp` directly and never pass through this door. |
| 97 | + |
| 98 | +**Still accepted, because the declaration accepts it.** An unknown key at the |
| 99 | +top level of the wrapped form (`{ manifest, bogus }`) is dropped, not refused. |
| 100 | +`PackageInstallRequestSchema` is declared in strip mode, and the door now does |
| 101 | +exactly what the declaration says. |
| 102 | + |
| 103 | +**If you are refused.** The refusal names the path of each failed constraint, |
| 104 | +and the form it read the body as. A misplaced install option also gets the wrapped form (and, for |
| 105 | +`overwrite`, `?overwrite=true`) spelled out. So the prescription arrives with |
| 106 | +the `400` rather than in a changelog. |
| 107 | + |
| 108 | +<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. `objectstack migrate meta` has nothing to reach, because there is no old spelling that maps to a new one — every refused shape is one the published declaration already refused, and a caller repairs it by sending the declared shape (a declared `type`, a `name`, the declared key or value, a JSON boolean, the wrapped form). The refusal itself carries the remedy. --> |
0 commit comments