@xemahq/xema 0.42.0 → 0.44.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.
@@ -370,7 +370,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
370
370
  }
371
371
  }
372
372
 
373
- async function checkCommittedRuntimeImageSourceIndex(checkSources) {
373
+ export async function checkCommittedRuntimeImageSourceIndex(checkSources) {
374
374
  try {
375
375
  const schemas = await loadKernelDistributionSchemas();
376
376
  schemas.parseRuntimeImageSourceIndex(
@@ -394,7 +394,7 @@ async function checkCommittedRuntimeImageSourceIndex(checkSources) {
394
394
  }
395
395
  }
396
396
 
397
- function checkCommittedIndex() {
397
+ export function checkCommittedIndex() {
398
398
  try {
399
399
  const index = validateImmutableBiomeIndex(
400
400
  JSON.parse(readFileSync(biomeIndexPath(), 'utf8')),
@@ -464,7 +464,7 @@ export function checkCommittedDataRootReaders() {
464
464
  return green;
465
465
  }
466
466
 
467
- async function checkCommittedPlatformServiceIndex() {
467
+ export async function checkCommittedPlatformServiceIndex() {
468
468
  try {
469
469
  const schemas = await loadKernelDistributionSchemas();
470
470
  const index = schemas.parsePlatformServiceSourceIndex(
@@ -493,7 +493,7 @@ async function checkCommittedPlatformServiceIndex() {
493
493
  }
494
494
  }
495
495
 
496
- function discoverDistributionIds() {
496
+ export function discoverDistributionIds() {
497
497
  if (!existsSync(editionsRoot())) {
498
498
  console.error(`[dist:check] ${editionsRoot()} does not exist.`);
499
499
  process.exit(1);
@@ -514,37 +514,16 @@ function discoverDistributionIds() {
514
514
  }
515
515
 
516
516
  /**
517
- * Guard for the capability-domain OWNERSHIP (reservation) table.
518
- *
519
- * This repo has no test runner (no package.json, no CI workflow of its own) —
520
- * `dist:check` IS its gate — so the invariant is asserted here, where it
521
- * actually runs, rather than in a spec file nothing would execute.
522
- *
523
- * THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
517
+ * The lock-free half of the ownership guard: the kernel's reader, exercised
518
+ * against synthetic tables. (a) two biomes claiming one domain is refused,
519
+ * naming it; (b) a listed domain resolves to its owner and an unlisted one is
520
+ * not first-party; (c) an invalid domain is refused. Reads no lock, so the
521
+ * authored check runs it too.
524
522
  *
525
- * Ownership is a property of the platform, not of what an edition preloads.
526
- * An earlier design put the claim on each `LockedBiome`, which made a trimmed
527
- * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
528
- * org admin could install the owning biome from the store and THEN bind
529
- * `kb:page.read@1` to a URL they control, silently shadowing the platform
530
- * data plane. The table is now built from the FULL biome index and reserves
531
- * the domain even when its owning biome is absent from that edition — which
532
- * is exactly the case the reservation exists to cover.
533
- *
534
- * Also re-asserts the two structural rules, through the KERNEL's own reader:
535
- * (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
536
- * no safe resolution — fail the build, never pick a winner);
537
- * (b) a domain in NO table entry is not first-party (so a claim that never
538
- * made it into a resolved, committed, drift-checked lock carries no
539
- * weight), and an invalid domain is refused rather than silently carried.
540
- *
541
- * @param {string[]} ids every distribution id discovered in this repo.
542
- * @returns {Promise<boolean>} true when green.
523
+ * @param {(lock: object) => Map<string, string>} resolveFirstPartyCapabilityDomains the kernel's reader
524
+ * @returns {string[]} the failures, empty when green
543
525
  */
544
- async function checkOwnershipInvariant(ids) {
545
- const { resolveFirstPartyCapabilityDomains } =
546
- await loadKernelDistributionSchemas();
547
-
526
+ export function ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains) {
548
527
  const lockOf = (capabilityDomainOwners) => ({
549
528
  schemaVersion: 1,
550
529
  distributionId: 'dist-check-selftest',
@@ -599,6 +578,43 @@ async function checkOwnershipInvariant(ids) {
599
578
  /* expected */
600
579
  }
601
580
 
581
+ return failures;
582
+ }
583
+
584
+ /**
585
+ * Guard for the capability-domain OWNERSHIP (reservation) table.
586
+ *
587
+ * This repo has no test runner (no package.json, no CI workflow of its own) —
588
+ * `dist:check` IS its gate — so the invariant is asserted here, where it
589
+ * actually runs, rather than in a spec file nothing would execute.
590
+ *
591
+ * THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
592
+ *
593
+ * Ownership is a property of the platform, not of what an edition preloads.
594
+ * An earlier design put the claim on each `LockedBiome`, which made a trimmed
595
+ * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
596
+ * org admin could install the owning biome from the store and THEN bind
597
+ * `kb:page.read@1` to a URL they control, silently shadowing the platform
598
+ * data plane. The table is now built from the FULL biome index and reserves
599
+ * the domain even when its owning biome is absent from that edition — which
600
+ * is exactly the case the reservation exists to cover.
601
+ *
602
+ * Also re-asserts the two structural rules, through the KERNEL's own reader:
603
+ * (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
604
+ * no safe resolution — fail the build, never pick a winner);
605
+ * (b) a domain in NO table entry is not first-party (so a claim that never
606
+ * made it into a resolved, committed, drift-checked lock carries no
607
+ * weight), and an invalid domain is refused rather than silently carried.
608
+ *
609
+ * @param {string[]} ids every distribution id discovered in this repo.
610
+ * @returns {Promise<boolean>} true when green.
611
+ */
612
+ async function checkOwnershipInvariant(ids) {
613
+ const { resolveFirstPartyCapabilityDomains } =
614
+ await loadKernelDistributionSchemas();
615
+
616
+ const failures = ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains);
617
+
602
618
  // (d) THE headline invariant — every committed edition carries the same,
603
619
  // non-empty table. An edition-specific table is the fail-open bug.
604
620
  let reference;
@@ -919,13 +935,16 @@ export async function checkBiomeContentHashes(id) {
919
935
  }
920
936
  }
921
937
 
922
- async function main() {
923
- let failed = false;
924
- // The tests spawned below resolve the editions root from THEIR OWN argv and
925
- // env; a root stated here by --editions-root must reach them, or they
926
- // would grade the vendor's editions under a customer's dist-check.
927
- const checkSources = process.argv.includes('--check-sources');
928
-
938
+ /**
939
+ * The data-root plane both checks grade before any edition: the kernel speaks
940
+ * a supported edition protocol (a refusal throws), the committed biome,
941
+ * platform-service and runtime-image-source indexes validate offline, the
942
+ * evidence trust policy and the data-root readers are sound. `checkSources`
943
+ * adds the trusted live-source audit of the indexes (release path only).
944
+ * Returns true when green.
945
+ */
946
+ export async function checkDataRootPlane({ checkSources }) {
947
+ let green = true;
929
948
  // 0. The pinned kernel speaks an edition protocol this CLI supports. This is
930
949
  // the ONE comparison between the CLI and the kernel; a refusal throws
931
950
  // EditionProtocolMismatchError and stops the check before anything is read.
@@ -938,13 +957,13 @@ async function main() {
938
957
  // A live sibling-source scan is an explicit trusted regeneration audit only;
939
958
  // CI/shared working trees must not make unrelated uncommitted sources into
940
959
  // packaging inputs.
941
- if (!checkCommittedIndex()) {failed = true;}
942
- if (!(await checkCommittedPlatformServiceIndex())) {failed = true;}
960
+ if (!checkCommittedIndex()) {green = false;}
961
+ if (!(await checkCommittedPlatformServiceIndex())) {green = false;}
943
962
  if (!(await checkCommittedRuntimeImageSourceIndex(checkSources))) {
944
- failed = true;
963
+ green = false;
945
964
  }
946
- if (!(await checkEvidenceTrustPolicy())) {failed = true;}
947
- if (!checkCommittedDataRootReaders()) {failed = true;}
965
+ if (!(await checkEvidenceTrustPolicy())) {green = false;}
966
+ if (!checkCommittedDataRootReaders()) {green = false;}
948
967
  if (checkSources) {
949
968
  try {
950
969
  execFileSync(
@@ -958,9 +977,48 @@ async function main() {
958
977
  { stdio: 'inherit' },
959
978
  );
960
979
  } catch {
961
- failed = true;
980
+ green = false;
962
981
  }
963
982
  }
983
+ return green;
984
+ }
985
+
986
+ /**
987
+ * The pinned kernel's authorization composer (`kernel` null when the pin
988
+ * predates it). `green` is false when the composer could not be loaded.
989
+ */
990
+ export async function loadPlaneAuthorizationKernel() {
991
+ try {
992
+ return { green: true, kernel: await loadKernelAuthorizationComposer({ kernelPackageDir: kernelPackageDir() }) };
993
+ } catch (err) {
994
+ console.error(`[dist:check] ${err.message}`);
995
+ return { green: false, kernel: undefined };
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * The fleet chart-set pin every edition must carry (edition-chart-set.mjs), as
1001
+ * committed text, or null when it is absent or not a rostered chart-set lock.
1002
+ */
1003
+ export function readFleetChartSetPin() {
1004
+ try {
1005
+ const text = readFileSync(fleetChartSetLockPath(), 'utf8');
1006
+ assertRosteredChartSetLock(JSON.parse(text), EDITION_CHART_SET_FILE);
1007
+ return text;
1008
+ } catch (err) {
1009
+ console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
1010
+ return null;
1011
+ }
1012
+ }
1013
+
1014
+ async function main() {
1015
+ let failed = false;
1016
+ // The tests spawned below resolve the editions root from THEIR OWN argv and
1017
+ // env; a root stated here by --editions-root must reach them, or they
1018
+ // would grade the vendor's editions under a customer's dist-check.
1019
+ const checkSources = process.argv.includes('--check-sources');
1020
+
1021
+ if (!(await checkDataRootPlane({ checkSources }))) {failed = true;}
964
1022
 
965
1023
  const distributionIds = discoverDistributionIds();
966
1024
 
@@ -972,25 +1030,12 @@ async function main() {
972
1030
  if (!(await checkOwnershipInvariant(distributionIds))) {failed = true;}
973
1031
 
974
1032
  // The pinned kernel's authorization composer (null when the pin predates it).
975
- let authorizationKernel;
976
- try {
977
- authorizationKernel = await loadKernelAuthorizationComposer({
978
- kernelPackageDir: kernelPackageDir(),
979
- });
980
- } catch (err) {
981
- console.error(`[dist:check] ${err.message}`);
982
- failed = true;
983
- }
1033
+ const { green: authorizationKernelGreen, kernel: authorizationKernel } = await loadPlaneAuthorizationKernel();
1034
+ if (!authorizationKernelGreen) {failed = true;}
984
1035
 
985
1036
  // The fleet chart-set pin every edition must carry (edition-chart-set.mjs).
986
- let chartSetPinText = null;
987
- try {
988
- chartSetPinText = readFileSync(fleetChartSetLockPath(), 'utf8');
989
- assertRosteredChartSetLock(JSON.parse(chartSetPinText), EDITION_CHART_SET_FILE);
990
- } catch (err) {
991
- console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
992
- failed = true;
993
- }
1037
+ const chartSetPinText = readFleetChartSetPin();
1038
+ if (chartSetPinText === null) {failed = true;}
994
1039
 
995
1040
  // 3 + 4 + 5 + 6 + 7. Every distribution: distribution.lock, images.lock,
996
1041
  // authorization.lock, chart-set.lock.
@@ -7,7 +7,9 @@
7
7
 
8
8
  import { join } from 'node:path';
9
9
 
10
+ import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
10
11
  import { dataRoot } from './edition-workspace.mjs';
12
+ import { isUnderSourcePrefix } from './git-source-auth.mjs';
11
13
  import { readStrictJsonFile } from './strict-json.mjs';
12
14
  import {
13
15
  parseConfinedBiomes,
@@ -163,6 +165,28 @@ function requireEvidenceTrustPolicy(config) {
163
165
  return Object.freeze({ policy: value.policy, pin: value.pin });
164
166
  }
165
167
 
168
+ /**
169
+ * Every first-party repository URL is canonical and under the workspace's
170
+ * `sourceRepositoryUrlPrefix`: the develop graph sends the source token there.
171
+ */
172
+ function assertFirstPartyUrls(firstPartySources, prefix) {
173
+ for (const [repo, { repositoryUrl }] of Object.entries(firstPartySources)) {
174
+ const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "firstPartySources"["${repo}"].repositoryUrl`;
175
+ let canonical;
176
+ try {
177
+ canonical = canonicalPublicHttpsRepositoryUrl(repositoryUrl, where);
178
+ } catch (err) {
179
+ throw new EditionWorkspaceConfigError(err.message);
180
+ }
181
+ if (canonical !== repositoryUrl) {
182
+ throw new EditionWorkspaceConfigError(`${where} ${JSON.stringify(repositoryUrl)} is not in its canonical form (${canonical})`);
183
+ }
184
+ if (!isUnderSourcePrefix(canonical, prefix)) {
185
+ throw new EditionWorkspaceConfigError(`${where} ${JSON.stringify(repositoryUrl)} is not under sourceRepositoryUrlPrefix ${prefix}`);
186
+ }
187
+ }
188
+ }
189
+
166
190
  /** Validate a parsed config document; returns the frozen, typed config. */
167
191
  export function parseEditionWorkspaceConfig(document) {
168
192
  if (document === null || typeof document !== 'object' || Array.isArray(document)) {
@@ -179,11 +203,13 @@ export function parseEditionWorkspaceConfig(document) {
179
203
  .filter(([key]) => document[key] !== undefined)
180
204
  .map(([key, parse]) => [key, parse(document[key])]),
181
205
  );
206
+ const sourceRepositoryUrlPrefix = requireString(document, 'sourceRepositoryUrlPrefix', SOURCE_URL_PREFIX, 'https://<host>/<organisation>/ with a trailing slash');
207
+ if (sections.firstPartySources !== undefined) {assertFirstPartyUrls(sections.firstPartySources, sourceRepositoryUrlPrefix);}
182
208
  return Object.freeze({
183
209
  ...sections,
184
210
  imageRegistry: requireString(document, 'imageRegistry', IMAGE_REGISTRY, '<host>/<namespace> with no scheme or trailing slash'),
185
211
  ...optionalChartSet(document),
186
- sourceRepositoryUrlPrefix: requireString(document, 'sourceRepositoryUrlPrefix', SOURCE_URL_PREFIX, 'https://<host>/<organisation>/ with a trailing slash'),
212
+ sourceRepositoryUrlPrefix,
187
213
  sourceAuthUsername: requireSourceAuthUsername(document),
188
214
  evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
189
215
  });
@@ -15,20 +15,23 @@ import { workspaceSection } from './edition-config.mjs';
15
15
  // `sources[]` entry, so its id is declared in the data root's
16
16
  // `edition-workspace.json` (`firstPartySources`), keyed by the `repo` handle the
17
17
  // runtime image source index pins it under. The key is a lookup handle and may
18
- // move with the aggregator layout; the VALUE is the identity and must not.
18
+ // move with the aggregator layout; the entry's `sourceId` is the identity and
19
+ // must not. The entry's `repositoryUrl` is where the repository lives
20
+ // (provenance, never identity): the develop graph resolves heads there.
19
21
  //
20
- // EVERY repository `runtime-image-source-index.json` pins is declared there (a
21
- // contract test holds the two sets equal), so a newly pinned repository is
22
- // refused at once rather than the day it first declares a composed image. A
23
- // lookup of an undeclared repository is refused (`firstPartySourceId`), never
24
- // named by a fallback.
22
+ // EVERY repository `runtime-image-source-index.json` pins is declared there, at
23
+ // the same URL (the offline index check refuses otherwise), so a newly pinned
24
+ // repository is refused at once rather than the day it first declares a
25
+ // composed image. A declared repository need not be pinned yet: the develop
26
+ // graph carries it before any release does. A lookup of an undeclared
27
+ // repository is refused (`firstPartySourceId`), never named by a fallback.
25
28
 
26
29
  /** The kernel's source-handle grammar (`BiomeSourceNameSchema`): lowercase kebab-case. */
27
30
  export const DECLARED_SOURCE_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
28
31
 
29
- /** The data root's declared id of each first-party repository (`firstPartySources`). */
30
- export const firstPartySourceIds = () =>
31
- workspaceSection('firstPartySources', 'naming a runtime subject after its first-party source');
32
+ /** The data root's first-party repositories (`firstPartySources`): repo handle -> { sourceId, repositoryUrl }. */
33
+ export const firstPartySources = (needed = 'naming a runtime subject after its first-party source') =>
34
+ workspaceSection('firstPartySources', needed);
32
35
 
33
36
  export class UndeclaredSourceIdError extends Error {
34
37
  constructor(message) {
@@ -48,12 +51,12 @@ export function assertDeclaredSourceId(sourceId, where) {
48
51
  }
49
52
 
50
53
  export function firstPartySourceId(repo) {
51
- const ids = firstPartySourceIds();
52
- if (!Object.hasOwn(ids, repo)) {
54
+ const sources = firstPartySources();
55
+ if (!Object.hasOwn(sources, repo)) {
53
56
  throw new UndeclaredSourceIdError(
54
57
  `first-party repository "${repo}" has no declared source id — declare it in ` +
55
58
  'edition-workspace.json under "firstPartySources"; a runtime identity is never derived from a repository name or URL',
56
59
  );
57
60
  }
58
- return assertDeclaredSourceId(ids[repo], `first-party repository "${repo}"`);
61
+ return assertDeclaredSourceId(sources[repo].sourceId, `first-party repository "${repo}"`);
59
62
  }
@@ -25,7 +25,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
25
25
  import { tmpdir } from 'node:os';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
28
+ import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
29
29
 
30
30
  /** The fixed environment variable holding the source read token. */
31
31
  export const SOURCE_READ_TOKEN_ENV = 'EVIDENCE_REPOSITORIES_TOKEN';
package/edition/lib.mjs CHANGED
@@ -3075,38 +3075,6 @@ export function validateImmutableImageLock(lock, schemas) {
3075
3075
  return lock;
3076
3076
  }
3077
3077
 
3078
- export function canonicalPublicHttpsRepositoryUrl(raw, context) {
3079
- if (typeof raw !== 'string' || raw.length === 0 || raw.trim() !== raw) {
3080
- throw new Error(`${context} must be a non-empty canonical HTTPS URL`);
3081
- }
3082
- let parsed;
3083
- try {
3084
- parsed = new URL(raw);
3085
- } catch {
3086
- throw new Error(`${context} is not a valid URL`);
3087
- }
3088
- if (parsed.protocol !== 'https:') {
3089
- throw new Error(`${context} must use HTTPS`);
3090
- }
3091
- if (parsed.username.length > 0 || parsed.password.length > 0) {
3092
- throw new Error(
3093
- `${context} must not contain username/password credentials`,
3094
- );
3095
- }
3096
- if (
3097
- parsed.search.length > 0 ||
3098
- parsed.hash.length > 0 ||
3099
- raw.includes('?') ||
3100
- raw.includes('#')
3101
- ) {
3102
- throw new Error(`${context} must not contain a query or fragment`);
3103
- }
3104
- if (parsed.hostname.length === 0) {
3105
- throw new Error(`${context} must identify a repository host`);
3106
- }
3107
- return parsed.toString();
3108
- }
3109
-
3110
3078
  /**
3111
3079
  * Assert that an exact 40-hex commit exists in a source repository. Delegates
3112
3080
  * to `@xemahq/distribution-source-hash` — the tooling holds no second copy of
@@ -10,8 +10,9 @@
10
10
  // published pin. This module owns the SHAPE of that map: a flat object whose
11
11
  // keys live in six closed namespaces and whose values are exact 40-hex SHAs.
12
12
  //
13
- // repos/<x> | submodules/<x> every `repositoryPins[].repo` of the runtime
14
- // image source index (the graph), no more, no fewer
13
+ // repos/<x> | submodules/<x> every repository of the develop graph (the
14
+ // data root's firstPartySources, develop-graph.mjs),
15
+ // no more, no fewer
15
16
  // <pinRoles.data> the commit of the data root checkout (editions and indices)
16
17
  // <pinRoles.kernel> the commit of the kernel the authorization lock is sealed
17
18
  // with (may match repos/<x>, but is NOT in the graph)
@@ -20,6 +21,7 @@
20
21
  // only when that root is NOT this repository
21
22
  // sources/<name> one per `sources[]` entry declared in the chain
22
23
 
24
+ import { developGraphMembers } from './develop-graph.mjs';
23
25
  import { workspaceSection } from './edition-config.mjs';
24
26
  import { compareStrings } from './lib.mjs';
25
27
  import { PinRole } from './workspace-sections.mjs';
@@ -109,19 +111,20 @@ function graphPinsOf(pins) {
109
111
  }
110
112
 
111
113
  /**
112
- * Validate a COMPLETE snapshot against the graph and the composed chain.
113
- * `declaredSourceNames` are the `sources[].name` entries the chain declares;
114
- * `editionsRootExternal` is whether the editions root is another repository.
115
- * Every refusal names the offending key; nothing falls back to a published pin.
114
+ * Validate a COMPLETE snapshot against the develop graph and the composed
115
+ * chain. `declaredSourceNames` are the `sources[].name` entries the chain
116
+ * declares; `editionsRootExternal` is whether the editions root is another
117
+ * repository. Every refusal names the offending key; nothing falls back to a
118
+ * published pin.
116
119
  */
117
120
  export function validateSnapshotPins(
118
121
  pins,
119
- { runtimeSourceIndex, declaredSourceNames, editionsRootExternal },
122
+ { declaredSourceNames, editionsRootExternal },
120
123
  ) {
121
124
  assertPinsObject(pins);
122
125
  assertExactShas(pins);
123
126
  for (const key of Object.keys(pins)) {snapshotPinNamespace(key);}
124
- checkGraphPins(runtimeSourceIndex.repositoryPins, pins);
127
+ checkGraphPins(developGraphMembers(), pins);
125
128
  for (const [key, what] of [
126
129
  [dataPinKey(), 'the commit of the data root checkout'],
127
130
  [kernelPinKey(), 'the commit of the kernel the authorization lock is sealed with'],
@@ -175,7 +178,10 @@ export function snapshotPinSource(pins) {
175
178
  /**
176
179
  * Apply a pin source to a parsed runtime image source index. Published returns
177
180
  * the SAME object (inert, byte for byte). Snapshot returns a copy whose
178
- * `repositoryPins` carry the snapshot SHAs.
181
+ * `repositoryPins` are the DEVELOP GRAPH (develop-graph.mjs) at the snapshot
182
+ * SHAs: the committed index's own pins are not read, so a first-party
183
+ * repository no release pins yet is in it, and a pinned one that is not
184
+ * first-party is not.
179
185
  */
180
186
  export function applyPinSource(runtimeSourceIndex, pinSource = PUBLISHED_PIN_SOURCE) {
181
187
  switch (pinSource?.kind) {
@@ -211,12 +217,13 @@ function applySnapshot(runtimeSourceIndex, pins) {
211
217
  assertPinsObject(pins);
212
218
  assertExactShas(pins);
213
219
  for (const key of Object.keys(pins)) {snapshotPinNamespace(key);}
214
- const graph = runtimeSourceIndex.repositoryPins;
220
+ const graph = developGraphMembers();
215
221
  checkGraphPins(graph, pins);
216
222
  return {
217
223
  ...runtimeSourceIndex,
218
- repositoryPins: graph.map((pin) => ({
219
- ...pin,
224
+ repositoryPins: graph.map(({ repo, repositoryUrl }) => ({
225
+ repo,
226
+ repositoryUrl,
220
227
  // A develop snapshot pins every repository at its develop head, so that one
221
228
  // SHA is both the built commit (image axis, `sourceCommit`) and the source
222
229
  // tip (`vendoredSourceCommit`): the image pin lagging the source tip cannot
@@ -226,8 +233,8 @@ function applySnapshot(runtimeSourceIndex, pins) {
226
233
  // The COMMITTED index keeps the axes apart (release channel): see
227
234
  // `source-authority.mjs#publishedSourceTip` and
228
235
  // `build-runtime-image-source-index.mjs#repositoryIdentity`.
229
- vendoredSourceCommit: pins[pin.repo],
230
- sourceCommit: pins[pin.repo],
236
+ sourceCommit: pins[repo],
237
+ vendoredSourceCommit: pins[repo],
231
238
  })),
232
239
  };
233
240
  }
@@ -91,16 +91,46 @@ function supplementalLauncher(value, where) {
91
91
  return Object.freeze({ kind: value.kind, biomeId: text(value.biomeId, `${where}.biomeId`) });
92
92
  }
93
93
 
94
+ /**
95
+ * The retired `{repo handle: source id}` string form of a `firstPartySources`
96
+ * entry. Refused, never read: the entry also declares where the repository
97
+ * lives, which the develop graph resolves its head from.
98
+ */
99
+ export class RetiredFirstPartySourceShapeError extends Error {
100
+ constructor(repo, id) {
101
+ super(
102
+ `${FILE}: "firstPartySources"["${repo}"] is the retired source-id string ${JSON.stringify(id)}. ` +
103
+ `Migrate it to { "sourceId": ${JSON.stringify(id)}, "repositoryUrl": "<the repository's canonical https URL>" }.`,
104
+ );
105
+ this.name = 'RetiredFirstPartySourceShapeError';
106
+ }
107
+ }
108
+
109
+ /**
110
+ * `firstPartySources`: every first-party repository, keyed by its source
111
+ * checkout handle, with its declared source id (the identity a runtime subject
112
+ * is named after) and its repository URL (where it lives: the develop graph
113
+ * resolves its head there, and a release pin must name the same URL). The URL
114
+ * is checked canonical and under `sourceRepositoryUrlPrefix` with the whole
115
+ * config (edition-config.mjs).
116
+ */
94
117
  export function parseFirstPartySources(value) {
95
118
  const where = '"firstPartySources"';
96
- if (!isObject(value) || Object.keys(value).length === 0) {fail(where, 'must be a non-empty {repo handle: source id} object');}
97
- const entries = Object.entries(value).map(([repo, id]) => {
119
+ if (!isObject(value) || Object.keys(value).length === 0) {
120
+ fail(where, 'must be a non-empty {repo handle: { sourceId, repositoryUrl }} object');
121
+ }
122
+ const entries = Object.entries(value).map(([repo, entry]) => {
98
123
  repoHandle(repo, `${where} key`);
99
- if (typeof id !== 'string' || !SOURCE_ID.test(id)) {
100
- fail(`${where}["${repo}"]`, `must be a lowercase kebab-case source id; got ${JSON.stringify(id)}`);
124
+ if (typeof entry === 'string') {throw new RetiredFirstPartySourceShapeError(repo, entry);}
125
+ const at = `${where}["${repo}"]`;
126
+ exactObject(entry, at, ['sourceId', 'repositoryUrl']);
127
+ if (typeof entry.sourceId !== 'string' || !SOURCE_ID.test(entry.sourceId)) {
128
+ fail(`${at}.sourceId`, `must be a lowercase kebab-case source id; got ${JSON.stringify(entry.sourceId)}`);
101
129
  }
102
- return [repo, id];
130
+ return [repo, Object.freeze({ sourceId: entry.sourceId, repositoryUrl: text(entry.repositoryUrl, `${at}.repositoryUrl`) })];
103
131
  });
132
+ uniqueBy(entries, `${where} sourceId`, ([, entry]) => entry.sourceId);
133
+ uniqueBy(entries, `${where} repositoryUrl`, ([, entry]) => entry.repositoryUrl);
104
134
  return Object.freeze(Object.fromEntries(entries));
105
135
  }
106
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -92,6 +92,6 @@
92
92
  "typecheck": "tsc --noEmit"
93
93
  },
94
94
  "xemaRelease": {
95
- "sourceDigest": "sha256:ae545509930a5b6f1215c413f3e7121b15a29d5fd3e14ab373f0910ceaa9063d"
95
+ "sourceDigest": "sha256:72008b3d37f2cbcef3c5ec25e2d85989198ebc1f9bc1f481b8b767965813d5aa"
96
96
  }
97
97
  }