Skip to content

Commit 4112752

Browse files
fix(metadata-protocol): duplicatePackage parses an explicit targetNamespace through the manifest namespace declaration (#19829)
Fixes #19577 Clause-②: no (narrowing) ## What changed `ObjectStackProtocolImplementation.duplicatePackage` (`packages/metadata-protocol/src/protocol.ts`, located by symbol) resolved its target namespace as `request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId)`, and the loud `400` fired only on `!targetNs`. So an explicit `targetNamespace` crossed no gate. That value is written as the copy's `manifest.namespace` and spliced into every copied object name as `${targetNs}_${short}`, which means `targetNamespace: 'my-ns'` minted `my-ns_ticket`, a name the object declaration refuses. Both branches now pass **one** parse, before the source scan and before the target package record is minted: - **The declaration, by reference.** `ManifestSchema.shape.namespace` (exported from `@objectstack/spec/kernel`, already imported by `protocol.ts` for the id gate). It is the `manifest.namespace` rule itself (`/^[a-z][a-z0-9_]{1,19}$/`, `packages/spec/src/kernel/manifest.zod.ts`), not a copied regex. The parse input is `targetNs ?? ''`, because the declaration is `.optional()` and would otherwise pass an absent value. - **Refused, not sanitised.** An explicit value the declaration refuses is refused. It is never rewritten the way the derivation sanitises an id. - **The declaration's sentence.** The refusal opens with the key and the value, then carries the declaration's own issue message verbatim. For example: `Invalid package namespace 'my-ns' on` + backtick `targetNamespace` backtick + `. Namespace must be 2-20 chars, lowercase alphanumeric + underscore. It becomes the copy's manifest.namespace and the prefix of every copied object name.` The derived branch keeps its `Cannot derive a package namespace from 'ID'. Pass targetNamespace explicitly.` opener. Its reworded rule clause is replaced by the same declaration sentence. Neither message opens with a bracketed tag. - **No new error code.** Both refusals throw `{ statusCode: 400 }` with no `code`, exactly as the id refusal above them does. An HTTP boundary (`resolveThrownHttpError`) therefore answers `400 VALIDATION_ERROR` on both branches, which is the status-derived code the derived branch already answered. - The comment that blessed the gap ("An explicitly declared `targetNamespace` still wins untouched …") is rewritten to state the new truth: the explicit value still wins over the default, but it is parsed. ## Dispatch assumptions, measured - **A1 (confirmed).** On base `c11852406` the construct is exactly as described. The ablation below shows the explicit branch resolving for every value the declaration refuses. - **A2 (refuted in part).** There is **no** exported namespace validator or regex that `deriveNamespaceFromPackageId` sanitises toward. It tests a module-private `NAMESPACE_RE` in `packages/spec/src/kernel/namespace-prefix.ts`, which is byte-identical to the manifest declaration's regex but not exported. The nearest published declaration is `ManifestSchema.shape.namespace`, and that is what this PR uses. The same shape was used for the id: `ManifestSchema.shape.id`. - **A3 (confirmed).** The comment is rewritten. - **A4 (confirmed).** No new code is minted. The explicit refusal reuses the derived branch's status-only shape, and the tests assert `declaredCode` is absent. - **Producer location.** The fix is at the producer. `POST /api/v1/packages/:id/duplicate` (`packages/runtime/src/domains/packages.ts`) forwards a string `targetNamespace` verbatim to this method and maps the throw with `errorFromThrown(e, 500)`. That route therefore answers `400 VALIDATION_ERROR` with no route change. ## Tests New file: `packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts`. The existing `protocol.install-manifest-id.test.ts` is **untouched** and still green, derived-branch refusal included. - **Refusal, 8 cases.** Hyphen `my-ns`, uppercase, leading digit, leading underscore, 1 char, 21 chars, padded `' leave2 '`, and `''`. Each case asserts: - the envelope via `resolveThrownHttpError`: `status 400`, `code VALIDATION_ERROR`, `declaredCode` undefined; - that the message **starts with** its first sentence; - that the message contains the declaration's own issue message, read from `ManifestSchema.shape.namespace.safeParse` and not retyped; - that nothing was installed, scanned or saved. - **Lit control, 4 cases.** `leave2`, `leave_copy`, 20 chars and 2 chars. Each still duplicates, `manifest.namespace` equals the value, and the written object names are exactly `${ns}_ticket`. - **Derived branch, 1 case.** Same envelope, and the declaration's sentence follows the `targetNamespace` remedy. Runs (all at HEAD `5e843ab81`, through `scripts/pm/os-verify-lock.sh`): - `pnpm --filter '@objectstack/metadata-protocol^...' build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.duplicate-package-target-namespace.test.ts src/protocol.install-manifest-id.test.ts src/protocol.bracketed-refusal-opener-absence.test.ts`: `Test Files 3 passed (3)`, `Tests 36 passed (36)`. The bracketed-opener pin is green. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` (whole package): `Test Files 188 passed | 3 skipped (191)`, `Tests 2671 passed | 19 skipped (2690)`, VERDICT command-exit 0. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --noEmit --listFiles` includes the new test file (1 hit). - Consumer check: `pnpm --filter @objectstack/metadata-protocol build && pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-package-lifecycle.test.ts` gave `10 passed`. That test is a downstream caller passing the conforming `targetNamespace: 'iojn2'` through the built dist. The runtime integration suite `package-duplicate-adopt-org-scope.integration.test.ts` also uses `iojn2` only; it is declared to CI and was not run here. **Ablation (one-shot, after commit `5e843ab81`).** Tool: `node scripts/ablation-replace.mjs`, which restores itself on EXIT/INT/TERM, wrapped in the verify lock. It replaced the anchor `if (targetNs == null || !declaredTargetNs.success) {` with `if (!targetNs) {`, which is the base guard, so the new parse is removed. - On disk: anchor x1 → x0, replacement x0 → x1, blob `74ee23265ed4` → `63700594eaa0`. The subject resolves via the relative `./protocol.js`, so it runs from `src/` and needed no rebuild. - Result: `Tests 7 failed | 6 passed (13)`. All 7 explicit refusal cases failed with `expected the call to be refused, but it resolved`. - The 6 that stayed green are what was predicted: `''` (the base `!targetNs` guard already caught it), the 4 lit controls, and the derived case. - Restore: blob after restore `74ee23265ed4` equals the HEAD blob, and `git diff HEAD` is empty. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` was run on HEAD `5e843ab81`: 61 commands, each run with its exit captured before any pipe. `--ran` verdict: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).` - `pnpm check:lean-entry-closure` first answered exit 3 (PREREQUISITE NOT MET: objectql dist absent). It was re-run after `pnpm exec turbo run build --filter=@objectstack/objectql --concurrency=2` and gave exit 0 (`2 published condition(s) measured from a real load`). The record carries the rerun. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`. Reason: PREREQUISITE NOT MET, since it reads the built output of every package (68 lack dist here). A full `pnpm build` does not fit the foreground cap, and this diff changes no `package.json`, `exports` or build config. CI runs it on a fresh full build. - NOT MEASURED: `pnpm check:type-check-debt`. Reason: PREREQUISITE NOT MET, since it needs the whole packages build closure. The only package touched is `@objectstack/metadata-protocol`, whose own `typecheck` exits 0. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-keyed-text-bounds.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 node scripts/check-platform-object-tenancy-census.mjs :: exit 0 node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-registry-log-declared.mjs :: exit 0 node scripts/check-registry-log-declared.mjs --self-test :: exit 0 node scripts/check-rest-log-spy-declared.mjs :: exit 0 node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 node scripts/check-system-context-census.mjs :: exit 0 node scripts/check-system-context-census.mjs --self-test :: exit 0 node scripts/check-undeclared-dep-imports.mjs :: exit 0 node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:objectui-changeset :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 3 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` **Narrowed lint (measured).** `pnpm exec eslint --no-inline-config --format json` was run over the two changed TS files: exit 0, and the JSON reports 2 files, 0 errors, 0 warnings. - ① Population, from `eslint.config.mjs`: `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` plus the `packages/**/*.{ts,…}` blocks. The changeset `.md` is in no `files` glob, so these two files are the whole lintable part of the diff. - ② File count: 2, read from the JSON output. - ③ Invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules; stated at `eslint.config.mjs` around line 327). The baselines it reads (`scripts/slot-lookup-baseline.json`, `scripts/query-options-erasure-baseline.json`) are untouched. So this diff cannot move any verdict on an untouched file. ## Changeset `.changeset/19577-duplicate-package-explicit-namespace.md`: `minor` for `@objectstack/metadata-protocol`, declaring `Clause-②: no (narrowing)`, a **BREAKING for callers** banner and an `adr-0087: not-required (no-migration-prescription)` disposition. It was re-graded from `patch` in a patch round on the seat's call, following the same door's precedent PR #19574. The claim's `Clause-②` VALUE (`no`) is unchanged; the `(narrowing)` arm carries the direction the changeset gates read. ## Acceptance notes - **The declaration's sentence under-states its own regex.** `ManifestSchema.shape.namespace`'s message, `Namespace must be 2-20 chars, lowercase alphanumeric + underscore`, and its TSDoc rule (`2-20 characters, lowercase letters, digits, and underscores only`) both omit "starts with a lowercase letter". Measured: `safeParse('1leave')` and `safeParse('_leave')` refuse with that sentence, although both values satisfy every clause of it. This PR surfaces the sentence verbatim, as the id precedent requires, so the explicit-refusal message inherits the gap. The derived-branch message used to spell the leading-letter clause itself. That is a spec-side fix outside this card's file surface, and it is reported to the seat as a finding. - `packages/spec/src/kernel/namespace-prefix.ts` keeps a private `NAMESPACE_RE` that duplicates the manifest declaration's regex. It is a second declaration of one rule; no drift is observed today. Carrier: none. - Reserved namespaces (`sys`, `base`, `system`) are named in the `manifest.namespace` TSDoc but are not part of its regex, so `targetNamespace: 'sys'` passes this parse. This PR validates the charset declaration only. Whether a `sys` copy is refused downstream is NOT MEASURED. Carrier: none. - #19576 (install-local door) is not addressed here. The #19417 derived-seam work is untouched apart from its refusal sentence now coming from the declaration. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 71ef221 commit 4112752

3 files changed

Lines changed: 235 additions & 12 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol): `duplicatePackage` parses an explicit `targetNamespace` through the manifest namespace declaration instead of taking it raw (#19577)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of `duplicatePackage` / `POST /api/v1/packages/:id/duplicate`** — an explicit `targetNamespace` outside the `manifest.namespace` declaration (`/^[a-z][a-z0-9_]{1,19}$/`) is now refused with a `400` before anything is copied, where it used to be accepted verbatim. Refused now: a hyphen or an uppercase letter (`my-ns`, `MyNs`), a leading digit or underscore (`1leave`, `_leave`), a single character (`l`), more than 20 characters, and surrounding whitespace. Some of these (`l`, `_leave`, a 21-character value) still yield legal object names, so they used to be copied, under a `manifest.namespace` the declaration refuses. Every conforming value — and every call that omits `targetNamespace` — duplicates exactly as before.
10+
11+
`ObjectStackProtocolImplementation.duplicatePackage` (and so `POST /api/v1/packages/:id/duplicate`, which forwards the body's `targetNamespace` verbatim) resolved its target namespace as `request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId)`. The derived default already had to satisfy the namespace charset; the explicit value crossed no gate at all. That value is written as the copy's `manifest.namespace` and spliced into every copied object name as `${namespace}_${short}`, so `targetNamespace: 'my-ns'` minted `my-ns_ticket` — a name the object declaration (`/^[a-z_][a-z0-9_]*$/`) refuses — under a manifest namespace the manifest declaration refuses.
12+
13+
- **One parse for both branches.** Whichever branch answered, the resolved namespace is now parsed by `ManifestSchema.shape.namespace` (`@objectstack/spec/kernel`) — the declaration itself, by reference, not a copied regex — before the source rows are scanned and before the target package record is minted, so a refusal never leaves an empty shell behind.
14+
- **Refused, not sanitised.** An explicit value the declaration refuses is refused; it is never rewritten the way the derivation sanitises an id, because a copy landing under a namespace the caller did not write is a silent rewrite.
15+
- **The sentence is the declaration's.** The refusal names the key and echoes the value, then carries the declaration's own rule text: `Invalid package namespace 'my-ns' on \`targetNamespace\`. Namespace must be 2-20 chars, lowercase alphanumeric + underscore. …`. The derived branch's refusal (an id whose final segment cannot carry the charset) now carries the same declaration sentence after its `Pass \`targetNamespace\` explicitly.` remedy, replacing a reworded one.
16+
- **No new error code.** Both refusals throw with `statusCode: 400` and no `code`, so an HTTP boundary answers `400 VALIDATION_ERROR`, the status-derived code the derived branch already answered.
17+
18+
**If you are refused:** pass a `targetNamespace` of 2–20 characters that starts with a lowercase letter and continues with lowercase letters, digits or underscores (`leave_copy`, not `leave-copy`), or omit it and let the door derive one from `targetPackageId`. Every conforming value duplicates exactly as before.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row, and no request field — `targetNamespace` keeps its name and its type. There is no old spelling that maps to a new one: a refused value is caller input the `manifest.namespace` declaration already forbade, and a namespace is the caller's choice, so no mechanical mapping could be prescribed. The refusal itself carries the remedy — it names the key, echoes the value and quotes the declaration's rule. -->
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19577] `duplicatePackage` parses an EXPLICIT `targetNamespace` through the
5+
* same declaration its derived default already had to satisfy.
6+
*
7+
* ---------------------------------------------------------------------------
8+
* The gap these pins close
9+
* ---------------------------------------------------------------------------
10+
* The target namespace is spliced into every copied object name
11+
* (`${targetNs}_${short}`) and written as the copy's `manifest.namespace`.
12+
* #19417 made the DERIVED default go through `deriveNamespaceFromPackageId`,
13+
* which sanitises toward the namespace charset and answers `null` when nothing
14+
* valid comes out. The explicit branch of the same `??` was left raw:
15+
* `targetNamespace: 'my-ns'` minted `my-ns_ticket`, a name the object
16+
* declaration (`/^[a-z_][a-z0-9_]*$/`) refuses, and stored `my-ns` as a
17+
* manifest namespace the manifest declaration refuses.
18+
*
19+
* ---------------------------------------------------------------------------
20+
* What is pinned, and how
21+
* ---------------------------------------------------------------------------
22+
* - The refusal is asserted as the ENVELOPE an HTTP boundary answers with —
23+
* `resolveThrownHttpError` (`@objectstack/types`), the one function both
24+
* package doors call — so `status` and `code` are read the way the wire reads
25+
* them, and `declaredCode` absent proves no new code was minted: the explicit
26+
* branch answers the same status-derived code the derived branch does.
27+
* - The rule sentence is read off the DECLARATION
28+
* (`ManifestSchema.shape.namespace`) rather than retyped here: a pin that
29+
* restated it would stay green on a reworded second sentence for one rule.
30+
* - Every refusal also asserts that nothing was minted or scanned, and every
31+
* refusal is answered by a lit control on a conforming explicit namespace
32+
* that still duplicates under the names it prefixes — a refusal pin alone
33+
* cannot tell "the rule is enforced" from "this door stopped duplicating".
34+
*/
35+
import { describe, it, expect, vi } from 'vitest';
36+
import { ManifestSchema } from '@objectstack/spec/kernel';
37+
import { resolveThrownHttpError } from '@objectstack/types';
38+
import { ObjectStackProtocolImplementation } from './protocol.js';
39+
40+
/** Explicit namespaces the declaration refuses, one per reason it refuses them. */
41+
const REFUSED = [
42+
['a hyphen — the Studio-shaped value the card names', 'my-ns'],
43+
['an uppercase letter', 'MyNs'],
44+
['a leading digit', '1leave'],
45+
['a leading underscore', '_leave'],
46+
['a single character — below the 2-char floor', 'l'],
47+
['21 characters — above the 20-char ceiling', 'abcdefghijklmnopqrstu'],
48+
['surrounding whitespace — parsed raw, never trimmed', ' leave2 '],
49+
['the empty string', ''],
50+
] as const;
51+
52+
/** Explicit namespaces the declaration admits — the lit controls. */
53+
const ADMITTED = [
54+
['a plain word', 'leave2'],
55+
['an inner underscore', 'leave_copy'],
56+
['exactly 20 characters — the ceiling itself', 'abcdefghijklmnopqrst'],
57+
['exactly 2 characters — the floor itself', 'lv'],
58+
] as const;
59+
60+
/** A duplicate-door harness: one source package holding one object row. */
61+
function makeDuplicateImpl(sourceId = 'com.example.leave') {
62+
const rows = [{
63+
id: 'r_1',
64+
type: 'object',
65+
name: 'leave_ticket',
66+
organization_id: null,
67+
package_id: sourceId,
68+
state: 'active',
69+
metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }),
70+
}];
71+
const installed: any[] = [];
72+
const engine: any = {
73+
find: vi.fn(async () => rows),
74+
registry: {
75+
getPackage: vi.fn(() => ({
76+
manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' },
77+
})),
78+
installPackage: vi.fn((manifest: any) => {
79+
installed.push(manifest);
80+
return { manifest, status: 'installed', enabled: true };
81+
}),
82+
},
83+
};
84+
const impl = new ObjectStackProtocolImplementation(engine as never, () => new Map());
85+
const saveMetaItem = vi.spyOn(impl, 'saveMetaItem' as never);
86+
(saveMetaItem as any).mockResolvedValue({ success: true } as never);
87+
return { impl, engine, installed, saveMetaItem };
88+
}
89+
90+
/** The thrown refusal, or a failure naming what happened instead. */
91+
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
92+
try {
93+
await run();
94+
} catch (e) {
95+
return e;
96+
}
97+
throw new Error('expected the call to be refused, but it resolved');
98+
}
99+
100+
/** The declaration's own sentence for a value — read, never retyped. */
101+
function declarationSentence(value: string): string {
102+
const parsed = ManifestSchema.shape.namespace.safeParse(value);
103+
expect(parsed.success, `the fixture '${value}' must be one the declaration refuses`).toBe(false);
104+
return parsed.error!.issues[0]!.message;
105+
}
106+
107+
describe('[#19577] duplicatePackage refuses an explicit targetNamespace the declaration refuses', () => {
108+
for (const [why, ns] of REFUSED) {
109+
it(`refuses ${why} (${JSON.stringify(ns)}) before anything is minted`, async () => {
110+
const { impl, engine, installed, saveMetaItem } = makeDuplicateImpl();
111+
const err = await refusalOf(() => (impl as any).duplicatePackage({
112+
sourcePackageId: 'com.example.leave',
113+
targetPackageId: 'com.example.leave-copy',
114+
targetNamespace: ns,
115+
}));
116+
117+
// The envelope, as the HTTP boundary resolves it: a 400 carrying
118+
// the status-derived code — the same answer the derived branch's
119+
// refusal gets — and no producer-minted code beside it.
120+
const envelope = resolveThrownHttpError(err);
121+
expect(envelope.status).toBe(400);
122+
expect(envelope.code).toBe('VALIDATION_ERROR');
123+
expect(envelope.declaredCode).toBeUndefined();
124+
125+
// The first sentence names the key the caller wrote and echoes the
126+
// value; the rule that follows is the DECLARATION's sentence.
127+
expect(err.message.startsWith(`Invalid package namespace '${ns}' on \`targetNamespace\`.`)).toBe(true);
128+
expect(err.message).toContain(declarationSentence(ns));
129+
130+
// ⭐ Nothing was minted and nothing was scanned: the refusal
131+
// precedes the manifest write AND the copy loop. The manifest write
132+
// sits inside a best-effort `catch {}`, so a refusal raised only
133+
// there would be swallowed and reported as `success: true`.
134+
expect(installed).toHaveLength(0);
135+
expect(engine.registry.installPackage).not.toHaveBeenCalled();
136+
expect(engine.find).not.toHaveBeenCalled();
137+
expect(saveMetaItem).not.toHaveBeenCalled();
138+
});
139+
}
140+
});
141+
142+
describe('[#19577] lit control — a conforming explicit targetNamespace still duplicates under it', () => {
143+
for (const [why, ns] of ADMITTED) {
144+
it(`${why} ('${ns}') is the copy's namespace and prefixes every copied object name`, async () => {
145+
expect(ManifestSchema.shape.namespace.safeParse(ns).success).toBe(true);
146+
const { impl, installed, saveMetaItem } = makeDuplicateImpl();
147+
const res: any = await (impl as any).duplicatePackage({
148+
sourcePackageId: 'com.example.leave',
149+
targetPackageId: 'com.example.leave-copy',
150+
targetNamespace: ns,
151+
});
152+
expect(res.success).toBe(true);
153+
expect(res.copiedCount).toBe(1);
154+
expect(installed).toHaveLength(1);
155+
expect(installed[0].namespace).toBe(ns);
156+
const written = (saveMetaItem as any).mock.calls.map((c: any[]) => c[0].name);
157+
expect(written).toEqual([`${ns}_ticket`]);
158+
});
159+
}
160+
});
161+
162+
describe('[#19577] the derived branch refuses through the same parse', () => {
163+
it('an id with no derivable namespace answers the same envelope, with the declaration\'s sentence', async () => {
164+
const { impl, installed, engine } = makeDuplicateImpl();
165+
const err = await refusalOf(() => (impl as any).duplicatePackage({
166+
sourcePackageId: 'com.example.leave',
167+
// Admitted by the id pattern; a single-letter final segment cannot
168+
// carry the namespace charset.
169+
targetPackageId: 'com.example.a',
170+
}));
171+
const envelope = resolveThrownHttpError(err);
172+
expect(envelope.status).toBe(400);
173+
expect(envelope.code).toBe('VALIDATION_ERROR');
174+
expect(envelope.declaredCode).toBeUndefined();
175+
expect(err.message.startsWith("Cannot derive a package namespace from 'com.example.a'.")).toBe(true);
176+
expect(err.message).toContain('`targetNamespace`');
177+
expect(err.message).toContain(declarationSentence(''));
178+
expect(installed).toHaveLength(0);
179+
expect(engine.find).not.toHaveBeenCalled();
180+
});
181+
});

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19371,22 +19371,44 @@ export class ObjectStackProtocolImplementation implements
1937119371
// under the SOURCE's names — the collision the re-namespacing exists to
1937219372
// prevent.
1937319373
//
19374-
// ⛔ An explicitly declared `targetNamespace` still wins untouched: it
19375-
// is the caller's decision and this seam is about the DEFAULT.
19374+
// [#19577] ⭐ AN EXPLICIT `targetNamespace` STILL WINS OVER THE DEFAULT,
19375+
// BUT IT IS PARSED, NOT TAKEN RAW. Whichever branch answered, the value
19376+
// becomes the copy's `manifest.namespace` AND the prefix of every copied
19377+
// object name, so both branches pass ONE parse before anything is
19378+
// minted — `ManifestSchema.shape.namespace`, the declaration itself,
19379+
// never a hand-copied regex. An explicit value was the more reachable
19380+
// hole of the two: it is pure caller input, and `targetNamespace:
19381+
// 'my-ns'` minted `my-ns_ticket`, a name the object declaration refuses.
19382+
//
19383+
// ⛔ A refused explicit value is REFUSED, never sanitised the way the
19384+
// derivation sanitises an id: it is what the caller wrote, and a copy
19385+
// quietly landing under a namespace nobody wrote is the silent rewrite
19386+
// this door exists not to perform.
19387+
//
19388+
// The sentence is the DECLARATION's, surfaced — the discipline the id
19389+
// gate above follows — and this door adds only the key it read and the
19390+
// value that arrived. The throw carries `statusCode: 400` and no `code`,
19391+
// exactly like the id refusal above, so an HTTP boundary answers the
19392+
// status-derived `VALIDATION_ERROR` on both branches (`resolveThrownHttpError`).
1937619393
const sourceNs: string =
1937719394
(srcPkg?.manifest?.namespace as string) ?? (deriveNamespaceFromPackageId(request.sourcePackageId) ?? '');
19378-
const targetNs: string | null =
19379-
request.targetNamespace ?? deriveNamespaceFromPackageId(request.targetPackageId);
19380-
if (!targetNs) {
19381-
// Reachable only for an id the pattern admits but the namespace
19382-
// charset cannot carry (a single-letter final segment), or for an
19383-
// explicit `targetNamespace: ''`. Loud, with the remedy — never a
19384-
// copy renamed with an empty prefix.
19395+
const explicitNs = request.targetNamespace;
19396+
const targetNs: string | null = explicitNs ?? deriveNamespaceFromPackageId(request.targetPackageId);
19397+
// `?? ''` because the declaration is `.optional()`: an ABSENT value
19398+
// passes it, and a derivation that produced nothing must not.
19399+
const declaredTargetNs = ManifestSchema.shape.namespace.safeParse(targetNs ?? '');
19400+
if (targetNs == null || !declaredTargetNs.success) {
19401+
const rule = declaredTargetNs.error?.issues[0]?.message ?? 'See `manifest.namespace`';
1938519402
throw Object.assign(
1938619403
new Error(
19387-
`Cannot derive a package namespace from '${request.targetPackageId}'. `
19388-
+ 'Pass `targetNamespace` explicitly — a lowercase letter followed by '
19389-
+ '1–19 letters, digits or underscores.',
19404+
explicitNs != null
19405+
? `Invalid package namespace '${String(explicitNs)}' on \`targetNamespace\`. ${rule}. `
19406+
+ 'It becomes the copy\'s `manifest.namespace` and the prefix of every copied object name.'
19407+
// Reachable only for an id the pattern admits but the
19408+
// namespace charset cannot carry (a single-letter final
19409+
// segment) — never a copy renamed with an empty prefix.
19410+
: `Cannot derive a package namespace from '${request.targetPackageId}'. `
19411+
+ `Pass \`targetNamespace\` explicitly. ${rule}.`,
1939019412
),
1939119413
{ statusCode: 400 },
1939219414
);

0 commit comments

Comments
 (0)