@xemahq/xema 0.51.0 → 0.53.0

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.
package/edition/lib.mjs CHANGED
@@ -1524,6 +1524,53 @@ export function signedBiomeDeclaredCapabilities(indexBiome, xema) {
1524
1524
  return [...new Set([...shipped, ...requiredForService])].sort(compareStrings);
1525
1525
  }
1526
1526
 
1527
+ /**
1528
+ * The components of a biome manifest that are image workloads: a service or worker with its own `oci-image`, or a
1529
+ * worker running its sibling service's image with its own entry module (`shared-oci-image`). Such a worker is a
1530
+ * standing workload like any other image component (rostered, with process facts, task queues and peers); it just
1531
+ * builds no image of its own, and `workerImages` in {@link manifestToIndexEntry} records whose it runs.
1532
+ */
1533
+ export function biomeImageComponents(components) {
1534
+ return components.filter(
1535
+ (component) =>
1536
+ component !== null &&
1537
+ typeof component === 'object' &&
1538
+ (component.kind === 'service' || component.kind === 'worker') &&
1539
+ (component.artifact?.kind === 'oci-image' ||
1540
+ (component.kind === 'worker' && component.artifact?.kind === 'shared-oci-image')),
1541
+ );
1542
+ }
1543
+
1544
+ /**
1545
+ * The image name of one image component of biome `id` (manifest `rel`, `target`), claimed in `taken` (the names the
1546
+ * manifest's earlier image components claimed). A `service` registers under `protocol.serviceName`; a `worker` never
1547
+ * registers with the service registry, so its image name is the component key. Same rule the producing repo's build
1548
+ * plane applies: the image name is the SERVICE build arg either way. Refused: no image name, a web biome owning a
1549
+ * deployable image, and one image name declared twice.
1550
+ */
1551
+ export function claimBiomeImageName(component, { rel, id, target, taken }) {
1552
+ const protocol = component.protocol !== null && typeof component.protocol === 'object' ? component.protocol : {};
1553
+ const name = component.kind === 'service' ? protocol.serviceName : component.key;
1554
+ if (typeof name !== 'string' || name.length === 0) {
1555
+ throw new Error(
1556
+ `${rel}: xema.components[] kind "${component.kind}" component ` +
1557
+ `"${String(component.key)}" has no image name — a service declares ` +
1558
+ `protocol.serviceName, a worker a non-empty key`,
1559
+ );
1560
+ }
1561
+ if (target !== 'server') {
1562
+ throw new Error(`${rel}: web biome "${id}" cannot own deployable image "${name}"`);
1563
+ }
1564
+ if (taken.has(name)) {
1565
+ throw new Error(`${rel}: xema.components[] declares image "${name}" more than once`);
1566
+ }
1567
+ taken.add(name);
1568
+ return name;
1569
+ }
1570
+
1571
+ /** An image component launched per allocation (`scheduler: "runtime"`): a runtime component, never a rostered service. */
1572
+ export const isRuntimeImageComponent = (component) => component.scheduler === 'runtime';
1573
+
1527
1574
  export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, path, authorizationModule }) {
1528
1575
  const xema = raw?.xema;
1529
1576
  if (!xema || typeof xema !== 'object') {
@@ -1691,19 +1738,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1691
1738
  }
1692
1739
  bundleSource = parsed.data;
1693
1740
  }
1694
- const imageComponents = components.filter(
1695
- (component) =>
1696
- component !== null &&
1697
- typeof component === 'object' &&
1698
- (component.kind === 'service' || component.kind === 'worker') &&
1699
- (component.artifact?.kind === 'oci-image' ||
1700
- // A worker running its sibling service's image with its own entry
1701
- // module is a standing workload like any other image component — it is
1702
- // rostered, has process facts, task queues and peers — it just builds
1703
- // no image of its own. `workerImages` below records whose it runs.
1704
- (component.kind === 'worker' &&
1705
- component.artifact?.kind === 'shared-oci-image')),
1706
- );
1741
+ const imageComponents = biomeImageComponents(components);
1707
1742
  // A worker is a standing workload that runs an image: its own oci-image or a
1708
1743
  // sibling service's (shared-oci-image, validated by `sharedImageWorker`).
1709
1744
  // A worker with neither would be rostered by nothing and ship no image.
@@ -1807,34 +1842,13 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1807
1842
  // service's image — see `sharedImageWorker`.
1808
1843
  const workerImages = {};
1809
1844
  if (imageComponents.length > 0) {
1845
+ const imageNames = new Set();
1810
1846
  for (const component of imageComponents) {
1811
1847
  const protocol =
1812
1848
  component.protocol !== null && typeof component.protocol === 'object'
1813
1849
  ? component.protocol
1814
1850
  : {};
1815
- // A `service` registers under `protocol.serviceName`; a `worker` never
1816
- // registers with the service registry, so its image name is the component
1817
- // key. Same rule the producing repo's build plane applies — the image name
1818
- // is the SERVICE build arg either way.
1819
- const name =
1820
- component.kind === 'service' ? protocol.serviceName : component.key;
1821
- if (typeof name !== 'string' || name.length === 0) {
1822
- throw new Error(
1823
- `${rel}: xema.components[] kind "${component.kind}" component ` +
1824
- `"${String(component.key)}" has no image name — a service declares ` +
1825
- `protocol.serviceName, a worker a non-empty key`,
1826
- );
1827
- }
1828
- if (target !== 'server') {
1829
- throw new Error(
1830
- `${rel}: web biome "${id}" cannot own deployable image "${name}"`,
1831
- );
1832
- }
1833
- if (services.includes(name) || runtimeComponents.includes(name)) {
1834
- throw new Error(
1835
- `${rel}: xema.components[] declares image "${name}" more than once`,
1836
- );
1837
- }
1851
+ const name = claimBiomeImageName(component, { rel, id, target, taken: imageNames });
1838
1852
  const where = `${rel}: xema.components[] "${name}"`;
1839
1853
  const requirements = parseComponentResourceDeclaration(
1840
1854
  'ResourceRequirementsSchema',
@@ -1847,7 +1861,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1847
1861
  `${where} protocol.providesResources`,
1848
1862
  );
1849
1863
  const nexus = componentNexusDeclarations(component, name, protocol, where);
1850
- if (component.scheduler === 'runtime') {
1864
+ if (isRuntimeImageComponent(component)) {
1851
1865
  // A Nexus binding names the provider COMPONENT the deploy plane points
1852
1866
  // an endpoint at, which must be a rostered image; a component launched
1853
1867
  // per allocation is not one. Refused rather than dropped.
@@ -7,6 +7,9 @@
7
7
  // [--input <file> --cosign <abs path> [--now <epoch-ms>]]
8
8
  // backfill-plan --distribution <id>… --producer-repository-root <abs dir>…
9
9
  //
10
+ // attach-plan and validate take --data-root <dir> --kernel <dir> (vendor mode) or --contract-authority <file>
11
+ // (public-input mode: a publisher's run with the kernel this CLI bundles); backfill-plan is vendor mode only.
12
+ //
10
13
  // The library reads and decides; it never writes to a registry. `attach-plan`
11
14
  // validates a produced evidence directory and prints the exact `oras attach`
12
15
  // operands; `validate` holds a pulled artifact to its manifest (and, with
@@ -32,6 +35,7 @@ import {
32
35
  isEntryModule,
33
36
  } from './edition-workspace.mjs';
34
37
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
38
+ import { EvidenceInputMode, configureEvidenceInputMode } from './evidence-input-mode.mjs';
35
39
  import { canonicalJson, loadValidatedImageLocks } from './image-evidence.mjs';
36
40
  import { compareStrings, loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
37
41
  import {
@@ -320,12 +324,24 @@ const readJsonFile = (path, label) => {
320
324
  }
321
325
  };
322
326
 
327
+ /**
328
+ * `attach-plan` and `validate` in one of the two input modes: the vendor's pinned authority and kernel checkout, or
329
+ * a publisher's `--contract-authority` file with the kernel this CLI bundles.
330
+ */
331
+ async function ociInputMode(argv, operation) {
332
+ const mode = await configureEvidenceInputMode(argv, { verb: `oci ${operation}` });
333
+ if (mode.mode === EvidenceInputMode.PublicInput) {
334
+ return { rest: mode.rest, schemas: mode.schemas, expectedContractAuthority: mode.contractAuthority };
335
+ }
336
+ const schemas = await loadKernelDistributionSchemas();
337
+ return { rest: mode.rest, schemas, expectedContractAuthority: readPinnedContractAuthority({ dataRoot: dataRoot(), schemas }) };
338
+ }
339
+
323
340
  async function main() {
324
341
  const [operation, ...argv] = process.argv.slice(2);
325
342
  if (operation === 'attach-plan') {
326
- const rest = configureEditionWorkspaceFromArgv(argv, [WorkspaceRoot.Data, WorkspaceRoot.Kernel]);
343
+ const { rest, schemas, expectedContractAuthority } = await ociInputMode(argv, operation);
327
344
  const flags = parseEvidenceCliFlags(rest, { single: ['evidence-directory', 'input'] });
328
- const schemas = await loadKernelDistributionSchemas();
329
345
  const input = flags.optional('input');
330
346
  process.stdout.write(
331
347
  serializeJson(
@@ -333,18 +349,17 @@ async function main() {
333
349
  evidenceDirectory: flags.require('evidence-directory'),
334
350
  input: input === undefined ? undefined : readJsonFile(input, 'producer input'),
335
351
  schemas,
336
- expectedContractAuthority: readPinnedContractAuthority({ dataRoot: dataRoot(), schemas }),
352
+ expectedContractAuthority,
337
353
  }),
338
354
  ),
339
355
  );
340
356
  return;
341
357
  }
342
358
  if (operation === 'validate') {
343
- const rest = configureEditionWorkspaceFromArgv(argv, [WorkspaceRoot.Data, WorkspaceRoot.Kernel]);
359
+ const { rest, schemas, expectedContractAuthority } = await ociInputMode(argv, operation);
344
360
  const flags = parseEvidenceCliFlags(rest, {
345
361
  single: ['evidence-directory', 'manifest', 'subject-digest', 'input', 'cosign', 'now'],
346
362
  });
347
- const schemas = await loadKernelDistributionSchemas();
348
363
  const input = flags.optional('input');
349
364
  const cosign = flags.optional('cosign');
350
365
  const now = flags.optional('now');
@@ -362,7 +377,7 @@ async function main() {
362
377
  subjectDigest: flags.require('subject-digest'),
363
378
  schemas,
364
379
  input: input === undefined ? undefined : readJsonFile(input, 'producer input'),
365
- expectedContractAuthority: readPinnedContractAuthority({ dataRoot: dataRoot(), schemas }),
380
+ expectedContractAuthority,
366
381
  runCosign: cosign === undefined ? undefined : createCosignVerifier(cosign).runCosign,
367
382
  now: now === undefined ? Date.now() : Number(now),
368
383
  }),
@@ -60,6 +60,7 @@ function loadKernelAudienceGrammars() {
60
60
  kernelAudienceGrammars = {
61
61
  ExactAudienceSchema: elementSchema('requiresAudiences'),
62
62
  AudienceNamespaceSchema: elementSchema('ownsAudienceNamespaces'),
63
+ PeerServiceNameSchema: elementSchema('optionalPeers'),
63
64
  };
64
65
  return kernelAudienceGrammars;
65
66
  }
@@ -162,6 +163,16 @@ export function componentOwnedAudienceNamespaces(component, where) {
162
163
  return declaredPrivilegeList(component, where, 'ownsAudienceNamespaces', AudienceNamespaceSchema, 'audience namespace');
163
164
  }
164
165
 
166
+ /**
167
+ * The peer services one platform service uses only when they are served
168
+ * (`optionalPeers`, edition protocol v38), each graded by the kernel's peer
169
+ * service name grammar. Declared INTENT only; `null` when undeclared.
170
+ */
171
+ export function componentOptionalPeers(component, where) {
172
+ const { PeerServiceNameSchema } = loadKernelAudienceGrammars();
173
+ return declaredPrivilegeList(component, where, 'optionalPeers', PeerServiceNameSchema, 'peer service name');
174
+ }
175
+
165
176
  /**
166
177
  * The event subscription clauses one workload component declares
167
178
  * (`subscribesEvents`), each graded by the kernel's clause grammar and returned
package/edition/oras.mjs CHANGED
@@ -197,8 +197,9 @@ const noticesGiven = new WeakSet();
197
197
  /**
198
198
  * Prepare the oras binary for use. With `trustedDigests` (an array) the binary
199
199
  * must hash to one of them and a private copy of exactly those bytes is what
200
- * runs; an empty list trusts no binary. With `trustedDigests: null` (a data
201
- * root that declares no evidence trust) the binary is held to its version only,
200
+ * runs; an empty list trusts no binary. With `trustedDigests: null` (no
201
+ * evidence trust in force: a publisher's run with no data root, or a data root
202
+ * that declares none) the binary is held to its version only,
202
203
  * and the run says so once.
203
204
  *
204
205
  * @returns {{version: string, pinned: boolean, path: () => string, dispose: () => void}}
@@ -215,8 +216,8 @@ export function prepareTrustedOras({ env = process.env, trustedDigests, notice =
215
216
  if (!noticesGiven.has(notice)) {
216
217
  noticesGiven.add(notice);
217
218
  notice(
218
- `[oras] ${located} is held to its version (${version}) only: this data root declares no evidence-trust pin, ` +
219
- 'so there are no trustedOrasBinaryDigests to hold it to.',
219
+ `[oras] ${located} is held to its version (${version}) only: no evidence-trust pin is in force ` +
220
+ '(a publisher run, or a data root that declares none), so there are no trustedOrasBinaryDigests to hold it to.',
220
221
  );
221
222
  }
222
223
  return {
@@ -1,5 +1,5 @@
1
1
  import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
2
- import { componentAudienceRequirements, componentOperationRequirements, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
2
+ import { componentAudienceRequirements, componentOperationRequirements, componentOptionalPeers, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
3
3
  import { compareStrings } from './string-order.mjs';
4
4
 
5
5
  /**
@@ -58,7 +58,7 @@ export function sourceOwnedCapabilityDomains(sourceManifest, where) {
58
58
  /**
59
59
  * What a platform service declares it requires and owns, as the index carries it, read from the
60
60
  * same declaration as {@link sourceOwnedCapabilityDomains} (the `xema` block of its source manifest):
61
- * `requiresOperations`, `requiresAudiences` and `ownsAudienceNamespaces`. Each is sorted, unique and
61
+ * `requiresOperations`, `requiresAudiences`, `ownsAudienceNamespaces` and `optionalPeers`. Each is sorted, unique and
62
62
  * non-empty, or no key at all. Declared INTENT: the lock admits or refuses each
63
63
  * (`admitOperationGrants`, `admitExactAudiences`).
64
64
  *
@@ -70,10 +70,12 @@ export function sourceDeclaredRequirements(sourceManifest, where) {
70
70
  const requiresOperations = componentOperationRequirements(xema, where);
71
71
  const requiresAudiences = componentAudienceRequirements(xema, where);
72
72
  const ownsAudienceNamespaces = componentOwnedAudienceNamespaces(xema, where);
73
+ const optionalPeers = componentOptionalPeers(xema, where);
73
74
  return {
74
75
  ...(requiresOperations === null ? {} : { requiresOperations }),
75
76
  ...(requiresAudiences === null ? {} : { requiresAudiences }),
76
77
  ...(ownsAudienceNamespaces === null ? {} : { ownsAudienceNamespaces }),
78
+ ...(optionalPeers === null ? {} : { optionalPeers }),
77
79
  };
78
80
  }
79
81
 
@@ -28,14 +28,13 @@ import { pathToFileURL } from 'node:url';
28
28
  import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
29
29
  import { editionWorkspaceConfig } from './edition-config.mjs';
30
30
  import {
31
- WorkspaceRoot,
32
- configureEditionWorkspaceFromArgv,
33
31
  dataRoot,
34
32
  isEntryModule,
35
33
  kernelDistPath,
36
34
  kernelRepoDir,
37
35
  } from './edition-workspace.mjs';
38
36
  import { COMMIT_PATTERN, GITHUB_REF_PATTERN, GITHUB_REPOSITORY_PATTERN, SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
37
+ import { EvidenceInputMode, configureEvidenceInputMode } from './evidence-input-mode.mjs';
39
38
  import {
40
39
  KERNEL_CONTRACT_AUTHORITY_NOT_PINNED_ONE,
41
40
  KernelContractAuthorityPinError,
@@ -610,7 +609,12 @@ export function stageImageSourceEvidence({
610
609
  * later step to pick up; a successful accept removes it too. Only the argument
611
610
  * refusals above the first read leave it in place.
612
611
  *
613
- * `cosignVersion` is the trust policy's pinned version.
612
+ * `inputMode` (EvidenceInputMode) decides the cosign version check. In vendor
613
+ * mode `cosignVersion` is the trust policy's pinned version and the cosign that
614
+ * verifies must be it. In public-input mode there is no vendor trust policy and
615
+ * no version is held (`cosignVersion` must be null): the vendor's admission
616
+ * re-verifies every bundle with its own pinned cosign, and that is the
617
+ * authority. The signature verification itself runs in both modes.
614
618
  */
615
619
  export function acceptImageSourceEvidence({
616
620
  input,
@@ -618,6 +622,7 @@ export function acceptImageSourceEvidence({
618
622
  outputDirectory,
619
623
  schemas,
620
624
  expectedContractAuthority,
625
+ inputMode,
621
626
  cosignVersion,
622
627
  readCosignVersion,
623
628
  runCosign,
@@ -625,6 +630,12 @@ export function acceptImageSourceEvidence({
625
630
  if (!isAbsolute(stagingDirectory) || !isAbsolute(outputDirectory)) {
626
631
  throw new Error('the staging and output directories must be absolute paths');
627
632
  }
633
+ if (!Object.values(EvidenceInputMode).includes(inputMode)) {
634
+ throw new Error(`accept needs an input mode, one of ${Object.values(EvidenceInputMode).join(', ')} (got ${String(inputMode)})`);
635
+ }
636
+ if (inputMode === EvidenceInputMode.PublicInput && cosignVersion !== null) {
637
+ throw new Error('public-input accept holds no cosign version: the vendor admission re-verifies with its pinned cosign');
638
+ }
628
639
  if (typeof readCosignVersion !== 'function' || typeof runCosign !== 'function') {
629
640
  throw new Error('cosign seams are required');
630
641
  }
@@ -634,7 +645,9 @@ export function acceptImageSourceEvidence({
634
645
  sealing,
635
646
  outputDirectory,
636
647
  build: () => {
637
- exactCosignVersion(readCosignVersion(), cosignVersion);
648
+ if (inputMode === EvidenceInputMode.Vendor) {
649
+ exactCosignVersion(readCosignVersion(), cosignVersion);
650
+ }
638
651
  const statements = buildImageSourceStatements(input, schemas, expectedContractAuthority);
639
652
  assertStagedExactly(
640
653
  stagingDirectory,
@@ -695,7 +708,7 @@ const USAGE =
695
708
  'usage: xema edition evidence statement producer <stage|accept> --input <json> --staging <absolute-directory> ' +
696
709
  '--github-repository <owner/repo> --github-ref <ref> --github-sha <commit> ' +
697
710
  '--github-workflow-ref <owner/repo/path@ref> [accept: --output <absolute-directory> --cosign <absolute-binary>] ' +
698
- '--data-root <dir> --kernel <dir>';
711
+ '(--data-root <dir> --kernel <dir> | --contract-authority <file>)';
699
712
 
700
713
  function parseFlags(args, known) {
701
714
  const flags = new Map();
@@ -717,20 +730,19 @@ function parseFlags(args, known) {
717
730
  }
718
731
 
719
732
  async function main() {
720
- const [phase, ...args] = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
721
- WorkspaceRoot.Data,
722
- WorkspaceRoot.Kernel,
723
- ]);
724
- const context = ['github-repository', 'github-ref', 'github-sha', 'github-workflow-ref'];
733
+ const [phase, ...argv] = process.argv.slice(2);
725
734
  if (phase !== 'stage' && phase !== 'accept') {
726
735
  throw new Error(USAGE);
727
736
  }
737
+ const mode = await configureEvidenceInputMode(argv, { verb: `statement producer ${phase}` });
738
+ const context = ['github-repository', 'github-ref', 'github-sha', 'github-workflow-ref'];
728
739
  const flags = parseFlags(
729
- args,
740
+ mode.rest,
730
741
  phase === 'stage' ? ['input', 'staging', ...context] : ['input', 'staging', 'output', 'cosign', ...context],
731
742
  );
732
743
  const input = JSON.parse(readFileSync(resolve(flags.get('input')), 'utf8'));
733
- const { schemas, contractAuthority } = await loadProducerKernelSchemas({ dataRoot: dataRoot() });
744
+ const { schemas, contractAuthority } =
745
+ mode.mode === EvidenceInputMode.PublicInput ? mode : await loadProducerKernelSchemas({ dataRoot: dataRoot() });
734
746
  assertProducerInput(input, contractAuthority);
735
747
  assertGitHubProducerContext(input, {
736
748
  repository: flags.get('github-repository'),
@@ -754,11 +766,15 @@ async function main() {
754
766
  outputDirectory: flags.get('output'),
755
767
  schemas,
756
768
  expectedContractAuthority: contractAuthority,
757
- cosignVersion: loadPinnedDeclaredTrustPolicy({
758
- dataRoot: dataRoot(),
759
- declaration: editionWorkspaceConfig().evidenceTrustPolicy,
760
- schemas,
761
- }).cosignVersion,
769
+ inputMode: mode.mode,
770
+ cosignVersion:
771
+ mode.mode === EvidenceInputMode.PublicInput
772
+ ? null
773
+ : loadPinnedDeclaredTrustPolicy({
774
+ dataRoot: dataRoot(),
775
+ declaration: editionWorkspaceConfig().evidenceTrustPolicy,
776
+ schemas,
777
+ }).cosignVersion,
762
778
  ...createCosignVerifier(flags.get('cosign')),
763
779
  });
764
780
  process.stdout.write(serializeJson({ schemaVersion: 1, kind: 'producer', outputDirectory: flags.get('output'), producerEvidence: evidence }));
@@ -127,6 +127,28 @@ export function loadRegistryCredentialDeclarations(
127
127
  { cause: error },
128
128
  );
129
129
  }
130
+ return parseRegistryCredentialDeclarations(text, REGISTRY_CREDENTIALS_FILE);
131
+ }
132
+
133
+ /**
134
+ * A registry credential declaration file the CALLER names, in the same strict
135
+ * schema: a third-party publisher states its own registries this way
136
+ * (`evidence assemble-third-party-input --registry-credentials <file>`). A
137
+ * missing file is refused; there is no default declaration.
138
+ */
139
+ export function loadRegistryCredentialDeclarationsFile(path) {
140
+ let text;
141
+ try {
142
+ text = readFileSync(path, 'utf8');
143
+ } catch (error) {
144
+ if (error?.code !== 'ENOENT') {throw error;}
145
+ throw new Error(`the registry credential declaration ${path} does not exist`, { cause: error });
146
+ }
147
+ return parseRegistryCredentialDeclarations(text, path);
148
+ }
149
+
150
+ /** The declarations of one credential declaration file's text, strict schema v1, by authority. */
151
+ function parseRegistryCredentialDeclarations(text, label) {
130
152
  const raw = JSON.parse(text);
131
153
  const keys = Object.keys(raw ?? {}).sort().join(',');
132
154
  if (
@@ -141,14 +163,14 @@ export function loadRegistryCredentialDeclarations(
141
163
  (!Array.isArray(raw.$comment) ||
142
164
  raw.$comment.some((line) => typeof line !== 'string')))
143
165
  ) {
144
- throw new Error(`${REGISTRY_CREDENTIALS_FILE} is not strict schema v1`);
166
+ throw new Error(`${label} is not strict schema v1`);
145
167
  }
146
168
  const byAuthority = new Map();
147
169
  for (const [index, entry] of raw.registries.entries()) {
148
170
  assertCredentialDeclaration(entry, index);
149
171
  if (byAuthority.has(entry.authority)) {
150
172
  throw new Error(
151
- `${REGISTRY_CREDENTIALS_FILE} declares "${entry.authority}" more than once`,
173
+ `${label} declares "${entry.authority}" more than once`,
152
174
  );
153
175
  }
154
176
  byAuthority.set(entry.authority, entry);
@@ -10,7 +10,7 @@
10
10
  // evidence/sha256-<image manifest hex>/producer-evidence.json (+ statements/, bundles/)
11
11
  // the image-source/v2 evidence, stored the way a first-party producer commits it.
12
12
 
13
- import { decodeExactUtf8, evidenceDirectoryForDigest, verifySigstoreEvidenceDescriptor } from './image-evidence.mjs';
13
+ import { assertProducerTtlCeiling, decodeExactUtf8, evidenceDirectoryForDigest, verifySigstoreEvidenceDescriptor } from './image-evidence.mjs';
14
14
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
15
15
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
16
16
  import { PRODUCER_EVIDENCE_INDEX_FILE } from './producer-evidence-index-file.mjs';
@@ -234,6 +234,13 @@ function verifiedImageEvidence({ source, image, evidenceRoot, context, hold, bun
234
234
  throw new Error(`${label}: ${error.message}`);
235
235
  }
236
236
  assertImageStatementBindsPin({ statement: verified.statement, source, image, expectedContractAuthority: context.expectedContractAuthority, label });
237
+ // The ceiling is held here, at the publisher's door, as well as by verify-evidence: a too-long lifetime is never pinned.
238
+ assertProducerTtlCeiling({
239
+ issuedAt: verified.statement.predicate.producerRun.issuedAt,
240
+ expiresAt: verified.statement.predicate.producerRun.expiresAt,
241
+ maxTtlSeconds: context.policy.maxProducerTtlSeconds,
242
+ label,
243
+ });
237
244
  const bundleBytes = readEvidenceFile(evidenceRoot, descriptor.bundlePath, `${label} bundle`, 16 * 1024 * 1024).bytes;
238
245
  assertCertificateExtensions({ bundle: JSON.parse(decodeExactUtf8(bundleBytes, `${label} bundle`)), source, label });
239
246
  return verified.statement;
@@ -61,7 +61,7 @@ function matching(flag, value, pattern, expectation) {
61
61
  return value;
62
62
  }
63
63
 
64
- const OCI_REPOSITORY_AT_DIGEST = /^[^@\s]+@sha256:[a-f0-9]{64}$/u;
64
+ export const OCI_REPOSITORY_AT_DIGEST = /^[^@\s]+@sha256:[a-f0-9]{64}$/u;
65
65
  const NUMERIC_ID = /^[1-9][0-9]*$/u;
66
66
 
67
67
  function parseImage(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -57,7 +57,7 @@
57
57
  "@xemahq/distribution-source-hash": "^0.2.2",
58
58
  "@xemahq/dsl": "^0.28.0",
59
59
  "@xemahq/identity-client": "^0.36.0",
60
- "@xemahq/contracts": "^0.112.0",
60
+ "@xemahq/contracts": "^0.116.0",
61
61
  "@xemahq/kernel-state-runtime": "^0.8.0",
62
62
  "@xemahq/license-verifier": "^0.2.0",
63
63
  "@xemahq/release-policy": "^17.6.0",
@@ -96,6 +96,6 @@
96
96
  "typecheck": "tsc --noEmit"
97
97
  },
98
98
  "xemaRelease": {
99
- "sourceDigest": "sha256:74f62d9a75483f8cdeec9b7264e56eb043e010e06b39b129c5598565765b1230"
99
+ "sourceDigest": "sha256:86b6ce1a0758e2ed6ab8e9a2cd84c29c52f72582ff30c670e16e8e5982038894"
100
100
  }
101
101
  }