@xemahq/xema 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +88 -11
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
  6. package/dist/runtime/edition/verb-map.d.ts +2 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +2 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  11. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  12. package/dist/runtime/runner/chart-set-lock.js +39 -4
  13. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  14. package/edition/README.md +69 -32
  15. package/edition/build-biome-index.mjs +1 -1
  16. package/edition/build-platform-service-index.mjs +1 -1
  17. package/edition/build-runtime-image-source-index.mjs +64 -20
  18. package/edition/catalogue-fetch.mjs +36 -9
  19. package/edition/catalogue-pin.mjs +71 -0
  20. package/edition/catalogue.mjs +11 -0
  21. package/edition/chart-set-source-commit.mjs +17 -7
  22. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  23. package/edition/customer-flags.mjs +0 -1
  24. package/edition/develop-customer-edition.mjs +23 -5
  25. package/edition/develop-edition.mjs +147 -48
  26. package/edition/develop-freeze.mjs +69 -25
  27. package/edition/develop-seal.mjs +39 -25
  28. package/edition/dist-check.mjs +1 -1
  29. package/edition/edition-artifact.mjs +65 -18
  30. package/edition/edition-biome-manifests.mjs +2 -3
  31. package/edition/edition-chart-set.mjs +59 -6
  32. package/edition/edition-config.mjs +28 -2
  33. package/edition/edition-constants.mjs +10 -4
  34. package/edition/edition-contract-parse.mjs +52 -0
  35. package/edition/edition-contract.json +10 -1
  36. package/edition/edition-workspace.mjs +12 -9
  37. package/edition/evidence-trust-policy.mjs +26 -0
  38. package/edition/external-source-walk.mjs +48 -61
  39. package/edition/git-source-auth.mjs +104 -25
  40. package/edition/image-evidence.mjs +20 -10
  41. package/edition/kernel-contract-authority.mjs +23 -1
  42. package/edition/lib.mjs +13 -24
  43. package/edition/oras.mjs +32 -10
  44. package/edition/pin-source.mjs +6 -1
  45. package/edition/registry-credentials.mjs +11 -1
  46. package/edition/release-edition-artifact.mjs +3 -3
  47. package/edition/resolve-chart-set.mjs +50 -30
  48. package/edition/resolve-customer-edition.mjs +63 -7
  49. package/edition/resolve-distribution.mjs +1 -1
  50. package/edition/resolve-edition.mjs +12 -11
  51. package/edition/resolve-images.mjs +36 -15
  52. package/edition/seal-image-digests.mjs +31 -7
  53. package/edition/source-closure.mjs +1 -2
  54. package/edition/trust-policy-arming-verb.mjs +68 -0
  55. package/package.json +3 -3
@@ -9,12 +9,13 @@
9
9
  // freezeGraph the graph repositories, the data root, the kernel, the deploy checkout, the editions root
10
10
  // freezeSources one pin per `sources[]` entry the chain declares
11
11
  //
12
- // `resolveHead(url)` is the one network seam (default: `git ls-remote`
13
- // authenticated through git-source-auth, token from the environment, never argv).
12
+ // `resolveHead({ name, url })` is the one network seam (default: `git ls-remote` through
13
+ // git-source-auth: the data root's token only for a repository under its
14
+ // `sourceRepositoryUrlPrefix`, no credential for any other, never argv).
14
15
 
15
16
  import { execFileSync } from 'node:child_process';
16
17
 
17
- import { gitAuthEnv } from './git-source-auth.mjs';
18
+ import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
18
19
  import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
19
20
  import {
20
21
  dataPinKey,
@@ -31,26 +32,32 @@ import {
31
32
  const DEVELOP_REF = 'refs/heads/develop';
32
33
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
33
34
 
34
- /** `git ls-remote <url> refs/heads/develop`, authenticated as `username` with `token`; throws naming the repository when absent. */
35
- export function lsRemoteDevelopHead(url, { username, token, runGit = execFileSync } = {}) {
36
- const output = runGit('git', ['ls-remote', url, DEVELOP_REF], {
37
- env: gitAuthEnv(url, username, token),
38
- encoding: 'utf8',
39
- stdio: ['ignore', 'pipe', 'pipe'],
40
- }).trim();
35
+ /**
36
+ * `git ls-remote <url> refs/heads/develop` for one source `{ name, url }`; throws naming the repository when
37
+ * absent. The source is held and its git environment built by `withSourceGitEnv`: the workspace credential
38
+ * reaches only a repository under `sourceRepositoryUrlPrefix`.
39
+ */
40
+ export function lsRemoteDevelopHead(source, { workspace, env = process.env, runGit = execFileSync }) {
41
+ const output = withSourceGitEnv(source, { workspace, env }, (gitEnv) =>
42
+ runGit('git', ['ls-remote', source.url, DEVELOP_REF], {
43
+ env: gitEnv,
44
+ encoding: 'utf8',
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ }),
47
+ ).trim();
41
48
  const [sha, ref] = output.split(/\s+/);
42
49
  if (!SHA_PATTERN.test(sha ?? '') || ref !== DEVELOP_REF) {
43
- throw new Error(`${url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
50
+ throw new Error(`${source.url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
44
51
  }
45
52
  return sha;
46
53
  }
47
54
 
48
- /** A resolver that asks `resolveHead` at most once per distinct URL. */
55
+ /** A resolver that asks `resolveHead` at most once per distinct URL; it takes the source `{ name, url }`. */
49
56
  function memoizedHeads(resolveHead) {
50
57
  const heads = new Map();
51
- return (url) => {
52
- if (!heads.has(url)) {heads.set(url, resolveHead(url));}
53
- return heads.get(url);
58
+ return (source) => {
59
+ if (!heads.has(source.url)) {heads.set(source.url, resolveHead(source));}
60
+ return heads.get(source.url);
54
61
  };
55
62
  }
56
63
 
@@ -135,11 +142,11 @@ export function freezeGraph({
135
142
  }
136
143
  const pins = Object.fromEntries(checkedOut.map(({ key, sha }) => [key, sha]));
137
144
  for (const pin of runtimeSourceIndex.repositoryPins) {
138
- pins[pin.repo] = overrides[pin.repo] ?? head(pin.repositoryUrl);
145
+ pins[pin.repo] = overrides[pin.repo] ?? head({ name: pin.repo, url: pin.repositoryUrl });
139
146
  }
140
147
  if (editionsRootUrl !== undefined) {
141
148
  const url = canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url');
142
- pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head(url);
149
+ pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head({ name: EDITIONS_ROOT_PIN_KEY, url });
143
150
  }
144
151
  return sortedSnapshotPins(pins);
145
152
  }
@@ -152,22 +159,59 @@ export function freezeGraph({
152
159
  export function freezeSources({ pins, declaredSources, overrides = {}, resolveHead }) {
153
160
  assertOverrides(overrides, SOURCE_PHASE);
154
161
  const head = memoizedHeads(resolveHead);
155
- const urlByName = new Map();
162
+ const sourceByName = new Map();
156
163
  for (const source of declaredSources) {
157
- const known = urlByName.get(source.name);
158
- if (known !== undefined && known !== source.url) {
159
- throw new Error(`source "${source.name}" is declared at two URLs (${known}, ${source.url})`);
164
+ const known = sourceByName.get(source.name);
165
+ if (known !== undefined && known.url !== source.url) {
166
+ throw new Error(`source "${source.name}" is declared at two URLs (${known.url}, ${source.url})`);
160
167
  }
161
- urlByName.set(source.name, source.url);
168
+ sourceByName.set(source.name, source);
162
169
  }
163
170
  for (const key of Object.keys(overrides)) {
164
- if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !urlByName.has(sourceNameOfPinKey(key))) {
171
+ if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !sourceByName.has(sourceNameOfPinKey(key))) {
165
172
  throw new Error(`override names "${key}", but the chain declares no such source`);
166
173
  }
167
174
  }
168
175
  const frozen = { ...pins };
169
- for (const [name, url] of urlByName) {
170
- frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(url);
176
+ for (const [name, source] of sourceByName) {
177
+ frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(source);
171
178
  }
172
179
  return sortedSnapshotPins(frozen);
173
180
  }
181
+
182
+ /**
183
+ * The declared source URLs a customer freeze may ask, held BEFORE any network call: the token of the
184
+ * run (EVIDENCE_REPOSITORIES_TOKEN) is sent to the origin of each URL, so a URL is refused unless it is
185
+ * a canonical public HTTPS repository URL written in its canonical form and lies under the workspace's
186
+ * `sourceRepositoryUrlPrefix`. A definition that merely names another origin never receives the token.
187
+ */
188
+ export function assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix) {
189
+ if (typeof sourceRepositoryUrlPrefix !== 'string' || sourceRepositoryUrlPrefix.length === 0) {
190
+ throw new Error('a customer freeze takes the workspace\'s sourceRepositoryUrlPrefix');
191
+ }
192
+ for (const source of sources) {
193
+ assertSourceDeclaresNoCredential(source);
194
+ const canonical = assertCanonicalSourceUrl(source);
195
+ const context = `source "${source.name}" url`;
196
+ if (!isUnderSourcePrefix(canonical, sourceRepositoryUrlPrefix)) {
197
+ throw new Error(
198
+ `${context} ${JSON.stringify(source.url)} is not under the workspace's sourceRepositoryUrlPrefix ${sourceRepositoryUrlPrefix}: ` +
199
+ 'the source token is sent only to repositories under that prefix',
200
+ );
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The pins of a CUSTOMER edition: exactly one `sources/<name>` commit per source its definition declares,
207
+ * each the head of refs/heads/develop of that source's repository, resolved once. Every URL is held by
208
+ * `assertCustomerSourceUrls` before the first head is asked.
209
+ */
210
+ export function freezeCustomerSources({ definition, resolveHead, sourceRepositoryUrlPrefix }) {
211
+ const sources = definition?.sources ?? [];
212
+ if (!Array.isArray(sources) || sources.some((source) => typeof source?.name !== 'string' || typeof source?.url !== 'string')) {
213
+ throw new Error('the definition\'s sources must be an array of {name, url}');
214
+ }
215
+ assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix);
216
+ return freezeSources({ pins: {}, declaredSources: sources, resolveHead });
217
+ }
@@ -25,26 +25,30 @@ function notBuiltYetMessage(subject) {
25
25
  }
26
26
 
27
27
  /**
28
+ * The row-level seal: every subject re-homed to its develop repository, verified
29
+ * there for its required platforms, and given the digest the registry served.
30
+ * It takes SUBJECTS, not a lock, so a caller that owns only some rows of a lock
31
+ * (a customer's own image rows) seals exactly those.
32
+ *
28
33
  * @param {object} request
29
- * @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
30
- * @returns the images lock with develop refs and verified digests, kernel-validated
34
+ * @param {string} request.distributionId the edition the subjects belong to (named in refusals)
35
+ * @param {object[]} request.subjects image subjects (release refs, unsealed)
36
+ * @returns the subjects with develop refs and verified digests
31
37
  */
32
- export async function sealDevelopImages({
33
- imageLock,
38
+ export async function sealDevelopImageSubjects({
39
+ distributionId,
40
+ subjects,
34
41
  distributionsRoot = editionsRoot(),
35
42
  env = process.env,
36
43
  fetchImpl = globalThis.fetch,
37
44
  run = null,
38
45
  }) {
39
- const developLock = {
40
- ...imageLock,
41
- subjects: imageLock.subjects.map((subject) => ({
42
- ...subject,
43
- imageRef: developImageRef(subject.imageRef),
44
- })),
45
- };
46
+ const developSubjects = subjects.map((subject) => ({
47
+ ...subject,
48
+ imageRef: developImageRef(subject.imageRef),
49
+ }));
46
50
  const report = await verifyDistributionPlatforms({
47
- imageLock: developLock,
51
+ imageLock: { distributionId, subjects: developSubjects },
48
52
  distributionsRoot,
49
53
  env,
50
54
  fetchImpl,
@@ -55,23 +59,33 @@ export async function sealDevelopImages({
55
59
  if (aborting) {throw aborting.error;}
56
60
  if (report.failures.length > 0) {
57
61
  throw new Error(
58
- `${report.failures.length} develop image(s) of "${developLock.distributionId}" are not built yet ` +
62
+ `${report.failures.length} develop image(s) of "${distributionId}" are not built yet ` +
59
63
  '(the producer\'s CI is not green or still running):\n ' +
60
64
  report.failures.map(({ subject }) => notBuiltYetMessage(subject)).join('\n '),
61
65
  );
62
66
  }
63
- const { writes, unchanged } = planDigestSeal({ subjects: developLock.subjects, verified: report.subjects });
67
+ const { writes, unchanged } = planDigestSeal({ subjects: developSubjects, verified: report.subjects });
64
68
  const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
69
+ return developSubjects.map((subject) => ({
70
+ ...subject,
71
+ imageDigest: digests.get(`${subject.kind}:${subject.id}`),
72
+ }));
73
+ }
74
+
75
+ /**
76
+ * The seal of a WHOLE images lock (the vendor snapshot): every subject sealed,
77
+ * and the result parsed as the kernel's image lock.
78
+ *
79
+ * @param {object} request
80
+ * @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
81
+ * @returns the images lock with develop refs and verified digests, kernel-validated
82
+ */
83
+ export async function sealDevelopImages({ imageLock, ...sealOptions }) {
84
+ const subjects = await sealDevelopImageSubjects({
85
+ distributionId: imageLock.distributionId,
86
+ subjects: imageLock.subjects,
87
+ ...sealOptions,
88
+ });
65
89
  const schemas = await loadKernelDistributionSchemas();
66
- const { parseImageLock } = schemas;
67
- return validateImmutableImageLock(
68
- parseImageLock({
69
- ...developLock,
70
- subjects: developLock.subjects.map((subject) => ({
71
- ...subject,
72
- imageDigest: digests.get(`${subject.kind}:${subject.id}`),
73
- })),
74
- }),
75
- schemas,
76
- );
90
+ return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
77
91
  }
@@ -74,7 +74,7 @@ import { withoutSealedDigests } from './seal-image-digests.mjs';
74
74
  import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
75
75
 
76
76
  if (isEntryModule(import.meta.url)) {
77
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
77
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache]);
78
78
  }
79
79
 
80
80
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -1,7 +1,8 @@
1
1
  // edition-artifact — the files of an edition and where they may go.
2
2
  //
3
3
  // ONE layout for develop and release: an OCI artifact at
4
- // `<registry>/xema-edition-<id>`, addressed by digest, whose titled layers are
4
+ // `<registry root>/<channel segment>/xema-edition-<id>` (release: `editions`, develop: `develop-editions`,
5
+ // from edition-contract.json), addressed by digest, whose titled layers are
5
6
  // `xema-edition.json` (the descriptor) plus the lock files. It is written to a
6
7
  // scratch directory; the workflow pushes it with oras and signs the digest.
7
8
  // Tags are for humans only (develop: the snapshot hash; release: the
@@ -22,6 +23,7 @@ import {
22
23
  EDITION_DESCRIPTOR_SCHEMA,
23
24
  EDITION_LOCKS,
24
25
  EDITION_REPOSITORY_PREFIX,
26
+ EDITION_REPOSITORY_SEGMENTS,
25
27
  EditionChannel,
26
28
  EditionLockFile,
27
29
  EvidenceTrustState,
@@ -144,42 +146,80 @@ export function parseEditionDescriptor(value) {
144
146
  }
145
147
 
146
148
  const REGISTRY_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+$/;
149
+ /** `.` and `..` match the segment grammar and name no namespace: a registry resolves them away. */
150
+ const isDotSegment = (segment) => segment === '.' || segment === '..';
147
151
  const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
148
152
 
149
- /** The registry is the caller's namespace; the CLI appends `xema-edition-<id>` itself, so a registry already naming an edition repository is refused. */
150
- export function assertRegistry(registry) {
151
- if (typeof registry !== 'string' || !REGISTRY_PATTERN.test(registry)) {
153
+ /**
154
+ * The registry ROOT is the caller's `<host>[:port]/<namespace>[/...]`; the CLI appends the channel's
155
+ * namespace segment AND `xema-edition-<id>` itself, so a root already ending in either is refused.
156
+ */
157
+ export function assertRegistry(registryRoot) {
158
+ if (typeof registryRoot !== 'string' || !REGISTRY_PATTERN.test(registryRoot) || registryRoot.split('/').slice(1).some(isDotSegment)) {
152
159
  throw new Error(
153
- `--registry ${JSON.stringify(registry)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
160
+ `--registry ${JSON.stringify(registryRoot)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
154
161
  );
155
162
  }
156
- const last = registry.split('/').pop();
157
- if (last.startsWith(EDITION_REPOSITORY_PREFIX)) {
163
+ const last = registryRoot.split('/').pop();
164
+ if (Object.values(EDITION_REPOSITORY_SEGMENTS).includes(last) || last.startsWith(EDITION_REPOSITORY_PREFIX)) {
158
165
  throw new Error(
159
- `--registry ${registry} ends in "${last}", which the CLI appends itself: ` +
160
- `the repository would be ${registry}/${EDITION_REPOSITORY_PREFIX}<id>. Pass the namespace only`,
166
+ `--registry ${registryRoot} ends in "${last}", which the CLI appends itself: pass the registry root; ` +
167
+ `the CLI appends <${Object.values(EDITION_REPOSITORY_SEGMENTS).join('|')}>/${EDITION_REPOSITORY_PREFIX}<id>`,
161
168
  );
162
169
  }
163
170
  }
164
171
 
165
- /** `<registry>/xema-edition-<id>`: the one repository of an edition, for either channel. */
166
- export function editionRepository(distributionId, registry) {
172
+ function assertChannel(channel) {
173
+ if (!Object.values(EditionChannel).includes(channel)) {
174
+ throw new Error(`edition channel ${JSON.stringify(channel)} must be one of ${Object.values(EditionChannel).join(' | ')}`);
175
+ }
176
+ }
177
+
178
+ /** `<registry root>/<channel segment>/xema-edition-<id>`: the repository of an edition produced for `channel`. */
179
+ export function editionRepository(distributionId, registryRoot, channel) {
167
180
  assertCanonicalDistributionId(distributionId);
168
- assertRegistry(registry);
169
- return `${registry}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
181
+ assertRegistry(registryRoot);
182
+ assertChannel(channel);
183
+ return `${registryRoot}/${EDITION_REPOSITORY_SEGMENTS[channel]}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
184
+ }
185
+
186
+ /**
187
+ * The one parser of an edition repository `<root>/<segment>/xema-edition-<id>`, returning
188
+ * `{ registryRoot, channel, distributionId }`. The penultimate segment must be one of the contract's
189
+ * channel segments and the last must carry the repository prefix.
190
+ */
191
+ export function parseEditionRepository(repository) {
192
+ const segments = typeof repository === 'string' ? repository.split('/') : [];
193
+ const last = segments.at(-1) ?? '';
194
+ const segment = segments.at(-2);
195
+ const channel = Object.values(EditionChannel).find((candidate) => EDITION_REPOSITORY_SEGMENTS[candidate] === segment);
196
+ if (channel === undefined) {
197
+ throw new Error(
198
+ `edition repository ${JSON.stringify(repository)}: its namespace segment ${JSON.stringify(segment)} is not one of ` +
199
+ Object.values(EDITION_REPOSITORY_SEGMENTS).map((name) => `"${name}"`).join(', '),
200
+ );
201
+ }
202
+ if (!last.startsWith(EDITION_REPOSITORY_PREFIX)) {
203
+ throw new Error(`edition repository ${JSON.stringify(repository)}: its name ${JSON.stringify(last)} lacks the "${EDITION_REPOSITORY_PREFIX}" prefix`);
204
+ }
205
+ const distributionId = last.slice(EDITION_REPOSITORY_PREFIX.length);
206
+ assertCanonicalDistributionId(distributionId);
207
+ const registryRoot = segments.slice(0, -2).join('/');
208
+ assertRegistry(registryRoot);
209
+ return { registryRoot, channel, distributionId };
170
210
  }
171
211
 
172
212
  /** `<repository>:<tag>`; the tag is for humans, the artifact is addressed by its digest. */
173
- export function editionTarget(distributionId, registry, tag) {
213
+ export function editionTarget(distributionId, registryRoot, channel, tag) {
174
214
  if (typeof tag !== 'string' || !TAG_PATTERN.test(tag)) {
175
215
  throw new Error(`tag ${JSON.stringify(tag)} is not an OCI tag`);
176
216
  }
177
- return `${editionRepository(distributionId, registry)}:${tag}`;
217
+ return `${editionRepository(distributionId, registryRoot, channel)}:${tag}`;
178
218
  }
179
219
 
180
220
  /** A develop edition's target: the tag is its snapshot hash. */
181
221
  export function developEditionTarget(distributionId, pins, { registry }) {
182
- return editionTarget(distributionId, registry, snapshotHash(pins));
222
+ return editionTarget(distributionId, registry, EditionChannel.Develop, snapshotHash(pins));
183
223
  }
184
224
 
185
225
  function realpathOfPlanned(path) {
@@ -249,9 +289,10 @@ export function writeEditionArtifact({
249
289
  * An existing snapshot-hash tag is reused only if it IS this snapshot: the tag
250
290
  * is a hash of the pins, but a tag is mutable registry state, so the pulled
251
291
  * descriptor must say the same edition, the same channel, exactly the same pins,
252
- * the same engine image, the same evidence trust state and the same lock set. Throws naming the first difference.
292
+ * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` given) must also record that digest
293
+ * as its catalogue provenance pin. Throws naming the first difference.
253
294
  */
254
- export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy }) {
295
+ export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest }) {
255
296
  const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
256
297
  if (!existsSync(descriptorPath)) {
257
298
  throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
@@ -267,6 +308,12 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
267
308
  if (existing.evidenceTrustPolicy !== evidenceTrustPolicy) {
268
309
  throw new Error(`the existing artifact records evidenceTrustPolicy ${JSON.stringify(existing.evidenceTrustPolicy)}, this data root's is ${JSON.stringify(evidenceTrustPolicy)}; refusing to reuse it`);
269
310
  }
311
+ if (catalogueDigest !== undefined && existing.provenance.pins[CATALOGUE_PIN_KEY] !== catalogueDigest) {
312
+ throw new Error(
313
+ `the existing artifact's catalogue provenance pin is ${JSON.stringify(existing.provenance.pins[CATALOGUE_PIN_KEY])}, ` +
314
+ `the pinned catalogue is ${catalogueDigest}; refusing to reuse it`,
315
+ );
316
+ }
270
317
  if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
271
318
  throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
272
319
  }
@@ -3,7 +3,7 @@
3
3
  // biome of one edition, as JSON.
4
4
  //
5
5
  // usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir>
6
- // --sources-root <dir> --source-hash <dir> --kernel <dir>
6
+ // --sources-root <dir> --kernel <dir>
7
7
  //
8
8
  // For every biome of the edition's resolved lock this reads the manifest AT the
9
9
  // commit the lock pins (the way the seal and `xema edition check` read it) and
@@ -31,7 +31,7 @@ import { committedRepositoryPins, createPinnedSourceAuthority } from './source-a
31
31
  export const EDITION_BIOME_MANIFESTS_SCHEMA = 'xema.edition-biome-manifests/v1';
32
32
 
33
33
  const USAGE =
34
- 'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --source-hash <dir> --kernel <dir>';
34
+ 'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>';
35
35
 
36
36
  function readLock(distributionId, file) {
37
37
  try {
@@ -64,7 +64,6 @@ async function main() {
64
64
  WorkspaceRoot.Data,
65
65
  WorkspaceRoot.Editions,
66
66
  WorkspaceRoot.Sources,
67
- WorkspaceRoot.SourceHash,
68
67
  WorkspaceRoot.Kernel,
69
68
  ]);
70
69
  const operands = rest.filter((arg) => arg !== '--json');
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // So each edition directory holds `<id>/chart-set.lock.json`: a byte-identical
13
13
  // copy of the fleet pin AT THE MOMENT IT IS WRITTEN, with each publisher's
14
- // ROSTER (chart name → {version, digest}) read from the index, so a
14
+ // ROSTER (chart name → {version, digest}) read from the index, and the index chart's own manifest digest (`indexDigest`), so a
15
15
  // consumer selects every chart without asking the registry. It is part of the
16
16
  // edition's file set (compute-edition-coordinates.sh), hashed into its
17
17
  // coordinate and published with it, and xema-deploy vendors it beside the other
@@ -51,7 +51,38 @@ const CHART_VERSION_COORDINATE = Object.freeze({
51
51
  [EditionChannel.Develop]: /-dev\.[0-9a-f]{12}$/,
52
52
  });
53
53
 
54
- /** A chart-set lock (schemaVersion 3) that pins every member by version and OCI manifest digest, with `channel`'s version coordinate. */
54
+ /** The chart-set lock schema every reader accepts; a lock of any other schema is re-resolved, never migrated. */
55
+ export const CHART_SET_LOCK_SCHEMA_VERSION = 4;
56
+
57
+ /** The version prefix of a develop snapshot chart set (`0.0.0-dev.<sha12>`): xema-deploy's frozen-SHA charts, which have no index chart. */
58
+ export const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
59
+
60
+ /** The length of the lowercase-hex xema-deploy SHA a develop snapshot version carries. */
61
+ export const DEVELOP_CHART_SHA_LENGTH = 12;
62
+
63
+ /** The full develop snapshot version grammar: `0.0.0-dev.<12 lowercase hex>`, anchored. */
64
+ export const DEVELOP_CHART_VERSION_PATTERN = new RegExp(
65
+ `^${DEVELOP_CHART_VERSION_PREFIX.replaceAll('.', '\\.')}[0-9a-f]{${DEVELOP_CHART_SHA_LENGTH}}$`,
66
+ );
67
+
68
+ /** Does `version` start with the develop prefix, whether or not it is the full develop grammar? */
69
+ export const hasDevelopChartVersionPrefix = (version) => typeof version === 'string' && version.startsWith(DEVELOP_CHART_VERSION_PREFIX);
70
+
71
+ /** Is `version` a develop snapshot version? The one predicate for "this publisher has no index chart". */
72
+ export const isDevelopChartVersion = (version) => typeof version === 'string' && DEVELOP_CHART_VERSION_PATTERN.test(version);
73
+
74
+ /** An OCI manifest digest: `sha256:` and 64 lowercase hex. */
75
+ export const OCI_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
76
+
77
+ /** The instruction every refusal of an outdated lock carries. */
78
+ export const RESOLVE_CHART_SET_INSTRUCTION = 're-resolve the chart set with `xema edition chart-set resolve`';
79
+
80
+ /**
81
+ * A chart-set lock (schemaVersion 4) that pins every member by version and OCI
82
+ * manifest digest, with `channel`'s version coordinate, and pins each publisher's
83
+ * index chart by `indexDigest` (null exactly for a develop snapshot version,
84
+ * which has no index chart; the release channel refuses null).
85
+ */
55
86
  export function assertRosteredChartSetLock(lock, label, channel) {
56
87
  const coordinate = CHART_VERSION_COORDINATE[channel];
57
88
  if (coordinate === undefined) {
@@ -60,24 +91,46 @@ export function assertRosteredChartSetLock(lock, label, channel) {
60
91
  const fail = (why) => {
61
92
  throw new Error(`${label} is not a rostered chart-set lock: ${why}`);
62
93
  };
63
- if (lock?.schemaVersion !== 3) {fail('schemaVersion must be 3');}
94
+ if (lock?.schemaVersion !== CHART_SET_LOCK_SCHEMA_VERSION) {
95
+ fail(`schemaVersion must be ${CHART_SET_LOCK_SCHEMA_VERSION} (got ${JSON.stringify(lock?.schemaVersion)}); ${RESOLVE_CHART_SET_INSTRUCTION}`);
96
+ }
64
97
  if (!Array.isArray(lock.publishers) || lock.publishers.length === 0) {fail('publishers must be a non-empty array');}
65
- for (const pin of lock.publishers) {
98
+ for (const [position, pin] of lock.publishers.entries()) {
66
99
  if (!pin?.charts || typeof pin.charts !== 'object' || Array.isArray(pin.charts) || Object.keys(pin.charts).length === 0) {
67
100
  fail(`${pin?.index ?? '?'} carries no roster (charts: name -> member version) — run resolve-chart-set.mjs`);
68
101
  }
69
102
  for (const [name, member] of Object.entries(pin.charts)) {
70
103
  if (!member || typeof member !== 'object' || Array.isArray(member)) {fail(`${pin.index}: ${name} must be {version, digest}`);}
71
104
  if (!coordinate.test(String(member.version))) {fail(`${pin.index}: ${name} has no ${channel} content coordinate (${member.version}); a ${channel} chart set carries ${coordinate}`);}
72
- if (!/^sha256:[a-f0-9]{64}$/.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
105
+ if (!OCI_DIGEST_PATTERN.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
73
106
  }
107
+ assertIndexDigest(pin, position, channel, fail);
74
108
  }
75
109
  return lock;
76
110
  }
77
111
 
112
+ /** The `indexDigest` rule: present, a sha256 digest unless the version is a develop snapshot (then null), and never null in a release lock. */
113
+ function assertIndexDigest(pin, position, channel, fail) {
114
+ const where = `publisher ${position} (${pin?.index ?? '?'})`;
115
+ if (!pin || typeof pin !== 'object' || !Object.hasOwn(pin, 'indexDigest')) {
116
+ fail(`${where} carries no indexDigest key (required: "sha256:<64 hex>" for a release index, null for a develop snapshot version)`);
117
+ }
118
+ if (hasDevelopChartVersionPrefix(pin.version) && !isDevelopChartVersion(pin.version)) {
119
+ fail(`${where} has malformed develop chart version ${JSON.stringify(pin.version)}; a develop version is exactly ${DEVELOP_CHART_VERSION_PREFIX}<${DEVELOP_CHART_SHA_LENGTH} lowercase hex>`);
120
+ }
121
+ const develop = isDevelopChartVersion(pin.version);
122
+ if (pin.indexDigest === null) {
123
+ if (!develop) {fail(`${where} has indexDigest null with version ${JSON.stringify(pin.version)}; null is only for a develop snapshot version (${DEVELOP_CHART_VERSION_PREFIX}<sha12>)`);}
124
+ if (channel === EditionChannel.Release) {fail(`${where} has indexDigest null, which a release chart set refuses; a release index chart is pinned by digest`);}
125
+ return;
126
+ }
127
+ if (develop) {fail(`${where} has an indexDigest with develop snapshot version ${pin.version}; a develop snapshot has no index chart, so indexDigest must be null`);}
128
+ if (!OCI_DIGEST_PATTERN.test(String(pin.indexDigest))) {fail(`${where} has a malformed indexDigest (${JSON.stringify(pin.indexDigest)}); expected "sha256:<64 lowercase hex>"`);}
129
+ }
130
+
78
131
  /**
79
132
  * Write the RELEASE edition's copy of the fleet pin — byte-identical to
80
- * `pinText`. Only a rostered pin (schemaVersion 3) with release chart
133
+ * `pinText`. Only a rostered pin (schemaVersion 4) with release chart
81
134
  * coordinates is written.
82
135
  */
83
136
  export function writeEditionChartSet(editionDir, pinText) {
@@ -78,6 +78,22 @@ function requireSourceAuthUsername(config) {
78
78
  return value;
79
79
  }
80
80
 
81
+ /** `chartRegistry` and `chartPublishers` are a vendor-only pair: both declared or both absent. */
82
+ function optionalChartSet(document) {
83
+ const registry = document.chartRegistry !== undefined;
84
+ const publishers = document.chartPublishers !== undefined;
85
+ if (registry !== publishers) {
86
+ throw new EditionWorkspaceConfigError(
87
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "chartRegistry" and "chartPublishers" are a vendor-only pair, both declared or both absent; only "${registry ? 'chartRegistry' : 'chartPublishers'}" is`,
88
+ );
89
+ }
90
+ if (!registry) {return {};}
91
+ return {
92
+ chartRegistry: requireString(document, 'chartRegistry', CHART_REGISTRY, 'oci://<host>/<path>'),
93
+ chartPublishers: requirePublishers(document),
94
+ };
95
+ }
96
+
81
97
  function requirePublishers(config) {
82
98
  const value = config.chartPublishers;
83
99
  if (!Array.isArray(value) || value.length === 0) {
@@ -166,8 +182,7 @@ export function parseEditionWorkspaceConfig(document) {
166
182
  return Object.freeze({
167
183
  ...sections,
168
184
  imageRegistry: requireString(document, 'imageRegistry', IMAGE_REGISTRY, '<host>/<namespace> with no scheme or trailing slash'),
169
- chartRegistry: requireString(document, 'chartRegistry', CHART_REGISTRY, 'oci://<host>/<path>'),
170
- chartPublishers: requirePublishers(document),
185
+ ...optionalChartSet(document),
171
186
  sourceRepositoryUrlPrefix: requireString(document, 'sourceRepositoryUrlPrefix', SOURCE_URL_PREFIX, 'https://<host>/<organisation>/ with a trailing slash'),
172
187
  sourceAuthUsername: requireSourceAuthUsername(document),
173
188
  evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
@@ -190,6 +205,17 @@ export function resetEditionWorkspaceConfig() {
190
205
  cached = undefined;
191
206
  }
192
207
 
208
+ /** The vendor chart set's coordinates `{chartRegistry, chartPublishers}`; a data root that declares neither (a customer's) is refused, naming the file and both keys. */
209
+ export function vendorChartSetConfig() {
210
+ const { chartRegistry, chartPublishers } = editionWorkspaceConfig();
211
+ if (chartRegistry === undefined) {
212
+ throw new EditionWorkspaceConfigError(
213
+ `${EDITION_WORKSPACE_CONFIG_FILE} in ${dataRoot()} declares no "chartRegistry" and "chartPublishers", which vendor chart-set code needs`,
214
+ );
215
+ }
216
+ return { chartRegistry, chartPublishers };
217
+ }
218
+
193
219
  /**
194
220
  * One product-data section, refused by name when the data root does not carry
195
221
  * it. `needed` says which operation wanted it, so the refusal is actionable.
@@ -1,11 +1,12 @@
1
1
  // edition-constants — the closed vocabulary of the edition OCI artifact, in one
2
2
  // place so the writer, the guards and the workflow cannot disagree about what an
3
3
  // edition IS. ONE layout serves both channels: an artifact at
4
- // `<registry>/xema-edition-<id>`, addressed by digest, carrying a titled
5
- // descriptor layer plus the lock layers.
4
+ // `<registry root>/<channel segment>/xema-edition-<id>`, addressed by digest,
5
+ // carrying a titled descriptor layer plus the lock layers.
6
6
 
7
7
  import { EditionChannel } from './edition-channel.mjs';
8
8
  import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
9
+ import { parseEditionContract } from './edition-contract-parse.mjs';
9
10
  import editionContract from './edition-contract.json' with { type: 'json' };
10
11
 
11
12
  /**
@@ -15,16 +16,21 @@ import editionContract from './edition-contract.json' with { type: 'json' };
15
16
  * It is a JSON import, not a read at run time, so a bundle (the evidence
16
17
  * verifier) carries it.
17
18
  */
18
- export const EDITION_CONTRACT = Object.freeze(structuredClone(editionContract));
19
+ export const EDITION_CONTRACT = Object.freeze(structuredClone(parseEditionContract(editionContract)));
19
20
 
20
21
  /** The descriptor layer's file name; its `org.opencontainers.image.title`. */
21
22
  export const EDITION_DESCRIPTOR_FILE = EDITION_CONTRACT.descriptorFile;
22
23
  export const EDITION_DESCRIPTOR_SCHEMA = EDITION_CONTRACT.descriptorSchema;
23
24
  /** The ONE artifactType of every edition artifact, develop or release. */
24
25
  export const EDITION_ARTIFACT_TYPE = EDITION_CONTRACT.artifactType;
25
- /** The repository name prefix: `<registry>/<prefix><id>`. */
26
+ /** The repository name prefix: `<registry root>/<channel segment>/<prefix><id>`. */
26
27
  export const EDITION_REPOSITORY_PREFIX = EDITION_CONTRACT.repositoryPrefix;
27
28
 
29
+ /** The namespace segment of each channel's edition repositories, keyed by channel: `<registry root>/<segment>/<prefix><id>`. */
30
+ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
31
+ Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
32
+ );
33
+
28
34
  export { EditionChannel };
29
35
 
30
36
  /** Whether the data root's editions are evidence-gated, as the descriptor records it. */