Repository navigation
Commit 59bcd22
fix(metadata-protocol, runtime)!: refuse navigationContributions on a package created at runtime (#30707)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DQwjrFVgyyoUSKB2w2w7kZf)_
Fixes #30072
Clause-②: yes (narrowing) — `POST /api/v1/packages` (both install limbs,
the no-protocol fallback included) and the protocol install primitive
`installPackage` (so the package duplicate and every in-process caller
too) refuse a manifest whose `navigationContributions` is a non-empty
list with `400 VALIDATION_ERROR`, where they stored it and answered
`201` before. Widened: `@objectstack/metadata-protocol` publishes one
new export, `runtimeNavigationContributionsRefusal`, the predicate both
doors ask (the shape `customizationPairManifestRefusal` already has).
Metadata-protocol: narrowed
Skills-docs: follow-up #30711
## 维护者速读
- 改了什么:运行时新建包(Studio、REST、MCP、AI 调 `POST /api/v1/packages`)如果带
`navigationContributions`,现在直接拒绝(400),不再存下来;代码包的菜单贡献照旧生效。
- 为什么改:这种包的菜单贡献从来不会注册,服务器却回「成功」,重启后也不出现,AI 会告诉用户「已加好菜单」。按裁决
A:响亮拒绝,并指出两条能走通的路(应用覆盖进定制包;贡献写进代码包)。
- 风险与回滚:只收窄运行时建包入口;已存进 `sys_packages` 的旧包照常加载(其菜单本来就不生效)。示例与 HotCRM
只在代码包里用这个键,不受影响。回滚即还原本 PR。
-
先看:`packages/metadata-protocol/src/protocol-package-install.ts`(判定函数与原语里的调用点)、`packages/runtime/src/domains/packages.ts`(入口在两条安装分支之前、409
之前询问)、`packages/runtime/src/domains/packages-install-navigation-contributions.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/packages-install-navigation-contributions.test.ts`,预期 10
个用例全过(两条分支各 5 个:拒绝、裸清单拒绝、先于 409、无键或空数组放行、代码包贡献照常)。
- 需要你做的:无,席位落地。
## What changes
- **`runtimeNavigationContributionsRefusal(manifest)`** (new,
`protocol-package-install.ts`, exported from the package entry):
`undefined` when the manifest carries no `navigationContributions` or an
empty list; otherwise the refusal sentence. It names the key and the
package, and both routes that work: an override of the app in its
customization package for a customer's own menu (ADR-0146 D2, what
Studio's navigation editor writes), and a code package for a
contribution into another package's app.
- **The install primitive** (`installPackage`) asks it right after the
whole-manifest declaration parse, so a malformed manifest keeps the
declaration's located answer, and before every write and every
server-state read. It throws with `statusCode: 400`, which an HTTP
boundary answers as `VALIDATION_ERROR`. Every caller passes here: the
HTTP door's composed limb, `duplicatePackage` (a base stored with the
key before this change is now refused on duplicate, by the rethrow rule
that refuses a duplicate whose record is refused), and an embedder.
- **`POST /api/v1/packages`** asks the same predicate after the
whole-body verdict and ahead of the duplicate-id `409`, so both install
limbs are judged (the no-protocol fallback writes the registry directly
and never reaches the primitive) and the answer does not depend on
whether the id is installed.
- **No new error code.** A request-shape refusal at an install door is a
validation refusal; the ledger's rule sends a generic condition to the
standard catalog, and the door's sibling refusals (`id`, `version`,
whole body, customization pair) all answer `400 VALIDATION_ERROR`.
Nothing in `packages/spec` moves.
## Why
On `main` the runtime door stored `navigationContributions` and answered
`201`, but only `ObjectQL.registerApp` (the door a code package takes at
boot, from the marketplace or on an artifact reload) registers
contributions. `SchemaRegistry.installPackage`, which the runtime
install and the `sys_packages` replay at boot both use, registers none,
so the entry never appeared, not after a restart either. The maintainer
ruled option A on the card (record 6631177389), verbatim: 「…其他全部同意」 —
refuse the key loudly and name the working routes.
**Doors enumerated.** Every runtime door that stores a manifest: `POST
/api/v1/packages` (both limbs) and the protocol primitive (with its
in-process callers: `duplicatePackage`, `ensureCustomizationPackage`,
which builds its own manifest without the key, and the unmanaged
install's base, which carries identity keys only and whose plan already
refuses `navigationContributions` as `422
UNMANAGED_INSTALL_UNCOPYABLE`). `PATCH /api/v1/packages/:id` accepts
only `name`, `description`, `version` and `dependencies`, so it never
stores the key. Marketplace installs and code packages go through
`registerApp`, which registers contributions, and are untouched.
**ADR-0087 disposition:** `not-required (no-migration-prescription)`, in
the changeset. No key of any schema is added, removed or renamed;
`ManifestSchema.navigationContributions` keeps its shape and meaning for
code packages. A refused contribution never reached any app's
navigation, so there is no rewrite that keeps what the author meant: the
author takes one of the two routes the refusal names. First-party users
of the key, measured: `examples/app-multi-package` (`orders` module) and
HotCRM (its artifact modules) use it only in code packages, which
register through `registerApp`; the platform's own users (Setup
capability plugins, `cloud-connection`, `mcp`, `plugin-approvals`) are
code packages too. Rows already in `sys_packages` are data at rest and
replay as stored.
## Tests
-
`packages/metadata-protocol/src/protocol.install-navigation-contributions.test.ts`:
the primitive refuses (envelope `400` / `VALIDATION_ERROR` via
`resolveThrownHttpError`, neither the registry write nor the durable
`publish` ran); the sentence's first clause and the two routes; a
malformed manifest keeps the declaration's answer first; lit controls
(no key, empty list) install.
-
`packages/runtime/src/domains/packages-install-navigation-contributions.test.ts`,
on both the composed and the no-protocol arm, over a real `ObjectQL`
engine and protocol: refusal with the ADR-0112 envelope (`code`,
`status`, `ApiErrorSchema` parse) and nothing stored (`GET` answers
`404`); the bare-manifest body form; refused ahead of the `409`; lit
controls; and a code package's contribution registered through
`registerApp` is still served and untouched by the refusal.
Ablation, one per guarded behaviour, through
`scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed, restored
to the `HEAD` blob with an empty `git diff HEAD`; both suites import the
source, so no `dist/` leg):
- the door's guard (`navRefusal !== undefined` → `&& false`) turned the
runtime suite red, `5 failed | 5 passed`: all four fallback-arm refusal
cases, plus the composed arm's "ahead of the 409" case (the 409 answers
first without it).
- the primitive's guard, the same mutation, turned the primitive suite
red, `1 failed | 3 passed`: the refusal case.
## Acceptance notes
- An empty `navigationContributions: []` installs: it promises nothing
and registers nothing.
- A writable base stored with the key before this change cannot be
duplicated until it is reinstalled without the key (`POST
/api/v1/packages` with `overwrite: true`); `PATCH` cannot remove a
manifest key. The refusal on duplicate is the same sentence.
- The refusal text is not pinned beyond its first clause and its two
route names.
## Verification
All at head `10809d6b`, after the `@objectstack/runtime^...` closure
build (plus `@objectstack/driver-turso`, which `runtime`'s test
typecheck reads).
<details><summary>Package tests and typecheck</summary>
- `pnpm --filter @objectstack/metadata-protocol test`: exit 0, `Test
Files 318 passed | 4 skipped (322)`, `Tests 22458 passed | 26 skipped`.
- `pnpm --filter @objectstack/runtime test`: exit 0, `Test Files 545
passed (545)`, `Tests 8718 passed | 19 skipped`.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0.
`pnpm --filter @objectstack/runtime typecheck`: exit 0 (a first run
exited 1 on `TS2307 @objectstack/driver-turso` in an untouched test file
because that package's `dist` was not built; rebuilt, exit 0).
</details>
<details><summary>Derived gate families (dispatch-gates)</summary>
`node scripts/pm/dispatch-gates.mjs --ran`: 60 derived families
accounted for, 59 run green, 1 NOT MEASURED: `pnpm
check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs a
whole-tree build); CI's `Lint & Repo Gates` runs it. `node
scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on
the shallow clone, and exited 0 after fetching its pinned fixture
commit. `pnpm lint`: exit 0. `check-adr-0087-registration --base
origin/main`: exit 0, the changeset carries `not-required
(no-migration-prescription)`. `check-changeset-no-major --base
origin/main`: exit 0.
</details>
<details><summary>Dogfood battery (owed by the narrowing arm)</summary>
`pnpm --filter @objectstack/dogfood test` under the verify lock, after
the `@objectstack/dogfood^...` closure build: `VERDICT command-exit 0`,
`Test Files 421 passed | 3 skipped (424)`, `Tests 3513 passed | 36
skipped (3549)`, no failures.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (not touched)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4cz2siW3GknVnPzUXgd
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4cz2siW3GknVnPzUXgd)_
<!-- signed: domain:server#1 · os-elon-musk ·
session_016WV4cz2siW3GknVnPzUXgd -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4564d6f commit 59bcd22
6 files changed
Lines changed: 343 additions & 4 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
273 | 279 | | |
274 | 280 | | |
275 | 281 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
43 | | - | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
44 | 46 | | |
45 | 47 | | |
46 | 48 | | |
| |||
54 | 56 | | |
55 | 57 | | |
56 | 58 | | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
57 | 98 | | |
58 | 99 | | |
59 | 100 | | |
| |||
238 | 279 | | |
239 | 280 | | |
240 | 281 | | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
241 | 292 | | |
242 | 293 | | |
243 | 294 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
0 commit comments