Skip to content

Commit 59bcd22

Browse files
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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(metadata-protocol, runtime)!: a package created at runtime is refused when it carries `navigationContributions`, which its install never registered (#30072)
7+
8+
Clause-②: yes (narrowing) — `POST /api/v1/packages` (both install limbs, its no-protocol fallback included) and the protocol install primitive `installPackage` (and so the package duplicate and every in-process caller) refuse a manifest whose `navigationContributions` is a non-empty list with `400 VALIDATION_ERROR`, a manifest they stored and answered `201` for before. Widened: `@objectstack/metadata-protocol` publishes one new export, `runtimeNavigationContributionsRefusal`, the predicate both doors ask.
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: ManifestSchema.navigationContributions keeps its shape and meaning, and a code package (a defineStack manifest loaded at boot, from the marketplace or by an artifact reload) registers its contributions exactly as before. Only the runtime install door refuses the key, and a refused contribution never reached any app's navigation, so there is no rewrite that keeps what the author meant; the author picks one of the two routes the refusal names. Rows already in sys_packages are data at rest and replay as stored. -->
12+
13+
**BREAKING** accept-set narrowing at the runtime package install door (Studio, REST, MCP and AI authors creating a package at runtime). `os validate`, `os build` and code packages are unchanged.
14+
15+
- **Before.** A package created through `POST /api/v1/packages` with `navigationContributions` targeting another app answered `201` and was stored, but no entry ever appeared in the target app, not after a restart either: only a code package's `registerApp` registers contributions, and neither the install nor the `sys_packages` replay at boot does. Nothing said why.
16+
- **Now.** The same install is refused `400 VALIDATION_ERROR` before anything is stored, with a sentence that names the key and both routes that work. The refusal is asked ahead of the duplicate-id `409`, so the answer does not depend on whether the id is installed. A manifest with no `navigationContributions`, or an empty list, installs as before.
17+
- **Not changed.** A code package's contributions (the Setup app's capability plugins, a multi-package artifact's modules, a marketplace package) are registered and served as before. A package already stored with the key keeps loading at boot, and its entries stay unregistered, as they always were.
18+
19+
**What an author does.** To add an entry to an installed app's menu, save an override of that app in its customization package (the app copied with the entry added; Studio's navigation editor writes it). To contribute menu entries into another package's app, ship them in a code package's manifest, which registers them when it loads. Then install the runtime package without `navigationContributions`.

‎packages/metadata-protocol/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ export { unmanagedInstallPostureRefusal } from './package-unmanaged-install.js';
270270
// its no-protocol fallback included, instead of keeping a second copy.
271271
export { customizationPairManifestRefusal } from './protocol-customization-package.js';
272272

273+
// [ADR-0029 D7] The refusal of `navigationContributions` on a package created
274+
// at runtime, whose install never registers them. The install primitive asks
275+
// it; exported so `POST /packages` (`@objectstack/runtime`) asks the same one
276+
// ahead of both its install limbs, its no-protocol fallback included.
277+
export { runtimeNavigationContributionsRefusal } from './protocol-package-install.js';
278+
273279
// #4556 — the `sys_metadata_history.recorded_by` sentinel → NULL conversion,
274280
// as an ADR-0119 D2 migration plan. Run by `os migrate recorded-by`.
275281
export {

‎packages/metadata-protocol/src/protocol-package-install.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@
3939
* module cycle is evaluation-safe because the one runtime import is read only
4040
* inside a function body, never while the two modules are first evaluated.
4141
* `index.ts` names what it publishes from `protocol.ts` one by one, so the
42-
* `export` they gained for this keeps them off the published surface, and
43-
* nothing re-exports this module, so the published surface does not move.
42+
* `export` they gained for this keeps them off the published surface. `index.ts`
43+
* re-exports one name from this module and nothing else of it:
44+
* {@link runtimeNavigationContributionsRefusal}, added after the move, which the
45+
* HTTP install door asks ahead of both its limbs.
4446
*/
4547

4648
import type { ObjectStackProtocolImplementation, PackagePersistService } from './protocol.js';
@@ -54,6 +56,45 @@ import { ManifestSchema, deriveNamespaceFromPackageId, manifestIdRefusal } from
5456
// [ADR-0146 D3] The pair-shaped-id predicate, asked before the namespace default.
5557
import { customizationPairManifestRefusal } from './protocol-customization-package.js';
5658

59+
/**
60+
* [#30072] The refusal of a runtime-created package's `navigationContributions`
61+
* (ADR-0029 D7, ADR-0049) — `undefined` when the manifest carries none.
62+
*
63+
* Navigation contributions are registered by `ObjectQL.registerApp`, the door
64+
* a CODE package takes when it loads (boot, marketplace, artifact reload). A
65+
* package created at runtime takes this primitive instead, whose registry
66+
* write (`SchemaRegistry.installPackage`) registers none of them, and so does
67+
* the `sys_packages` replay at the next boot. The key was stored, answered
68+
* success and never applied: an author (a person or an AI) was told the menu
69+
* entry was added while it never appeared. Maintainer ruling A on the card
70+
* (record 6631177389): the runtime door refuses the key loudly, naming the two
71+
* routes that work — an override of the app in its customization package for
72+
* a customer's own menu (ADR-0146 D2), and a code package for a contribution.
73+
*
74+
* One predicate for every runtime install door: this primitive asks it, so the
75+
* duplicate and every in-process caller are judged; `POST /packages`
76+
* (`@objectstack/runtime`) asks it ahead of both its install limbs, its
77+
* no-protocol fallback included. An empty array promises nothing and passes.
78+
* ⛔ Rows already in `sys_packages` are not judged: they are data at rest and
79+
* the boot hydration replays them as stored (the tolerance stated at the
80+
* whole-manifest parse below).
81+
*
82+
* The answer is a request-shape refusal: `400`, whose standard member is
83+
* `VALIDATION_ERROR`, with no code minted for it.
84+
*/
85+
export function runtimeNavigationContributionsRefusal(manifest: unknown): string | undefined {
86+
const contributions = (manifest as { navigationContributions?: unknown } | null | undefined)?.navigationContributions;
87+
if (contributions === undefined || (Array.isArray(contributions) && contributions.length === 0)) return undefined;
88+
const id = (manifest as { id?: unknown }).id;
89+
const named = typeof id === 'string' && id !== '' ? ` '${id}'` : '';
90+
return `manifest.navigationContributions is not accepted on a package created at runtime: the package${named} would be `
91+
+ 'stored with it, but its contributions are never registered, so the menu entries would never appear. '
92+
+ "To add an entry to an installed app's menu, save an override of that app in its customization package "
93+
+ '(the app copied with your entry added). To contribute menu entries into another package\'s app, ship '
94+
+ 'them in a code package, whose navigationContributions are registered when it loads. '
95+
+ 'Remove navigationContributions from this manifest and retry.';
96+
}
97+
5798
/**
5899
* [#21243] Write `manifest` to `sys_packages`, and say whether it failed.
59100
*
@@ -238,6 +279,16 @@ export async function installPackage(
238279
);
239280
}
240281

282+
// [#30072] A runtime-created package cannot contribute navigation: its
283+
// contributions would be stored and never registered. Asked AFTER the
284+
// declaration parse, so a malformed manifest keeps the declaration's own
285+
// located answer, and before every write and every server-state read
286+
// below — a request-shape refusal, the predicate's docblock says why.
287+
const navRefusal = runtimeNavigationContributionsRefusal(manifest);
288+
if (navRefusal !== undefined) {
289+
throw Object.assign(new Error(navRefusal), { statusCode: 400 });
290+
}
291+
241292
// [ADR-0146 D3] A paired customization package id (`<base>.custom`) is
242293
// installed only with what the pair's ensure door writes: the base's pair
243294
// namespace, explicit, and the dependency on the base. Asked BEFORE the
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30072] ADR-0029 D7 — the protocol install primitive refuses
5+
* `navigationContributions` on a package created at runtime.
6+
*
7+
* The primitive's registry write (`SchemaRegistry.installPackage`) registers
8+
* no contribution, and neither does the `sys_packages` replay at boot: only a
9+
* code package's `ObjectQL.registerApp` does. So the key was stored, answered
10+
* success and never applied. Every in-process caller passes here (the HTTP
11+
* door's composed limb, the duplicate, an embedder), so the gate is on the
12+
* primitive.
13+
*
14+
* Each refusal is pinned by its envelope — what an HTTP boundary answers for
15+
* the throw (`resolveThrownHttpError`), status AND code — and by the absence of
16+
* both writes. The lit controls hold the other direction.
17+
*/
18+
import { describe, it, expect, vi } from 'vitest';
19+
import { resolveThrownHttpError } from '@objectstack/types';
20+
import { ObjectStackProtocolImplementation } from './protocol.js';
21+
import { runtimeNavigationContributionsRefusal } from './protocol-package-install.js';
22+
23+
function makeImpl() {
24+
const registryCalls: Array<{ manifest: any }> = [];
25+
const engine = {
26+
registry: {
27+
installPackage: (manifest: any) => {
28+
registryCalls.push({ manifest });
29+
return { manifest, status: 'installed', enabled: true };
30+
},
31+
},
32+
find: async () => [],
33+
};
34+
const publish = vi.fn(async () => ({ success: true }));
35+
const services = new Map<string, any>([['package', { publish }]]);
36+
const impl = new ObjectStackProtocolImplementation(engine as never, () => services);
37+
return { impl, registryCalls, publish };
38+
}
39+
40+
/** The thrown refusal, or a failure naming what happened instead. */
41+
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
42+
try {
43+
await run();
44+
} catch (e) {
45+
return e;
46+
}
47+
throw new Error('expected the call to be refused, but it resolved');
48+
}
49+
50+
const BASE = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'module' } as const;
51+
const CONTRIBUTION = { app: 'crm_app', group: 'group_sales', items: [{ id: 'nav_leave', type: 'object', label: 'Leave', objectName: 'leave_request' }] };
52+
53+
describe('[#30072] installPackage refuses navigationContributions on a runtime-created package', () => {
54+
it('refuses a contribution — 400 VALIDATION_ERROR with the prescription, and writes nothing', async () => {
55+
const { impl, registryCalls, publish } = makeImpl();
56+
const manifest = { ...BASE, navigationContributions: [CONTRIBUTION] };
57+
const err = await refusalOf(() => (impl as any).installPackage({ manifest }));
58+
expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
59+
expect(err.message).toBe(runtimeNavigationContributionsRefusal(manifest));
60+
// Neither writer ran: not the in-memory registry, not the durable row.
61+
expect(registryCalls).toHaveLength(0);
62+
expect(publish).not.toHaveBeenCalled();
63+
});
64+
65+
it('the refusal names the key, the package and both routes that work', () => {
66+
const message = runtimeNavigationContributionsRefusal({ ...BASE, navigationContributions: [CONTRIBUTION] });
67+
// The sentence is the contract an author (or an AI) repairs from: its
68+
// first clause names the key and where it is refused.
69+
expect(message?.startsWith('manifest.navigationContributions is not accepted on a package created at runtime')).toBe(true);
70+
expect(message).toContain(`'${BASE.id}'`);
71+
expect(message).toContain('customization package');
72+
expect(message).toContain('code package');
73+
});
74+
75+
it('a malformed manifest keeps the declaration\'s located answer, ahead of this refusal', async () => {
76+
const { impl, registryCalls } = makeImpl();
77+
const err = await refusalOf(() => (impl as any).installPackage({
78+
manifest: { ...BASE, type: 'application', navigationContributions: [CONTRIBUTION] },
79+
}));
80+
expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
81+
expect(err.message).toContain('manifest.type');
82+
expect(registryCalls).toHaveLength(0);
83+
});
84+
85+
it('lit control — the same manifest without the key, or with an empty list, installs', async () => {
86+
const { impl, registryCalls, publish } = makeImpl();
87+
await (impl as any).installPackage({ manifest: { ...BASE } });
88+
await (impl as any).installPackage({ manifest: { ...BASE, id: 'com.example.empty', navigationContributions: [] } });
89+
expect(registryCalls.map((c) => c.manifest.id)).toEqual([BASE.id, 'com.example.empty']);
90+
expect(publish).toHaveBeenCalledTimes(2);
91+
});
92+
});

0 commit comments

Comments
 (0)