@xemahq/xema 0.45.1 → 0.46.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.
Files changed (51) hide show
  1. package/dist/commands/biome.d.ts.map +1 -1
  2. package/dist/commands/biome.js +3 -0
  3. package/dist/commands/biome.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +1613 -846
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +516 -474
  6. package/dist/runtime/biome-publish-image-layers.d.ts +20 -0
  7. package/dist/runtime/biome-publish-image-layers.d.ts.map +1 -0
  8. package/dist/runtime/biome-publish-image-layers.js +64 -0
  9. package/dist/runtime/biome-publish-image-layers.js.map +1 -0
  10. package/dist/runtime/biome-publish.d.ts +4 -0
  11. package/dist/runtime/biome-publish.d.ts.map +1 -1
  12. package/dist/runtime/biome-publish.js +25 -5
  13. package/dist/runtime/biome-publish.js.map +1 -1
  14. package/dist/runtime/config/schema.d.ts +4 -3
  15. package/dist/runtime/config/schema.d.ts.map +1 -1
  16. package/dist/runtime/config/schema.js +8 -2
  17. package/dist/runtime/config/schema.js.map +1 -1
  18. package/dist/runtime/installation/contract.d.ts +2 -2
  19. package/dist/runtime/installation/contract.d.ts.map +1 -1
  20. package/dist/runtime/installation/contract.js +1 -1
  21. package/dist/runtime/installation/contract.js.map +1 -1
  22. package/edition/README.md +2 -1
  23. package/edition/assemble-producer-input.mjs +2 -0
  24. package/edition/biome-content.mjs +6 -0
  25. package/edition/build-platform-service-index.mjs +2 -0
  26. package/edition/canonical-repository-url.mjs +12 -0
  27. package/edition/cosign-certificate-constraints.mjs +40 -0
  28. package/edition/customer-edition-verbs.mjs +6 -0
  29. package/edition/develop-coverage-readers.mjs +6 -4
  30. package/edition/develop-edition.mjs +20 -0
  31. package/edition/develop-record-ancestry.mjs +121 -0
  32. package/edition/develop-select-records.mjs +365 -0
  33. package/edition/distribution-evidence-set.mjs +14 -2
  34. package/edition/edition-config.mjs +27 -1
  35. package/edition/edition-protocol.mjs +8 -1
  36. package/edition/image-evidence.mjs +2 -12
  37. package/edition/kernel-signer-kind.mjs +23 -0
  38. package/edition/lib.mjs +104 -2
  39. package/edition/oci-evidence-verbs.mjs +2 -2
  40. package/edition/oci-image-source-evidence.mjs +84 -39
  41. package/edition/operation-grant-admission.mjs +98 -4
  42. package/edition/operation-requirements.mjs +118 -4
  43. package/edition/plan-image-evidence-refresh.mjs +20 -6
  44. package/edition/platform-service-class.mjs +23 -0
  45. package/edition/platform-service-ownership.mjs +23 -13
  46. package/edition/produce-distribution-completeness-evidence.mjs +2 -0
  47. package/edition/produce-image-source-evidence.mjs +9 -11
  48. package/edition/producer-evidence-bundle.mjs +12 -1
  49. package/edition/record-refused.mjs +11 -0
  50. package/edition/resolve-distribution.mjs +43 -1
  51. package/package.json +3 -3
@@ -14,7 +14,7 @@
14
14
  export class OperationGrantAdmissionError extends Error {
15
15
  /**
16
16
  * @param {string} distributionId
17
- * @param {ReadonlyArray<{code: string, biome: string, component: string, operation: string, message: string}>} refusals
17
+ * @param {ReadonlyArray<{code: string, operation: string, message: string}>} refusals
18
18
  */
19
19
  constructor(distributionId, refusals) {
20
20
  super(
@@ -45,9 +45,11 @@ export function ungrantedOptionalOperationLines(distributionId, ungrantedOptiona
45
45
  * {@link ungrantedOptionalOperationLines}; an ungranted optional operation never fails.
46
46
  */
47
47
  export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
48
- const { grants, refusals, ungrantedOptional } = schemas.admitOperationGrants(lock);
49
- if (refusals.length > 0) {
50
- throw new OperationGrantAdmissionError(lock.distributionId, refusals);
48
+ const { grants, refusals, ungrantedOptional, platformServiceRefusals } = schemas.admitOperationGrants(lock);
49
+ // A platform service's refused requirement fails exactly like a biome component's, through the same error.
50
+ const allRefusals = [...refusals, ...platformServiceRefusals];
51
+ if (allRefusals.length > 0) {
52
+ throw new OperationGrantAdmissionError(lock.distributionId, allRefusals);
51
53
  }
52
54
  if (report !== undefined) {
53
55
  for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
@@ -86,6 +88,37 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
86
88
  return grants;
87
89
  }
88
90
 
91
+ /** Thrown when one or more declared exact audiences are not admitted. */
92
+ export class ExactAudienceAdmissionError extends Error {
93
+ /**
94
+ * @param {string} distributionId
95
+ * @param {ReadonlyArray<{code: string, message: string}>} refusals
96
+ */
97
+ constructor(distributionId, refusals) {
98
+ super(
99
+ `distribution "${distributionId}" declares audience requirements that are not admitted:\n` +
100
+ refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
101
+ );
102
+ this.name = 'ExactAudienceAdmissionError';
103
+ this.distributionId = distributionId;
104
+ this.refusals = refusals;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Admit every exact-audience requirement of `lock` through the kernel
110
+ * (`admitExactAudiences`), or throw {@link ExactAudienceAdmissionError} listing
111
+ * every refusal. Returns the admitted grants. The resolve and the seal call it
112
+ * beside {@link assertOperationGrantsAdmitted}.
113
+ */
114
+ export function assertExactAudiencesAdmitted(schemas, lock) {
115
+ const { grants, refusals } = schemas.admitExactAudiences(lock);
116
+ if (refusals.length > 0) {
117
+ throw new ExactAudienceAdmissionError(lock.distributionId, refusals);
118
+ }
119
+ return grants;
120
+ }
121
+
89
122
  /** Thrown when one or more requested installed operations are not admitted into the ceiling. */
90
123
  export class InstalledOperationCeilingAdmissionError extends Error {
91
124
  /**
@@ -147,3 +180,64 @@ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
147
180
  }
148
181
  return ceiling;
149
182
  }
183
+
184
+ /** Thrown when one or more declared organization-context relays are not admitted. */
185
+ export class OrgContextRelayAdmissionError extends Error {
186
+ /**
187
+ * @param {string} distributionId
188
+ * @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
189
+ */
190
+ constructor(distributionId, refusals) {
191
+ super(
192
+ `distribution "${distributionId}" declares organization-context relays that are not admitted:\n` +
193
+ refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
194
+ );
195
+ this.name = 'OrgContextRelayAdmissionError';
196
+ this.distributionId = distributionId;
197
+ this.refusals = refusals;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Admit every `orgContextRelayers` entry of `lock` through the kernel
203
+ * (`admitOrgContextRelays`), or throw {@link OrgContextRelayAdmissionError}
204
+ * listing every refusal. Returns the admitted relay rows.
205
+ */
206
+ export function assertOrgContextRelaysAdmitted(schemas, lock) {
207
+ const { relays, refusals } = schemas.admitOrgContextRelays(lock);
208
+ if (refusals.length > 0) {
209
+ throw new OrgContextRelayAdmissionError(lock.distributionId, refusals);
210
+ }
211
+ return relays;
212
+ }
213
+
214
+ /** Thrown when one or more declared event subscription clauses are not admitted. */
215
+ export class EventSubscriptionAdmissionError extends Error {
216
+ /**
217
+ * @param {string} distributionId
218
+ * @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
219
+ */
220
+ constructor(distributionId, refusals) {
221
+ super(
222
+ `distribution "${distributionId}" declares event subscriptions that are not admitted:\n` +
223
+ refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
224
+ );
225
+ this.name = 'EventSubscriptionAdmissionError';
226
+ this.distributionId = distributionId;
227
+ this.refusals = refusals;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Admit every `eventSubscriptionRequirements` clause of `lock` through the
233
+ * kernel (`admitEventSubscriptions`), or throw
234
+ * {@link EventSubscriptionAdmissionError} listing every refusal. Returns the
235
+ * admission: the first-party grants and the installed ceilings.
236
+ */
237
+ export function assertEventSubscriptionsAdmitted(schemas, lock) {
238
+ const admission = schemas.admitEventSubscriptions(lock);
239
+ if (admission.refusals.length > 0) {
240
+ throw new EventSubscriptionAdmissionError(lock.distributionId, admission.refusals);
241
+ }
242
+ return admission;
243
+ }
@@ -6,6 +6,7 @@ import { compareStrings } from './string-order.mjs';
6
6
  const requireFromHere = createRequire(import.meta.url);
7
7
 
8
8
  let kernelPrivilegeGrammars;
9
+ let kernelAudienceGrammars;
9
10
 
10
11
  /**
11
12
  * The kernel's ONE operation-id grammar (`OperationIdSchema`) and closed
@@ -15,16 +16,54 @@ let kernelPrivilegeGrammars;
15
16
  function loadKernelPrivilegeGrammars() {
16
17
  if (kernelPrivilegeGrammars) {return kernelPrivilegeGrammars;}
17
18
  const mod = requireFromHere(kernelDistPath('distribution', 'index.js'));
18
- if (typeof mod.OperationIdSchema?.safeParse !== 'function' || typeof mod.PlatformSweepSchema?.safeParse !== 'function') {
19
+ if (
20
+ typeof mod.OperationIdSchema?.safeParse !== 'function' ||
21
+ typeof mod.PlatformSweepSchema?.safeParse !== 'function' ||
22
+ typeof mod.DeclaredEventSubscriptionClauseSchema?.safeParse !== 'function' ||
23
+ typeof mod.canonicalEventSubscriptionClauses !== 'function'
24
+ ) {
19
25
  throw new Error(
20
26
  `Built kernel distribution module at ${kernelDistPath('distribution', 'index.js')} does not export ` +
21
- 'OperationIdSchema and PlatformSweepSchema — the dist is stale; rebuild kernel-contracts at a commit that carries them.',
27
+ 'OperationIdSchema, PlatformSweepSchema, DeclaredEventSubscriptionClauseSchema and canonicalEventSubscriptionClauses — the dist is stale; rebuild kernel-contracts at a commit that carries them.',
22
28
  );
23
29
  }
24
30
  kernelPrivilegeGrammars = mod;
25
31
  return mod;
26
32
  }
27
33
 
34
+ /**
35
+ * The kernel's exact-audience and audience-namespace grammars, as ELEMENT schemas.
36
+ * The edition protocol exposes no subpath that exports the element grammars, so each is read
37
+ * from the one list schema the distribution subpath does export
38
+ * (`PlatformServiceSourceIdentitySchema.shape.requiresAudiences` / `.ownsAudienceNamespaces`):
39
+ * a value is graded as a one-element list, and the parsed element is returned.
40
+ */
41
+ function loadKernelAudienceGrammars() {
42
+ if (kernelAudienceGrammars) {return kernelAudienceGrammars;}
43
+ const mod = requireFromHere(kernelDistPath('distribution', 'index.js'));
44
+ const shape = mod.PlatformServiceSourceIdentitySchema?.shape;
45
+ const elementSchema = (listField) => {
46
+ const list = shape?.[listField]?.unwrap?.();
47
+ if (typeof list?.safeParse !== 'function') {
48
+ throw new Error(
49
+ `Built kernel distribution module at ${kernelDistPath('distribution', 'index.js')} has no PlatformServiceSourceIdentitySchema.${listField} — ` +
50
+ 'the dist is stale; rebuild kernel-contracts at a commit that carries the index-row audience fields.',
51
+ );
52
+ }
53
+ return {
54
+ safeParse: (value) => {
55
+ const parsed = list.safeParse([value]);
56
+ return parsed.success ? { success: true, data: parsed.data[0] } : parsed;
57
+ },
58
+ };
59
+ };
60
+ kernelAudienceGrammars = {
61
+ ExactAudienceSchema: elementSchema('requiresAudiences'),
62
+ AudienceNamespaceSchema: elementSchema('ownsAudienceNamespaces'),
63
+ };
64
+ return kernelAudienceGrammars;
65
+ }
66
+
28
67
  /**
29
68
  * One component's declared privilege list, as the index carries it: sorted,
30
69
  * unique, every entry graded by the kernel schema. `null` when undeclared. An
@@ -32,7 +71,7 @@ function loadKernelPrivilegeGrammars() {
32
71
  * reads the RAW manifest, and the kernel's component schema is not applied on
33
72
  * this path.
34
73
  */
35
- function declaredPrivilegeList(component, where, field, schema, what) {
74
+ export function declaredPrivilegeList(component, where, field, schema, what) {
36
75
  const declared = component[field];
37
76
  if (declared === undefined) {return null;}
38
77
  if (!Array.isArray(declared) || declared.length === 0) {
@@ -45,7 +84,7 @@ function declaredPrivilegeList(component, where, field, schema, what) {
45
84
  const parsed = schema.safeParse(value);
46
85
  if (!parsed.success) {
47
86
  throw new Error(
48
- `${where} ${field}[${position}] ${JSON.stringify(value)} is not ${what === 'operation id' ? 'an' : 'a'} ${what}: ` +
87
+ `${where} ${field}[${position}] ${JSON.stringify(value)} is not ${/^[aeiou]/.test(what) ? 'an' : 'a'} ${what}: ` +
49
88
  parsed.error.issues.map((issue) => issue.message).join('; '),
50
89
  );
51
90
  }
@@ -104,6 +143,77 @@ export function componentOrganizationSweepRequirements(component, where) {
104
143
  return declaredPrivilegeList(component, where, 'requiresOrganizationSweeps', PlatformSweepSchema, 'platform sweep');
105
144
  }
106
145
 
146
+ /**
147
+ * The exact audiences one workload component requires (`requiresAudiences`),
148
+ * graded by the kernel's exact-audience grammar (no wildcard, no prefix).
149
+ * Declared INTENT only; `admitExactAudiences` admits or refuses each.
150
+ */
151
+ export function componentAudienceRequirements(component, where) {
152
+ const { ExactAudienceSchema } = loadKernelAudienceGrammars();
153
+ return declaredPrivilegeList(component, where, 'requiresAudiences', ExactAudienceSchema, 'exact audience');
154
+ }
155
+
156
+ /**
157
+ * The audience namespaces one workload component owns (`ownsAudienceNamespaces`),
158
+ * graded by the kernel's namespace grammar.
159
+ */
160
+ export function componentOwnedAudienceNamespaces(component, where) {
161
+ const { AudienceNamespaceSchema } = loadKernelAudienceGrammars();
162
+ return declaredPrivilegeList(component, where, 'ownsAudienceNamespaces', AudienceNamespaceSchema, 'audience namespace');
163
+ }
164
+
165
+ /**
166
+ * The event subscription clauses one workload component declares
167
+ * (`subscribesEvents`), each graded by the kernel's clause grammar and returned
168
+ * in the kernel's canonical order, as the lock carries them
169
+ * (`eventSubscriptionRequirements`). An empty list, a repeated clause or an
170
+ * invalid one REFUSES naming the manifest; `null` when undeclared. Declared
171
+ * INTENT only; the lock admits it (`admitEventSubscriptions`).
172
+ */
173
+ export function componentEventSubscriptionRequirements(component, where) {
174
+ const { DeclaredEventSubscriptionClauseSchema, canonicalEventSubscriptionClauses } = loadKernelPrivilegeGrammars();
175
+ const declared = component.subscribesEvents;
176
+ if (declared === undefined) {return null;}
177
+ if (!Array.isArray(declared) || declared.length === 0) {
178
+ throw new Error(
179
+ `${where} declares subscribesEvents that is not a non-empty array of event subscription clauses — ` +
180
+ 'a list that names nothing states nothing, so omit the field',
181
+ );
182
+ }
183
+ const clauses = declared.map((value, position) => {
184
+ const parsed = DeclaredEventSubscriptionClauseSchema.safeParse(value);
185
+ if (!parsed.success) {
186
+ throw new Error(
187
+ `${where} subscribesEvents[${position}] ${JSON.stringify(value)} is not an event subscription clause: ` +
188
+ parsed.error.issues.map((issue) => issue.message).join('; '),
189
+ );
190
+ }
191
+ return parsed.data;
192
+ });
193
+ const canonical = canonicalEventSubscriptionClauses(clauses);
194
+ if (canonical.length !== clauses.length) {
195
+ throw new Error(`${where} subscribesEvents lists the same clause more than once`);
196
+ }
197
+ return canonical;
198
+ }
199
+
200
+ /**
201
+ * Whether one workload component declares `relaysOrgContext`: the privilege to
202
+ * relay an organization context its caller handed it. The only legal value is
203
+ * `true` (the kernel's component grammar); absent means it does not relay.
204
+ * Declared INTENT only; the lock admits it (`admitOrgContextRelays`).
205
+ */
206
+ export function componentRelaysOrgContext(component, where) {
207
+ const declared = component.relaysOrgContext;
208
+ if (declared === undefined) {return false;}
209
+ if (declared !== true) {
210
+ throw new Error(
211
+ `${where} declares relaysOrgContext ${JSON.stringify(declared)} — the only legal value is true; omit the field to not relay`,
212
+ );
213
+ }
214
+ return true;
215
+ }
216
+
107
217
  /**
108
218
  * The privilege declarations a component may carry only if it is a workload
109
219
  * principal in the lock.
@@ -112,7 +222,11 @@ export const PRINCIPAL_PRIVILEGE_FIELDS = Object.freeze([
112
222
  'requiresOperations',
113
223
  'optionalOperations',
114
224
  'requestsInstalledOperations',
225
+ 'relaysOrgContext',
226
+ 'subscribesEvents',
115
227
  'requiresOrganizationSweeps',
228
+ 'requiresAudiences',
229
+ 'ownsAudienceNamespaces',
116
230
  ]);
117
231
 
118
232
  /**
@@ -72,8 +72,9 @@ import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.
72
72
  import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
73
73
  import {
74
74
  createRegistryClientResolver,
75
+ describeInvalidReferrer,
75
76
  resolveImageSourceEvidenceReferrer,
76
- writeForeignAuthorityReferrerReport,
77
+ writeInvalidReferrerReport,
77
78
  } from './oci-image-source-evidence.mjs';
78
79
  import {
79
80
  loadProducerKernelSchemas,
@@ -169,7 +170,10 @@ export async function planImageEvidenceRefresh({
169
170
  // planner: measured that day, ALL 68 locked coordinates were absent from
170
171
  // ghcr, and the run died on the first of them and said nothing about the
171
172
  // other 67.
172
- let coordinateAbsence = null;
173
+ let unusableReason = null;
174
+ // Every referrer of this subject that is not valid evidence, as reported
175
+ // by the resolver. All of them invalid is a decided UNUSABLE verdict.
176
+ const invalidReferrers = [];
173
177
  {
174
178
  const { authority, repository } = splitImageRef(subject.imageRef);
175
179
  const client = clientFor(authority);
@@ -182,7 +186,7 @@ export async function planImageEvidenceRefresh({
182
186
  // that cannot decide must never report "nothing to do" — so it still
183
187
  // escapes and the run still exits 1.
184
188
  if (error?.status !== 404) {throw error;}
185
- coordinateAbsence =
189
+ unusableReason =
186
190
  `the registry has no image at the locked coordinate ` +
187
191
  `${subject.imageRef}:${subject.contentHash} — nothing was ever ` +
188
192
  `published there, so no producer evidence can attest it and the ` +
@@ -195,8 +199,18 @@ export async function planImageEvidenceRefresh({
195
199
  subjectDigest: image.digest,
196
200
  schemas,
197
201
  expectedContractAuthority,
198
- onForeignAuthorityReferrer: writeForeignAuthorityReferrerReport,
202
+ onInvalidReferrer: (skipped) => {
203
+ invalidReferrers.push(skipped);
204
+ writeInvalidReferrerReport(skipped);
205
+ },
199
206
  });
207
+ if (artifact === null && invalidReferrers.length > 0) {
208
+ unusableReason =
209
+ `every evidence referrer of ${subject.imageRef}@${image.digest} ` +
210
+ `(repo ${subject.repo}) is invalid under the required kernel contract ` +
211
+ `authority ${expectedContractAuthority.sourceCommit}: ` +
212
+ invalidReferrers.map(describeInvalidReferrer).join('; ');
213
+ }
200
214
  if (artifact !== null) {
201
215
  matches = artifact.records.filter(
202
216
  (record) =>
@@ -207,11 +221,11 @@ export async function planImageEvidenceRefresh({
207
221
  }
208
222
  }
209
223
  const verdict =
210
- coordinateAbsence !== null
224
+ unusableReason !== null
211
225
  ? {
212
226
  status: EvidenceRefreshStatus.Unusable,
213
227
  remainingSeconds: null,
214
- reason: coordinateAbsence,
228
+ reason: unusableReason,
215
229
  }
216
230
  : classifySubject({ matches, horizonSeconds, now });
217
231
  subjects.push({
@@ -1,4 +1,5 @@
1
1
  import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
2
+ import { componentAudienceRequirements, componentOperationRequirements, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
2
3
  import { compareStrings } from './string-order.mjs';
3
4
 
4
5
  /**
@@ -54,6 +55,28 @@ export function sourceOwnedCapabilityDomains(sourceManifest, where) {
54
55
  return { ownsCapabilityDomains: sorted };
55
56
  }
56
57
 
58
+ /**
59
+ * What a platform service declares it requires and owns, as the index carries it, read from the
60
+ * same declaration as {@link sourceOwnedCapabilityDomains} (the `xema` block of its source manifest):
61
+ * `requiresOperations`, `requiresAudiences` and `ownsAudienceNamespaces`. Each is sorted, unique and
62
+ * non-empty, or no key at all. Declared INTENT: the lock admits or refuses each
63
+ * (`admitOperationGrants`, `admitExactAudiences`).
64
+ *
65
+ * @param {unknown} sourceManifest the parsed source manifest
66
+ * @param {string} where names the manifest in a refusal
67
+ */
68
+ export function sourceDeclaredRequirements(sourceManifest, where) {
69
+ const xema = sourceManifest?.xema ?? {};
70
+ const requiresOperations = componentOperationRequirements(xema, where);
71
+ const requiresAudiences = componentAudienceRequirements(xema, where);
72
+ const ownsAudienceNamespaces = componentOwnedAudienceNamespaces(xema, where);
73
+ return {
74
+ ...(requiresOperations === null ? {} : { requiresOperations }),
75
+ ...(requiresAudiences === null ? {} : { requiresAudiences }),
76
+ ...(ownsAudienceNamespaces === null ? {} : { ownsAudienceNamespaces }),
77
+ };
78
+ }
79
+
57
80
  /** The (name, tier, declared class) a declaration and an index entry must agree on. */
58
81
  export function comparablePlatformService(entry) {
59
82
  return { name: entry.name, tier: entry.tier, ...declaredInstallationClass(entry) };
@@ -1,6 +1,14 @@
1
1
  import { sourcePath } from './edition-workspace.mjs';
2
2
  import { readGitJsonAtCommit, serializeJson, sha256Hex } from './lib.mjs';
3
- import { sourceOwnedCapabilityDomains } from './platform-service-class.mjs';
3
+ import { sourceDeclaredRequirements, sourceOwnedCapabilityDomains } from './platform-service-class.mjs';
4
+
5
+ /** The index-row fields re-derived from a service's own source manifest and compared on read. */
6
+ const DERIVED_ROW_FIELDS = Object.freeze([
7
+ 'ownsCapabilityDomains',
8
+ 'requiresOperations',
9
+ 'requiresAudiences',
10
+ 'ownsAudienceNamespaces',
11
+ ]);
4
12
 
5
13
  /** Why a platform service's capability-domain ownership was refused. */
6
14
  export const PlatformServiceOwnershipFault = Object.freeze({
@@ -8,7 +16,7 @@ export const PlatformServiceOwnershipFault = Object.freeze({
8
16
  ManifestUnavailable: 'manifest-unavailable',
9
17
  /** The manifest bytes do not hash to the index entry's `sourceManifestHash`. */
10
18
  ManifestHashMismatch: 'manifest-hash-mismatch',
11
- /** The index entry's `ownsCapabilityDomains` differs from the source manifest's. */
19
+ /** An index entry's ownership or requirement field differs from the source manifest's. */
12
20
  OwnershipMismatch: 'ownership-mismatch',
13
21
  });
14
22
 
@@ -38,7 +46,7 @@ export function readPinnedSourceManifest(entry) {
38
46
  }
39
47
 
40
48
  /**
41
- * Re-derive each platform service's `ownsCapabilityDomains` from its pinned
49
+ * Re-derive each platform service's ownership and requirement fields (`DERIVED_ROW_FIELDS`) from its pinned
42
50
  * source manifest and refuse the index when an entry disagrees. The index is a
43
51
  * hand-editable file; the SERVICE's own manifest is the authority for which
44
52
  * service owns a domain, and so for where grants land.
@@ -61,17 +69,19 @@ export function verifiedPlatformServiceIndex(index, { names, readManifest = read
61
69
  `but the index pins sourceManifestHash ${entry.sourceManifestHash}`,
62
70
  );
63
71
  }
64
- const derived = sourceOwnedCapabilityDomains(manifest, `${entry.path} at ${entry.sourceCommit}`);
65
- const claimed = entry.ownsCapabilityDomains;
66
- if (serializeJson(claimed ?? null) !== serializeJson(derived.ownsCapabilityDomains ?? null)) {
67
- throw new PlatformServiceOwnershipError(
68
- PlatformServiceOwnershipFault.OwnershipMismatch,
69
- `platform service "${entry.name}": the index claims ownsCapabilityDomains ${JSON.stringify(claimed ?? [])}, ` +
70
- `its source manifest declares ${JSON.stringify(derived.ownsCapabilityDomains ?? [])} ` +
71
- `(sourceManifestHash ${entry.sourceManifestHash})`,
72
- );
72
+ const where = `${entry.path} at ${entry.sourceCommit}`;
73
+ const derived = { ...sourceOwnedCapabilityDomains(manifest, where), ...sourceDeclaredRequirements(manifest, where) };
74
+ for (const field of DERIVED_ROW_FIELDS) {
75
+ if (serializeJson(entry[field] ?? null) !== serializeJson(derived[field] ?? null)) {
76
+ throw new PlatformServiceOwnershipError(
77
+ PlatformServiceOwnershipFault.OwnershipMismatch,
78
+ `platform service "${entry.name}": the index claims ${field} ${JSON.stringify(entry[field] ?? [])}, ` +
79
+ `its source manifest declares ${JSON.stringify(derived[field] ?? [])} ` +
80
+ `(sourceManifestHash ${entry.sourceManifestHash})`,
81
+ );
82
+ }
73
83
  }
74
- const { ownsCapabilityDomains: _claimed, ...rest } = entry;
84
+ const rest = Object.fromEntries(Object.entries(entry).filter(([key]) => !DERIVED_ROW_FIELDS.includes(key)));
75
85
  return { ...rest, ...derived };
76
86
  });
77
87
  return { ...index, services };
@@ -37,6 +37,7 @@ import {
37
37
  isEntryModule,
38
38
  } from './edition-workspace.mjs';
39
39
  import { canonicalJsonBytes } from './image-evidence.mjs';
40
+ import { oidcCertificateSignerKind } from './kernel-signer-kind.mjs';
40
41
  import { serializeJson } from './lib.mjs';
41
42
  import {
42
43
  assertGitHubProducerContext,
@@ -52,6 +53,7 @@ const COMPLETENESS_BUNDLE_PATH = 'completeness/bundle.sigstore.json';
52
53
  /** The keyless signer a GitHub workflow is: its certificate identity and issuer. */
53
54
  export function githubWorkflowSigner({ repository, ref, path }) {
54
55
  return {
56
+ kind: oidcCertificateSignerKind(),
55
57
  certificateIdentity: `https://github.com/${repository}/${path}@${ref}`,
56
58
  oidcIssuer: 'https://token.actions.githubusercontent.com',
57
59
  githubWorkflow: { repository, ref, path },
@@ -25,6 +25,7 @@ import {
25
25
  import { isAbsolute, join, resolve } from 'node:path';
26
26
  import { pathToFileURL } from 'node:url';
27
27
 
28
+ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
28
29
  import { editionWorkspaceConfig } from './edition-config.mjs';
29
30
  import {
30
31
  WorkspaceRoot,
@@ -50,6 +51,7 @@ import {
50
51
  sha256Digest,
51
52
  } from './image-evidence.mjs';
52
53
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
54
+ import { oidcCertificateSignerKind } from './kernel-signer-kind.mjs';
53
55
  import { serializeJson } from './lib.mjs';
54
56
  import { assertStagedExactly, reserveSealing, sealAccepted } from './sealed-evidence-accept.mjs';
55
57
  import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
@@ -219,7 +221,7 @@ function artifactStem(subject) {
219
221
  function assertSigner(signer) {
220
222
  exactKeys(
221
223
  signer,
222
- ['certificateIdentity', 'oidcIssuer', 'githubWorkflow'],
224
+ ['kind', 'certificateIdentity', 'oidcIssuer', 'githubWorkflow'],
223
225
  'image-source producer signer',
224
226
  );
225
227
  exactKeys(
@@ -238,6 +240,11 @@ function assertSigner(signer) {
238
240
  throw new Error('image-source producer GitHub workflow is not canonical');
239
241
  }
240
242
  const expectedIdentity = `https://github.com/${repository}/${path}@${ref}`;
243
+ if (signer.kind !== oidcCertificateSignerKind()) {
244
+ throw new Error(
245
+ `image-source producer signer kind must be "${oidcCertificateSignerKind()}"`,
246
+ );
247
+ }
241
248
  if (
242
249
  signer.certificateIdentity !== expectedIdentity ||
243
250
  signer.oidcIssuer !== 'https://token.actions.githubusercontent.com'
@@ -514,16 +521,7 @@ export function cosignVerifyAttestationArgs({ bundlePath, signer, certificateWor
514
521
  '--new-bundle-format',
515
522
  '--bundle',
516
523
  bundlePath,
517
- '--certificate-identity',
518
- signer.certificateIdentity,
519
- '--certificate-oidc-issuer',
520
- signer.oidcIssuer,
521
- '--certificate-github-workflow-repository',
522
- signer.githubWorkflow.repository,
523
- '--certificate-github-workflow-ref',
524
- signer.githubWorkflow.ref,
525
- '--certificate-github-workflow-sha',
526
- certificateWorkflowSha,
524
+ ...certificateConstraintArgs(signer, certificateWorkflowSha),
527
525
  '--type',
528
526
  predicateType,
529
527
  ];
@@ -93,6 +93,17 @@ export function readRawStatementAuthorityCommits({ directory }) {
93
93
  return commits;
94
94
  }
95
95
 
96
+ /**
97
+ * Evidence bytes that do not match the digests that describe them: a tampering
98
+ * signal. It is never an invalid-but-skippable referrer; it fails closed.
99
+ */
100
+ export class EvidenceIntegrityError extends Error {
101
+ constructor(message) {
102
+ super(message);
103
+ this.name = 'EvidenceIntegrityError';
104
+ }
105
+ }
106
+
96
107
  /** Read and structurally validate one producer evidence directory. */
97
108
  export function readProducerEvidenceDirectory({ directory, schemas }) {
98
109
  if (!isAbsolute(directory)) {
@@ -150,7 +161,7 @@ export function readProducerEvidenceDirectory({ directory, schemas }) {
150
161
  sha256Digest(statementBytes) !== descriptor.statementDigest ||
151
162
  sha256Digest(bundleBytes) !== descriptor.bundleDigest
152
163
  ) {
153
- throw new Error(`${key} artifacts do not match their descriptor`);
164
+ throw new EvidenceIntegrityError(`${key} artifacts do not match their descriptor`);
154
165
  }
155
166
  validateProducedBundle(statementBytes, bundleBytes);
156
167
  const statement = schemas.parseImageProducerStatement(
@@ -0,0 +1,11 @@
1
+ // record-refused — the refusal class of `select-records`: the data says no (a record is missing, unsigned,
2
+ // contradicts its tag, or its commit is not the develop line). Leaf module, so the ancestry check and the
3
+ // selection can both subclass it. A registry, cosign or git that could not answer is a plain Error instead.
4
+
5
+ export class RecordRefused extends Error {
6
+ constructor(repo, message) {
7
+ super(`select-records: ${repo}: ${message}`);
8
+ this.name = 'RecordRefused';
9
+ this.repo = repo;
10
+ }
11
+ }