@xemahq/xema 0.26.0 → 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.
- package/README.md +70 -37
- package/dist/assets/distribution-assets.manifest.json +1 -2
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +5 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +2 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +18 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +2 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +31 -7
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +1 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +270 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +99 -0
- package/edition/release-edition-artifact.mjs +158 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +430 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +14 -6
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -156
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- 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
|
+
}
|