@xemahq/xema 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +70 -37
  2. package/dist/assets/appliance-base/images.lock.json +126 -126
  3. package/dist/assets/appliance-lean/images.lock.json +123 -123
  4. package/dist/assets/cloud/images.lock.json +126 -126
  5. package/dist/assets/core/images.lock.json +120 -120
  6. package/dist/assets/distribution-assets.manifest.json +15 -16
  7. package/dist/assets/minimal/images.lock.json +108 -108
  8. package/dist/assets/oss/images.lock.json +126 -126
  9. package/dist/assets/starter/images.lock.json +123 -123
  10. package/dist/cli.js +2 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/dev.js +1 -0
  13. package/dist/commands/dev.js.map +1 -1
  14. package/dist/commands/down.d.ts.map +1 -1
  15. package/dist/commands/down.js +7 -1
  16. package/dist/commands/down.js.map +1 -1
  17. package/dist/commands/edition.d.ts +3 -0
  18. package/dist/commands/edition.d.ts.map +1 -0
  19. package/dist/commands/edition.js +46 -0
  20. package/dist/commands/edition.js.map +1 -0
  21. package/dist/commands/init.js +2 -2
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/serve.js +1 -0
  24. package/dist/commands/serve.js.map +1 -1
  25. package/dist/commands/up.js +1 -0
  26. package/dist/commands/up.js.map +1 -1
  27. package/dist/dev/env-defaults.d.ts.map +1 -1
  28. package/dist/dev/env-defaults.js +6 -1
  29. package/dist/dev/env-defaults.js.map +1 -1
  30. package/dist/dev/infra-preflight.d.ts +3 -2
  31. package/dist/dev/infra-preflight.d.ts.map +1 -1
  32. package/dist/dev/infra-preflight.js +18 -8
  33. package/dist/dev/infra-preflight.js.map +1 -1
  34. package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
  35. package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
  36. package/dist/runtime/backing-registration.d.ts.map +1 -1
  37. package/dist/runtime/backing-registration.js +7 -2
  38. package/dist/runtime/backing-registration.js.map +1 -1
  39. package/dist/runtime/config/schema.d.ts +1 -0
  40. package/dist/runtime/config/schema.d.ts.map +1 -1
  41. package/dist/runtime/custody/local-custody.d.ts +12 -0
  42. package/dist/runtime/custody/local-custody.d.ts.map +1 -0
  43. package/dist/runtime/custody/local-custody.js +22 -0
  44. package/dist/runtime/custody/local-custody.js.map +1 -0
  45. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
  46. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
  47. package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
  48. package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
  49. package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
  50. package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
  51. package/dist/runtime/custody/openbao-custody-store.js +55 -0
  52. package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
  53. package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
  54. package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
  55. package/dist/runtime/custody/openbao-http-client.js +154 -0
  56. package/dist/runtime/custody/openbao-http-client.js.map +1 -0
  57. package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
  58. package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
  59. package/dist/runtime/custody/openbao-local-constants.js +48 -0
  60. package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
  61. package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
  62. package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
  63. package/dist/runtime/custody/openbao-local-runner.js +86 -0
  64. package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
  65. package/dist/runtime/edition/run-entry.d.ts +9 -0
  66. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  67. package/dist/runtime/edition/run-entry.js +36 -0
  68. package/dist/runtime/edition/run-entry.js.map +1 -0
  69. package/dist/runtime/edition/verb-map.d.ts +60 -0
  70. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  71. package/dist/runtime/edition/verb-map.js +146 -0
  72. package/dist/runtime/edition/verb-map.js.map +1 -0
  73. package/dist/runtime/env-seeder.d.ts +3 -0
  74. package/dist/runtime/env-seeder.d.ts.map +1 -1
  75. package/dist/runtime/env-seeder.js +20 -3
  76. package/dist/runtime/env-seeder.js.map +1 -1
  77. package/dist/runtime/launch.d.ts +3 -1
  78. package/dist/runtime/launch.d.ts.map +1 -1
  79. package/dist/runtime/launch.js +37 -8
  80. package/dist/runtime/launch.js.map +1 -1
  81. package/dist/runtime/paths.d.ts +1 -2
  82. package/dist/runtime/paths.d.ts.map +1 -1
  83. package/dist/runtime/paths.js +0 -10
  84. package/dist/runtime/paths.js.map +1 -1
  85. package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
  86. package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
  87. package/dist/runtime/runner/chart-local-provenance.js +114 -0
  88. package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
  89. package/dist/runtime/runner/chart-preflight.d.ts +8 -8
  90. package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
  91. package/dist/runtime/runner/chart-preflight.js +30 -69
  92. package/dist/runtime/runner/chart-preflight.js.map +1 -1
  93. package/dist/runtime/runner/chart-pull.d.ts +14 -0
  94. package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
  95. package/dist/runtime/runner/chart-pull.js +96 -0
  96. package/dist/runtime/runner/chart-pull.js.map +1 -0
  97. package/dist/runtime/runner/chart-resolver.d.ts +4 -12
  98. package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
  99. package/dist/runtime/runner/chart-resolver.js +16 -62
  100. package/dist/runtime/runner/chart-resolver.js.map +1 -1
  101. package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
  102. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  103. package/dist/runtime/runner/chart-set-lock.js +55 -99
  104. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  105. package/dist/runtime/runner/helm-cli.d.ts +3 -1
  106. package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
  107. package/dist/runtime/runner/helm-cli.js +9 -7
  108. package/dist/runtime/runner/helm-cli.js.map +1 -1
  109. package/dist/runtime/runner/helm-runner.d.ts +4 -4
  110. package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
  111. package/dist/runtime/runner/helm-runner.js +18 -16
  112. package/dist/runtime/runner/helm-runner.js.map +1 -1
  113. package/dist/runtime/sources/kinds/git.d.ts +1 -2
  114. package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
  115. package/dist/runtime/sources/kinds/git.js +8 -5
  116. package/dist/runtime/sources/kinds/git.js.map +1 -1
  117. package/dist/runtime/sources/manifest-walk.d.ts +3 -1
  118. package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
  119. package/dist/runtime/sources/manifest-walk.js +8 -3
  120. package/dist/runtime/sources/manifest-walk.js.map +1 -1
  121. package/dist/runtime/sources/schema.d.ts +3 -0
  122. package/dist/runtime/sources/schema.d.ts.map +1 -1
  123. package/dist/runtime/sources/schema.js +6 -1
  124. package/dist/runtime/sources/schema.js.map +1 -1
  125. package/dist/runtime/supervisor.d.ts +3 -0
  126. package/dist/runtime/supervisor.d.ts.map +1 -1
  127. package/dist/runtime/supervisor.js +2 -0
  128. package/dist/runtime/supervisor.js.map +1 -1
  129. package/edition/README.md +315 -0
  130. package/edition/assemble-producer-input.mjs +1251 -0
  131. package/edition/authorization-base.mjs +347 -0
  132. package/edition/biome-content.mjs +268 -0
  133. package/edition/build-biome-index.mjs +175 -0
  134. package/edition/build-platform-service-index.mjs +288 -0
  135. package/edition/build-runtime-image-source-index.mjs +821 -0
  136. package/edition/built-verifier.mjs +55 -0
  137. package/edition/catalogue-edition.mjs +49 -0
  138. package/edition/catalogue-fetch.mjs +64 -0
  139. package/edition/catalogue.mjs +135 -0
  140. package/edition/chart-set-source-commit.mjs +92 -0
  141. package/edition/check-committed-image-evidence.mjs +383 -0
  142. package/edition/checkout-indexed-source-repositories.mjs +105 -0
  143. package/edition/comment-blanker.mjs +75 -0
  144. package/edition/connector-source-authority.mjs +60 -0
  145. package/edition/customer-definition.mjs +183 -0
  146. package/edition/customer-flags.mjs +7 -0
  147. package/edition/develop-customer-edition.mjs +99 -0
  148. package/edition/develop-edition-resolve.mjs +65 -0
  149. package/edition/develop-edition.mjs +274 -0
  150. package/edition/develop-freeze.mjs +173 -0
  151. package/edition/develop-image-coordinate.mjs +54 -0
  152. package/edition/develop-seal.mjs +77 -0
  153. package/edition/develop-snapshot-inputs.mjs +104 -0
  154. package/edition/dist-check.mjs +986 -0
  155. package/edition/distribution-evidence-set.mjs +987 -0
  156. package/edition/distribution-paths.mjs +191 -0
  157. package/edition/edition-artifact.mjs +270 -0
  158. package/edition/edition-biome-manifests.mjs +85 -0
  159. package/edition/edition-channel.mjs +6 -0
  160. package/edition/edition-chart-set.mjs +93 -0
  161. package/edition/edition-config.mjs +207 -0
  162. package/edition/edition-constants.mjs +57 -0
  163. package/edition/edition-contract.json +23 -0
  164. package/edition/edition-list.mjs +35 -0
  165. package/edition/edition-locks.mjs +69 -0
  166. package/edition/edition-protocol.mjs +228 -0
  167. package/edition/edition-workspace.mjs +176 -0
  168. package/edition/evidence-checkout.mjs +418 -0
  169. package/edition/evidence-grammars.mjs +16 -0
  170. package/edition/evidence-trust-pin.mjs +304 -0
  171. package/edition/evidence-trust-policy.mjs +256 -0
  172. package/edition/external-source-walk.mjs +463 -0
  173. package/edition/first-party-source-ids.mjs +59 -0
  174. package/edition/fleet-coordinate-finality.mjs +350 -0
  175. package/edition/git-source-auth.mjs +52 -0
  176. package/edition/image-evidence-replay-state.mjs +242 -0
  177. package/edition/image-evidence.mjs +1225 -0
  178. package/edition/kernel-contract-authority.mjs +443 -0
  179. package/edition/lib.mjs +3018 -0
  180. package/edition/no-follow-file.mjs +94 -0
  181. package/edition/oci-evidence-verbs.mjs +392 -0
  182. package/edition/oci-image-source-evidence.mjs +239 -0
  183. package/edition/oras.mjs +648 -0
  184. package/edition/pin-source.mjs +227 -0
  185. package/edition/plan-image-evidence-refresh.mjs +408 -0
  186. package/edition/platform-lock-readability.mjs +892 -0
  187. package/edition/platform-service-class.mjs +18 -0
  188. package/edition/platform-service-source-authority.mjs +105 -0
  189. package/edition/positive-closure.mjs +162 -0
  190. package/edition/produce-distribution-completeness-evidence.mjs +311 -0
  191. package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
  192. package/edition/produce-image-source-evidence.mjs +765 -0
  193. package/edition/producer-evidence-bundle.mjs +157 -0
  194. package/edition/registry-credentials.mjs +193 -0
  195. package/edition/release-catalogue.mjs +39 -0
  196. package/edition/release-channel-guard.mjs +99 -0
  197. package/edition/release-edition-artifact.mjs +158 -0
  198. package/edition/remedy-command.mjs +89 -0
  199. package/edition/repin-evidence-trust.mjs +101 -0
  200. package/edition/required-resource-provider.mjs +58 -0
  201. package/edition/resolve-chart-set.mjs +415 -0
  202. package/edition/resolve-customer-edition.mjs +288 -0
  203. package/edition/resolve-distribution.mjs +2318 -0
  204. package/edition/resolve-edition.mjs +186 -0
  205. package/edition/resolve-images.mjs +1606 -0
  206. package/edition/seal-image-digests.mjs +855 -0
  207. package/edition/sealed-evidence-accept.mjs +82 -0
  208. package/edition/source-authority.mjs +725 -0
  209. package/edition/source-closure.mjs +116 -0
  210. package/edition/strict-json.mjs +121 -0
  211. package/edition/string-order.mjs +5 -0
  212. package/edition/trust-policy-arming.mjs +120 -0
  213. package/edition/trusted-executable.mjs +166 -0
  214. package/edition/verifier-binding.mjs +45 -0
  215. package/edition/verifier-files.mjs +8 -0
  216. package/edition/verifier-kernel-schemas.mjs +22 -0
  217. package/edition/verifier-provenance.mjs +64 -0
  218. package/edition/verify-image-evidence.mjs +430 -0
  219. package/edition/verify-image-platforms.mjs +495 -0
  220. package/edition/why.mjs +56 -0
  221. package/edition/workspace-sections.mjs +195 -0
  222. package/examples/06-biome-sources.yaml +1 -0
  223. package/package.json +18 -7
  224. package/dist/commands/dist.d.ts +0 -16
  225. package/dist/commands/dist.d.ts.map +0 -1
  226. package/dist/commands/dist.js +0 -182
  227. package/dist/commands/dist.js.map +0 -1
  228. package/dist/runtime/distribution/available-biome.d.ts +0 -10
  229. package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
  230. package/dist/runtime/distribution/available-biome.js +0 -154
  231. package/dist/runtime/distribution/available-biome.js.map +0 -1
  232. package/dist/runtime/distribution/compose.d.ts +0 -23
  233. package/dist/runtime/distribution/compose.d.ts.map +0 -1
  234. package/dist/runtime/distribution/compose.js +0 -48
  235. package/dist/runtime/distribution/compose.js.map +0 -1
  236. package/dist/runtime/runner/chart-trust.d.ts +0 -57
  237. package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
  238. package/dist/runtime/runner/chart-trust.js +0 -270
  239. package/dist/runtime/runner/chart-trust.js.map +0 -1
@@ -0,0 +1,347 @@
1
+ // authorization-base — the edition's composed AUTHORIZATION BASE (plan
2
+ // `biome-declared-authorization.md` §2.3 steps 3–7, §11 step 4).
3
+ //
4
+ // An edition's base is the platform modules the KERNEL ships (`xema_core`,
5
+ // `xema_capability`, `xema_sharing`) plus every authorization module the
6
+ // edition's biomes ship. This file:
7
+ //
8
+ // 1. reads each biome's module FROM ITS PINNED SOURCE into the biome index
9
+ // (`readBiomeAuthorizationModuleAt`), so the sealer composes the exact
10
+ // bytes the biome bundle signs;
11
+ // 2. composes with the KERNEL composer — `composeAuthorizationBase` from
12
+ // `@xemahq/contracts/authorization-module/server`, loaded from the
13
+ // edition's pinned, built kernel — never a local re-implementation;
14
+ // 3. runs every composed `.fga.yaml` (platform and biome) with the standard
15
+ // `fga model test`, from the composition root the composer describes —
16
+ // one failure refuses the edition;
17
+ // 4. seals `authorization.lock.json` beside `distribution.lock.json`: the
18
+ // `modelHash` authorization-api adopts, the model, the platform
19
+ // generation it was composed for, and the module provenance.
20
+ //
21
+ // The platform generation is the pinned kernel's own declared
22
+ // `PLATFORM_GENERATION` integer (kernel `platform-generation` subpath), read
23
+ // from the built kernel and never written down here.
24
+ //
25
+ // A pinned kernel that does not ship the composer has no authorization plane
26
+ // to seal: the resolver retires any lock, and dist-check refuses a committed
27
+ // lock that kernel cannot reproduce. Once the pin carries the composer, every
28
+ // edition seals — the platform modules always compose.
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { dirname, join } from 'node:path';
34
+
35
+ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
36
+
37
+ /** Where a biome keeps its module, relative to its manifest directory (plan §2.1). */
38
+ export const AUTHORIZATION_MODULE_DIRECTORY = 'authorization';
39
+ export const AUTHORIZATION_ENVELOPE_FILENAME = 'envelope.json';
40
+ export const AUTHORIZATION_LOCK_FILENAME = 'authorization.lock.json';
41
+ export const AUTHORIZATION_LOCK_SCHEMA_VERSION = 1;
42
+
43
+ /**
44
+ * The OpenFGA CLI the sealer tests with, EXACTLY. 0.8.1 embeds
45
+ * openfga/openfga v1.21.0 (github) (its go.mod), the server version every
46
+ * installation runs (xema-infra-services chart 0.3.15) — so a sealed test ran
47
+ * against the same engine that will answer in production.
48
+ */
49
+ export const FGA_CLI_VERSION = '0.8.1';
50
+ /** The environment variable naming the `fga` binary the sealer runs. */
51
+ export const FGA_CLI_ENV = 'XEMA_FGA_CLI';
52
+
53
+ /**
54
+ * Read a biome's authorization module at a commit, or `null` when the biome
55
+ * ships none. The shape is the kernel's
56
+ * `AuthorizationModuleContributionManifest`: `{ envelope, moduleFiles,
57
+ * testFile }` — the envelope plus the CONTENTS of every file it names.
58
+ *
59
+ * `readText(repoRelativePath)` returns a file's text at the pinned commit, or
60
+ * `null` when it does not exist there. A present envelope naming an absent
61
+ * file is a refusal, never a partial module. The envelope itself is validated
62
+ * by the kernel composer, not here.
63
+ */
64
+ export function readBiomeAuthorizationModuleAt(manifestDirectory, readText, rel) {
65
+ const base = `${manifestDirectory}/${AUTHORIZATION_MODULE_DIRECTORY}`;
66
+ const envelopeText = readText(`${base}/${AUTHORIZATION_ENVELOPE_FILENAME}`);
67
+ if (envelopeText === null) {return null;}
68
+ let envelope;
69
+ try {
70
+ envelope = JSON.parse(envelopeText);
71
+ } catch (err) {
72
+ throw new Error(`${rel}: ${base}/${AUTHORIZATION_ENVELOPE_FILENAME} is not JSON: ${err.message}`);
73
+ }
74
+ const files = Array.isArray(envelope?.files) ? envelope.files : null;
75
+ if (!files || files.length === 0 || typeof envelope.tests !== 'string') {
76
+ throw new Error(
77
+ `${rel}: ${base}/${AUTHORIZATION_ENVELOPE_FILENAME} must name its module files ("files") ` +
78
+ 'and its REQUIRED OpenFGA test file ("tests").',
79
+ );
80
+ }
81
+ const read = (name) => {
82
+ const contents = readText(`${base}/${name}`);
83
+ if (contents === null) {
84
+ throw new Error(`${rel}: the authorization envelope names ${base}/${name}, which does not exist at the pinned commit.`);
85
+ }
86
+ return { name, contents };
87
+ };
88
+ return {
89
+ envelope,
90
+ moduleFiles: [...files].sort().map(read),
91
+ testFile: read(envelope.tests),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The composer's `AuthorizationModuleInput[]` for an edition: one per selected
97
+ * biome that ships a module, sorted by id. Built with the KERNEL's own two
98
+ * helpers, never by hand:
99
+ *
100
+ * manifest = stampAuthorizationModuleContribution(source, signedBiome)
101
+ * input = authorizationModuleInputFromContribution({ owner, manifest })
102
+ *
103
+ * The sealer reads modules from the biome index rather than through
104
+ * contribution-sync, so it is the sealer that stamps: `signedBiome` (tier,
105
+ * declared biome dependencies, service names) was derived from the pinned
106
+ * `xema-biome.json` when the index was built, and the owner is the lock's
107
+ * signed id and version. A pinned kernel without the composer cannot stamp,
108
+ * so an edition carrying a module under such a pin is refused.
109
+ */
110
+ export function editionAuthorizationModuleInputs(lockBiomes, indexById, kernel) {
111
+ const inputs = [];
112
+ for (const { id, version } of lockBiomes) {
113
+ const entry = indexById.get(id);
114
+ if (!entry) {throw new Error(`edition biome "${id}" is missing from the biome index`);}
115
+ if (!entry.authorizationModule) {continue;}
116
+ if (kernel === null) {
117
+ throw new Error(
118
+ `edition biome "${id}" ships an authorization module, but the pinned kernel ships no composer to stamp or compose it.`,
119
+ );
120
+ }
121
+ const { source, signedBiome } = entry.authorizationModule;
122
+ const manifest = kernel.stampAuthorizationModuleContribution(source, signedBiome);
123
+ inputs.push(kernel.authorizationModuleInputFromContribution({ owner: { id, version }, manifest }));
124
+ }
125
+ return inputs.sort((x, y) => (x.signedBiomeId < y.signedBiomeId ? -1 : x.signedBiomeId > y.signedBiomeId ? 1 : 0));
126
+ }
127
+
128
+ /**
129
+ * The pinned kernel's authorization composer, or `null` when that kernel does
130
+ * not ship one (its built `authorization-module/server` subpath is absent).
131
+ * An ABSENT subpath is a fact about the pin; a PRESENT one that fails to load
132
+ * or lacks the export is an error.
133
+ *
134
+ * The platform generation is the kernel's declared `PLATFORM_GENERATION`
135
+ * integer, validated by the kernel's own `PlatformGenerationSchema`. It is NOT
136
+ * the package version: an additive kernel release does not move it.
137
+ */
138
+ export async function loadKernelAuthorizationComposer({ kernelPackageDir }) {
139
+ assertKernelEditionProtocol(kernelPackageDir);
140
+ const serverDist = join(kernelPackageDir, 'dist', 'authorization-module', 'server', 'index.js');
141
+ if (!existsSync(serverDist)) {return null;}
142
+ let server;
143
+ let contribution;
144
+ let generation;
145
+ try {
146
+ server = await import(`file://${serverDist}`);
147
+ contribution = await import(`file://${join(kernelPackageDir, 'dist', 'authorization-module', 'index.js')}`);
148
+ generation = await import(`file://${join(kernelPackageDir, 'dist', 'platform-generation', 'index.js')}`);
149
+ } catch (err) {
150
+ throw new Error(`Cannot load the pinned kernel's authorization composer from ${serverDist}: ${err.message}`);
151
+ }
152
+ for (const [mod, name] of [
153
+ [server, 'composeAuthorizationBase'],
154
+ [server, 'authorizationModuleInputFromContribution'],
155
+ [contribution, 'stampAuthorizationModuleContribution'],
156
+ ]) {
157
+ if (typeof mod[name] !== 'function') {
158
+ throw new Error(`the pinned kernel's authorization-module subpaths do not export ${name}.`);
159
+ }
160
+ }
161
+ const parsed = generation.PlatformGenerationSchema?.safeParse?.(generation.PLATFORM_GENERATION);
162
+ if (!parsed?.success) {
163
+ throw new Error(
164
+ "the pinned kernel's platform-generation subpath does not export a valid PLATFORM_GENERATION " +
165
+ `(a positive integer validated by PlatformGenerationSchema); got ${JSON.stringify(generation.PLATFORM_GENERATION)}.`,
166
+ );
167
+ }
168
+ return {
169
+ composeAuthorizationBase: server.composeAuthorizationBase,
170
+ authorizationModuleInputFromContribution: server.authorizationModuleInputFromContribution,
171
+ stampAuthorizationModuleContribution: contribution.stampAuthorizationModuleContribution,
172
+ currentPlatformGeneration: parsed.data,
173
+ kernelPackageDir,
174
+ };
175
+ }
176
+
177
+ /** The `adopted` input the composer diffs against: the committed lock, if any. */
178
+ export function adoptedFromLock(lock) {
179
+ if (!lock) {return undefined;}
180
+ return {
181
+ model: lock.model,
182
+ modules: Object.fromEntries(
183
+ lock.modules.map((m) => [m.namespace, { moduleVersion: m.moduleVersion, contentHash: m.contentHash }]),
184
+ ),
185
+ };
186
+ }
187
+
188
+ /** Compose an edition's base; a refusal names the edition and every violation. */
189
+ export function composeEditionAuthorizationBase({ distributionId, inputs, kernel, adopted }) {
190
+ try {
191
+ return kernel.composeAuthorizationBase({
192
+ modules: inputs,
193
+ currentPlatformGeneration: kernel.currentPlatformGeneration,
194
+ ...(adopted ? { adopted } : {}),
195
+ });
196
+ } catch (err) {
197
+ if (Array.isArray(err?.violations)) {
198
+ throw new Error(`${distributionId}: the authorization base was REFUSED by the kernel composer.\n${err.message}`);
199
+ }
200
+ throw err;
201
+ }
202
+ }
203
+
204
+ /** The lock body for a composed base. Deterministic: no timestamp. */
205
+ export function authorizationLockOf(distributionId, kernel, composed) {
206
+ return {
207
+ schemaVersion: AUTHORIZATION_LOCK_SCHEMA_VERSION,
208
+ distributionId,
209
+ platformGeneration: kernel.currentPlatformGeneration,
210
+ modelHash: composed.modelHash,
211
+ modelBytes: composed.modelBytes,
212
+ testedWith: `openfga/cli v${FGA_CLI_VERSION}`,
213
+ modules: composed.modules.map((m) => ({
214
+ namespace: m.namespace,
215
+ moduleVersion: m.moduleVersion,
216
+ contentHash: m.contentHash,
217
+ signedBiomeVersion: m.signedBiomeVersion,
218
+ })),
219
+ parentImplementers: [...composed.parentImplementers],
220
+ model: composed.model,
221
+ };
222
+ }
223
+
224
+ /** Resolve and verify the pinned `fga` CLI. */
225
+ export function resolveFgaCli(env = process.env) {
226
+ const bin = env[FGA_CLI_ENV];
227
+ if (!bin) {
228
+ throw new Error(
229
+ `sealing an authorization base runs \`fga model test\`, and ${FGA_CLI_ENV} is unset. ` +
230
+ `Point it at the openfga/cli v${FGA_CLI_VERSION} binary.`,
231
+ );
232
+ }
233
+ const out = execFileSync(bin, ['version'], { encoding: 'utf8' });
234
+ const match = /v`?(\d+\.\d+\.\d+)`?/.exec(out);
235
+ if (!match || match[1] !== FGA_CLI_VERSION) {
236
+ throw new Error(`${FGA_CLI_ENV}=${bin} reports "${out.trim()}"; the sealer requires exactly v${FGA_CLI_VERSION}.`);
237
+ }
238
+ return bin;
239
+ }
240
+
241
+ /**
242
+ * Run every composed `.fga.yaml` with the real CLI, from the composition root:
243
+ * the composer's `files` (the filled templates, the biome files and `fga.mod`)
244
+ * and `testFiles` are written at their composed names, and each test runs
245
+ * with that root as its working directory.
246
+ */
247
+ export function fgaComposedModelTestRunner(bin) {
248
+ return (composed) => {
249
+ const root = mkdtempSync(join(tmpdir(), 'xema-authz-seal-'));
250
+ try {
251
+ for (const f of [...composed.files, ...composed.testFiles]) {
252
+ mkdirSync(dirname(join(root, f.name)), { recursive: true });
253
+ writeFileSync(join(root, f.name), f.contents);
254
+ }
255
+ if (composed.testFiles.length === 0) {
256
+ throw new Error('the composed base carries no test file — every module ships one, the platform modules included.');
257
+ }
258
+ for (const t of composed.testFiles) {
259
+ try {
260
+ execFileSync(bin, ['model', 'test', '--tests', t.name], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
261
+ } catch (err) {
262
+ throw new Error(
263
+ `${t.owner}: ${t.name} FAILED against the composed edition base:\n` +
264
+ `${String(err.stdout ?? '').trim()}\n${String(err.stderr ?? '').trim()}`,
265
+ );
266
+ }
267
+ }
268
+ } finally {
269
+ rmSync(root, { recursive: true, force: true });
270
+ }
271
+ };
272
+ }
273
+
274
+ /** Compose, test, describe. `runTests` is the `fga model test` runner. */
275
+ export function sealEditionAuthorizationBase({ distributionId, inputs, kernel, adopted, runTests }) {
276
+ const composed = composeEditionAuthorizationBase({ distributionId, inputs, kernel, adopted });
277
+ runTests(composed);
278
+ return authorizationLockOf(distributionId, kernel, composed);
279
+ }
280
+
281
+ /** Outcome of the committed-vs-recomposed authorization lock comparison. */
282
+ export const AuthorizationLockCheck = Object.freeze({
283
+ GREEN: 'green',
284
+ MISSING: 'missing',
285
+ STALE: 'stale',
286
+ NOT_SEALABLE: 'not-sealable',
287
+ });
288
+
289
+ /**
290
+ * Is the committed `authorization.lock.json` what this edition composes NOW?
291
+ * Recomposes with the committed lock as `adopted` (so an undeclared
292
+ * incompatible change, a reused module version or a downgrade is refused
293
+ * exactly as the sealer would refuse it) and compares the whole lock. The
294
+ * tests are not re-run: an equal `modelHash` is the model the sealer tested.
295
+ */
296
+ export function checkAuthorizationLockCurrent({ distributionId, committed, inputs, kernel, serialize }) {
297
+ if (kernel === null) {
298
+ return committed === null ? AuthorizationLockCheck.GREEN : AuthorizationLockCheck.NOT_SEALABLE;
299
+ }
300
+ if (committed === null) {return AuthorizationLockCheck.MISSING;}
301
+ const composed = composeEditionAuthorizationBase({
302
+ distributionId,
303
+ inputs,
304
+ kernel,
305
+ adopted: adoptedFromLock(committed),
306
+ });
307
+ return serialize(authorizationLockOf(distributionId, kernel, composed)) === serialize(committed)
308
+ ? AuthorizationLockCheck.GREEN
309
+ : AuthorizationLockCheck.STALE;
310
+ }
311
+
312
+ /**
313
+ * dist-check's authorization-currency verdict for ONE edition: the committed
314
+ * `authorization.lock.json` against a fresh composition over the edition's
315
+ * lock biomes, looked up in the index that lock was RESOLVED FROM (platform
316
+ * entries plus the edition's source-walked ones). Returns an
317
+ * `AuthorizationLockCheck` member; throws on a refusal.
318
+ */
319
+ export function checkEditionAuthorizationLock({ distributionId, editionDir, lockBiomes, index, kernel, serialize }) {
320
+ if (!Array.isArray(index)) {
321
+ throw new Error(`${distributionId}: the authorization check needs the index the lock was resolved from.`);
322
+ }
323
+ return checkAuthorizationLockCurrent({
324
+ distributionId,
325
+ committed: readAuthorizationLock(editionDir),
326
+ inputs: editionAuthorizationModuleInputs(lockBiomes, new Map(index.map((e) => [e.id, e])), kernel),
327
+ kernel,
328
+ serialize,
329
+ });
330
+ }
331
+
332
+ /** Read an edition's committed authorization lock, or `null`. */
333
+ export function readAuthorizationLock(editionDir) {
334
+ const path = join(editionDir, AUTHORIZATION_LOCK_FILENAME);
335
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
336
+ }
337
+
338
+ /** Write (or retire) an edition's authorization lock. */
339
+ export function writeAuthorizationLock(editionDir, lock, serialize) {
340
+ const path = join(editionDir, AUTHORIZATION_LOCK_FILENAME);
341
+ if (lock === null) {
342
+ if (existsSync(path)) {rmSync(path);}
343
+ return null;
344
+ }
345
+ writeFileSync(path, serialize(lock));
346
+ return path;
347
+ }
@@ -0,0 +1,268 @@
1
+ // biome-content — the content address of every biome a distribution lock ships.
2
+ //
3
+ // A sealed (v5) lock carries, per biome, `biomeContentHash`: the kernel's
4
+ // `computeBiomeContentHash` over THREE facts the lock itself cannot vouch for:
5
+ //
6
+ // * the biome's source manifest hash, recomputed from the manifest AT the
7
+ // pinned commit (`computeBiomeSourceManifestHash`);
8
+ // * the git tree object id of the biome directory at that commit
9
+ // (`git rev-parse <commit>:<path>`);
10
+ // * the sorted image subject digests of the images the biome owns, from the
11
+ // SEALED images lock.
12
+ //
13
+ // The kernel cannot recompute any of them ("sealed" is only a field), so the
14
+ // producer of the seal AND every verifier recompute from the sources and REFUSE
15
+ // a mismatch, naming the biome. Nothing here assembles a v5 lock: the unsealed
16
+ // document goes to the kernel's `sealDistributionLock` together with the hashes
17
+ // computed below.
18
+
19
+ import { execFileSync } from 'node:child_process';
20
+ import { relative, sep } from 'node:path';
21
+
22
+ import { readGitJsonAtCommit } from './lib.mjs';
23
+
24
+ /** A biome whose recomputed content hash is not the lock's, or whose inputs cannot be recomputed. */
25
+ export class BiomeContentHashError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'BiomeContentHashError';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The commit a biome's content is pinned at. A biome that ships images (or a web
34
+ * bundle) is pinned where its images lock row is; a declared source at its
35
+ * locked commit; a content-only first-party biome at the vendored-source pin
36
+ * (`vendoredCommit(repo)`), the axis its manifest hash was read on.
37
+ */
38
+ function pinnedCommit({ biome, imageLock, ownerKinds, vendoredCommit }) {
39
+ if (biome.source !== undefined) {return biome.source.commit;}
40
+ const owned = imageLock.subjects.filter((subject) =>
41
+ subject.owners.some((owner) => owner.kind === ownerKinds.Biome && owner.biomeId === biome.id),
42
+ );
43
+ const commits = new Set(owned.map((subject) => subject.sourceCommit));
44
+ if (commits.size > 1) {
45
+ throw new BiomeContentHashError(`biome "${biome.id}": its images pin ${commits.size} different commits`);
46
+ }
47
+ if (commits.size === 1) {return [...commits][0];}
48
+ const web = imageLock.webBiomes.find((row) => row.biomeId === biome.id);
49
+ if (web !== undefined) {return web.sourceCommit;}
50
+ return vendoredCommit(biome.repo);
51
+ }
52
+
53
+ function treeId({ parseGitTreeId, repoDirectory, commit, repoRelativePath }) {
54
+ const object = repoRelativePath === '.' ? `${commit}^{tree}` : `${commit}:${repoRelativePath}`;
55
+ const out = execFileSync('git', ['-C', repoDirectory, 'rev-parse', '--verify', object], {
56
+ encoding: 'utf8',
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ });
59
+ return parseGitTreeId(out.trim());
60
+ }
61
+
62
+ /** The sorted `sha256:` subject digests of the images `biome` owns; an unsealed row is refused. */
63
+ function imageSubjectDigests({ biome, imageLock, ownerKinds, parseOciDigest }) {
64
+ return imageLock.subjects
65
+ .filter((subject) => subject.owners.some((owner) => owner.kind === ownerKinds.Biome && owner.biomeId === biome.id))
66
+ .map((subject) => {
67
+ if (subject.imageDigest === undefined) {
68
+ throw new BiomeContentHashError(
69
+ `biome "${biome.id}": image ${subject.kind}:${subject.id} is not sealed (no imageDigest); seal the images lock first`,
70
+ );
71
+ }
72
+ return parseOciDigest(subject.imageDigest);
73
+ });
74
+ }
75
+
76
+ function ownedSubjects(imageLock, ownerKinds, biomeId) {
77
+ return imageLock.subjects.filter((subject) =>
78
+ subject.owners.some((owner) => owner.kind === ownerKinds.Biome && owner.biomeId === biomeId),
79
+ );
80
+ }
81
+
82
+ /** The biome's `xema-biome.json` AT the commit its content is pinned at, read from the source checkout `locate` names. */
83
+ function readPinnedManifest({ biome, commit, locate }) {
84
+ const { dir: repoDirectory, base } = locate(biome.repo);
85
+ const repoRelativePath = relative(repoDirectory, `${base}${sep}${biome.path}`).split(sep).join('/') || '.';
86
+ const manifestPath = repoRelativePath === '.' ? 'xema-biome.json' : `${repoRelativePath}/xema-biome.json`;
87
+ return { repoDirectory, repoRelativePath, manifestPath, manifest: readGitJsonAtCommit(repoDirectory, manifestPath, commit) };
88
+ }
89
+
90
+ /**
91
+ * The manifest at the pinned commit, held to the lock: its hash, by the kernel's
92
+ * `computeBiomeSourceManifestHash`, is the lock's `sourceManifestHash`, or the biome is
93
+ * refused. The ONE comparison, for the seal, the verify step and `biome-manifests`.
94
+ */
95
+ async function readLockedManifest({ schemas, biome, commit, locate }) {
96
+ const pinned = readPinnedManifest({ biome, commit, locate });
97
+ const sourceManifestHash = await schemas.computeBiomeSourceManifestHash(pinned.manifest);
98
+ if (sourceManifestHash !== biome.sourceManifestHash) {
99
+ throw new BiomeContentHashError(
100
+ `biome "${biome.id}": the manifest at ${biome.repo}@${commit}:${pinned.manifestPath} hashes to ${sourceManifestHash}, ` +
101
+ `not the lock's sourceManifestHash ${biome.sourceManifestHash}`,
102
+ );
103
+ }
104
+ return { ...pinned, sourceManifestHash };
105
+ }
106
+
107
+ /**
108
+ * The pinned `xema-biome.json` of every biome of a lock, read exactly as the seal
109
+ * and the verify step read it, sorted by biome id: `[{ id, repo, sourceCommit, manifest }]`.
110
+ * A manifest that cannot be read at its pinned commit, or that is not the manifest
111
+ * the lock names (`sourceManifestHash`), is refused, naming the biome.
112
+ */
113
+ export async function pinnedBiomeManifests({ schemas, lock, imageLock, locate, vendoredCommit }) {
114
+ const ownerKinds = schemas.RuntimeImageOwnerKind;
115
+ const manifests = [];
116
+ for (const biome of [...lock.biomes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
117
+ const sourceCommit = pinnedCommit({ biome, imageLock, ownerKinds, vendoredCommit });
118
+ let manifest;
119
+ try {
120
+ ({ manifest } = await readLockedManifest({ schemas, biome, commit: sourceCommit, locate }));
121
+ } catch (error) {
122
+ if (error instanceof BiomeContentHashError) {throw error;}
123
+ throw new BiomeContentHashError(`biome "${biome.id}": its manifest at ${biome.repo}@${sourceCommit} cannot be read: ${error.message}`);
124
+ }
125
+ manifests.push({ id: biome.id, repo: biome.repo, sourceCommit, manifest });
126
+ }
127
+ return manifests;
128
+ }
129
+
130
+ /** The three facts of a biome recomputed from a checkout of its source at the pinned commit. */
131
+ async function sourceFacts({ schemas, biome, imageLock, locate, vendoredCommit }) {
132
+ const ownerKinds = schemas.RuntimeImageOwnerKind;
133
+ const commit = pinnedCommit({ biome, imageLock, ownerKinds, vendoredCommit });
134
+ const { repoDirectory, repoRelativePath, sourceManifestHash } = await readLockedManifest({ schemas, biome, commit, locate });
135
+ return {
136
+ sourceManifestHash,
137
+ sourceTreeId: treeId({ parseGitTreeId: schemas.parseGitTreeId, repoDirectory, commit, repoRelativePath }),
138
+ imageSubjectDigests: imageSubjectDigests({ biome, imageLock, ownerKinds, parseOciDigest: schemas.parseOciDigest }),
139
+ };
140
+ }
141
+
142
+ const CATALOGUE_AUTHORITY_NOTE =
143
+ "the vendor biome's source tree id is an INPUT taken on the catalogue's authority; it is not re-derived " +
144
+ 'from a checkout (the customer has none), so this is not a verification of the vendor source';
145
+
146
+ /**
147
+ * The three facts of a VENDOR biome in a customer edition. The customer holds no
148
+ * checkout of the vendor source, so, on the catalogue's authority (and ONLY
149
+ * there): the tree id is the catalogue's `sourceTreeIds[biome]`, and the
150
+ * manifest hash is the catalogue row's. Everything the customer CAN check is
151
+ * checked: the lock's manifest hash equals the row's, and the image digests the
152
+ * customer's images lock carries for the biome equal the catalogue's. A biome
153
+ * the catalogue does not carry, or carries without a tree id, is refused.
154
+ */
155
+ async function catalogueFacts({ schemas, biome, imageLock, catalogue }) {
156
+ const ownerKinds = schemas.RuntimeImageOwnerKind;
157
+ const row = catalogue.biomes.find((candidate) => candidate.id === biome.id);
158
+ if (row === undefined) {
159
+ throw new BiomeContentHashError(`vendor biome "${biome.id}" is absent from catalogue "${catalogue.offeringId}"; ${CATALOGUE_AUTHORITY_NOTE}`);
160
+ }
161
+ const treeIdText = catalogue.sourceTreeIds?.[biome.id];
162
+ if (treeIdText === undefined) {
163
+ throw new BiomeContentHashError(`catalogue "${catalogue.offeringId}" carries no source tree id for vendor biome "${biome.id}"; ${CATALOGUE_AUTHORITY_NOTE}`);
164
+ }
165
+ if (row.sourceManifestHash !== biome.sourceManifestHash) {
166
+ throw new BiomeContentHashError(
167
+ `vendor biome "${biome.id}": the lock's sourceManifestHash ${biome.sourceManifestHash} is not the catalogue row's ${row.sourceManifestHash}`,
168
+ );
169
+ }
170
+ const own = imageSubjectDigests({ biome, imageLock, ownerKinds, parseOciDigest: schemas.parseOciDigest });
171
+ const offered = ownedSubjects({ subjects: catalogue.images.subjects }, ownerKinds, biome.id)
172
+ .map((subject) => schemas.parseOciDigest(subject.imageDigest));
173
+ if (JSON.stringify([...own].sort()) !== JSON.stringify([...offered].sort())) {
174
+ throw new BiomeContentHashError(
175
+ `vendor biome "${biome.id}": its image digests in the images lock differ from the catalogue's (${[...own].sort().join(',')} vs ${[...offered].sort().join(',')})`,
176
+ );
177
+ }
178
+ return {
179
+ sourceManifestHash: schemas.parseBiomeSourceManifestHash(row.sourceManifestHash),
180
+ sourceTreeId: schemas.parseGitTreeId(treeIdText),
181
+ imageSubjectDigests: own,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Customer mode: the lock must say which catalogue produced its hashes, and it must be
187
+ * the catalogue supplied. A lock without `catalogueDigest`, or with another one, was not
188
+ * resolved against this catalogue, so its hashes cannot be checked on this catalogue's authority.
189
+ */
190
+ function assertLockNamesCatalogue({ lock, catalogue, catalogueDigest }) {
191
+ if (catalogueDigest === undefined) {
192
+ throw new BiomeContentHashError(`customer mode needs the digest of catalogue "${catalogue.offeringId}"`);
193
+ }
194
+ if (lock.catalogueDigest === undefined) {
195
+ throw new BiomeContentHashError(
196
+ `distribution "${lock.distributionId}": the lock carries no catalogueDigest, so it was not resolved against a catalogue; supplied catalogue ${catalogueDigest}`,
197
+ );
198
+ }
199
+ if (lock.catalogueDigest !== catalogueDigest) {
200
+ throw new BiomeContentHashError(
201
+ `distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock.catalogueDigest}, not the supplied ${catalogueDigest}`,
202
+ );
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Recompute every biome's `biomeContentHash`.
208
+ *
209
+ * @param {object} request
210
+ * @param {object} request.schemas the kernel distribution schemas (`loadKernelDistributionSchemas`)
211
+ * @param {object} request.lock the unsealed or sealed distribution lock
212
+ * @param {object} request.imageLock the SEALED images lock
213
+ * @param {(repo: string) => {dir: string, base: string}} request.locate repo handle -> checkout
214
+ * @param {(repo: string) => string} request.vendoredCommit the vendored-source pin of a first-party repo
215
+ * @param {object} [request.catalogue] customer mode: the digest-pinned catalogue vendor biomes are taken from
216
+ * @param {string} [request.catalogueDigest] customer mode: the digest of that catalogue; the lock must carry the same one
217
+ * @returns {Promise<Map<string, string>>} biome id -> BiomeContentHash
218
+ */
219
+ export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
220
+ if (catalogue !== undefined) {assertLockNamesCatalogue({ lock, catalogue, catalogueDigest });}
221
+ const hashes = new Map();
222
+ for (const biome of lock.biomes) {
223
+ const facts =
224
+ catalogue !== undefined && biome.source === undefined
225
+ ? await catalogueFacts({ schemas, biome, imageLock, catalogue })
226
+ : await sourceFacts({ schemas, biome, imageLock, locate, vendoredCommit });
227
+ hashes.set(biome.id, await schemas.computeBiomeContentHash(facts));
228
+ }
229
+ return hashes;
230
+ }
231
+
232
+ /**
233
+ * The git tree id of every biome of a VENDOR offering at its pinned commit, as
234
+ * the catalogue publishes them: `{ [biomeId]: GitTreeId }`, sorted by id.
235
+ * THIS is the writer's source of the catalogue's `sourceTreeIds` (callers:
236
+ * release-catalogue.mjs `releaseCatalogue`, develop-edition-resolve.mjs).
237
+ */
238
+ export async function biomeSourceTreeIds({ schemas, lock, imageLock, locate, vendoredCommit }) {
239
+ const ids = {};
240
+ for (const biome of [...lock.biomes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
241
+ ids[biome.id] = (await sourceFacts({ schemas, biome, imageLock, locate, vendoredCommit })).sourceTreeId;
242
+ }
243
+ return ids;
244
+ }
245
+
246
+ /** Seal: the unsealed lock + the recomputed hashes -> the kernel's `sealDistributionLock`. */
247
+ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
248
+ const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest });
249
+ return schemas.sealDistributionLock(unsealed, hashes);
250
+ }
251
+
252
+ /**
253
+ * Verify: recompute every biome's hash and REFUSE a mismatch, naming the biome.
254
+ * The lock's own field is never trusted; a missing or extra biome is a mismatch.
255
+ */
256
+ export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
257
+ const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest });
258
+ const mismatched = lock.biomes
259
+ .filter((biome) => biome.biomeContentHash !== expected.get(biome.id))
260
+ .map((biome) => `${biome.id} (lock ${String(biome.biomeContentHash)}, recomputed ${expected.get(biome.id)})`);
261
+ if (mismatched.length > 0) {
262
+ throw new BiomeContentHashError(
263
+ `distribution "${lock.distributionId}": biomeContentHash does not match its inputs for ${mismatched.join('; ')}` +
264
+ (catalogue === undefined ? '' : `; ${CATALOGUE_AUTHORITY_NOTE}`),
265
+ );
266
+ }
267
+ return expected;
268
+ }