Skip to content

Commit 2306a75

Browse files
fix(metadata-protocol)!: the protocol install primitive parses manifest.id, and the duplicate door parses its target id (#19417) (#19574)
Fixes #19417 Clause-②: no (narrowing) `Fixes`, not `Part of`, and the reason is measured rather than assumed: the landing record `5754746826` held this card open for exactly one thing — the `packages/metadata-protocol` seam, `protocol.ts` building `dupManifest` with `id: request.targetPackageId` and writing it through `installPackage` with no `ManifestSchema` parse. That seam is closed here. Every other ask on the card already landed with #19473 and is verified present on this branch's base: the HTTP door's `ManifestSchema.shape.id.safeParse` gate, the refusal pins, and the REVERSED `domain-handler-registry.test.ts` pin (`[#19417] POST /packages refuses 'pkg-a' — the id the pattern refuses (the REVERSED pin)`). Nothing on the card is left standing, so merging it should close it. Diff measured between `b3615f1a4cd7f3ff59ff0548530daa042627f732` (the merge base, `origin/main` at branch creation) and `3128642fd60833d130844364f1b5e6c872efafb5` (head). 3 files, +465 / -3. ## ⏳ The measurement the order asked for FIRST — and it is not what the seam note feared The order's blocking question: **does the platform itself mint package ids that `MANIFEST_ID_PATTERN` refuses, through this door?** Enforcing here narrows a live door whose docblock says it serves packages that never take the `defineStack` path. **Measured answer: no — no first-party code mints a refused id through this door.** The instrument and its controls: | probe | reading | control | | --- | --- | --- | | non-test callers of `protocol.installPackage`, whole repo | **2**, both opened | the same instrument returns the registry-direct call sites it must not confuse them with (`objectql/src/engine.ts`, `service-package/src/index.ts`) — lit | | caller 1 — `packages/runtime/src/domains/packages.ts` (`POST /packages`) | the `ManifestSchema.shape.id.safeParse` gate #19473 landed sits **above** the `protocolSvc.installPackage(...)` call in the same branch, unconditionally ⇒ only conforming ids reach the primitive from there | the branch's other gates (`!pkgId`, the `version` leg) are present in the same read | | caller 2 — `duplicatePackage` (same file) | id is `request.targetPackageId`, **caller-supplied**: its only non-test caller is `POST /packages/:id/duplicate`, which reads it from the request body and checks only that it is non-empty | `duplicatePackage` non-test callers = 1, enumerated | | first-party id MINTING sites | `os init` stamps `com.example.` + `manifestIdSlug(name)`, and `manifestIdSlug` forces a letter-initial segment — conforming by construction. The shipped example manifests are `com.example.crm` / `.showcase` / `.todo` | `defineStack` occurrence counts in `examples/` are non-zero — lit | | the Studio UI (objectui `0cf2d6644bdb96a9a6784ef801ee6a60a5306bd8`, cloned to read it) | both duplicate dialogs **prefill** the source id with `-copy` appended — conforming whenever the source is. Nothing auto-generates a refused id | `targetPackageId` occurrence count in objectui is non-zero, every hit opened | ⚠️ **The seat's two failed probes are not repeated here**: `packages/studio/src` and `packages/setup/src` do not exist in this tree, and a `packages/*/src` pathspec is dead against `git grep`. Every reading above names a real path and carries a control that hit. ⇒ the narrowing refuses **caller input**, never a value the platform produces. No grandfather clause is invented, and nothing is routed around. ## The fix, and why it is on the primitive `ObjectStackProtocolImplementation.installPackage` spread the request into `any` and handed it to `SchemaRegistry.installPackage` with a second `as any`. `grep ManifestSchema` over its 22,686 lines returned exactly 2 hits, both comments — no parse anywhere in the file. ⭐ **#19473 is not this door, and it is also not unrelated** — measured, because both readings matter. It landed in `packages/runtime/src/domains/packages.ts`, and that HTTP door *does* route through `protocol.installPackage` when the protocol service resolves. So its gate protects that one caller and nothing else: `duplicatePackage` is a second caller and an embedder holding the protocol object is a third. Gating a door buys that door; this gate is on the method every caller passes. Three changes, all in `packages/metadata-protocol/src/protocol.ts`: 1. **`installPackage` parses the raw `manifest.id`** through `ManifestSchema.shape.id` — by reference, never a copy of the grammar — ahead of the spread, the version default and the namespace derivation. The refusal is the declaration's own sentence (`manifestIdRefusal`), surfaced rather than reworded, and the throw carries `statusCode: 400` so an HTTP boundary answers 400 rather than the 500 an unannotated throw earns (`resolveThrownHttpError`). `statusCode` is the spelling this file already uses for its 404. 2. **`duplicatePackage` parses its target id at the top of the method.** It has to be there, not only in `installPackage`: the manifest write below sits inside a deliberately best-effort `catch {}`, so a refusal raised only there would be **swallowed** and the caller would read `success: true` on a package with no manifest row — a silent partial state, strictly worse than the status quo this card set out to close. The position also honours the #14451 rule already on this door: refuse before the mint, or the empty shell is left behind. The key named is `targetPackageId`, the path the caller actually wrote. 3. **One assumption, one implementation.** Both namespace derivations on the duplicate path moved from a raw `id.split('.').pop()` to the spec helper `deriveNamespaceFromPackageId` that `installPackage` already used. This is not cosmetic: the target namespace is spliced into every copied object name as `namespace + '_' + short`, and an object name is `/^[a-z_][a-z0-9_]*$/` (`packages/spec/src/data/object.zod.ts`). The Studio's own default duplicate id — the source id with `-copy` appended — therefore derived `leave-copy` and minted `leave-copy_ticket`, a name the object declaration refuses. The helper answers `leave_copy`. The source side is the same rule read backwards: the prefix those rows actually carry is the one `installPackage` stamped, so matching them with the raw split found nothing and the copy landed under the SOURCE's names — the collision the re-namespacing exists to prevent. An explicit `targetNamespace` still wins untouched; when neither an explicit nor a derivable namespace exists, the door refuses loudly naming `targetNamespace` as the remedy instead of renaming rows with an empty prefix. ## Both directions pinned `packages/metadata-protocol/src/protocol.install-manifest-id.test.ts`, 18 cases, all passing: - **refusal** — four refused ids (a bare word, an underscore inside a segment, the empty string, a digit-initial segment), a manifest with no `id` at all, and a whitespace-padded conforming id. Each asserts the 400 tag, the message **identical to `manifestIdRefusal` itself** (a pin that retyped the sentence would go green on a reworded fourth sentence for one rule), and that **neither writer ran** — not the in-memory registry, not the durable `publish`. - **lit control** — `com.example.crm`, `com.example.my-erp` and `org.apache.superset` still install, registry called exactly once with the id intact. - **the repair survives** — the refusal for `com.example.my_erp` still contains `com.example.my-erp`, the mechanical repair the declaration verifies before offering. - **duplicate door** — the same four refused targets are refused with nothing minted and nothing scanned (`registry.installPackage`, `engine.find` and `saveMetaItem` all uncalled), against a lit control where a conforming target duplicates rows and all. - **namespace** — the Studio default yields `leave_copy` and writes `leave_copy_ticket`; an explicit `targetNamespace` wins; an underivable one refuses naming the remedy. ## Reverse verification — direction predicted BEFORE running, both legs restored and proven Run through `scripts/ablation-replace.mjs`, which proves the mutation reached disk by anchor count and blob hash and proves the restore against `HEAD` (no bare `git checkout --`). The subject resolves from `src` here — the suite imports `./protocol.js` inside its own package — so `dist` is not on this resolution path. | ablation | predicted | measured | | --- | --- | --- | | **A** — delete the `installPackage` id gate | the installPackage refusal pins turn RED; the lit controls and every duplicate-door pin stay GREEN | **7 failed / 11 passed.** Exactly the installPackage arm, including the repair pin I had not counted; both control groups green. Blob `be9dd23ad9c8` → `8981aea29d39`, restored to `be9dd23ad9c8`, `git diff HEAD` empty | | **B** — put the raw `split('.').pop()` back for `targetNs` | only the two namespace pins turn RED | **2 failed / 16 passed**, exactly those two. Blob `be9dd23ad9c8` → `613737784401`, restored to `be9dd23ad9c8`, `git diff HEAD` empty | ## Gates — 61 commands, harvested at this head `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `3128642fd6` (the script derived the change set itself from the merge base; ⛔ not a hand-written path list). **58 green, 0 red, 3 NOT MEASURED** — then one of the three was converted by building its prerequisite, leaving **59 green / 2 NOT MEASURED**. Green, by name: `check-adr-0087-registration` (+ self-test), `check-changeset-no-major` (+ self-test), `check-ci-filter-parity`, `check-closing-keyword-parity` (+ self-test), `check-comment-mask-adoption` (+ self-test), `check-comment-mask-corpus`, `check-empty-changeset` (+ self-test), `check-keyed-text-bounds` (+ self-test), `check-platform-object-tenancy-census` (+ self-test), `check-plugin-teardown-shape` (+ self-test), `check-registry-log-declared` (+ self-test), `check-rest-log-spy-declared` (+ self-test), `check-system-context-census` (+ self-test), `check-undeclared-dep-imports` (+ self-test), `docs-audit/check-affected-docs`, `docs-audit/check-drift-comment`, `pm/release-rehearsal-clone --self-test`, `spec check:duration-unit-keys`, `check:changeset-gate-self-tests`, `check:cross-package-test-inputs`, `check:dispatcher-error-vocabulary`, `check:doc-authoring`, `check:driver-memory-census`, `check:dts-closure`, `check:durability-log-level`, `check:engine-double-contract`, `check:filter-alias-parity`, `check:gitlink-declared`, `check:issue-citations`, `check:lean-entry-closure`, `check:logger-receiver-detach`, `check:nul-bytes`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:org-identifier`, `check:page-declaration-shape`, `check:pm-changeset-deadline-census`, `check:published-files`, `check:query-options-erasure`, `check:refd-timer-probe`, `check:slot-lookup`, `check:sourcemap-no-sources-content`, `check:test-source-alias`, `check:tier-file-adoption`, `check:type-check-coverage`, `check:watch-hint-literal`, `check:where-matcher`. `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET — it loads built entry points and `@objectstack/objectql` had no `dist`); after `turbo run build --filter=@objectstack/objectql` it **measured green**: 2 published conditions, 15 packages, admitted set held exactly. **NOT MEASURED, and recorded as such — neither a pass nor a failure:** - `check:dual-build-cjs-loads` — **exit 3**, `PREREQUISITE NOT MET`: it reads built output and 68 packages have no `dist/`. That needs a whole-repo build, which is CI's `Build Core`. - `check:type-check-debt` — **exit 3**, same class: `--re-measure` refuses to record a number against an unbuilt closure, since an unresolved import invents TS2307/TS7006 and erases the real debt. `lint.yml` builds the closure before this step. Plus, beyond the harvest: `pnpm --filter '@objectstack/metadata-protocol^...' build` green; the package's **full** suite green (185 files, 2,645 tests, 0 failures — no existing pin moved); `pnpm --filter @objectstack/metadata-protocol typecheck` green, with `--listFiles` confirming the new test file is in the program; the three `@objectstack/objectql` suites that drive a REAL protocol instance green (29 tests); and the repo-wide union `pnpm lint` (`eslint . --no-inline-config`) green at `3128642fd6` — the union, so no narrowing had to be proven. ## Scope held, and what is deliberately left standing - **The `id` leg alone.** `InstallPackageRequestSchema` / `ManifestSchema` are still not parsed whole here; the residual classes the HTTP door's own docblock records are untouched and are each their own narrowing. - **`packages/spec` does not move**, and was never opened: the declaration was already right, and the order fenced it out. - **Boot-time and in-process installs are unaffected** — they reach `SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly and never pass this primitive. Versionless and namespace-less manifests still install; their defaults simply run behind the id gate instead of ahead of it. ## Acceptance notes — observed, ⛔ not fixed here 1. **objectui declares the same rule, looser.** `packages/app-shell/src/views/studio-design/packages-io.ts` exports `PACKAGE_ID_RE = /^[a-z][a-z0-9_.-]*(\.[a-z0-9_-]+)+$/`, which admits underscores and digit-initial segments; `MANIFEST_ID_PATTERN` is `/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/` and its own TSDoc says underscores are NOT admitted. Both the package-create dialog and the duplicate dialog validate against the looser copy, so the Studio accepts `com.example.my_erp` and the server has refused it since #19473 — a second declaration of one rule, in the sibling repo. Reported for a card of its own; ⛔ not touched from here. 2. **`duplicatePackage`'s explicit `targetNamespace` is still unvalidated.** An explicitly passed `my-ns` is spliced into object names as `my-ns_x`, which the object declaration refuses. This change only aligned the DERIVED default, which is the seam the order named. 3. **`reassignOrphanedMetadata` reads `targetPackageId` without parsing it**, the same positional read one method over. Left alone deliberately: it rebinds rows to an EXISTING package rather than minting one, so it is a different question about a different door. --- _Generated by [Claude Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3615f1 commit 2306a75

3 files changed

Lines changed: 465 additions & 3 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol): the protocol install primitive parses the manifest's `id` leg, and the duplicate door parses its target id (#19417)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the protocol install and duplicate doors**
10+
`ObjectStackProtocolImplementation.installPackage` and `duplicatePackage` now
11+
refuse a package id that is not reverse-domain notation, throwing a `400`-tagged
12+
error carrying the declaration's own sentence. Both used to install and report
13+
success.
14+
15+
The accept set only shrinks back to what the published declaration has always
16+
said. `MANIFEST_ID_PATTERN` is declared once in
17+
`packages/spec/src/kernel/manifest.zod.ts` and referenced by both faces of one
18+
identity — `ManifestSchema.id`, what an author writes, and
19+
`PackageSchema.manifestId`, what the registry stores and publishes by.
20+
`installPackage` parsed nothing at all: it spread the request into `any` and
21+
handed it to `SchemaRegistry.installPackage` with a second `as any`, so
22+
`id: 'pkg-a'` — or `com.example.my_erp` — installed and PERSISTED while
23+
`defineStack()`, `os build`, `os validate` and the publish face all refused the
24+
same id. That is «declared ≠ enforced» on a published contract, and nothing in
25+
`packages/spec` moves for it: the declaration was already right.
26+
27+
**Why the primitive and not only a door.** #19473 landed the same parse at the
28+
HTTP door (`POST /api/v1/packages`). That door is ONE caller of this primitive —
29+
it routes through `protocol.installPackage` whenever the protocol service
30+
resolves. `duplicatePackage` is a second, and an embedder holding the protocol
31+
object is a third. A gate on one door buys that door; this one is on the method
32+
every caller passes through.
33+
34+
The gate asks the declaration **by reference**`ManifestSchema.shape.id`
35+
rather than keeping a copy of the grammar, so a future move of the
36+
reverse-domain rule reaches this seam with no further edit. The sentence the
37+
caller reads is the declaration's own (`manifestIdRefusal`), **surfaced rather
38+
than reworded**: it names the key, echoes the value, lists the two examples and
39+
carries a suggestion arm that verifies its candidate against the pattern before
40+
offering it. Installing `id: 'com.example.my_erp'` now throws, with:
41+
42+
```text
43+
Invalid package id 'com.example.my_erp' on `manifest.id`. Expected
44+
reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase
45+
dot-separated segments; hyphens allowed inside a segment, underscores are not.
46+
Did you mean 'com.example.my-erp'?
47+
```
48+
49+
**The duplicate door refuses BEFORE it mints anything.** `duplicatePackage`
50+
builds its target manifest and writes it through `installPackage` inside a
51+
deliberately best-effort `catch {}` — a refusal raised only there would be
52+
swallowed and the caller would read `success: true` on a package with no
53+
manifest row. So the target id is parsed at the top of the method, ahead of the
54+
row scan and ahead of the copy loop, and the refusal names the key the caller
55+
actually wrote (`targetPackageId`).
56+
57+
**One assumption, one implementation.** The duplicate door derived both
58+
namespaces with a raw `id.split('.').pop()` while `installPackage` derived the
59+
same default with the spec helper `deriveNamespaceFromPackageId`, which
60+
sanitises to the namespace charset, truncates to 20 and answers `null` when
61+
nothing valid comes out. That mattered: the target namespace is spliced into
62+
every copied object name as `${namespace}_${short}`, and an object name is
63+
`/^[a-z_][a-z0-9_]*$/`. The Studio's own default duplicate id —
64+
`<sourceId>-copy` — therefore minted `leave-copy_ticket`, a name the object
65+
declaration refuses. Both sides now use the helper, so a duplicate of
66+
`com.example.leave` into `com.example.leave-copy` is namespaced `leave_copy`.
67+
An explicitly declared `targetNamespace` still wins untouched; when neither an
68+
explicit nor a derivable namespace exists the door refuses loudly, naming
69+
`targetNamespace` as the remedy, instead of renaming rows with an empty prefix.
70+
71+
**What is not affected.** Boot-time and in-process installs that reach
72+
`SchemaRegistry.installPackage` / `ObjectQL.registerApp` directly never pass
73+
through this primitive, so nothing about how a package is loaded from disk or
74+
registered by a plugin changes. A conforming manifest installs exactly as
75+
before, versionless and namespace-less manifests included — the version default
76+
and the namespace default still run, now behind the id gate rather than ahead of
77+
it.
78+
79+
**Scope — the `id` leg alone.** `InstallPackageRequestSchema` / `ManifestSchema`
80+
are still not parsed whole here. The residual classes the HTTP door's own
81+
docblock records are untouched by this change and are each their own narrowing
82+
of a published contract.
83+
84+
**If you are refused.** Give the package an id in reverse-domain notation —
85+
lowercase dot-separated segments, hyphens allowed inside a segment, underscores
86+
not. The refusal names the key, echoes what you wrote and, where a mechanical
87+
repair exists, offers one it has already checked against the rule, so the
88+
prescription arrives with the failure rather than in a changelog.
89+
90+
<!-- 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 — a caller supplies an id the declaration already required, and an id's repair changes the package's identity, so no mechanical mapping could be prescribed even in principle. The refusal itself carries the remedy. -->
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19417] The protocol install primitive parses `manifest.id` through the
5+
* declaration that owns it.
6+
*
7+
* ---------------------------------------------------------------------------
8+
* The gap these pins close
9+
* ---------------------------------------------------------------------------
10+
* `MANIFEST_ID_PATTERN` (`packages/spec/src/kernel/manifest.zod.ts`) is the
11+
* reverse-domain rule declared ONCE and referenced by both faces of the package
12+
* identity — `ManifestSchema.id` (what an author writes) and
13+
* `PackageSchema.manifestId` (what the registry stores and publishes by).
14+
* `ObjectStackProtocolImplementation.installPackage` never parsed it: the
15+
* request was spread into `any` and handed to the registry with a second
16+
* `as any`, so `pkg-a` and `com.example.my_erp` installed and persisted while
17+
* `defineStack()`, `os build`, `os validate` and the publish face all refused
18+
* the same id.
19+
*
20+
* #19473 landed the same parse at the HTTP door (`POST /packages`,
21+
* `packages/runtime/src/domains/packages.ts`). That door is ONE caller of this
22+
* primitive; `duplicatePackage` is a second and an embedder holding the
23+
* protocol object is a third — which is why the gate belongs here.
24+
*
25+
* ---------------------------------------------------------------------------
26+
* Both directions are pinned, deliberately
27+
* ---------------------------------------------------------------------------
28+
* A refusal pin alone cannot tell "the rule is enforced" from "this door stopped
29+
* installing anything": every refusal case is answered by a lit control on a
30+
* conforming id that still installs, and by the assertion that the registry was
31+
* never reached on the refused ones.
32+
*
33+
* The refusal text is compared against `manifestIdRefusal` itself rather than
34+
* retyped here: the sentence is the declaration's, SURFACED, and a pin that
35+
* restated it would go green on a reworded fourth sentence for one rule.
36+
*/
37+
import { describe, it, expect, vi } from 'vitest';
38+
import { manifestIdRefusal } from '@objectstack/spec/kernel';
39+
import { ObjectStackProtocolImplementation } from './protocol.js';
40+
41+
/** Ids `MANIFEST_ID_PATTERN` refuses, one per reason it refuses them. */
42+
const REFUSED = [
43+
['a bare word — no reverse-domain prefix at all', 'pkg-a'],
44+
['an underscore inside a segment', 'com.example.my_erp'],
45+
['the empty string', ''],
46+
['a segment opening with a digit', 'com.4example.crm'],
47+
] as const;
48+
49+
/** Ids the declaration admits — the lit controls. */
50+
const ADMITTED = ['com.example.crm', 'com.example.my-erp', 'org.apache.superset'] as const;
51+
52+
function makeImpl() {
53+
const registryCalls: Array<{ manifest: any }> = [];
54+
const engine = {
55+
registry: {
56+
installPackage: (manifest: any) => {
57+
registryCalls.push({ manifest });
58+
return { manifest, status: 'installed', enabled: true };
59+
},
60+
},
61+
find: async () => [],
62+
};
63+
const publish = vi.fn(async () => ({ success: true }));
64+
const services = new Map<string, any>([['package', { publish }]]);
65+
const impl = new ObjectStackProtocolImplementation(engine as never, () => services);
66+
return { impl, registryCalls, publish };
67+
}
68+
69+
/** The thrown refusal, or a failure naming what happened instead. */
70+
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
71+
try {
72+
await run();
73+
} catch (e) {
74+
return e;
75+
}
76+
throw new Error('expected the call to be refused, but it resolved');
77+
}
78+
79+
describe('[#19417] installPackage parses `manifest.id` through its declaration', () => {
80+
for (const [why, id] of REFUSED) {
81+
it(`refuses ${why} (${JSON.stringify(id)}) before any write`, async () => {
82+
const { impl, registryCalls, publish } = makeImpl();
83+
const err = await refusalOf(() => (impl as any).installPackage({
84+
manifest: { id, name: 'X', version: '1.0.0' },
85+
}));
86+
// The envelope: an HTTP boundary answers 400, not the 500 an
87+
// unannotated throw earns (`resolveThrownHttpError`).
88+
expect(err.statusCode).toBe(400);
89+
// The sentence is the DECLARATION's, surfaced — not this door's.
90+
expect(err.message).toBe(manifestIdRefusal('manifest.id', id));
91+
// Neither writer ran: not the in-memory registry, not the durable row.
92+
expect(registryCalls).toHaveLength(0);
93+
expect(publish).not.toHaveBeenCalled();
94+
});
95+
}
96+
97+
it('refuses a manifest carrying no `id` at all, and still never writes', async () => {
98+
const { impl, registryCalls, publish } = makeImpl();
99+
const err = await refusalOf(() => (impl as any).installPackage({
100+
manifest: { name: 'X', version: '1.0.0' },
101+
}));
102+
expect(err.statusCode).toBe(400);
103+
expect(registryCalls).toHaveLength(0);
104+
expect(publish).not.toHaveBeenCalled();
105+
});
106+
107+
it('carries the declaration\'s mechanical repair, not just its rule', async () => {
108+
const { impl } = makeImpl();
109+
const err = await refusalOf(() => (impl as any).installPackage({
110+
manifest: { id: 'com.example.my_erp', version: '1.0.0' },
111+
}));
112+
// The repair arm is the whole difference between a rule restated and a
113+
// fix; `manifestIdRefusal` verifies its candidate before offering it.
114+
expect(err.message).toContain('com.example.my-erp');
115+
});
116+
117+
for (const id of ADMITTED) {
118+
it(`lit control — '${id}' still installs`, async () => {
119+
const { impl, registryCalls } = makeImpl();
120+
const res: any = await (impl as any).installPackage({
121+
manifest: { id, name: 'X', version: '1.0.0' },
122+
});
123+
expect(registryCalls).toHaveLength(1);
124+
expect(registryCalls[0].manifest.id).toBe(id);
125+
expect(res.package.status).toBe('installed');
126+
});
127+
}
128+
129+
it('the raw value is parsed — a padded id is refused, not laundered by a trim', async () => {
130+
const { impl, registryCalls } = makeImpl();
131+
const err = await refusalOf(() => (impl as any).installPackage({
132+
manifest: { id: ' com.example.crm ', version: '1.0.0' },
133+
}));
134+
expect(err.statusCode).toBe(400);
135+
expect(registryCalls).toHaveLength(0);
136+
});
137+
});
138+
139+
/** A duplicate-door harness: one source package holding one object row. */
140+
function makeDuplicateImpl(sourceId = 'com.example.leave') {
141+
const rows = [{
142+
id: 'r_1',
143+
type: 'object',
144+
name: 'leave_ticket',
145+
organization_id: null,
146+
package_id: sourceId,
147+
state: 'active',
148+
metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }),
149+
}];
150+
const installed: any[] = [];
151+
const engine: any = {
152+
find: vi.fn(async () => rows),
153+
registry: {
154+
getPackage: vi.fn(() => ({
155+
manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' },
156+
})),
157+
installPackage: vi.fn((manifest: any) => {
158+
installed.push(manifest);
159+
return { manifest, status: 'installed', enabled: true };
160+
}),
161+
},
162+
};
163+
const impl = new ObjectStackProtocolImplementation(engine as never, () => new Map());
164+
const saveMetaItem = vi.spyOn(impl, 'saveMetaItem' as never);
165+
(saveMetaItem as any).mockResolvedValue({ success: true } as never);
166+
return { impl, engine, installed, saveMetaItem };
167+
}
168+
169+
describe('[#19417] duplicatePackage refuses a target id the declaration refuses', () => {
170+
for (const [why, id] of REFUSED) {
171+
it(`refuses ${why} (${JSON.stringify(id)}) before anything is minted`, async () => {
172+
const { impl, engine, installed, saveMetaItem } = makeDuplicateImpl();
173+
const err = await refusalOf(() => (impl as any).duplicatePackage({
174+
sourcePackageId: 'com.example.leave',
175+
targetPackageId: id,
176+
}));
177+
expect(err.statusCode).toBe(400);
178+
// The key named is the one the caller actually wrote.
179+
expect(err.message).toBe(manifestIdRefusal('targetPackageId', id));
180+
// ⭐ Nothing was minted and nothing was scanned: the refusal
181+
// precedes the manifest write AND the copy loop, so no empty shell
182+
// is left behind. The manifest write below sits inside a
183+
// best-effort `catch {}` — a refusal raised only there would be
184+
// swallowed and reported as `success: true`.
185+
expect(installed).toHaveLength(0);
186+
expect(engine.registry.installPackage).not.toHaveBeenCalled();
187+
expect(engine.find).not.toHaveBeenCalled();
188+
expect(saveMetaItem).not.toHaveBeenCalled();
189+
});
190+
}
191+
192+
it('lit control — a conforming target still duplicates, rows and all', async () => {
193+
const { impl, installed, saveMetaItem } = makeDuplicateImpl();
194+
const res: any = await (impl as any).duplicatePackage({
195+
sourcePackageId: 'com.example.leave',
196+
targetPackageId: 'com.example.leave-copy',
197+
});
198+
expect(res.success).toBe(true);
199+
expect(res.copiedCount).toBe(1);
200+
expect(installed).toHaveLength(1);
201+
expect(installed[0].id).toBe('com.example.leave-copy');
202+
expect(saveMetaItem).toHaveBeenCalledTimes(1);
203+
});
204+
});
205+
206+
describe('[#19417] duplicatePackage derives its namespace with the spec helper', () => {
207+
it('the Studio default `<sourceId>-copy` yields a LEGAL object-name prefix', async () => {
208+
const { impl, installed, saveMetaItem } = makeDuplicateImpl();
209+
await (impl as any).duplicatePackage({
210+
sourcePackageId: 'com.example.leave',
211+
targetPackageId: 'com.example.leave-copy',
212+
});
213+
// `deriveNamespaceFromPackageId` sanitises the hyphen; the raw
214+
// `split('.').pop()` this replaced answered 'leave-copy', and an object
215+
// name is /^[a-z_][a-z0-9_]*$/ — so the copy used to be minted under
216+
// names the object declaration refuses.
217+
expect(installed[0].namespace).toBe('leave_copy');
218+
const written = (saveMetaItem as any).mock.calls.map((c: any[]) => c[0].name);
219+
expect(written).toEqual(['leave_copy_ticket']);
220+
});
221+
222+
it('an explicit `targetNamespace` still wins', async () => {
223+
const { impl, installed } = makeDuplicateImpl();
224+
await (impl as any).duplicatePackage({
225+
sourcePackageId: 'com.example.leave',
226+
targetPackageId: 'com.example.leave-copy',
227+
targetNamespace: 'leave2',
228+
});
229+
expect(installed[0].namespace).toBe('leave2');
230+
});
231+
232+
it('refuses loudly when no namespace can be derived, naming the remedy', async () => {
233+
const { impl, installed } = makeDuplicateImpl();
234+
const err = await refusalOf(() => (impl as any).duplicatePackage({
235+
sourcePackageId: 'com.example.leave',
236+
// Admitted by the id pattern, but a single-letter final segment
237+
// cannot carry the namespace charset's 2–20 char rule.
238+
targetPackageId: 'com.example.a',
239+
}));
240+
expect(err.statusCode).toBe(400);
241+
expect(err.message).toContain('targetNamespace');
242+
expect(installed).toHaveLength(0);
243+
});
244+
});

0 commit comments

Comments
 (0)