@xemahq/xema 0.48.0 → 0.49.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/dist/assets/appliance-base/distribution.lock.json +3936 -1515
- package/dist/assets/appliance-base/images.lock.json +878 -1161
- package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
- package/dist/assets/appliance-lean/images.lock.json +825 -924
- package/dist/assets/boot-graph.json +398 -93
- package/dist/assets/distribution-assets.manifest.json +10 -10
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +5 -3
- package/dist/commands/edition.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +0 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
- package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
- package/dist/runtime/auth/cli-application.d.ts +18 -0
- package/dist/runtime/auth/cli-application.d.ts.map +1 -0
- package/dist/runtime/auth/cli-application.js +39 -0
- package/dist/runtime/auth/cli-application.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +5 -1
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +21 -0
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +0 -18
- package/dist/runtime/launch.js.map +1 -1
- package/edition/README.md +20 -3
- package/edition/biome-content.mjs +5 -2
- package/edition/build-runtime-image-source-index.mjs +79 -5
- package/edition/canonical-evidence-json.mjs +82 -0
- package/edition/catalogue-evidence.mjs +8 -5
- package/edition/customer-edition-verbs.mjs +9 -3
- package/edition/develop-coverage-run.mjs +2 -1
- package/edition/develop-coverage.mjs +12 -1
- package/edition/develop-edition-resolve.mjs +3 -2
- package/edition/develop-edition.mjs +15 -3
- package/edition/develop-record-verify.mjs +216 -0
- package/edition/develop-select-records.mjs +24 -225
- package/edition/develop-verify-image-record.mjs +90 -0
- package/edition/dist-check.mjs +22 -2
- package/edition/distribution-evidence-set.mjs +30 -2
- package/edition/edition-artifact.mjs +110 -22
- package/edition/edition-ceiling.mjs +180 -0
- package/edition/edition-config.mjs +91 -0
- package/edition/edition-constants.mjs +21 -3
- package/edition/edition-contract-parse.mjs +88 -3
- package/edition/edition-contract.json +47 -4
- package/edition/edition-image-seal.mjs +9 -2
- package/edition/edition-kind.mjs +10 -0
- package/edition/edition-protocol.mjs +7 -1
- package/edition/edition-publisher.mjs +21 -0
- package/edition/edition-seal.mjs +257 -0
- package/edition/evidence-grammars.mjs +2 -0
- package/edition/evidence-trust-pin.mjs +66 -10
- package/edition/evidence-trust-policy.mjs +39 -29
- package/edition/external-source-walk.mjs +16 -3
- package/edition/fleet-coordinate-finality.mjs +18 -5
- package/edition/fulcio-certificate-extensions.mjs +231 -0
- package/edition/image-evidence.mjs +34 -79
- package/edition/locked-bundle-signer.mjs +224 -0
- package/edition/oci-evidence-verbs.mjs +7 -1
- package/edition/pinned-cosign.mjs +70 -0
- package/edition/plan-image-evidence-refresh.mjs +3 -1
- package/edition/producer-evidence-bundle.mjs +3 -1
- package/edition/producer-evidence-index-file.mjs +4 -0
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/repin-evidence-trust.mjs +6 -1
- package/edition/resolve-distribution.mjs +47 -6
- package/edition/resolve-edition.mjs +1 -1
- package/edition/resolve-images.mjs +53 -7
- package/edition/seal-image-digests.mjs +19 -8
- package/edition/sealed-distribution-lock.mjs +2 -2
- package/edition/third-party-admission.mjs +91 -0
- package/edition/third-party-bundle-registry.mjs +147 -0
- package/edition/third-party-certificate-extensions.mjs +21 -0
- package/edition/third-party-image-pins.mjs +41 -0
- package/edition/third-party-pins-trusted.mjs +24 -0
- package/edition/third-party-producer-evidence.mjs +76 -0
- package/edition/third-party-source-check.mjs +28 -0
- package/edition/third-party-source-context.mjs +41 -0
- package/edition/third-party-source-evidence.mjs +255 -0
- package/edition/third-party-source-flags.mjs +134 -0
- package/edition/third-party-source-pins.mjs +259 -0
- package/edition/third-party-source-uniqueness.mjs +54 -0
- package/edition/third-party-source-walk.mjs +115 -0
- package/edition/third-party-source-writer.mjs +229 -0
- package/edition/third-party-subjects.mjs +127 -0
- package/edition/trust-policy-image-ownership.mjs +85 -0
- package/edition/verify-catalogue-evidence.mjs +2 -0
- package/edition/verify-image-evidence.mjs +2 -0
- package/edition/verify-image-platforms.mjs +33 -9
- package/package.json +5 -5
- package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
- package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
- package/dist/runtime/auth/client-bootstrap.js +0 -60
- package/dist/runtime/auth/client-bootstrap.js.map +0 -1
|
@@ -27,8 +27,11 @@ import {
|
|
|
27
27
|
EDITION_REPOSITORY_PREFIX,
|
|
28
28
|
EDITION_REPOSITORY_SEGMENTS,
|
|
29
29
|
EditionChannel,
|
|
30
|
+
EditionKind,
|
|
30
31
|
EditionLockFile,
|
|
31
|
-
|
|
32
|
+
SEALED_EDITION_CHANNEL,
|
|
33
|
+
SEAL_OF_PATTERN,
|
|
34
|
+
admittedEvidenceTrustStates,
|
|
32
35
|
} from './edition-constants.mjs';
|
|
33
36
|
import { dataRoot, editionsRoot } from './edition-workspace.mjs';
|
|
34
37
|
import { serializeJson, sha256Hex } from './lib.mjs';
|
|
@@ -64,21 +67,18 @@ export function assertEngineImage(image) {
|
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/**
|
|
67
|
-
* The descriptor: what this artifact is
|
|
68
|
-
*
|
|
69
|
-
* engine image that consumes it.
|
|
70
|
+
* The descriptor (xema.edition/v2): what this artifact is (its `kind`, and for a sealed edition the
|
|
71
|
+
* resolved edition it seals, `sealOf`), which locks it carries, where it came from, whether the data
|
|
72
|
+
* root's editions are evidence-gated, and the deploy engine image that consumes it. Every writer of a
|
|
73
|
+
* descriptor passes here, and the strict parse it ends in holds the contract's rule: no route writes
|
|
74
|
+
* an edition whose evidence trust state its kind and channel do not admit, whatever its caller computed.
|
|
70
75
|
*/
|
|
71
|
-
export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
|
|
76
|
+
export function editionDescriptor({ kind, sealOf, distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
|
|
72
77
|
assertCanonicalDistributionId(distributionId);
|
|
73
|
-
// Every writer of a descriptor passes here: no route writes a release
|
|
74
|
-
// edition that is not armed, whatever state its caller computed.
|
|
75
|
-
if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
`a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
78
|
return parseEditionDescriptor({
|
|
81
79
|
schema: EDITION_DESCRIPTOR_SCHEMA,
|
|
80
|
+
kind,
|
|
81
|
+
...(kind === EditionKind.Sealed ? { sealOf } : {}),
|
|
82
82
|
distributionId,
|
|
83
83
|
locks: [...EDITION_LOCKS],
|
|
84
84
|
provenance: {
|
|
@@ -93,6 +93,25 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
|
|
|
93
93
|
|
|
94
94
|
const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
|
|
95
95
|
const DIGEST_PIN_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
96
|
+
const DESCRIPTOR_KEYS = Object.freeze(['schema', 'kind', 'distributionId', 'locks', 'provenance', 'evidenceTrustPolicy', 'engine']);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The one parser of a sealed edition's `sealOf`, the digest-pinned ref of the resolved edition it seals: its form is the
|
|
100
|
+
* contract's `sealOf.pattern` (`<registry>/<path>@sha256:<64 hex>`), and its repository is an edition repository
|
|
101
|
+
* (`<registry root>/<channel segment>/xema-edition-<id>`). Returns `{ repository, digest, registryRoot, channel, distributionId }`.
|
|
102
|
+
*/
|
|
103
|
+
export function parseSealOf(sealOf) {
|
|
104
|
+
if (typeof sealOf !== 'string' || !SEAL_OF_PATTERN.test(sealOf)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`sealOf ${JSON.stringify(sealOf)} does not match the contract's sealOf.pattern ${SEAL_OF_PATTERN.source}: a seal names the resolved edition ` +
|
|
107
|
+
`<registry root>/<channel segment>/${EDITION_REPOSITORY_PREFIX}<id> by digest`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const at = sealOf.lastIndexOf('@');
|
|
111
|
+
const digest = sealOf.slice(at + 1);
|
|
112
|
+
const repository = sealOf.slice(0, at);
|
|
113
|
+
return { repository, digest, ...parseEditionRepository(repository) };
|
|
114
|
+
}
|
|
96
115
|
|
|
97
116
|
const exactKeys = (value, keys, where) => {
|
|
98
117
|
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) {
|
|
@@ -100,12 +119,28 @@ const exactKeys = (value, keys, where) => {
|
|
|
100
119
|
}
|
|
101
120
|
};
|
|
102
121
|
|
|
103
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The strict parse of a descriptor: exact keys, the closed kind enum (no default, never inferred), `sealOf` exactly on a
|
|
124
|
+
* sealed edition, closed vocabularies, the evidence trust state the contract admits for the kind on the channel, an engine
|
|
125
|
+
* image by digest. Returns it unchanged.
|
|
126
|
+
*/
|
|
104
127
|
export function parseEditionDescriptor(value) {
|
|
105
|
-
|
|
128
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
129
|
+
throw new Error(`${EDITION_DESCRIPTOR_FILE} must be a JSON object`);
|
|
130
|
+
}
|
|
106
131
|
if (value.schema !== EDITION_DESCRIPTOR_SCHEMA) {
|
|
107
132
|
throw new Error(`${EDITION_DESCRIPTOR_FILE}: schema is ${JSON.stringify(value.schema)}, expected ${JSON.stringify(EDITION_DESCRIPTOR_SCHEMA)}`);
|
|
108
133
|
}
|
|
134
|
+
if (!Object.values(EditionKind).includes(value.kind)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${EDITION_DESCRIPTOR_FILE}: kind is ${JSON.stringify(value.kind)}, expected exactly ${Object.values(EditionKind).join(' | ')}; there is no default kind and none is inferred`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const sealed = value.kind === EditionKind.Sealed;
|
|
140
|
+
if (!sealed && 'sealOf' in value) {
|
|
141
|
+
throw new Error(`${EDITION_DESCRIPTOR_FILE}: kind ${value.kind} carries sealOf; only a sealed edition names the resolved edition it seals`);
|
|
142
|
+
}
|
|
143
|
+
exactKeys(value, sealed ? [...DESCRIPTOR_KEYS, 'sealOf'] : DESCRIPTOR_KEYS, EDITION_DESCRIPTOR_FILE);
|
|
109
144
|
assertCanonicalDistributionId(value.distributionId);
|
|
110
145
|
if (!Array.isArray(value.locks) || value.locks.length === 0 || value.locks.some((file) => typeof file !== 'string' || file === '')) {
|
|
111
146
|
throw new Error(`${EDITION_DESCRIPTOR_FILE}: locks must be a non-empty array of file names`);
|
|
@@ -133,16 +168,30 @@ export function parseEditionDescriptor(value) {
|
|
|
133
168
|
if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
|
|
134
169
|
throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
|
|
135
170
|
}
|
|
136
|
-
if (
|
|
137
|
-
throw new Error(
|
|
171
|
+
if (sealed && value.provenance.channel !== SEALED_EDITION_CHANNEL) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${EDITION_DESCRIPTOR_FILE}: a sealed edition records provenance.channel ${SEALED_EDITION_CHANNEL} (a seal is ${SEALED_EDITION_CHANNEL}-only); "${value.distributionId}" records ${JSON.stringify(value.provenance.channel)}`,
|
|
174
|
+
);
|
|
138
175
|
}
|
|
139
|
-
// The reader holds the writer's rule:
|
|
140
|
-
|
|
141
|
-
if (
|
|
176
|
+
// The reader holds the writer's rule (contract evidenceTrust.statesByKind): `not-armed` only on a resolved develop edition.
|
|
177
|
+
const admitted = admittedEvidenceTrustStates(value.kind, value.provenance.channel);
|
|
178
|
+
if (!admitted.includes(value.evidenceTrustPolicy)) {
|
|
142
179
|
throw new Error(
|
|
143
|
-
`${EDITION_DESCRIPTOR_FILE}: a
|
|
180
|
+
`${EDITION_DESCRIPTOR_FILE}: a ${value.kind} ${value.provenance.channel} edition records evidenceTrustPolicy ${admitted.join(' | ')}; ` +
|
|
181
|
+
`"${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
|
|
144
182
|
);
|
|
145
183
|
}
|
|
184
|
+
if (sealed) {
|
|
185
|
+
const sealOf = parseSealOf(value.sealOf);
|
|
186
|
+
if (sealOf.distributionId !== value.distributionId) {
|
|
187
|
+
throw new Error(`${EDITION_DESCRIPTOR_FILE}: sealOf ${value.sealOf} is an edition of "${sealOf.distributionId}", not of "${value.distributionId}"`);
|
|
188
|
+
}
|
|
189
|
+
if (sealOf.channel !== value.provenance.channel) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${EDITION_DESCRIPTOR_FILE}: sealOf ${value.sealOf} is a ${sealOf.channel} edition, but the seal records provenance.channel ${value.provenance.channel}; a seal keeps the channel of the edition it seals`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
146
195
|
exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
|
|
147
196
|
assertEngineImage(value.engine.image);
|
|
148
197
|
return value;
|
|
@@ -300,7 +349,7 @@ function assertOutsideCommittedTrees(outDir) {
|
|
|
300
349
|
}
|
|
301
350
|
}
|
|
302
351
|
|
|
303
|
-
/** Write the descriptor and the locks of
|
|
352
|
+
/** Write the descriptor and the locks of a RESOLVED edition of `channel` to `outDir`; returns the descriptor. */
|
|
304
353
|
export function writeEditionArtifact({
|
|
305
354
|
outDir,
|
|
306
355
|
channel,
|
|
@@ -327,7 +376,7 @@ export function writeEditionArtifact({
|
|
|
327
376
|
}
|
|
328
377
|
}
|
|
329
378
|
assertRosteredChartSetLock(chartSetLock, EDITION_CHART_SET_FILE);
|
|
330
|
-
const descriptor = editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
|
|
379
|
+
const descriptor = editionDescriptor({ kind: EditionKind.Resolved, distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
|
|
331
380
|
mkdirSync(outDir, { recursive: true });
|
|
332
381
|
for (const [file, value] of [[EDITION_DESCRIPTOR_FILE, descriptor], ...byFile]) {
|
|
333
382
|
writeFileSync(join(outDir, file), serializeJson(value));
|
|
@@ -349,6 +398,9 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
|
|
|
349
398
|
throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
|
|
350
399
|
}
|
|
351
400
|
const existing = parseEditionDescriptor(readStrictJsonFile(descriptorPath));
|
|
401
|
+
if (existing.kind !== EditionKind.Resolved) {
|
|
402
|
+
throw new Error(`the existing artifact is a ${existing.kind} edition; only a resolved edition is reused for a snapshot, refusing to reuse it`);
|
|
403
|
+
}
|
|
352
404
|
assertCanonicalDistributionId(distributionId);
|
|
353
405
|
if (existing.distributionId !== distributionId) {
|
|
354
406
|
throw new Error(`the existing artifact's distributionId is ${JSON.stringify(existing.distributionId)}, expected ${JSON.stringify(distributionId)}; refusing to reuse it`);
|
|
@@ -386,3 +438,39 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
|
|
|
386
438
|
}
|
|
387
439
|
}
|
|
388
440
|
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Write the SEALED edition of one exact resolved edition to `outDir`: every layer of the resolved artifact other than
|
|
444
|
+
* its descriptor, byte for byte (`layers`: title -> bytes, already proven to be the layers of `sealOf`), plus a sealed
|
|
445
|
+
* descriptor. The seal keeps the resolved edition's distribution, locks, channel, pins and engine image; it records
|
|
446
|
+
* `sealOf`, the run that sealed it, and an evidence trust state its kind admits (contract: armed). Returns the descriptor.
|
|
447
|
+
*/
|
|
448
|
+
export function writeSealedEditionArtifact({ outDir, resolved, sealOf, layers, workflow, evidenceTrustPolicy }) {
|
|
449
|
+
assertOutsideCommittedTrees(outDir);
|
|
450
|
+
if (resolved.kind !== EditionKind.Resolved) {
|
|
451
|
+
throw new Error(`${sealOf} is a ${resolved.kind} edition; a seal seals exactly one resolved edition`);
|
|
452
|
+
}
|
|
453
|
+
if (layers.has(EDITION_DESCRIPTOR_FILE)) {
|
|
454
|
+
throw new Error(`the layers of a seal exclude the resolved ${EDITION_DESCRIPTOR_FILE}; the seal writes its own`);
|
|
455
|
+
}
|
|
456
|
+
const missing = resolved.locks.filter((file) => !layers.has(file));
|
|
457
|
+
if (missing.length > 0) {
|
|
458
|
+
throw new Error(`${sealOf} lacks ${missing.join(', ')}, which its descriptor lists`);
|
|
459
|
+
}
|
|
460
|
+
const descriptor = editionDescriptor({
|
|
461
|
+
kind: EditionKind.Sealed,
|
|
462
|
+
sealOf,
|
|
463
|
+
distributionId: resolved.distributionId,
|
|
464
|
+
channel: resolved.provenance.channel,
|
|
465
|
+
pins: resolved.provenance.pins,
|
|
466
|
+
workflow,
|
|
467
|
+
evidenceTrustPolicy,
|
|
468
|
+
engineImage: resolved.engine.image,
|
|
469
|
+
});
|
|
470
|
+
mkdirSync(outDir, { recursive: true });
|
|
471
|
+
writeFileSync(join(outDir, EDITION_DESCRIPTOR_FILE), serializeJson(descriptor));
|
|
472
|
+
for (const [file, bytes] of layers) {
|
|
473
|
+
writeFileSync(join(outDir, file), bytes);
|
|
474
|
+
}
|
|
475
|
+
return descriptor;
|
|
476
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `xema edition ceiling`: the biomes a dev deployment may run, resolved at one distributions commit.
|
|
3
|
+
//
|
|
4
|
+
// usage: xema edition ceiling --distribution <id> --at <distributions sha> --producer-commit <owner/repo>=<sha> [...]
|
|
5
|
+
// --data-root <dir> --sources-root <dir> --kernel <dir>
|
|
6
|
+
//
|
|
7
|
+
// stdout: a `DevelopCeilingSchema` document (`@xemahq/contracts/develop-ceiling`): schema, distributionsCommit,
|
|
8
|
+
// distributionId and biomes sorted by id, each { id, origin, version, repo, services, runtimeComponents,
|
|
9
|
+
// serviceProcessFacts, trustCeiling? }. No images and no seal.
|
|
10
|
+
//
|
|
11
|
+
// THE SELECTION is the distribution definition `<id>/xema-distribution.json` and the committed `biome-index.json`
|
|
12
|
+
// and `platform-service-index.json`, all read from the data root (the distributions checkout) AT `--at`, resolved
|
|
13
|
+
// by the release resolver itself (`composeManifest` + `resolveDistributionWithIndex`): id, origin, repo and
|
|
14
|
+
// trustCeiling of each biome are the resolution's, never re-derived here.
|
|
15
|
+
//
|
|
16
|
+
// THE PROCESS FACTS (version, services, runtimeComponents, serviceProcessFacts) are the producer's own: its
|
|
17
|
+
// `xema-biome.json` and generated `xema-biome.index.json` AT the producer commit given by `--producer-commit`,
|
|
18
|
+
// projected by `manifestToIndexEntry`, the function that builds `biome-index.json`. A service new on the
|
|
19
|
+
// producer's develop inside a selected biome is admitted; a biome the selection does not name is never read.
|
|
20
|
+
// A selected biome whose producer has no stated commit, or whose manifest is absent at that commit, is refused:
|
|
21
|
+
// there is no fallback to the committed index's facts.
|
|
22
|
+
|
|
23
|
+
import { DevelopCeilingSchema } from '@xemahq/contracts/develop-ceiling';
|
|
24
|
+
|
|
25
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
26
|
+
import { developGraphMembers } from './develop-graph.mjs';
|
|
27
|
+
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
28
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromArgv, dataRoot, isEntryModule, sourcePath } from './edition-workspace.mjs';
|
|
29
|
+
import { readGitJsonAtCommit } from './git-json.mjs';
|
|
30
|
+
import {
|
|
31
|
+
BIOME_GENERATED_INDEX_FILENAME,
|
|
32
|
+
compareStrings,
|
|
33
|
+
loadKernelDistributionSchemas,
|
|
34
|
+
manifestToIndexEntry,
|
|
35
|
+
serializeJson,
|
|
36
|
+
} from './lib.mjs';
|
|
37
|
+
import { composeManifest, lockedServiceProcessFacts, parseEditionManifest, resolveDistributionWithIndex } from './resolve-distribution.mjs';
|
|
38
|
+
|
|
39
|
+
const USAGE =
|
|
40
|
+
'usage: xema edition ceiling --distribution <id> --at <distributions sha> --producer-commit <owner/repo>=<sha> [--producer-commit ...] --data-root <dir> --sources-root <dir> --kernel <dir>';
|
|
41
|
+
const GIT_COMMIT = /^[0-9a-f]{40}$/;
|
|
42
|
+
const PRODUCER_COMMIT = /^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)=([0-9a-f]{40})$/;
|
|
43
|
+
const MANIFEST_FILE = 'xema-biome.json';
|
|
44
|
+
|
|
45
|
+
/** The ceiling named a producer or a commit this verb cannot read: nothing is guessed. */
|
|
46
|
+
export class CeilingRefused extends Error {
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'CeilingRefused';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `owner/name` -> the sources-root handle (`repos/<name>`) of the first-party repository it is, per the develop graph. */
|
|
54
|
+
function producersByHandle(members) {
|
|
55
|
+
return new Map(
|
|
56
|
+
members.map(({ repo, repositoryUrl }) => [
|
|
57
|
+
repo,
|
|
58
|
+
repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(repositoryUrl, `firstPartySources["${repo}"].repositoryUrl`), `firstPartySources["${repo}"].repositoryUrl`),
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One selected biome's facts, read from its producer's manifest and ledger at the producer commit. */
|
|
64
|
+
async function producerFacts({ selected, producer, commit, repoDir }) {
|
|
65
|
+
const manifestDirectory = selected.path.slice(`${selected.repo}/`.length);
|
|
66
|
+
const manifestPath = `${manifestDirectory}/${MANIFEST_FILE}`;
|
|
67
|
+
const ledgerPath = `${manifestDirectory}/${BIOME_GENERATED_INDEX_FILENAME}`;
|
|
68
|
+
const rel = `${producer}/${manifestPath}`;
|
|
69
|
+
let raw;
|
|
70
|
+
let generatedIndex;
|
|
71
|
+
try {
|
|
72
|
+
raw = readGitJsonAtCommit(repoDir, manifestPath, commit);
|
|
73
|
+
generatedIndex = readGitJsonAtCommit(repoDir, ledgerPath, commit);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new CeilingRefused(`biome "${selected.id}": ${rel} or its ${BIOME_GENERATED_INDEX_FILENAME} cannot be read at ${producer}@${commit}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
const entry = await manifestToIndexEntry({ raw, generatedIndex, rel, repo: selected.repo, path: selected.path });
|
|
78
|
+
if (entry.id !== selected.id) {
|
|
79
|
+
throw new CeilingRefused(`${rel} at ${commit} declares biome "${entry.id}", but the selection names "${selected.id}" there`);
|
|
80
|
+
}
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} request
|
|
86
|
+
* @param {string} request.distributionId
|
|
87
|
+
* @param {string} request.distributionsCommit 40-hex commit of the data root checkout
|
|
88
|
+
* @param {ReadonlyMap<string, string>} request.producerCommits `owner/repo` -> the commit its facts are read at
|
|
89
|
+
* @param {object} request.schemas the kernel's distribution exports
|
|
90
|
+
* @returns {Promise<object>} the validated ceiling
|
|
91
|
+
*/
|
|
92
|
+
export async function composeCeiling({ distributionId, distributionsCommit, producerCommits, schemas }) {
|
|
93
|
+
assertCanonicalDistributionId(distributionId);
|
|
94
|
+
if (!GIT_COMMIT.test(distributionsCommit)) {throw new Error(`--at must be a 40-hex commit; got ${JSON.stringify(distributionsCommit)}`);}
|
|
95
|
+
const read = (path) => readGitJsonAtCommit(dataRoot(), path, distributionsCommit);
|
|
96
|
+
const manifest = parseEditionManifest(schemas.parseDistribution, distributionId, read(`${distributionId}/xema-distribution.json`), `${distributionsCommit}:${distributionId}/xema-distribution.json`);
|
|
97
|
+
const composed = composeManifest(manifest);
|
|
98
|
+
if (composed.declaredSources.length > 0) {
|
|
99
|
+
throw new CeilingRefused(`distribution "${distributionId}" declares external sources[]; a ceiling is resolved from the committed indexes alone`);
|
|
100
|
+
}
|
|
101
|
+
const { lock } = await resolveDistributionWithIndex(distributionId, {
|
|
102
|
+
inputs: {
|
|
103
|
+
composed,
|
|
104
|
+
biomeIndex: read('biome-index.json'),
|
|
105
|
+
platformServiceIndex: read('platform-service-index.json'),
|
|
106
|
+
sourcesAvailable: false,
|
|
107
|
+
storePublisherTrust: undefined,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const producers = producersByHandle(developGraphMembers());
|
|
111
|
+
const biomes = [];
|
|
112
|
+
for (const selected of lock.biomes) {
|
|
113
|
+
const producer = producers.get(selected.repo);
|
|
114
|
+
if (producer === undefined) {
|
|
115
|
+
throw new CeilingRefused(`biome "${selected.id}" is selected from "${selected.repo}", which is not a first-party repository of the develop graph`);
|
|
116
|
+
}
|
|
117
|
+
const commit = producerCommits.get(producer);
|
|
118
|
+
if (commit === undefined) {
|
|
119
|
+
throw new CeilingRefused(`biome "${selected.id}" is selected from ${producer}, but no --producer-commit states the commit its facts are read at`);
|
|
120
|
+
}
|
|
121
|
+
const entry = await producerFacts({ selected, producer, commit, repoDir: sourcePath(selected.repo) });
|
|
122
|
+
biomes.push({
|
|
123
|
+
id: selected.id,
|
|
124
|
+
origin: selected.origin,
|
|
125
|
+
version: entry.version,
|
|
126
|
+
repo: selected.repo,
|
|
127
|
+
services: entry.services,
|
|
128
|
+
runtimeComponents: entry.runtimeComponents,
|
|
129
|
+
serviceProcessFacts: lockedServiceProcessFacts(entry),
|
|
130
|
+
...(selected.trustCeiling === undefined ? {} : { trustCeiling: selected.trustCeiling }),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
biomes.sort((a, b) => compareStrings(a.id, b.id));
|
|
134
|
+
return DevelopCeilingSchema.parse({ schema: 1, distributionsCommit, distributionId, biomes });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `--distribution`, `--at` and the repeatable `--producer-commit <owner/repo>=<sha>` of the argv left after the root flags. */
|
|
138
|
+
export function parseCeilingArgs(argv) {
|
|
139
|
+
const options = { producerCommits: new Map() };
|
|
140
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
141
|
+
const flag = argv[i];
|
|
142
|
+
const value = argv[i + 1];
|
|
143
|
+
if (value === undefined || (value.startsWith('--') && value.length > 2)) {throw new Error(`${flag} requires a value\n${USAGE}`);}
|
|
144
|
+
if (flag === '--producer-commit') {
|
|
145
|
+
const match = PRODUCER_COMMIT.exec(value);
|
|
146
|
+
if (match === null) {throw new Error(`--producer-commit must be <owner/repo>=<40-hex commit>; got ${JSON.stringify(value)}`);}
|
|
147
|
+
if (options.producerCommits.has(match[1])) {throw new Error(`--producer-commit given twice for ${match[1]}`);}
|
|
148
|
+
options.producerCommits.set(match[1], match[2]);
|
|
149
|
+
} else if (flag === '--distribution' || flag === '--at') {
|
|
150
|
+
const key = flag.slice(2);
|
|
151
|
+
if (options[key] !== undefined) {throw new Error(`${flag} given twice`);}
|
|
152
|
+
options[key] = value;
|
|
153
|
+
} else {
|
|
154
|
+
throw new Error(`unknown argument ${flag}\n${USAGE}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const required of ['distribution', 'at']) {
|
|
158
|
+
if (options[required] === undefined) {throw new Error(`--${required} is required\n${USAGE}`);}
|
|
159
|
+
}
|
|
160
|
+
return options;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function main() {
|
|
164
|
+
const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [WorkspaceRoot.Data, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
|
|
165
|
+
const options = parseCeilingArgs(rest);
|
|
166
|
+
const ceiling = await composeCeiling({
|
|
167
|
+
distributionId: options.distribution,
|
|
168
|
+
distributionsCommit: options.at,
|
|
169
|
+
producerCommits: options.producerCommits,
|
|
170
|
+
schemas: await loadKernelDistributionSchemas(),
|
|
171
|
+
});
|
|
172
|
+
process.stdout.write(serializeJson(ceiling));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (isEntryModule(import.meta.url)) {
|
|
176
|
+
main().catch((error) => {
|
|
177
|
+
console.error(`[edition-ceiling] ${error.message}`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
// `<dataRoot>/edition-workspace.json`; there is no default for any of them, and
|
|
5
5
|
// a file with a missing, unknown or malformed key is refused. `evidenceTrustPolicy`
|
|
6
6
|
// declares the data root's evidence gating; its `required` flag has no default.
|
|
7
|
+
// `editionPublishers` names the exact workflow that publishes an edition of each
|
|
8
|
+
// kind on each channel (the shape is edition-contract.json signers.publishers): a
|
|
9
|
+
// customer data root declares its own; a publisher it does not declare is refused
|
|
10
|
+
// when an operation needs it, with no default.
|
|
7
11
|
|
|
8
12
|
import { join } from 'node:path';
|
|
9
13
|
|
|
10
14
|
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
15
|
+
import { EDITION_PUBLISHER_CHANNELS } from './edition-constants.mjs';
|
|
16
|
+
import { assertPublisher } from './edition-publisher.mjs';
|
|
11
17
|
import { dataRoot } from './edition-workspace.mjs';
|
|
18
|
+
import { CANONICAL_IDENTIFIER } from './evidence-grammars.mjs';
|
|
12
19
|
import { isUnderSourcePrefix } from './git-source-auth.mjs';
|
|
13
20
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
14
21
|
import {
|
|
@@ -52,6 +59,8 @@ const KEYS = Object.freeze([
|
|
|
52
59
|
'dataRootRepositoryUrl',
|
|
53
60
|
'sourceAuthUsername',
|
|
54
61
|
'evidenceTrustPolicy',
|
|
62
|
+
'firstPartyBundleSigner',
|
|
63
|
+
'editionPublishers',
|
|
55
64
|
...Object.keys(SECTIONS),
|
|
56
65
|
]);
|
|
57
66
|
|
|
@@ -166,6 +175,32 @@ function requireEvidenceTrustPolicy(config) {
|
|
|
166
175
|
return Object.freeze({ policy: value.policy, pin: value.pin });
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The OPTIONAL `firstPartyBundleSigner` declaration: exactly `{policyId}`, naming the one `public-key` row of the
|
|
180
|
+
* evidence trust policy that signs the data root's first-party OCI bundles. No default: a data root that does not
|
|
181
|
+
* declare it cannot seal a first-party OCI bundle row.
|
|
182
|
+
*/
|
|
183
|
+
function optionalFirstPartyBundleSigner(document) {
|
|
184
|
+
if (document.firstPartyBundleSigner === undefined) {return {};}
|
|
185
|
+
const value = document.firstPartyBundleSigner;
|
|
186
|
+
const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "firstPartyBundleSigner"`;
|
|
187
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
188
|
+
throw new EditionWorkspaceConfigError(`${where} must be exactly {"policyId": "<canonical id>"}; got ${JSON.stringify(value)}`);
|
|
189
|
+
}
|
|
190
|
+
const keys = Object.keys(value);
|
|
191
|
+
const unknown = keys.filter((key) => key !== 'policyId');
|
|
192
|
+
if (unknown.length > 0) {
|
|
193
|
+
throw new EditionWorkspaceConfigError(`${where} has unknown key ${unknown.map((key) => `"${key}"`).join(', ')}; it is exactly {"policyId": "<canonical id>"}`);
|
|
194
|
+
}
|
|
195
|
+
if (!Object.hasOwn(value, 'policyId')) {
|
|
196
|
+
throw new EditionWorkspaceConfigError(`${where} is missing "policyId"; it is exactly {"policyId": "<canonical id>"}`);
|
|
197
|
+
}
|
|
198
|
+
if (typeof value.policyId !== 'string' || !CANONICAL_IDENTIFIER.test(value.policyId)) {
|
|
199
|
+
throw new EditionWorkspaceConfigError(`${where}.policyId must be a canonical policy id; got ${JSON.stringify(value.policyId)}`);
|
|
200
|
+
}
|
|
201
|
+
return { firstPartyBundleSigner: Object.freeze({ policyId: value.policyId }) };
|
|
202
|
+
}
|
|
203
|
+
|
|
169
204
|
/**
|
|
170
205
|
* Every first-party repository URL is canonical and under the workspace's
|
|
171
206
|
* `sourceRepositoryUrlPrefix`: the develop graph sends the source token there.
|
|
@@ -212,6 +247,44 @@ function optionalDataRootRepositoryUrl(document, prefix) {
|
|
|
212
247
|
return { dataRootRepositoryUrl: canonical };
|
|
213
248
|
}
|
|
214
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The OPTIONAL `editionPublishers`: `{<kind>: {<channel>: {repository, workflow, ref}}}`, only the kinds and channels
|
|
252
|
+
* edition-contract.json signers.publishers.channelsByKind names, each publisher in the contract's shape. A data root
|
|
253
|
+
* may declare a subset; an empty kind, an unknown kind or channel, or a malformed publisher is refused.
|
|
254
|
+
*/
|
|
255
|
+
function optionalEditionPublishers(document) {
|
|
256
|
+
const value = document.editionPublishers;
|
|
257
|
+
if (value === undefined) {return {};}
|
|
258
|
+
const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "editionPublishers"`;
|
|
259
|
+
const isObject = (entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry);
|
|
260
|
+
if (!isObject(value) || Object.keys(value).length === 0) {
|
|
261
|
+
throw new EditionWorkspaceConfigError(`${where} must be a non-empty {<kind>: {<channel>: {repository, workflow, ref}}}`);
|
|
262
|
+
}
|
|
263
|
+
const parsed = {};
|
|
264
|
+
for (const [kind, byChannel] of Object.entries(value)) {
|
|
265
|
+
const channels = EDITION_PUBLISHER_CHANNELS[kind];
|
|
266
|
+
if (channels === undefined) {
|
|
267
|
+
throw new EditionWorkspaceConfigError(`${where} names kind "${kind}"; edition-contract.json signers.publishers.channelsByKind names ${Object.keys(EDITION_PUBLISHER_CHANNELS).join(' | ')}`);
|
|
268
|
+
}
|
|
269
|
+
if (!isObject(byChannel) || Object.keys(byChannel).length === 0) {
|
|
270
|
+
throw new EditionWorkspaceConfigError(`${where}.${kind} must be a non-empty {<channel>: {repository, workflow, ref}}`);
|
|
271
|
+
}
|
|
272
|
+
parsed[kind] = {};
|
|
273
|
+
for (const [channel, publisher] of Object.entries(byChannel)) {
|
|
274
|
+
if (!channels.includes(channel)) {
|
|
275
|
+
throw new EditionWorkspaceConfigError(`${where}.${kind} names channel "${channel}"; a ${kind} edition has a publisher on ${channels.join(' | ')} only`);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
parsed[kind][channel] = Object.freeze({ ...assertPublisher(publisher, `${where}.${kind}.${channel}`) });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw new EditionWorkspaceConfigError(error.message);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
Object.freeze(parsed[kind]);
|
|
284
|
+
}
|
|
285
|
+
return { editionPublishers: Object.freeze(parsed) };
|
|
286
|
+
}
|
|
287
|
+
|
|
215
288
|
/** Validate a parsed config document; returns the frozen, typed config. */
|
|
216
289
|
export function parseEditionWorkspaceConfig(document) {
|
|
217
290
|
if (document === null || typeof document !== 'object' || Array.isArray(document)) {
|
|
@@ -238,6 +311,8 @@ export function parseEditionWorkspaceConfig(document) {
|
|
|
238
311
|
...optionalDataRootRepositoryUrl(document, sourceRepositoryUrlPrefix),
|
|
239
312
|
sourceAuthUsername: requireSourceAuthUsername(document),
|
|
240
313
|
evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
|
|
314
|
+
...optionalFirstPartyBundleSigner(document),
|
|
315
|
+
...optionalEditionPublishers(document),
|
|
241
316
|
});
|
|
242
317
|
}
|
|
243
318
|
|
|
@@ -281,3 +356,19 @@ export function workspaceSection(name, needed) {
|
|
|
281
356
|
}
|
|
282
357
|
return section;
|
|
283
358
|
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The publisher `{repository, workflow, ref}` of an edition of `kind` on `channel`, as this data root declares it
|
|
362
|
+
* (edition-workspace.json editionPublishers.<kind>.<channel>). One it does not declare is refused by name; there is
|
|
363
|
+
* no default publisher and no fallback.
|
|
364
|
+
*/
|
|
365
|
+
export function editionPublisher(kind, channel) {
|
|
366
|
+
const publisher = editionWorkspaceConfig().editionPublishers?.[kind]?.[channel];
|
|
367
|
+
if (publisher === undefined) {
|
|
368
|
+
throw new EditionWorkspaceConfigError(
|
|
369
|
+
`${EDITION_WORKSPACE_CONFIG_FILE} in ${dataRoot()} declares no "editionPublishers"."${kind}"."${channel}": the exact workflow ` +
|
|
370
|
+
`{repository, workflow, ref} that publishes a ${kind} ${channel} edition; there is no default publisher`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return publisher;
|
|
374
|
+
}
|
|
@@ -8,6 +8,7 @@ import { EditionChannel } from './edition-channel.mjs';
|
|
|
8
8
|
import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
|
|
9
9
|
import { parseEditionContract } from './edition-contract-parse.mjs';
|
|
10
10
|
import editionContract from './edition-contract.json' with { type: 'json' };
|
|
11
|
+
import { EditionKind, EvidenceTrustState } from './edition-kind.mjs';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The vocabulary itself lives in edition-contract.json, the ONE source:
|
|
@@ -21,6 +22,14 @@ export const EDITION_CONTRACT = Object.freeze(structuredClone(parseEditionContra
|
|
|
21
22
|
/** The descriptor layer's file name; its `org.opencontainers.image.title`. */
|
|
22
23
|
export const EDITION_DESCRIPTOR_FILE = EDITION_CONTRACT.descriptorFile;
|
|
23
24
|
export const EDITION_DESCRIPTOR_SCHEMA = EDITION_CONTRACT.descriptorSchema;
|
|
25
|
+
/** The form of a sealed edition's `sealOf` (`<registry>/<path>@sha256:<64 hex>`): the contract's pattern, never restated. */
|
|
26
|
+
export const SEAL_OF_PATTERN = new RegExp(EDITION_CONTRACT.sealOf.pattern);
|
|
27
|
+
/** The one channel a seal is of (contract sealOf.channel, D99: release-only): the channel of the resolved edition it seals and of the sealed edition. */
|
|
28
|
+
export const SEALED_EDITION_CHANNEL = EDITION_CONTRACT.sealOf.channel;
|
|
29
|
+
/** The OIDC issuer of every edition publisher's keyless certificate (contract signers.oidcIssuer); never overridden. */
|
|
30
|
+
export const EDITION_SIGNER_OIDC_ISSUER = EDITION_CONTRACT.signers.oidcIssuer;
|
|
31
|
+
/** The channels on which an edition of `kind` has a publisher (contract signers.publishers.channelsByKind); its values are the data root's. */
|
|
32
|
+
export const EDITION_PUBLISHER_CHANNELS = EDITION_CONTRACT.signers.publishers.channelsByKind;
|
|
24
33
|
/** The ONE artifactType of every edition artifact, develop or release. */
|
|
25
34
|
export const EDITION_ARTIFACT_TYPE = EDITION_CONTRACT.artifactType;
|
|
26
35
|
/** The repository name prefix: `<registry root>/<channel segment>/<prefix><id>`. */
|
|
@@ -31,10 +40,19 @@ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
|
|
|
31
40
|
Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
|
|
32
41
|
);
|
|
33
42
|
|
|
34
|
-
export { EditionChannel };
|
|
43
|
+
export { EditionChannel, EditionKind, EvidenceTrustState };
|
|
35
44
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The evidence trust states an edition of `kind` on `channel` may record (contract `evidenceTrust.statesByKind`):
|
|
47
|
+
* `not-armed` only for a resolved develop edition; every other edition is armed or is not written.
|
|
48
|
+
*/
|
|
49
|
+
export function admittedEvidenceTrustStates(kind, channel) {
|
|
50
|
+
const states = EDITION_CONTRACT.evidenceTrust.statesByKind[kind]?.[channel];
|
|
51
|
+
if (states === undefined) {
|
|
52
|
+
throw new Error(`edition kind ${JSON.stringify(kind)} on channel ${JSON.stringify(channel)} is not one edition-contract.json names`);
|
|
53
|
+
}
|
|
54
|
+
return states;
|
|
55
|
+
}
|
|
38
56
|
|
|
39
57
|
/** The owner-relative segment of a develop image repository: `<host>/<owner>/<segment>/<name>`. */
|
|
40
58
|
export const DEVELOP_IMAGE_NAMESPACE_SEGMENT = EDITION_CONTRACT.imageNamespaceSegment;
|