@xemahq/xema 0.53.0 → 0.55.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/README.md +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +66 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +2062 -1025
- package/dist/edition/verify-image-evidence.bundle.provenance.json +595 -505
- package/dist/runtime/edition/verb-map.d.ts +2 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/lock-loader.d.ts +2 -1
- package/dist/runtime/lock-loader.d.ts.map +1 -1
- package/dist/runtime/lock-loader.js +15 -5
- package/dist/runtime/lock-loader.js.map +1 -1
- package/edition/README.md +61 -4
- package/edition/adapter-bundled-versions.mjs +2 -1
- package/edition/authorization-base.mjs +9 -8
- package/edition/biome-content.mjs +23 -29
- package/edition/candidate-edition.mjs +692 -0
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/checkout-indexed-source-repositories.mjs +6 -8
- package/edition/collected-refusals.mjs +59 -0
- package/edition/contract-authority.mjs +122 -0
- package/edition/cosign-certificate-constraints.mjs +18 -8
- package/edition/customer-edition-verbs.mjs +43 -10
- package/edition/develop-chart-set.mjs +7 -6
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-invariants.mjs +2 -1
- package/edition/develop-edition-resolve.mjs +110 -31
- package/edition/develop-edition.mjs +73 -11
- package/edition/develop-engine-image.mjs +95 -0
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +8 -5
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- package/edition/develop-resolve-report.mjs +159 -0
- package/edition/develop-select-records.mjs +29 -21
- package/edition/develop-selection-line.mjs +30 -0
- package/edition/develop-selection-pins.mjs +30 -0
- package/edition/develop-snapshot-inputs.mjs +4 -3
- package/edition/develop-verify-image-record.mjs +23 -4
- package/edition/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-artifact.mjs +17 -7
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +8 -19
- package/edition/image-evidence.mjs +6 -2
- package/edition/image-lock-service-rows.mjs +4 -4
- package/edition/image-subject-identity.mjs +24 -0
- package/edition/images-from-record.mjs +298 -0
- package/edition/lib.mjs +25 -45
- package/edition/locked-bundle-signer.mjs +15 -3
- package/edition/operation-grant-admission.mjs +51 -9
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +95 -82
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +233 -14
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/shallow-checkout.mjs +18 -0
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +33 -3
- package/package.json +7 -3
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
evidenceDirectoryForDigest,
|
|
67
67
|
sha256Digest,
|
|
68
68
|
} from './image-evidence.mjs';
|
|
69
|
+
import { evidenceSubjectKey } from './image-subject-identity.mjs';
|
|
69
70
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
70
71
|
import {
|
|
71
72
|
assertProducerInput,
|
|
@@ -124,6 +125,29 @@ function assertContainedRelativePath(value, label) {
|
|
|
124
125
|
*
|
|
125
126
|
* @returns {{ evidenceDirectory: string, reusable: boolean, reason: string }}
|
|
126
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* The key of an index entry: its subject's evidence identity, which for a composed artifact names the generation, so it is read
|
|
130
|
+
* from the statement the entry points at (the index states only kind and id). An entry whose statement cannot be read keeps
|
|
131
|
+
* `kind\0id`; the unusable statement is reported below.
|
|
132
|
+
*/
|
|
133
|
+
export function entryKey(entry, absoluteDirectory) {
|
|
134
|
+
try {
|
|
135
|
+
const { predicate } = JSON.parse(
|
|
136
|
+
readBoundedFile(join(absoluteDirectory, assertContainedRelativePath(entry.evidence.statementPath, 'statementPath'))).toString('utf8'),
|
|
137
|
+
);
|
|
138
|
+
return evidenceSubjectKey({
|
|
139
|
+
kind: entry.subjectKind,
|
|
140
|
+
id: entry.subjectId,
|
|
141
|
+
imageRef: predicate.artifact.imageRepository,
|
|
142
|
+
contentHash: predicate.source.sourceTreeHash,
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
return `${entry.subjectKind}\0${entry.subjectId}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const printable = (key) => key.replaceAll('\0', ':');
|
|
150
|
+
|
|
127
151
|
export async function assessCommittedEvidence({
|
|
128
152
|
input,
|
|
129
153
|
repositoryRoot,
|
|
@@ -190,7 +214,12 @@ export async function assessCommittedEvidence({
|
|
|
190
214
|
}
|
|
191
215
|
const expectedByKey = new Map(
|
|
192
216
|
expectedStatements.map((statement) => [
|
|
193
|
-
|
|
217
|
+
evidenceSubjectKey({
|
|
218
|
+
kind: statement.predicate.source.kind,
|
|
219
|
+
id: statement.predicate.source.id,
|
|
220
|
+
imageRef: statement.predicate.artifact.imageRepository,
|
|
221
|
+
contentHash: statement.predicate.source.sourceTreeHash,
|
|
222
|
+
}),
|
|
194
223
|
statement,
|
|
195
224
|
]),
|
|
196
225
|
);
|
|
@@ -200,20 +229,20 @@ export async function assessCommittedEvidence({
|
|
|
200
229
|
if (entry === null || typeof entry !== 'object') {
|
|
201
230
|
return unusable('producer-evidence.json carries a non-object entry');
|
|
202
231
|
}
|
|
203
|
-
const key =
|
|
232
|
+
const key = entryKey(entry, absoluteDirectory);
|
|
204
233
|
if (seen.has(key)) {
|
|
205
|
-
return unusable(`producer-evidence.json duplicates subject ${key}`);
|
|
234
|
+
return unusable(`producer-evidence.json duplicates subject ${printable(key)}`);
|
|
206
235
|
}
|
|
207
236
|
seen.add(key);
|
|
208
237
|
const expected = expectedByKey.get(key);
|
|
209
238
|
if (expected === undefined) {
|
|
210
239
|
return unusable(
|
|
211
|
-
`committed evidence carries subject ${key}, which this input does not evidence`,
|
|
240
|
+
`committed evidence carries subject ${printable(key)}, which this input does not evidence`,
|
|
212
241
|
);
|
|
213
242
|
}
|
|
214
243
|
const descriptor = entry.evidence;
|
|
215
244
|
if (descriptor === null || typeof descriptor !== 'object') {
|
|
216
|
-
return unusable(`subject ${key} has no evidence descriptor`);
|
|
245
|
+
return unusable(`subject ${printable(key)} has no evidence descriptor`);
|
|
217
246
|
}
|
|
218
247
|
let statementBytes;
|
|
219
248
|
let bundleBytes;
|
|
@@ -223,7 +252,7 @@ export async function assessCommittedEvidence({
|
|
|
223
252
|
absoluteDirectory,
|
|
224
253
|
assertContainedRelativePath(
|
|
225
254
|
descriptor.statementPath,
|
|
226
|
-
`subject ${key} statementPath`,
|
|
255
|
+
`subject ${printable(key)} statementPath`,
|
|
227
256
|
),
|
|
228
257
|
),
|
|
229
258
|
);
|
|
@@ -232,25 +261,25 @@ export async function assessCommittedEvidence({
|
|
|
232
261
|
absoluteDirectory,
|
|
233
262
|
assertContainedRelativePath(
|
|
234
263
|
descriptor.bundlePath,
|
|
235
|
-
`subject ${key} bundlePath`,
|
|
264
|
+
`subject ${printable(key)} bundlePath`,
|
|
236
265
|
),
|
|
237
266
|
),
|
|
238
267
|
);
|
|
239
268
|
} catch (error) {
|
|
240
|
-
return unusable(`subject ${key} artifacts are unreadable: ${error.message}`);
|
|
269
|
+
return unusable(`subject ${printable(key)} artifacts are unreadable: ${error.message}`);
|
|
241
270
|
}
|
|
242
271
|
if (
|
|
243
272
|
sha256Digest(statementBytes) !== descriptor.statementDigest ||
|
|
244
273
|
sha256Digest(bundleBytes) !== descriptor.bundleDigest
|
|
245
274
|
) {
|
|
246
275
|
return unusable(
|
|
247
|
-
`subject ${key} artifacts do not match the digests producer-evidence.json records`,
|
|
276
|
+
`subject ${printable(key)} artifacts do not match the digests producer-evidence.json records`,
|
|
248
277
|
);
|
|
249
278
|
}
|
|
250
279
|
try {
|
|
251
280
|
validateProducedBundle(statementBytes, bundleBytes);
|
|
252
281
|
} catch (error) {
|
|
253
|
-
return unusable(`subject ${key} bundle is malformed: ${error.message}`);
|
|
282
|
+
return unusable(`subject ${printable(key)} bundle is malformed: ${error.message}`);
|
|
254
283
|
}
|
|
255
284
|
let committed;
|
|
256
285
|
try {
|
|
@@ -259,7 +288,7 @@ export async function assessCommittedEvidence({
|
|
|
259
288
|
);
|
|
260
289
|
} catch (error) {
|
|
261
290
|
return unusable(
|
|
262
|
-
`subject ${key} statement does not parse under the kernel schema: ${error.message}`,
|
|
291
|
+
`subject ${printable(key)} statement does not parse under the kernel schema: ${error.message}`,
|
|
263
292
|
);
|
|
264
293
|
}
|
|
265
294
|
// producerRun is deliberately fresh on every execution (run id, attempt,
|
|
@@ -269,24 +298,24 @@ export async function assessCommittedEvidence({
|
|
|
269
298
|
// validate the existing run's own signature and lifetime independently.
|
|
270
299
|
if (!producerStatementMatchesInput(committed, expected)) {
|
|
271
300
|
return unusable(
|
|
272
|
-
`subject ${key} committed statement is not bound to this producer input ` +
|
|
301
|
+
`subject ${printable(key)} committed statement is not bound to this producer input ` +
|
|
273
302
|
'(authority, source, build, artifact or platform set changed)',
|
|
274
303
|
);
|
|
275
304
|
}
|
|
276
305
|
const expiresAt = Date.parse(committed.predicate.producerRun.expiresAt);
|
|
277
306
|
if (!Number.isFinite(expiresAt)) {
|
|
278
|
-
return unusable(`subject ${key} statement has an unreadable expiresAt`);
|
|
307
|
+
return unusable(`subject ${printable(key)} statement has an unreadable expiresAt`);
|
|
279
308
|
}
|
|
280
309
|
const remainingSeconds = Math.floor((expiresAt - now) / 1000);
|
|
281
310
|
if (remainingSeconds <= 0) {
|
|
282
311
|
return unusable(
|
|
283
|
-
`subject ${key} evidence EXPIRED at ${committed.predicate.producerRun.expiresAt} — ` +
|
|
312
|
+
`subject ${printable(key)} evidence EXPIRED at ${committed.predicate.producerRun.expiresAt} — ` +
|
|
284
313
|
'it can never verify again and must be re-produced',
|
|
285
314
|
);
|
|
286
315
|
}
|
|
287
316
|
if (remainingSeconds < MIN_REMAINING_TTL_SECONDS) {
|
|
288
317
|
return unusable(
|
|
289
|
-
`subject ${key} evidence expires in ${remainingSeconds}s, under the ` +
|
|
318
|
+
`subject ${printable(key)} evidence expires in ${remainingSeconds}s, under the ` +
|
|
290
319
|
`${MIN_REMAINING_TTL_SECONDS}s reuse floor — refreshing it now`,
|
|
291
320
|
);
|
|
292
321
|
}
|
|
@@ -11,6 +11,7 @@ import { readEditionSourcePins } from './edition-source-pins.mjs';
|
|
|
11
11
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, configuredWorkspace, editionsRoot, isEntryModule, platformServiceIndexPath, runtimeImageSourceIndexPath, sourcesRoot } from './edition-workspace.mjs';
|
|
12
12
|
import { holdSource, withSourceGitEnv } from './git-source-auth.mjs';
|
|
13
13
|
import { applyPinSource, snapshotPinSource } from './pin-source.mjs';
|
|
14
|
+
import { shallowCheckout, shallowFetch } from './shallow-checkout.mjs';
|
|
14
15
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
15
16
|
|
|
16
17
|
const REPO_PATH = /^(repos|submodules)\/([a-z0-9][a-z0-9-]*)$/;
|
|
@@ -188,7 +189,7 @@ export function fetchCrossRepoVendoredCommits({ workspaceRoot, plan, index, work
|
|
|
188
189
|
if (!commits) {continue;}
|
|
189
190
|
withSourceGitEnv({ name: entry.repo, url: entry.repositoryUrl }, { workspace, env }, (gitEnv) => {
|
|
190
191
|
for (const commit of commits) {
|
|
191
|
-
runGit
|
|
192
|
+
shallowFetch({ runGit, gitEnv, target: resolve(workspaceRoot, entry.repo), commit });
|
|
192
193
|
console.log(`[source-checkout] ${entry.repo} also holds cross-repo vendored commit ${commit}`);
|
|
193
194
|
}
|
|
194
195
|
});
|
|
@@ -210,10 +211,7 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
|
|
|
210
211
|
}
|
|
211
212
|
withSourceGitEnv({ name: entry.repo, url: entry.repositoryUrl }, { workspace, env }, (gitEnv) => {
|
|
212
213
|
mkdirSync(dirname(target), { recursive: true });
|
|
213
|
-
runGit
|
|
214
|
-
runGit(['-C', target, 'remote', 'add', 'origin', entry.repositoryUrl], gitEnv);
|
|
215
|
-
runGit(['-C', target, 'fetch', '--quiet', '--depth=1', 'origin', entry.sourceCommit], gitEnv);
|
|
216
|
-
runGit(['-C', target, 'checkout', '--quiet', '--detach', entry.sourceCommit], gitEnv);
|
|
214
|
+
shallowCheckout({ runGit, gitEnv, target, url: entry.repositoryUrl, commit: entry.sourceCommit });
|
|
217
215
|
const actual = execFileSync('git', ['-C', target, 'rev-parse', 'HEAD'], {
|
|
218
216
|
env: gitEnv,
|
|
219
217
|
encoding: 'utf8',
|
|
@@ -223,11 +221,11 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
|
|
|
223
221
|
}
|
|
224
222
|
if (sourceAxisCommits.has(entry.repo)) {
|
|
225
223
|
const commit = sourceAxisCommits.get(entry.repo);
|
|
226
|
-
|
|
224
|
+
shallowFetch({ runGit, gitEnv, target, commit });
|
|
227
225
|
console.log(`[source-checkout] ${entry.repo} also holds source-axis commit ${commit}`);
|
|
228
226
|
}
|
|
229
227
|
for (const commit of extraCommits.get(entry.repo) ?? []) {
|
|
230
|
-
|
|
228
|
+
shallowFetch({ runGit, gitEnv, target, commit });
|
|
231
229
|
console.log(`[source-checkout] ${entry.repo} also holds platform-service commit ${commit}`);
|
|
232
230
|
}
|
|
233
231
|
// A repository an edition holds: its main history (containment in origin/main is checked from it,
|
|
@@ -235,7 +233,7 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
|
|
|
235
233
|
if (heldCommits.has(entry.repo)) {
|
|
236
234
|
runGit(['-C', target, 'fetch', '--quiet', 'origin', '+refs/heads/main:refs/remotes/origin/main'], gitEnv);
|
|
237
235
|
for (const commit of heldCommits.get(entry.repo)) {
|
|
238
|
-
|
|
236
|
+
shallowFetch({ runGit, gitEnv, target, commit });
|
|
239
237
|
console.log(`[source-checkout] ${entry.repo} also holds edition-held commit ${commit}`);
|
|
240
238
|
}
|
|
241
239
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The one rule for a check that runs every independent assertion before it refuses.
|
|
2
|
+
//
|
|
3
|
+
// A REFUSAL is the answer "no" to the input: a lock the data does not admit, a commit that is not pinned, an image that is
|
|
4
|
+
// not built. It is a `RefusalError` and is collected so one run names them all. Anything else is a DEFECT (a TypeError, a
|
|
5
|
+
// missing file, a registry that answered 5xx, an unnamed Error): it is never collected, never reported as a refusal, and
|
|
6
|
+
// propagates unchanged with its stack.
|
|
7
|
+
//
|
|
8
|
+
// Nothing refused returns; exactly one refusal is rethrown unchanged (its type and message are the contract of whoever
|
|
9
|
+
// catches it); several become ONE error of the caller's class that joins their messages and keeps them as `.errors`.
|
|
10
|
+
|
|
11
|
+
/** An error that is a REFUSAL of the input. Extend it for a named refusal; throw it directly for an unnamed one. */
|
|
12
|
+
export class RefusalError extends Error {}
|
|
13
|
+
|
|
14
|
+
/** The kernel's own bundle-signer refusals, named as the kernel names them (their classes load lazily from the built kernel). */
|
|
15
|
+
export const KernelRefusalName = Object.freeze({
|
|
16
|
+
UnknownPolicy: 'LockedBundleSignerUnknownPolicyError',
|
|
17
|
+
AmbiguousPolicy: 'LockedBundleSignerAmbiguousPolicyError',
|
|
18
|
+
Mismatch: 'LockedBundleSignerMismatchError',
|
|
19
|
+
});
|
|
20
|
+
const KERNEL_REFUSAL_NAMES = new Set(Object.values(KernelRefusalName));
|
|
21
|
+
|
|
22
|
+
/** Whether a thrown value is a refusal. Everything else (a TypeError, a failed system call, an unnamed Error) is a defect. */
|
|
23
|
+
export const isRefusal = (error) => error instanceof RefusalError || (error instanceof Error && KERNEL_REFUSAL_NAMES.has(error.name));
|
|
24
|
+
|
|
25
|
+
/** Run `fn`; a refusal is pushed on `errors` and `undefined` returned, anything else is rethrown unchanged. */
|
|
26
|
+
export function captureRefusal(errors, fn, refusal) {
|
|
27
|
+
try {
|
|
28
|
+
return fn();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (!refusal(error)) {throw error;}
|
|
31
|
+
errors.push(error);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run `fn` on every item, keeping each refusal, then refuse once all were seen: one refusal is rethrown unchanged, several
|
|
38
|
+
* are one `CollectedError(errors)`. A defect stops the run at once and is rethrown as it was thrown.
|
|
39
|
+
*
|
|
40
|
+
* @param {Iterable<T>} items
|
|
41
|
+
* @param {(item: T) => void} fn
|
|
42
|
+
* @param {(error: unknown) => boolean} refusal which thrown values are refusals (`isRefusal`)
|
|
43
|
+
* @param {new (errors: readonly Error[]) => Error} CollectedError the class that carries several
|
|
44
|
+
*/
|
|
45
|
+
export function collectRefusals(items, fn, refusal, CollectedError) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
for (const item of items) {captureRefusal(errors, () => fn(item), refusal);}
|
|
48
|
+
throwCollectedRefusals(errors, CollectedError);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Nothing collected returns, one is rethrown unchanged, several become one `CollectedError`. */
|
|
52
|
+
export function throwCollectedRefusals(errors, CollectedError) {
|
|
53
|
+
if (errors.length === 0) {return;}
|
|
54
|
+
if (errors.length === 1) {throw errors[0];}
|
|
55
|
+
throw new CollectedError(errors);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The message of an error that stands for several: each refusal's message, separated by a blank line. */
|
|
59
|
+
export const joinRefusalMessages = (errors) => errors.map((error) => error.message).join('\n\n');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `xema edition contract-authority`: the full kernel contract authority of the `@xemahq/contracts` this CLI
|
|
3
|
+
// ships, as canonical JSON on stdout — the `--contract-authority <file>` a third-party publisher's public-input
|
|
4
|
+
// run passes (Third-party sources, README).
|
|
5
|
+
//
|
|
6
|
+
// usage: xema edition contract-authority
|
|
7
|
+
// stdout: the KernelContractAuthority object, canonical JSON, one line.
|
|
8
|
+
//
|
|
9
|
+
// The kernel's release derives the object at publish time, `sourceCommit` = the commit as the kernel release states it (carried, never proven here: only contentDigest and
|
|
10
|
+
// sourcePaths are proven against the package), and ships
|
|
11
|
+
// it as `dist/edition-protocol/contract-authority.json` (exported by name). This verb resolves that export from
|
|
12
|
+
// this CLI's own resolution, parses it with the kernel's KernelContractAuthoritySchema, refuses it unless its
|
|
13
|
+
// contentDigest is the bundled kernel's edition protocol digest, and then proves the bundled package IS that
|
|
14
|
+
// authority (assertKernelPackageIsCatalogueAuthority). It fails closed: a kernel with no file (a dev build, or a
|
|
15
|
+
// release older than the file) is refused by name, with no fallback to any derived or remembered authority.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { sep } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { KernelContractAuthoritySchema } from '@xemahq/contracts/distribution';
|
|
22
|
+
|
|
23
|
+
import { canonicalJson } from './canonical-evidence-json.mjs';
|
|
24
|
+
import { readKernelEditionProtocol } from './edition-protocol.mjs';
|
|
25
|
+
import { configureEditionWorkspaceFromArgv, isEntryModule, stateKernelPackageDir } from './edition-workspace.mjs';
|
|
26
|
+
import { KERNEL_PACKAGE_NAME, assertKernelPackageIsCatalogueAuthority, shippedKernelPackageDir } from './kernel-package.mjs';
|
|
27
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
28
|
+
|
|
29
|
+
export const CONTRACT_AUTHORITY_EXPORT = '@xemahq/contracts/edition-protocol/contract-authority.json';
|
|
30
|
+
export const CONTRACT_AUTHORITY_USAGE = 'usage: xema edition contract-authority';
|
|
31
|
+
|
|
32
|
+
export class BundledContractAuthorityError extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'BundledContractAuthorityError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ownRequire = createRequire(import.meta.url);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The ONE refusal of a kernel that carries no contract-authority.json, for the verb and for every edition test
|
|
43
|
+
* fixture that reads the authority through this module: the file is emitted only at release, so a dev build (an
|
|
44
|
+
* aggregator-linked kernel) never carries one, by design.
|
|
45
|
+
*/
|
|
46
|
+
function missingContractAuthority(kernelDir, detail) {
|
|
47
|
+
return new BundledContractAuthorityError(
|
|
48
|
+
`the ${KERNEL_PACKAGE_NAME} at ${kernelDir} carries no edition-protocol/contract-authority.json (${detail}): the file is emitted ` +
|
|
49
|
+
`only at release, so a dev build of the kernel never has one. The verb, and the edition evidence tests, need a published ` +
|
|
50
|
+
`${KERNEL_PACKAGE_NAME} (install a released @xemahq/xema, or run the tests against the published kernel the CLI's lockfile installs)`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The bundled kernel's contract-authority.json, resolved by its package export from this CLI's own resolution. */
|
|
55
|
+
export function resolveBundledContractAuthorityFile({ resolve = (specifier) => ownRequire.resolve(specifier), kernelDir = shippedKernelPackageDir() } = {}) {
|
|
56
|
+
try {
|
|
57
|
+
return resolve(CONTRACT_AUTHORITY_EXPORT);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error?.code !== 'MODULE_NOT_FOUND' && error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {throw error;}
|
|
60
|
+
throw missingContractAuthority(kernelDir, error.code);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read, parse and prove the contract authority at `file` against the kernel package at `kernelDir`: the file must
|
|
66
|
+
* be inside that package, parse as KernelContractAuthoritySchema, carry the package's edition protocol digest, and
|
|
67
|
+
* the package must be that authority. Returns the parsed authority.
|
|
68
|
+
*/
|
|
69
|
+
export async function readBundledContractAuthority({
|
|
70
|
+
kernelDir = shippedKernelPackageDir(),
|
|
71
|
+
file = resolveBundledContractAuthorityFile({ kernelDir }),
|
|
72
|
+
loadSchemas = loadKernelDistributionSchemas,
|
|
73
|
+
} = {}) {
|
|
74
|
+
let real;
|
|
75
|
+
try {
|
|
76
|
+
real = realpathSync(file);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === 'ENOENT') {throw missingContractAuthority(kernelDir, `${file} does not exist`);}
|
|
79
|
+
throw new BundledContractAuthorityError(`${file} cannot be read: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (!real.startsWith(`${realpathSync(kernelDir)}${sep}`)) {
|
|
82
|
+
throw new BundledContractAuthorityError(`${real} is not inside the kernel package this CLI ships (${kernelDir})`);
|
|
83
|
+
}
|
|
84
|
+
let raw;
|
|
85
|
+
try {
|
|
86
|
+
raw = JSON.parse(readFileSync(real, 'utf8'));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new BundledContractAuthorityError(`${real} is not JSON: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
const parsed = KernelContractAuthoritySchema.safeParse(raw);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');
|
|
93
|
+
throw new BundledContractAuthorityError(`${real} is not a kernel contract authority: ${issues}`);
|
|
94
|
+
}
|
|
95
|
+
const authority = parsed.data;
|
|
96
|
+
const bundledDigest = readKernelEditionProtocol(kernelDir).contentDigest;
|
|
97
|
+
if (authority.contentDigest !== bundledDigest) {
|
|
98
|
+
throw new BundledContractAuthorityError(
|
|
99
|
+
`${real} states contentDigest ${authority.contentDigest}, but the bundled kernel's edition protocol digest is ${bundledDigest}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
await assertKernelPackageIsCatalogueAuthority({ catalogue: { contractAuthority: authority }, kernelPackageDir: kernelDir, loadSchemas });
|
|
103
|
+
return authority;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [], { refuseOthers: true });
|
|
108
|
+
if (rest.length > 0) {throw new Error(CONTRACT_AUTHORITY_USAGE);}
|
|
109
|
+
const kernelDir = shippedKernelPackageDir();
|
|
110
|
+
stateKernelPackageDir(kernelDir);
|
|
111
|
+
const authority = await readBundledContractAuthority({ kernelDir });
|
|
112
|
+
process.stdout.write(`${canonicalJson(authority)}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isEntryModule(import.meta.url)) {
|
|
116
|
+
try {
|
|
117
|
+
await main();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`[contract-authority] ${error.message}`);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -14,17 +14,17 @@ function requireText(value, what) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
+
* The flags that pin a signer's exact identity, issuer, workflow repository and ref. They carry no workflow commit: use
|
|
18
|
+
* {@link certificateConstraintArgs} unless the signing workflow's own commit is, by design, not known to the verifier (a
|
|
19
|
+
* `workflow_run` follower runs the default branch's copy), and then bind the built commit by a signed annotation.
|
|
20
|
+
*
|
|
17
21
|
* @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
|
|
18
|
-
* @
|
|
19
|
-
* @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
|
|
22
|
+
* @returns {string[]}
|
|
20
23
|
*/
|
|
21
|
-
export function
|
|
24
|
+
export function certificateIdentityArgs(signer) {
|
|
22
25
|
if (signer === null || typeof signer !== 'object') {
|
|
23
26
|
throw new Error('certificate constraints: the signer must be an object');
|
|
24
27
|
}
|
|
25
|
-
if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
|
|
26
|
-
throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
|
|
27
|
-
}
|
|
28
28
|
return [
|
|
29
29
|
'--certificate-identity',
|
|
30
30
|
requireText(signer.certificateIdentity, 'certificateIdentity'),
|
|
@@ -34,7 +34,17 @@ export function certificateConstraintArgs(signer, workflowSha) {
|
|
|
34
34
|
requireText(signer.githubWorkflow?.repository, 'githubWorkflow.repository'),
|
|
35
35
|
'--certificate-github-workflow-ref',
|
|
36
36
|
requireText(signer.githubWorkflow?.ref, 'githubWorkflow.ref'),
|
|
37
|
-
'--certificate-github-workflow-sha',
|
|
38
|
-
workflowSha,
|
|
39
37
|
];
|
|
40
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
|
|
42
|
+
* @param {string} workflowSha the 40-hex commit the signing workflow ran at
|
|
43
|
+
* @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
|
|
44
|
+
*/
|
|
45
|
+
export function certificateConstraintArgs(signer, workflowSha) {
|
|
46
|
+
if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
|
|
47
|
+
throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
|
|
48
|
+
}
|
|
49
|
+
return [...certificateIdentityArgs(signer), '--certificate-github-workflow-sha', workflowSha];
|
|
50
|
+
}
|
|
@@ -178,25 +178,44 @@ export function customerRootsOf(command) {
|
|
|
178
178
|
// customer form: the flags only it takes, the further flags it requires, and the vendor flags it refuses.
|
|
179
179
|
const CREDENTIAL_FLAGS = Object.freeze([CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
|
|
180
180
|
const REPEATABLE = Object.freeze([SOURCE_IMAGE_FLAG]);
|
|
181
|
+
// A vendor develop resolve finds and proves its engine itself (develop-engine-image.mjs); a customer resolve is handed one.
|
|
182
|
+
const ENGINE_IMAGE_FLAG = '--engine-image';
|
|
183
|
+
const ENGINE_REPOSITORY_FLAG = '--engine-repository';
|
|
184
|
+
const ENGINE_SIGNER_FLAG = '--engine-signer';
|
|
185
|
+
export const IMAGES_FROM_FLAG = '--images-from';
|
|
186
|
+
export const PREVIOUS_LOCK_FLAG = '--previous-lock';
|
|
187
|
+
export const PREVIOUS_LOCK_SHA256_FLAG = '--previous-lock-sha256';
|
|
188
|
+
export const FIRST_SEAL_FLAG = '--first-seal';
|
|
181
189
|
|
|
182
190
|
const COMMANDS = Object.freeze({
|
|
183
191
|
resolve: {
|
|
184
192
|
positional: true,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
// --images-from: the everyday job's image build record (images-from-record.mjs) instead of the registry probe; vendor only.
|
|
194
|
+
// --previous-lock with --previous-lock-sha256 (the digest of the latest signed, published edition's images.lock layer, passed by the workflow under
|
|
195
|
+
// the per-edition concurrency group) or --first-seal: the previous seal whose composed artifacts carry their generation forward; vendor only.
|
|
196
|
+
values: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG],
|
|
197
|
+
switches: ['--local', FIRST_SEAL_FLAG],
|
|
198
|
+
required: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
|
|
199
|
+
customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG, ENGINE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, FIRST_SEAL_FLAG], cosign: true },
|
|
200
|
+
// A vendor resolve finds its engine itself; the old flag is refused by name, never read as a customer flag.
|
|
201
|
+
removedFromVendor: { [ENGINE_IMAGE_FLAG]: `use ${ENGINE_REPOSITORY_FLAG} and ${ENGINE_SIGNER_FLAG}` },
|
|
202
|
+
},
|
|
203
|
+
engine: {
|
|
204
|
+
positional: true,
|
|
205
|
+
values: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
|
|
206
|
+
switches: [],
|
|
207
|
+
required: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
|
|
189
208
|
},
|
|
190
209
|
target: {
|
|
191
210
|
positional: true,
|
|
192
|
-
values: ['--pins', '--registry', '--engine-image'],
|
|
211
|
+
values: ['--pins', '--registry', '--engine-image', IMAGES_FROM_FLAG],
|
|
193
212
|
switches: [],
|
|
194
213
|
required: ['--pins', '--registry', '--engine-image'],
|
|
195
|
-
customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [] },
|
|
214
|
+
customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG] },
|
|
196
215
|
},
|
|
197
216
|
'freeze-graph': {
|
|
198
217
|
positional: false,
|
|
199
|
-
values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--out'],
|
|
218
|
+
values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--member-pins', '--out'],
|
|
200
219
|
switches: [],
|
|
201
220
|
required: ['--data-sha', '--kernel-sha', '--deploy-sha', '--out'],
|
|
202
221
|
},
|
|
@@ -219,6 +238,12 @@ const COMMANDS = Object.freeze({
|
|
|
219
238
|
switches: [],
|
|
220
239
|
required: ['--branch', '--registry', '--cosign', '--out'],
|
|
221
240
|
},
|
|
241
|
+
'image-record': {
|
|
242
|
+
positional: true,
|
|
243
|
+
values: ['--selection', '--pins', '--out'],
|
|
244
|
+
switches: [],
|
|
245
|
+
required: ['--selection', '--pins', '--out'],
|
|
246
|
+
},
|
|
222
247
|
'verify-image-record': {
|
|
223
248
|
positional: false,
|
|
224
249
|
values: ['--image', '--producer', '--registry', '--cosign'],
|
|
@@ -256,7 +281,8 @@ export function parseEditionVerbArgs(channel, argv) {
|
|
|
256
281
|
if (spec === undefined) {
|
|
257
282
|
throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(commands).join(' | ')}`);
|
|
258
283
|
}
|
|
259
|
-
|
|
284
|
+
// The flags only the customer form takes: a flag the vendor form takes too (`--cosign` of a develop resolve) does not make it one.
|
|
285
|
+
const customerValues = (spec.customer?.values ?? []).filter((flag) => !spec.values.includes(flag));
|
|
260
286
|
const options = {};
|
|
261
287
|
const positional = [];
|
|
262
288
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -283,13 +309,20 @@ export function parseEditionVerbArgs(channel, argv) {
|
|
|
283
309
|
}
|
|
284
310
|
}
|
|
285
311
|
const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
|
|
286
|
-
// `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`) takes its own `--cosign`.
|
|
287
|
-
if (spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
|
|
312
|
+
// `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`, `resolve`, `engine`) takes its own `--cosign`.
|
|
313
|
+
if (customer && spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
|
|
288
314
|
throw new Error(`${COSIGN_FLAG} verifies a release catalogue's evidence; a ${channel} ${command} takes none`);
|
|
289
315
|
}
|
|
290
316
|
if (channel === EditionChannel.Release && options[DEFINITION_FLAG] === undefined) {
|
|
291
317
|
throw new Error(`a ${channel} ${command} requires ${DEFINITION_FLAG}`);
|
|
292
318
|
}
|
|
319
|
+
// A removed vendor flag with no other sign of a customer form (no --definition, no customer-only flag) is a stale vendor call.
|
|
320
|
+
const removedFlags = Object.keys(spec.removedFromVendor ?? {});
|
|
321
|
+
if (!customerValues.some((flag) => !removedFlags.includes(flag) && options[flag] !== undefined)) {
|
|
322
|
+
for (const flag of removedFlags) {
|
|
323
|
+
if (options[flag] !== undefined) {throw new Error(`${flag} was removed from a vendor ${command}: ${spec.removedFromVendor[flag]}`);}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
293
326
|
if (customer) {
|
|
294
327
|
if (options[DEFINITION_FLAG] === undefined) {throw new Error(`a customer ${command} requires ${DEFINITION_FLAG}`);}
|
|
295
328
|
for (const refused of spec.customer.refused) {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { basename } from 'node:path';
|
|
31
31
|
|
|
32
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
32
33
|
import { readDevelopChartRoster } from './develop-chart-roster.mjs';
|
|
33
34
|
import { assertRosteredChartSetLock } from './edition-chart-set.mjs';
|
|
34
35
|
import { deployRoot } from './edition-workspace.mjs';
|
|
@@ -53,7 +54,7 @@ function deployPublisher() {
|
|
|
53
54
|
const deployRepo = basename(deployPinKey());
|
|
54
55
|
const publishers = chartSetPublishers().filter((publisher) => basename(publisher.repo) === deployRepo);
|
|
55
56
|
if (publishers.length !== 1) {
|
|
56
|
-
throw new
|
|
57
|
+
throw new RefusalError(
|
|
57
58
|
`${publishers.length} configured chart publishers have repository ${deployRepo} (the deploy pin role); exactly one must, ` +
|
|
58
59
|
'since a develop chart set is that publisher\'s set at the deploy commit',
|
|
59
60
|
);
|
|
@@ -72,13 +73,13 @@ function committedInfraPublisher() {
|
|
|
72
73
|
const path = chartSetLockPath();
|
|
73
74
|
const committed = readLock(path);
|
|
74
75
|
if (committed === null) {
|
|
75
|
-
throw new
|
|
76
|
+
throw new RefusalError(`${path} is absent or unreadable; a develop edition carries the release infra chart set from it`);
|
|
76
77
|
}
|
|
77
78
|
assertRosteredChartSetLock(committed, path);
|
|
78
79
|
const indexes = infraPublisherIndexes();
|
|
79
80
|
const found = committed.publishers.filter((publisher) => indexes.includes(publisher.index));
|
|
80
81
|
if (indexes.length !== 1 || found.length !== 1) {
|
|
81
|
-
throw new
|
|
82
|
+
throw new RefusalError(
|
|
82
83
|
`${path} carries ${found.length} infra publisher entries (${indexes.length} infra publishers configured); exactly one is required`,
|
|
83
84
|
);
|
|
84
85
|
}
|
|
@@ -91,7 +92,7 @@ async function published(read, what, { publisher, xemaDeploySha }) {
|
|
|
91
92
|
return await read();
|
|
92
93
|
} catch (error) {
|
|
93
94
|
if (error?.status !== NOT_FOUND) {throw error;}
|
|
94
|
-
throw new
|
|
95
|
+
throw new RefusalError(`${what} is not published yet; ${publisher.repo} publish-charts for ${xemaDeploySha} not green`, { cause: error });
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -102,7 +103,7 @@ function assertIndexIsRoster(indexRoster, members, label) {
|
|
|
102
103
|
const missing = expected.filter((line) => !actual.includes(line));
|
|
103
104
|
const extra = actual.filter((line) => !expected.includes(line));
|
|
104
105
|
if (missing.length > 0 || extra.length > 0) {
|
|
105
|
-
throw new
|
|
106
|
+
throw new RefusalError(
|
|
106
107
|
`${label} does not carry the deploy checkout's develop roster: ` +
|
|
107
108
|
`missing [${missing.join(', ')}], unexpected [${extra.join(', ')}]`,
|
|
108
109
|
);
|
|
@@ -116,7 +117,7 @@ function assertIndexIsRoster(indexRoster, members, label) {
|
|
|
116
117
|
*/
|
|
117
118
|
export async function resolveDevelopChartSet({ xemaDeploySha, deployDir = deployRoot(), readRoster = readDevelopChartRoster, client }) {
|
|
118
119
|
if (!COMMIT_PATTERN.test(xemaDeploySha ?? '')) {
|
|
119
|
-
throw new
|
|
120
|
+
throw new RefusalError(`the deploy checkout SHA is not a 40-hex commit: ${JSON.stringify(xemaDeploySha)}`);
|
|
120
121
|
}
|
|
121
122
|
const publisher = deployPublisher();
|
|
122
123
|
const infraPublisher = committedInfraPublisher();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// develop-current-resolution — the members that keep the CURRENT develop resolution instead of a signed build record.
|
|
2
|
+
//
|
|
3
|
+
// A member keeps its current resolution (develop HEAD freeze plus the registry probe) only when the data root declares
|
|
4
|
+
// it: `currentResolution: "<ticket>"` on its `firstPartySources` entry in edition-workspace.json, the ticket that
|
|
5
|
+
// removes the exception (today xema-docs, whose develop lane is unsigned by owner decision and which leaves the
|
|
6
|
+
// edition). It is a declared exception, not a fallback: absent means none, and every other member without a record
|
|
7
|
+
// still refuses.
|
|
8
|
+
|
|
9
|
+
import { compareStrings } from './lib.mjs';
|
|
10
|
+
|
|
11
|
+
/** Whether a member (or its `firstPartySources` entry) keeps its current resolution. */
|
|
12
|
+
export const keepsCurrentResolution = (member) => Object.hasOwn(member, 'currentResolution');
|
|
13
|
+
|
|
14
|
+
/** The graph `members` that declare a current resolution, repo -> ticket, keys sorted: what a selection records. */
|
|
15
|
+
export function currentResolutionOf(members) {
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
members
|
|
18
|
+
.filter(keepsCurrentResolution)
|
|
19
|
+
.map(({ repo, currentResolution }) => [repo, currentResolution])
|
|
20
|
+
.sort(([a], [b]) => compareStrings(a, b)),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Why a selection's `currentResolution` (absent = none) is not exactly the one the graph `members` declare; null when it is. */
|
|
25
|
+
export function currentResolutionProblem(declared, members) {
|
|
26
|
+
const expected = currentResolutionOf(members);
|
|
27
|
+
if (JSON.stringify(declared ?? {}) === JSON.stringify(expected)) {return null;}
|
|
28
|
+
return `"currentResolution" is ${JSON.stringify(declared)}, not ${JSON.stringify(expected)}, the members the data root declares keep their current resolution`;
|
|
29
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// admission of this edition for a biome it does not ship is a stale permission
|
|
23
23
|
// and is refused; a declaration without a substantive consequence is refused.
|
|
24
24
|
|
|
25
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
25
26
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
26
27
|
import { buildCapabilityDomainOwners, serializeJson } from './lib.mjs';
|
|
27
28
|
|
|
@@ -40,7 +41,7 @@ export const DevelopEditionInvariantReason = Object.freeze({
|
|
|
40
41
|
const MIN_CONSEQUENCE_LENGTH = 40;
|
|
41
42
|
|
|
42
43
|
/** A develop candidate broke a plane invariant. `violations` carries every one, each with its reason. */
|
|
43
|
-
export class DevelopEditionInvariantError extends
|
|
44
|
+
export class DevelopEditionInvariantError extends RefusalError {
|
|
44
45
|
/** @param {string} id @param {{ reason: string, detail: string }[]} violations */
|
|
45
46
|
constructor(id, violations) {
|
|
46
47
|
super(
|