@msn-control/liftoff 0.9.9 → 0.10.1
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/DEVELOPER.md +142 -0
- package/README.md +26 -18
- package/assets/governance/single-maintainer-gitflow/policy.md +13 -11
- package/dist/args.js +23 -1
- package/dist/args.js.map +1 -1
- package/dist/artifact-lifecycle.d.ts +1 -1
- package/dist/artifact-lifecycle.js +5 -0
- package/dist/artifact-lifecycle.js.map +1 -1
- package/dist/catalogs.d.ts +2 -0
- package/dist/catalogs.js +13 -1
- package/dist/catalogs.js.map +1 -1
- package/dist/commands.js +365 -17
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +100 -13
- package/dist/file-system.js.map +1 -1
- package/dist/governance-activation/activation-state.d.ts +32 -0
- package/dist/governance-activation/activation-state.js +165 -0
- package/dist/governance-activation/activation-state.js.map +1 -0
- package/dist/governance-activation/approvals.d.ts +188 -0
- package/dist/governance-activation/approvals.js +508 -0
- package/dist/governance-activation/approvals.js.map +1 -0
- package/dist/governance-activation/canonical-json.d.ts +3 -0
- package/dist/governance-activation/canonical-json.js +36 -0
- package/dist/governance-activation/canonical-json.js.map +1 -0
- package/dist/governance-activation/commands.d.ts +10 -0
- package/dist/governance-activation/commands.js +1023 -0
- package/dist/governance-activation/commands.js.map +1 -0
- package/dist/governance-activation/compatibility.d.ts +68 -0
- package/dist/governance-activation/compatibility.js +447 -0
- package/dist/governance-activation/compatibility.js.map +1 -0
- package/dist/governance-activation/credentials.d.ts +160 -0
- package/dist/governance-activation/credentials.js +403 -0
- package/dist/governance-activation/credentials.js.map +1 -0
- package/dist/governance-activation/doctor.d.ts +10 -0
- package/dist/governance-activation/doctor.js +379 -0
- package/dist/governance-activation/doctor.js.map +1 -0
- package/dist/governance-activation/evidence.d.ts +49 -0
- package/dist/governance-activation/evidence.js +246 -0
- package/dist/governance-activation/evidence.js.map +1 -0
- package/dist/governance-activation/graph.d.ts +397 -0
- package/dist/governance-activation/graph.js +441 -0
- package/dist/governance-activation/graph.js.map +1 -0
- package/dist/governance-activation/identity.d.ts +35 -0
- package/dist/governance-activation/identity.js +89 -0
- package/dist/governance-activation/identity.js.map +1 -0
- package/dist/governance-activation/index.d.ts +18 -0
- package/dist/governance-activation/index.js +19 -0
- package/dist/governance-activation/index.js.map +1 -0
- package/dist/governance-activation/migration.d.ts +44 -0
- package/dist/governance-activation/migration.js +268 -0
- package/dist/governance-activation/migration.js.map +1 -0
- package/dist/governance-activation/readiness.d.ts +23 -0
- package/dist/governance-activation/readiness.js +158 -0
- package/dist/governance-activation/readiness.js.map +1 -0
- package/dist/governance-activation/reconciliation.d.ts +11 -0
- package/dist/governance-activation/reconciliation.js +58 -0
- package/dist/governance-activation/reconciliation.js.map +1 -0
- package/dist/governance-activation/release-integrity.d.ts +11 -0
- package/dist/governance-activation/release-integrity.js +49 -0
- package/dist/governance-activation/release-integrity.js.map +1 -0
- package/dist/governance-activation/seed-lifecycle.d.ts +116 -0
- package/dist/governance-activation/seed-lifecycle.js +609 -0
- package/dist/governance-activation/seed-lifecycle.js.map +1 -0
- package/dist/governance-activation/source-of-truth.d.ts +164 -0
- package/dist/governance-activation/source-of-truth.js +986 -0
- package/dist/governance-activation/source-of-truth.js.map +1 -0
- package/dist/governance-activation/task-projection.d.ts +21 -0
- package/dist/governance-activation/task-projection.js +75 -0
- package/dist/governance-activation/task-projection.js.map +1 -0
- package/dist/governance-activation/transitions.d.ts +163 -0
- package/dist/governance-activation/transitions.js +2128 -0
- package/dist/governance-activation/transitions.js.map +1 -0
- package/dist/governance-activation/type-contract.d.ts +1 -0
- package/dist/governance-activation/type-contract.js +27 -0
- package/dist/governance-activation/type-contract.js.map +1 -0
- package/dist/governance-activation/types.d.ts +390 -0
- package/dist/governance-activation/types.js +108 -0
- package/dist/governance-activation/types.js.map +1 -0
- package/dist/governance-activation/validators.d.ts +17 -0
- package/dist/governance-activation/validators.js +1305 -0
- package/dist/governance-activation/validators.js.map +1 -0
- package/dist/init-filesystem.js +5 -1
- package/dist/init-filesystem.js.map +1 -1
- package/dist/interactive.js +4 -3
- package/dist/interactive.js.map +1 -1
- package/dist/planner.js +5 -5
- package/dist/planner.js.map +1 -1
- package/dist/power-apps-templates.js +14 -1
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/process-runner.d.ts +3 -0
- package/dist/process-runner.js +68 -10
- package/dist/process-runner.js.map +1 -1
- package/dist/repository-governance.d.ts +13 -3
- package/dist/repository-governance.js +357 -51
- package/dist/repository-governance.js.map +1 -1
- package/dist/telemetry/contract.d.ts +1 -1
- package/dist/telemetry/contract.js +6 -0
- package/dist/telemetry/contract.js.map +1 -1
- package/dist/templates.js +320 -25
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +15 -7
- package/docs/cli-reference.md +61 -13
- package/docs/configuration-and-manifests.md +43 -16
- package/docs/existing-repositories.md +22 -11
- package/docs/getting-started.md +46 -10
- package/docs/project-structure.md +35 -6
- package/docs/repository-governance.md +200 -245
- package/docs/safety-and-consent.md +24 -2
- package/docs/troubleshooting.md +59 -4
- package/docs/workloads.md +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
import { activationCompatibility, canonicalPhaseGraph, canonicalPhaseGraphHash, currentActivationIdentity } from './graph.js';
|
|
2
|
+
import { activationContractVersion, activationStateSchemaVersion, approvalEnvelopeSchemaVersion, credentialPolicySchemaVersion, evidenceHeaderSchemaVersion, governanceActivationPolicyVersion, liftoffActivationPackageVersion, liftoffManifestArtifactVersion, phaseGraphSchemaVersion, resolveActivationCompatibility, supersessionSchemaVersion } from './identity.js';
|
|
3
|
+
import { normalizeApprovalCostCeiling, normalizeApprovalDestinations, normalizeApprovalDestructiveScope, normalizeApprovalPermissions, normalizeApprovalPolicyExceptions, normalizeApprovalResources } from './approvals.js';
|
|
4
|
+
import { approvalGateKinds, invalidationInputKinds, mutationClasses, phaseIds, phaseStates, rollbackKinds, runnerPreflightDisplayNameTemplate, runnerPreflightOrganizationPermissions, runnerPreflightPatLifetimeDays, runnerPreflightRepositoryPermissions, runnerPreflightRotationLeadDays, runnerPreflightSecretName } from './types.js';
|
|
5
|
+
const phaseIdSet = new Set(phaseIds);
|
|
6
|
+
const terminalPhaseStateSet = new Set(['approved', 'verified', 'failed', 'inapplicable', 'retained', 'disposed']);
|
|
7
|
+
const resultStateSet = new Set(['verified', 'failed', 'inapplicable', 'retained', 'disposed']);
|
|
8
|
+
const hex64Pattern = /^[a-f0-9]{64}$/;
|
|
9
|
+
const isoLikePattern = /^\d{4}-\d{2}-\d{2}T/;
|
|
10
|
+
const liveReadbackProviderSet = new Set(['github', 'azure']);
|
|
11
|
+
const githubRemoteWriteMutations = new Set(['github-write', 'github-secret-write', 'github-ruleset-write']);
|
|
12
|
+
const azureRemoteWriteMutations = new Set([
|
|
13
|
+
'azure-provider-register',
|
|
14
|
+
'azure-network-provision',
|
|
15
|
+
'azure-state-import',
|
|
16
|
+
'azure-resource-provision'
|
|
17
|
+
]);
|
|
18
|
+
const transitionAdapterIds = new Set([
|
|
19
|
+
'local-evidence',
|
|
20
|
+
'selected-spec-workflow',
|
|
21
|
+
'git',
|
|
22
|
+
'github',
|
|
23
|
+
'azure-opentofu',
|
|
24
|
+
'local-state'
|
|
25
|
+
]);
|
|
26
|
+
function record(value, path) {
|
|
27
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
28
|
+
throw new Error(`${path} must be an object.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function exact(value, requiredKeys, path) {
|
|
33
|
+
const item = record(value, path);
|
|
34
|
+
for (const key of requiredKeys) {
|
|
35
|
+
if (!Object.hasOwn(item, key)) {
|
|
36
|
+
throw new Error(`${path}.${key} is required.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const allowed = new Set(requiredKeys);
|
|
40
|
+
for (const key of Object.keys(item)) {
|
|
41
|
+
if (!allowed.has(key)) {
|
|
42
|
+
throw new Error(`${path}.${key} is not allowed.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return item;
|
|
46
|
+
}
|
|
47
|
+
function exactWithOptional(value, requiredKeys, optionalKeys, path) {
|
|
48
|
+
const item = record(value, path);
|
|
49
|
+
for (const key of requiredKeys) {
|
|
50
|
+
if (!Object.hasOwn(item, key)) {
|
|
51
|
+
throw new Error(`${path}.${key} is required.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const allowed = new Set([...requiredKeys, ...optionalKeys]);
|
|
55
|
+
for (const key of Object.keys(item)) {
|
|
56
|
+
if (!allowed.has(key)) {
|
|
57
|
+
throw new Error(`${path}.${key} is not allowed.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return item;
|
|
61
|
+
}
|
|
62
|
+
function stringField(item, key, path) {
|
|
63
|
+
const value = item[key];
|
|
64
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
65
|
+
throw new Error(`${path}.${key} must be a non-empty string.`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function booleanField(item, key, path) {
|
|
70
|
+
const value = item[key];
|
|
71
|
+
if (typeof value !== 'boolean') {
|
|
72
|
+
throw new Error(`${path}.${key} must be a boolean.`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function numberField(item, key, path) {
|
|
77
|
+
const value = item[key];
|
|
78
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
79
|
+
throw new Error(`${path}.${key} must be a finite number.`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function integerField(item, key, path) {
|
|
84
|
+
const value = numberField(item, key, path);
|
|
85
|
+
if (!Number.isSafeInteger(value)) {
|
|
86
|
+
throw new Error(`${path}.${key} must be a safe integer.`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function stringArray(value, path) {
|
|
91
|
+
if (!Array.isArray(value)) {
|
|
92
|
+
throw new Error(`${path} must be an array.`);
|
|
93
|
+
}
|
|
94
|
+
return value.map((entry, index) => {
|
|
95
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
96
|
+
throw new Error(`${path}[${index}] must be a non-empty string.`);
|
|
97
|
+
}
|
|
98
|
+
return entry;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function optionalStringArray(value, path) {
|
|
102
|
+
return value === undefined ? undefined : stringArray(value, path);
|
|
103
|
+
}
|
|
104
|
+
function pathPartsArray(value, path) {
|
|
105
|
+
if (!Array.isArray(value)) {
|
|
106
|
+
throw new Error(`${path} must be an array.`);
|
|
107
|
+
}
|
|
108
|
+
return value.map((entry, index) => stringArray(entry, `${path}[${index}]`));
|
|
109
|
+
}
|
|
110
|
+
function enumValue(value, allowed, path) {
|
|
111
|
+
if (typeof value !== 'string' || !allowed.has(value)) {
|
|
112
|
+
throw new Error(`${path} contains unsupported value ${JSON.stringify(value)}.`);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function requireVersion(value, expected, path) {
|
|
117
|
+
if (value !== expected) {
|
|
118
|
+
throw new Error(`${path} must be ${JSON.stringify(expected)}.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function validateActivationIdentityShape(value, path) {
|
|
122
|
+
const identity = exact(value, [
|
|
123
|
+
'liftoffVersion',
|
|
124
|
+
'manifestArtifactVersion',
|
|
125
|
+
'policyVersion',
|
|
126
|
+
'activationContractVersion',
|
|
127
|
+
'phaseGraphSchemaVersion',
|
|
128
|
+
'phaseGraphHash',
|
|
129
|
+
'activationStateSchemaVersion',
|
|
130
|
+
'evidenceHeaderSchemaVersion',
|
|
131
|
+
'approvalEnvelopeSchemaVersion',
|
|
132
|
+
'supersessionSchemaVersion',
|
|
133
|
+
'credentialPolicySchemaVersion'
|
|
134
|
+
], path);
|
|
135
|
+
requireVersion(identity.liftoffVersion, liftoffActivationPackageVersion, `${path}.liftoffVersion`);
|
|
136
|
+
requireVersion(identity.manifestArtifactVersion, liftoffManifestArtifactVersion, `${path}.manifestArtifactVersion`);
|
|
137
|
+
requireVersion(identity.policyVersion, governanceActivationPolicyVersion, `${path}.policyVersion`);
|
|
138
|
+
requireVersion(identity.activationContractVersion, activationContractVersion, `${path}.activationContractVersion`);
|
|
139
|
+
requireVersion(identity.phaseGraphSchemaVersion, phaseGraphSchemaVersion, `${path}.phaseGraphSchemaVersion`);
|
|
140
|
+
requireVersion(identity.activationStateSchemaVersion, activationStateSchemaVersion, `${path}.activationStateSchemaVersion`);
|
|
141
|
+
requireVersion(identity.evidenceHeaderSchemaVersion, evidenceHeaderSchemaVersion, `${path}.evidenceHeaderSchemaVersion`);
|
|
142
|
+
requireVersion(identity.approvalEnvelopeSchemaVersion, approvalEnvelopeSchemaVersion, `${path}.approvalEnvelopeSchemaVersion`);
|
|
143
|
+
requireVersion(identity.supersessionSchemaVersion, supersessionSchemaVersion, `${path}.supersessionSchemaVersion`);
|
|
144
|
+
requireVersion(identity.credentialPolicySchemaVersion, credentialPolicySchemaVersion, `${path}.credentialPolicySchemaVersion`);
|
|
145
|
+
const phaseGraphHash = stringField(identity, 'phaseGraphHash', path);
|
|
146
|
+
if (!hex64Pattern.test(phaseGraphHash)) {
|
|
147
|
+
throw new Error(`${path}.phaseGraphHash must be a SHA-256 hex digest.`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
liftoffVersion: identity.liftoffVersion,
|
|
151
|
+
manifestArtifactVersion: identity.manifestArtifactVersion,
|
|
152
|
+
policyVersion: identity.policyVersion,
|
|
153
|
+
activationContractVersion: identity.activationContractVersion,
|
|
154
|
+
phaseGraphSchemaVersion: identity.phaseGraphSchemaVersion,
|
|
155
|
+
phaseGraphHash,
|
|
156
|
+
activationStateSchemaVersion: identity.activationStateSchemaVersion,
|
|
157
|
+
evidenceHeaderSchemaVersion: identity.evidenceHeaderSchemaVersion,
|
|
158
|
+
approvalEnvelopeSchemaVersion: identity.approvalEnvelopeSchemaVersion,
|
|
159
|
+
supersessionSchemaVersion: identity.supersessionSchemaVersion,
|
|
160
|
+
credentialPolicySchemaVersion: identity.credentialPolicySchemaVersion
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function validateActivationIdentity(value) {
|
|
164
|
+
const typed = validateActivationIdentityShape(value, 'identity');
|
|
165
|
+
const compatibility = resolveActivationCompatibility(typed, activationCompatibility);
|
|
166
|
+
if (!compatibility.compatible) {
|
|
167
|
+
throw new Error(compatibility.reason);
|
|
168
|
+
}
|
|
169
|
+
return typed;
|
|
170
|
+
}
|
|
171
|
+
function hexDigest(value, path) {
|
|
172
|
+
if (typeof value !== 'string' || !hex64Pattern.test(value)) {
|
|
173
|
+
throw new Error(`${path} must be a SHA-256 hex digest.`);
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
function isoTimestamp(value, path) {
|
|
178
|
+
if (typeof value !== 'string' || !isoLikePattern.test(value) || Number.isNaN(Date.parse(value))) {
|
|
179
|
+
throw new Error(`${path} must be a valid ISO timestamp.`);
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
function dateDaysBetween(start, end) {
|
|
184
|
+
return (Date.parse(end) - Date.parse(start)) / (24 * 60 * 60 * 1000);
|
|
185
|
+
}
|
|
186
|
+
function addDaysIso(start, days) {
|
|
187
|
+
return new Date(Date.parse(start) + days * 24 * 60 * 60 * 1000).toISOString();
|
|
188
|
+
}
|
|
189
|
+
function exactStringSet(value, expected, path) {
|
|
190
|
+
const sortedValue = [...value].sort();
|
|
191
|
+
const sortedExpected = [...expected].sort();
|
|
192
|
+
if (sortedValue.length !== sortedExpected.length ||
|
|
193
|
+
sortedValue.some((entry, index) => entry !== sortedExpected[index])) {
|
|
194
|
+
throw new Error(`${path} must exactly equal ${expected.join(', ')}.`);
|
|
195
|
+
}
|
|
196
|
+
return expected;
|
|
197
|
+
}
|
|
198
|
+
function assertNoDuplicateStrings(value, path) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
for (const entry of value) {
|
|
201
|
+
if (seen.has(entry)) {
|
|
202
|
+
throw new Error(`${path} contains duplicate value ${entry}.`);
|
|
203
|
+
}
|
|
204
|
+
seen.add(entry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function validateCredentialRepositoryIdentity(value, path) {
|
|
208
|
+
const repository = exact(value, ['id', 'owner', 'name', 'fullName'], path);
|
|
209
|
+
const owner = stringField(repository, 'owner', path);
|
|
210
|
+
const name = stringField(repository, 'name', path);
|
|
211
|
+
const fullName = stringField(repository, 'fullName', path);
|
|
212
|
+
if (fullName !== `${owner}/${name}`) {
|
|
213
|
+
throw new Error(`${path}.fullName must equal owner/name.`);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
id: stringField(repository, 'id', path),
|
|
217
|
+
owner,
|
|
218
|
+
name,
|
|
219
|
+
fullName
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function assertTimestampNotExpired(value, path, now) {
|
|
223
|
+
if (Date.parse(value) <= now.getTime()) {
|
|
224
|
+
throw new Error(`${path} must be in the future.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function validateEvidenceTransitionIdentity(value, path) {
|
|
228
|
+
const transition = exact(value, ['phaseId', 'baselineSha', 'inputDigest', 'transitionDigest'], path);
|
|
229
|
+
return {
|
|
230
|
+
phaseId: enumValue(transition.phaseId, phaseIdSet, `${path}.phaseId`),
|
|
231
|
+
baselineSha: hexDigest(transition.baselineSha, `${path}.baselineSha`),
|
|
232
|
+
inputDigest: hexDigest(transition.inputDigest, `${path}.inputDigest`),
|
|
233
|
+
transitionDigest: hexDigest(transition.transitionDigest, `${path}.transitionDigest`)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function validateDependency(value, path) {
|
|
237
|
+
const dependency = exact(value, ['anyOf', 'accepts', 'description'], path);
|
|
238
|
+
const anyOf = stringArray(dependency.anyOf, `${path}.anyOf`).map((id) => enumValue(id, phaseIdSet, `${path}.anyOf`));
|
|
239
|
+
if (anyOf.length === 0) {
|
|
240
|
+
throw new Error(`${path}.anyOf must not be empty.`);
|
|
241
|
+
}
|
|
242
|
+
const accepts = stringArray(dependency.accepts, `${path}.accepts`).map((state) => enumValue(state, terminalPhaseStateSet, `${path}.accepts`));
|
|
243
|
+
if (accepts.length === 0) {
|
|
244
|
+
throw new Error(`${path}.accepts must not be empty.`);
|
|
245
|
+
}
|
|
246
|
+
return { anyOf, accepts, description: stringField(dependency, 'description', path) };
|
|
247
|
+
}
|
|
248
|
+
function validateApplicability(value, path) {
|
|
249
|
+
const base = record(value, path);
|
|
250
|
+
if (base.kind === 'always') {
|
|
251
|
+
exact(value, ['kind'], path);
|
|
252
|
+
return { kind: 'always' };
|
|
253
|
+
}
|
|
254
|
+
const applicability = exact(value, [
|
|
255
|
+
'kind',
|
|
256
|
+
'discriminator',
|
|
257
|
+
'when',
|
|
258
|
+
'inapplicableWhen',
|
|
259
|
+
'exclusiveWith'
|
|
260
|
+
], path);
|
|
261
|
+
enumValue(applicability.kind, new Set(['conditional']), `${path}.kind`);
|
|
262
|
+
const discriminator = enumValue(applicability.discriminator, new Set(['state-path', 'private-staging-dast', 'credential-required']), `${path}.discriminator`);
|
|
263
|
+
const exclusiveWith = stringArray(applicability.exclusiveWith, `${path}.exclusiveWith`).map((id) => enumValue(id, phaseIdSet, `${path}.exclusiveWith`));
|
|
264
|
+
return {
|
|
265
|
+
kind: 'conditional',
|
|
266
|
+
discriminator,
|
|
267
|
+
when: stringField(applicability, 'when', path),
|
|
268
|
+
inapplicableWhen: stringField(applicability, 'inapplicableWhen', path),
|
|
269
|
+
exclusiveWith
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function validateMutations(value, path) {
|
|
273
|
+
const mutations = exact(value, ['local', 'remote'], path);
|
|
274
|
+
const mutationSet = new Set(mutationClasses);
|
|
275
|
+
const local = stringArray(mutations.local, `${path}.local`).map((entry) => enumValue(entry, mutationSet, `${path}.local`));
|
|
276
|
+
const remote = stringArray(mutations.remote, `${path}.remote`).map((entry) => enumValue(entry, mutationSet, `${path}.remote`));
|
|
277
|
+
for (const [label, entries] of [['local', local], ['remote', remote]]) {
|
|
278
|
+
if (entries.length === 0) {
|
|
279
|
+
throw new Error(`${path}.${label} must not be empty.`);
|
|
280
|
+
}
|
|
281
|
+
if (entries.includes('none') && entries.length > 1) {
|
|
282
|
+
throw new Error(`${path}.${label} cannot combine none with other mutations.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { local, remote };
|
|
286
|
+
}
|
|
287
|
+
function validateNode(value, path) {
|
|
288
|
+
const node = exact(value, [
|
|
289
|
+
'id',
|
|
290
|
+
'label',
|
|
291
|
+
'dependencies',
|
|
292
|
+
'applicability',
|
|
293
|
+
'allowedMutations',
|
|
294
|
+
'evidence',
|
|
295
|
+
'approvalGate',
|
|
296
|
+
'invalidationInputs',
|
|
297
|
+
'rollback',
|
|
298
|
+
'terminalStates'
|
|
299
|
+
], path);
|
|
300
|
+
const id = enumValue(node.id, phaseIdSet, `${path}.id`);
|
|
301
|
+
const label = stringField(node, 'label', path);
|
|
302
|
+
if (!Array.isArray(node.dependencies)) {
|
|
303
|
+
throw new Error(`${path}.dependencies must be an array.`);
|
|
304
|
+
}
|
|
305
|
+
const dependencies = node.dependencies.map((dependency, index) => validateDependency(dependency, `${path}.dependencies[${index}]`));
|
|
306
|
+
const applicability = validateApplicability(node.applicability, `${path}.applicability`);
|
|
307
|
+
const allowedMutations = validateMutations(node.allowedMutations, `${path}.allowedMutations`);
|
|
308
|
+
const evidence = exact(node.evidence, ['schema', 'required', 'headerSchemaVersion', 'liveReadbackProviders'], `${path}.evidence`);
|
|
309
|
+
requireVersion(evidence.headerSchemaVersion, evidenceHeaderSchemaVersion, `${path}.evidence.headerSchemaVersion`);
|
|
310
|
+
const liveReadbackProviders = stringArray(evidence.liveReadbackProviders, `${path}.evidence.liveReadbackProviders`).map((provider) => enumValue(provider, liveReadbackProviderSet, `${path}.evidence.liveReadbackProviders`));
|
|
311
|
+
if (new Set(liveReadbackProviders).size !== liveReadbackProviders.length) {
|
|
312
|
+
throw new Error(`${path}.evidence.liveReadbackProviders must not contain duplicates.`);
|
|
313
|
+
}
|
|
314
|
+
const approvalGate = exact(node.approvalGate, ['kind', 'required', 'envelopeSchemaVersion'], `${path}.approvalGate`);
|
|
315
|
+
const approvalKind = enumValue(approvalGate.kind, new Set(approvalGateKinds), `${path}.approvalGate.kind`);
|
|
316
|
+
requireVersion(approvalGate.envelopeSchemaVersion, approvalEnvelopeSchemaVersion, `${path}.approvalGate.envelopeSchemaVersion`);
|
|
317
|
+
const invalidationSet = new Set(invalidationInputKinds);
|
|
318
|
+
const invalidationInputs = stringArray(node.invalidationInputs, `${path}.invalidationInputs`).map((entry) => enumValue(entry, invalidationSet, `${path}.invalidationInputs`));
|
|
319
|
+
const rollback = exact(node.rollback, ['kind', 'target', 'description'], `${path}.rollback`);
|
|
320
|
+
const rollbackKind = enumValue(rollback.kind, new Set(rollbackKinds), `${path}.rollback.kind`);
|
|
321
|
+
const target = rollback.target === null
|
|
322
|
+
? null
|
|
323
|
+
: enumValue(rollback.target, phaseIdSet, `${path}.rollback.target`);
|
|
324
|
+
const terminalStates = stringArray(node.terminalStates, `${path}.terminalStates`).map((state) => enumValue(state, terminalPhaseStateSet, `${path}.terminalStates`));
|
|
325
|
+
if (terminalStates.length === 0) {
|
|
326
|
+
throw new Error(`${path}.terminalStates must not be empty.`);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
id,
|
|
330
|
+
label,
|
|
331
|
+
dependencies,
|
|
332
|
+
applicability,
|
|
333
|
+
allowedMutations,
|
|
334
|
+
evidence: {
|
|
335
|
+
schema: stringField(evidence, 'schema', `${path}.evidence`),
|
|
336
|
+
required: booleanField(evidence, 'required', `${path}.evidence`),
|
|
337
|
+
headerSchemaVersion: evidenceHeaderSchemaVersion,
|
|
338
|
+
liveReadbackProviders
|
|
339
|
+
},
|
|
340
|
+
approvalGate: {
|
|
341
|
+
kind: approvalKind,
|
|
342
|
+
required: booleanField(approvalGate, 'required', `${path}.approvalGate`),
|
|
343
|
+
envelopeSchemaVersion: approvalEnvelopeSchemaVersion
|
|
344
|
+
},
|
|
345
|
+
invalidationInputs,
|
|
346
|
+
rollback: {
|
|
347
|
+
kind: rollbackKind,
|
|
348
|
+
target,
|
|
349
|
+
description: stringField(rollback, 'description', `${path}.rollback`)
|
|
350
|
+
},
|
|
351
|
+
terminalStates
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function hasDependency(node, phaseId) {
|
|
355
|
+
return node.dependencies.some((dependency) => dependency.anyOf.includes(phaseId));
|
|
356
|
+
}
|
|
357
|
+
function assertReachableToFinal(nodes) {
|
|
358
|
+
const adjacency = new Map();
|
|
359
|
+
for (const id of phaseIds) {
|
|
360
|
+
adjacency.set(id, []);
|
|
361
|
+
}
|
|
362
|
+
for (const node of nodes) {
|
|
363
|
+
for (const dependency of node.dependencies) {
|
|
364
|
+
for (const parent of dependency.anyOf) {
|
|
365
|
+
adjacency.get(parent)?.push(node.id);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const start of phaseIds) {
|
|
370
|
+
if (start === 'bootstrap-state-disposed') {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
let reachesFinal = false;
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
const stack = [start];
|
|
376
|
+
while (stack.length > 0) {
|
|
377
|
+
const id = stack.pop();
|
|
378
|
+
if (id === 'bootstrap-state-disposed') {
|
|
379
|
+
reachesFinal = true;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
if (seen.has(id)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
seen.add(id);
|
|
386
|
+
stack.push(...(adjacency.get(id) ?? []));
|
|
387
|
+
}
|
|
388
|
+
if (!reachesFinal) {
|
|
389
|
+
throw new Error(`${start} cannot reach terminal bootstrap-state-disposed.`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export function validateManagedPhaseGraph(value) {
|
|
394
|
+
const graph = exact(value, ['schemaVersion', 'versions', 'phases'], 'phaseGraph');
|
|
395
|
+
requireVersion(graph.schemaVersion, phaseGraphSchemaVersion, 'phaseGraph.schemaVersion');
|
|
396
|
+
const versions = exact(graph.versions, [
|
|
397
|
+
'liftoffVersion',
|
|
398
|
+
'policyVersion',
|
|
399
|
+
'activationContractVersion',
|
|
400
|
+
'phaseGraphSchemaVersion'
|
|
401
|
+
], 'phaseGraph.versions');
|
|
402
|
+
requireVersion(versions.liftoffVersion, liftoffActivationPackageVersion, 'phaseGraph.versions.liftoffVersion');
|
|
403
|
+
requireVersion(versions.policyVersion, governanceActivationPolicyVersion, 'phaseGraph.versions.policyVersion');
|
|
404
|
+
requireVersion(versions.activationContractVersion, activationContractVersion, 'phaseGraph.versions.activationContractVersion');
|
|
405
|
+
requireVersion(versions.phaseGraphSchemaVersion, phaseGraphSchemaVersion, 'phaseGraph.versions.phaseGraphSchemaVersion');
|
|
406
|
+
if (!Array.isArray(graph.phases)) {
|
|
407
|
+
throw new Error('phaseGraph.phases must be an array.');
|
|
408
|
+
}
|
|
409
|
+
const nodes = graph.phases.map((node, index) => validateNode(node, `phaseGraph.phases[${index}]`));
|
|
410
|
+
if (nodes.length !== phaseIds.length) {
|
|
411
|
+
throw new Error('phaseGraph must declare every canonical phase exactly once.');
|
|
412
|
+
}
|
|
413
|
+
const seen = new Set();
|
|
414
|
+
for (const [index, node] of nodes.entries()) {
|
|
415
|
+
if (seen.has(node.id)) {
|
|
416
|
+
throw new Error(`Duplicate phase id ${node.id}.`);
|
|
417
|
+
}
|
|
418
|
+
seen.add(node.id);
|
|
419
|
+
if (node.id !== phaseIds[index]) {
|
|
420
|
+
throw new Error(`Phase ${node.id} is not in canonical order at index ${index}.`);
|
|
421
|
+
}
|
|
422
|
+
for (const dependency of node.dependencies) {
|
|
423
|
+
for (const dependencyId of dependency.anyOf) {
|
|
424
|
+
const dependencyIndex = phaseIds.indexOf(dependencyId);
|
|
425
|
+
if (dependencyIndex >= index) {
|
|
426
|
+
throw new Error(`Reversed dependency order: ${node.id} depends on ${dependencyId}.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (node.rollback.target !== null && phaseIds.indexOf(node.rollback.target) >= index) {
|
|
431
|
+
throw new Error(`Rollback target for ${node.id} must be an earlier phase.`);
|
|
432
|
+
}
|
|
433
|
+
if (node.approvalGate.required && node.approvalGate.kind === 'none') {
|
|
434
|
+
throw new Error(`${node.id} cannot require approval gate none.`);
|
|
435
|
+
}
|
|
436
|
+
if (!node.approvalGate.required && node.approvalGate.kind !== 'none') {
|
|
437
|
+
throw new Error(`${node.id} has a non-none optional approval gate.`);
|
|
438
|
+
}
|
|
439
|
+
const requiredLiveReadbackProviders = [
|
|
440
|
+
...(node.allowedMutations.remote.some((mutation) => githubRemoteWriteMutations.has(mutation)) ? ['github'] : []),
|
|
441
|
+
...(node.allowedMutations.remote.some((mutation) => azureRemoteWriteMutations.has(mutation)) ? ['azure'] : [])
|
|
442
|
+
];
|
|
443
|
+
for (const provider of requiredLiveReadbackProviders) {
|
|
444
|
+
if (!node.evidence.liveReadbackProviders.includes(provider)) {
|
|
445
|
+
throw new Error(`${node.id} must declare ${provider} live readback proof for remote mutation evidence.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (requiredLiveReadbackProviders.length === 0 && node.evidence.liveReadbackProviders.length > 0) {
|
|
449
|
+
throw new Error(`${node.id} declares live readback proof without a GitHub or Azure remote mutation.`);
|
|
450
|
+
}
|
|
451
|
+
if (node.applicability.kind === 'conditional') {
|
|
452
|
+
for (const exclusiveId of node.applicability.exclusiveWith) {
|
|
453
|
+
const other = nodes.find((candidate) => candidate.id === exclusiveId);
|
|
454
|
+
if (!other || other.applicability.kind !== 'conditional') {
|
|
455
|
+
throw new Error(`${node.id} exclusive phase ${exclusiveId} must be conditional.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!node.terminalStates.includes('inapplicable')) {
|
|
459
|
+
throw new Error(`${node.id} conditional phase must permit inapplicable terminal state.`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const id of phaseIds) {
|
|
464
|
+
if (!seen.has(id)) {
|
|
465
|
+
throw new Error(`Missing canonical phase ${id}.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const byId = Object.fromEntries(nodes.map((node) => [node.id, node]));
|
|
469
|
+
if (!hasDependency(byId['bootstrap-local'], 'provider-ready')) {
|
|
470
|
+
throw new Error('bootstrap-local must depend on provider-ready.');
|
|
471
|
+
}
|
|
472
|
+
if (!hasDependency(byId['private-backend-proof'], 'runner-ready')) {
|
|
473
|
+
throw new Error('private-backend-proof must depend on runner-ready.');
|
|
474
|
+
}
|
|
475
|
+
if (!hasDependency(byId['remote-import-verified'], 'private-backend-proof')) {
|
|
476
|
+
throw new Error('remote-import-verified must depend on private-backend-proof.');
|
|
477
|
+
}
|
|
478
|
+
if (!hasDependency(byId['remote-ready'], 'remote-import-verified')) {
|
|
479
|
+
throw new Error('remote-ready must depend on remote-import-verified.');
|
|
480
|
+
}
|
|
481
|
+
const visiting = new Set();
|
|
482
|
+
const visited = new Set();
|
|
483
|
+
const visit = (id) => {
|
|
484
|
+
if (visiting.has(id)) {
|
|
485
|
+
throw new Error(`Cycle detected at ${id}.`);
|
|
486
|
+
}
|
|
487
|
+
if (visited.has(id)) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
visiting.add(id);
|
|
491
|
+
for (const dependency of byId[id].dependencies) {
|
|
492
|
+
for (const dependencyId of dependency.anyOf) {
|
|
493
|
+
visit(dependencyId);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
visiting.delete(id);
|
|
497
|
+
visited.add(id);
|
|
498
|
+
};
|
|
499
|
+
for (const id of phaseIds) {
|
|
500
|
+
visit(id);
|
|
501
|
+
}
|
|
502
|
+
assertReachableToFinal(nodes);
|
|
503
|
+
return {
|
|
504
|
+
schemaVersion: phaseGraphSchemaVersion,
|
|
505
|
+
versions: {
|
|
506
|
+
liftoffVersion: liftoffActivationPackageVersion,
|
|
507
|
+
policyVersion: governanceActivationPolicyVersion,
|
|
508
|
+
activationContractVersion,
|
|
509
|
+
phaseGraphSchemaVersion
|
|
510
|
+
},
|
|
511
|
+
phases: nodes
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
export function assertCanonicalGraphValid() {
|
|
515
|
+
validateManagedPhaseGraph(canonicalPhaseGraph);
|
|
516
|
+
}
|
|
517
|
+
function validateEvidenceReference(value, path) {
|
|
518
|
+
const reference = exact(value, ['phaseId', 'evidenceId', 'headerDigest', 'result'], path);
|
|
519
|
+
const phaseId = enumValue(reference.phaseId, phaseIdSet, `${path}.phaseId`);
|
|
520
|
+
const result = enumValue(reference.result, resultStateSet, `${path}.result`);
|
|
521
|
+
const headerDigest = stringField(reference, 'headerDigest', path);
|
|
522
|
+
if (!hex64Pattern.test(headerDigest)) {
|
|
523
|
+
throw new Error(`${path}.headerDigest must be a SHA-256 hex digest.`);
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
phaseId,
|
|
527
|
+
evidenceId: stringField(reference, 'evidenceId', path),
|
|
528
|
+
headerDigest,
|
|
529
|
+
result
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
export function validateUserActivationState(value) {
|
|
533
|
+
const state = exactWithOptional(value, [
|
|
534
|
+
'schemaVersion',
|
|
535
|
+
'identity',
|
|
536
|
+
'repository',
|
|
537
|
+
'activeChange',
|
|
538
|
+
'applicability',
|
|
539
|
+
'phases',
|
|
540
|
+
'createdAt',
|
|
541
|
+
'updatedAt'
|
|
542
|
+
], ['bootstrapState'], 'activationState');
|
|
543
|
+
requireVersion(state.schemaVersion, activationStateSchemaVersion, 'activationState.schemaVersion');
|
|
544
|
+
const identity = validateActivationIdentity(state.identity);
|
|
545
|
+
if (identity.phaseGraphHash !== canonicalPhaseGraphHash) {
|
|
546
|
+
throw new Error('activationState.identity.phaseGraphHash does not match canonical graph.');
|
|
547
|
+
}
|
|
548
|
+
const repository = exact(state.repository, ['id', 'name', 'defaultBranch'], 'activationState.repository');
|
|
549
|
+
let activeChange = null;
|
|
550
|
+
if (state.activeChange !== null) {
|
|
551
|
+
const change = exact(state.activeChange, ['id', 'kind'], 'activationState.activeChange');
|
|
552
|
+
activeChange = {
|
|
553
|
+
id: stringField(change, 'id', 'activationState.activeChange'),
|
|
554
|
+
kind: enumValue(change.kind, new Set(['openspec', 'spec-kit']), 'activationState.activeChange.kind')
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const applicability = exact(state.applicability, [
|
|
558
|
+
'statePath',
|
|
559
|
+
'privateStagingDast',
|
|
560
|
+
'credentialRequired'
|
|
561
|
+
], 'activationState.applicability');
|
|
562
|
+
const phases = record(state.phases, 'activationState.phases');
|
|
563
|
+
const phaseStatesById = {};
|
|
564
|
+
for (const id of phaseIds) {
|
|
565
|
+
if (!Object.hasOwn(phases, id)) {
|
|
566
|
+
throw new Error(`activationState.phases.${id} is required.`);
|
|
567
|
+
}
|
|
568
|
+
const phase = exact(phases[id], ['state', 'updatedAt', 'evidence', 'approvals', 'blockers'], `activationState.phases.${id}`);
|
|
569
|
+
const phaseState = enumValue(phase.state, new Set(phaseStates), `activationState.phases.${id}.state`);
|
|
570
|
+
if (!Array.isArray(phase.evidence) || !Array.isArray(phase.approvals) || !Array.isArray(phase.blockers)) {
|
|
571
|
+
throw new Error(`activationState.phases.${id} evidence, approvals, and blockers must be arrays.`);
|
|
572
|
+
}
|
|
573
|
+
phaseStatesById[id] = {
|
|
574
|
+
state: phaseState,
|
|
575
|
+
updatedAt: stringField(phase, 'updatedAt', `activationState.phases.${id}`),
|
|
576
|
+
evidence: phase.evidence.map((entry, index) => validateEvidenceReference(entry, `activationState.phases.${id}.evidence[${index}]`)),
|
|
577
|
+
approvals: stringArray(phase.approvals, `activationState.phases.${id}.approvals`),
|
|
578
|
+
blockers: stringArray(phase.blockers, `activationState.phases.${id}.blockers`)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
for (const key of Object.keys(phases)) {
|
|
582
|
+
if (!phaseIdSet.has(key)) {
|
|
583
|
+
throw new Error(`activationState.phases.${key} is not a canonical phase.`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
let bootstrapState;
|
|
587
|
+
if (state.bootstrapState !== undefined) {
|
|
588
|
+
const retention = exactWithOptional(state.bootstrapState, [
|
|
589
|
+
'status',
|
|
590
|
+
'remoteImportEvidenceId',
|
|
591
|
+
'remoteImportEvidenceDigest',
|
|
592
|
+
'retainedAt',
|
|
593
|
+
'disposeAfter',
|
|
594
|
+
'encryptedStatePathParts',
|
|
595
|
+
'encryptionKeyPathParts'
|
|
596
|
+
], ['disposedAt', 'deletionEvidenceId', 'incompleteCleanup'], 'activationState.bootstrapState');
|
|
597
|
+
const retainedAt = isoTimestamp(retention.retainedAt, 'activationState.bootstrapState.retainedAt');
|
|
598
|
+
const disposeAfter = isoTimestamp(retention.disposeAfter, 'activationState.bootstrapState.disposeAfter');
|
|
599
|
+
if (Date.parse(disposeAfter) - Date.parse(retainedAt) !== 30 * 24 * 60 * 60 * 1000) {
|
|
600
|
+
throw new Error('activationState.bootstrapState.disposeAfter must be exactly 30 days after retainedAt.');
|
|
601
|
+
}
|
|
602
|
+
const status = enumValue(retention.status, new Set(['retained', 'disposed']), 'activationState.bootstrapState.status');
|
|
603
|
+
if (status === 'disposed' && retention.disposedAt === undefined) {
|
|
604
|
+
throw new Error('activationState.bootstrapState.disposedAt is required when status is disposed.');
|
|
605
|
+
}
|
|
606
|
+
bootstrapState = {
|
|
607
|
+
status,
|
|
608
|
+
remoteImportEvidenceId: stringField(retention, 'remoteImportEvidenceId', 'activationState.bootstrapState'),
|
|
609
|
+
remoteImportEvidenceDigest: hexDigest(retention.remoteImportEvidenceDigest, 'activationState.bootstrapState.remoteImportEvidenceDigest'),
|
|
610
|
+
retainedAt,
|
|
611
|
+
disposeAfter,
|
|
612
|
+
encryptedStatePathParts: pathPartsArray(retention.encryptedStatePathParts, 'activationState.bootstrapState.encryptedStatePathParts'),
|
|
613
|
+
encryptionKeyPathParts: pathPartsArray(retention.encryptionKeyPathParts, 'activationState.bootstrapState.encryptionKeyPathParts'),
|
|
614
|
+
...(retention.disposedAt !== undefined ? { disposedAt: isoTimestamp(retention.disposedAt, 'activationState.bootstrapState.disposedAt') } : {}),
|
|
615
|
+
...(retention.deletionEvidenceId !== undefined ? { deletionEvidenceId: stringField(retention, 'deletionEvidenceId', 'activationState.bootstrapState') } : {}),
|
|
616
|
+
...(retention.incompleteCleanup !== undefined ? { incompleteCleanup: stringArray(retention.incompleteCleanup, 'activationState.bootstrapState.incompleteCleanup') } : {})
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
schemaVersion: activationStateSchemaVersion,
|
|
621
|
+
identity,
|
|
622
|
+
repository: {
|
|
623
|
+
id: stringField(repository, 'id', 'activationState.repository'),
|
|
624
|
+
name: stringField(repository, 'name', 'activationState.repository'),
|
|
625
|
+
defaultBranch: stringField(repository, 'defaultBranch', 'activationState.repository')
|
|
626
|
+
},
|
|
627
|
+
activeChange,
|
|
628
|
+
applicability: {
|
|
629
|
+
statePath: enumValue(applicability.statePath, new Set(['existing-private', 'bootstrap-local', 'none']), 'activationState.applicability.statePath'),
|
|
630
|
+
privateStagingDast: booleanField(applicability, 'privateStagingDast', 'activationState.applicability'),
|
|
631
|
+
credentialRequired: booleanField(applicability, 'credentialRequired', 'activationState.applicability')
|
|
632
|
+
},
|
|
633
|
+
...(bootstrapState ? { bootstrapState } : {}),
|
|
634
|
+
phases: phaseStatesById,
|
|
635
|
+
createdAt: stringField(state, 'createdAt', 'activationState'),
|
|
636
|
+
updatedAt: stringField(state, 'updatedAt', 'activationState')
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
export function validateEvidenceHeader(value) {
|
|
640
|
+
const header = exact(value, [
|
|
641
|
+
'schemaVersion',
|
|
642
|
+
'repositoryId',
|
|
643
|
+
'identity',
|
|
644
|
+
'phaseGraphHash',
|
|
645
|
+
'phaseId',
|
|
646
|
+
'phaseContractDigest',
|
|
647
|
+
'inputDigest',
|
|
648
|
+
'baselineSha',
|
|
649
|
+
'transition',
|
|
650
|
+
'producedAt',
|
|
651
|
+
'producer',
|
|
652
|
+
'result'
|
|
653
|
+
], 'evidenceHeader');
|
|
654
|
+
requireVersion(header.schemaVersion, evidenceHeaderSchemaVersion, 'evidenceHeader.schemaVersion');
|
|
655
|
+
const identity = validateActivationIdentityShape(header.identity, 'evidenceHeader.identity');
|
|
656
|
+
const phaseGraphHash = hexDigest(header.phaseGraphHash, 'evidenceHeader.phaseGraphHash');
|
|
657
|
+
if (phaseGraphHash !== identity.phaseGraphHash) {
|
|
658
|
+
throw new Error('evidenceHeader.phaseGraphHash must match evidenceHeader.identity.phaseGraphHash.');
|
|
659
|
+
}
|
|
660
|
+
const phaseId = enumValue(header.phaseId, phaseIdSet, 'evidenceHeader.phaseId');
|
|
661
|
+
const phaseContractDigest = hexDigest(header.phaseContractDigest, 'evidenceHeader.phaseContractDigest');
|
|
662
|
+
const inputDigest = hexDigest(header.inputDigest, 'evidenceHeader.inputDigest');
|
|
663
|
+
const baselineSha = hexDigest(header.baselineSha, 'evidenceHeader.baselineSha');
|
|
664
|
+
const transition = validateEvidenceTransitionIdentity(header.transition, 'evidenceHeader.transition');
|
|
665
|
+
if (transition.phaseId !== phaseId) {
|
|
666
|
+
throw new Error('evidenceHeader.transition.phaseId must match evidenceHeader.phaseId.');
|
|
667
|
+
}
|
|
668
|
+
if (transition.baselineSha !== baselineSha) {
|
|
669
|
+
throw new Error('evidenceHeader.transition.baselineSha must match evidenceHeader.baselineSha.');
|
|
670
|
+
}
|
|
671
|
+
if (transition.inputDigest !== inputDigest) {
|
|
672
|
+
throw new Error('evidenceHeader.transition.inputDigest must match evidenceHeader.inputDigest.');
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
schemaVersion: evidenceHeaderSchemaVersion,
|
|
676
|
+
repositoryId: stringField(header, 'repositoryId', 'evidenceHeader'),
|
|
677
|
+
identity,
|
|
678
|
+
phaseGraphHash,
|
|
679
|
+
phaseId,
|
|
680
|
+
phaseContractDigest,
|
|
681
|
+
inputDigest,
|
|
682
|
+
baselineSha,
|
|
683
|
+
transition,
|
|
684
|
+
producedAt: isoTimestamp(header.producedAt, 'evidenceHeader.producedAt'),
|
|
685
|
+
producer: stringField(header, 'producer', 'evidenceHeader'),
|
|
686
|
+
result: enumValue(header.result, resultStateSet, 'evidenceHeader.result')
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
export function validateLiveReadbackProof(value) {
|
|
690
|
+
const proof = exact(value, [
|
|
691
|
+
'schemaVersion',
|
|
692
|
+
'repositoryId',
|
|
693
|
+
'identity',
|
|
694
|
+
'phaseGraphHash',
|
|
695
|
+
'phaseId',
|
|
696
|
+
'baselineSha',
|
|
697
|
+
'inputDigest',
|
|
698
|
+
'transition',
|
|
699
|
+
'observedAt',
|
|
700
|
+
'provider',
|
|
701
|
+
'resourceType',
|
|
702
|
+
'resourceId',
|
|
703
|
+
'sourceDigest',
|
|
704
|
+
'readbackDigest',
|
|
705
|
+
'matches'
|
|
706
|
+
], 'liveReadbackProof');
|
|
707
|
+
requireVersion(proof.schemaVersion, evidenceHeaderSchemaVersion, 'liveReadbackProof.schemaVersion');
|
|
708
|
+
const identity = validateActivationIdentityShape(proof.identity, 'liveReadbackProof.identity');
|
|
709
|
+
const phaseGraphHash = hexDigest(proof.phaseGraphHash, 'liveReadbackProof.phaseGraphHash');
|
|
710
|
+
if (phaseGraphHash !== identity.phaseGraphHash) {
|
|
711
|
+
throw new Error('liveReadbackProof.phaseGraphHash must match liveReadbackProof.identity.phaseGraphHash.');
|
|
712
|
+
}
|
|
713
|
+
const phaseId = enumValue(proof.phaseId, phaseIdSet, 'liveReadbackProof.phaseId');
|
|
714
|
+
const baselineSha = hexDigest(proof.baselineSha, 'liveReadbackProof.baselineSha');
|
|
715
|
+
const inputDigest = hexDigest(proof.inputDigest, 'liveReadbackProof.inputDigest');
|
|
716
|
+
const transition = validateEvidenceTransitionIdentity(proof.transition, 'liveReadbackProof.transition');
|
|
717
|
+
if (transition.phaseId !== phaseId) {
|
|
718
|
+
throw new Error('liveReadbackProof.transition.phaseId must match liveReadbackProof.phaseId.');
|
|
719
|
+
}
|
|
720
|
+
if (transition.baselineSha !== baselineSha) {
|
|
721
|
+
throw new Error('liveReadbackProof.transition.baselineSha must match liveReadbackProof.baselineSha.');
|
|
722
|
+
}
|
|
723
|
+
if (transition.inputDigest !== inputDigest) {
|
|
724
|
+
throw new Error('liveReadbackProof.transition.inputDigest must match liveReadbackProof.inputDigest.');
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
schemaVersion: evidenceHeaderSchemaVersion,
|
|
728
|
+
repositoryId: stringField(proof, 'repositoryId', 'liveReadbackProof'),
|
|
729
|
+
identity,
|
|
730
|
+
phaseGraphHash,
|
|
731
|
+
phaseId,
|
|
732
|
+
baselineSha,
|
|
733
|
+
inputDigest,
|
|
734
|
+
transition,
|
|
735
|
+
observedAt: isoTimestamp(proof.observedAt, 'liveReadbackProof.observedAt'),
|
|
736
|
+
provider: enumValue(proof.provider, new Set(['github', 'azure']), 'liveReadbackProof.provider'),
|
|
737
|
+
resourceType: stringField(proof, 'resourceType', 'liveReadbackProof'),
|
|
738
|
+
resourceId: stringField(proof, 'resourceId', 'liveReadbackProof'),
|
|
739
|
+
sourceDigest: hexDigest(proof.sourceDigest, 'liveReadbackProof.sourceDigest'),
|
|
740
|
+
readbackDigest: hexDigest(proof.readbackDigest, 'liveReadbackProof.readbackDigest'),
|
|
741
|
+
matches: booleanField(proof, 'matches', 'liveReadbackProof')
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function validateApprovalResource(value, path) {
|
|
745
|
+
const resource = exact(value, ['type', 'identity'], path);
|
|
746
|
+
return {
|
|
747
|
+
type: stringField(resource, 'type', path),
|
|
748
|
+
identity: stringField(resource, 'identity', path)
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function validateApprovalDestination(value, path) {
|
|
752
|
+
const destination = exact(value, ['type', 'identity', 'repository', 'subscriptionId'], path);
|
|
753
|
+
return {
|
|
754
|
+
type: enumValue(destination.type, new Set(['repository', 'subscription', 'environment', 'tenant', 'local', 'external']), `${path}.type`),
|
|
755
|
+
identity: stringField(destination, 'identity', path),
|
|
756
|
+
repository: destination.repository === null ? null : stringField(destination, 'repository', path),
|
|
757
|
+
subscriptionId: destination.subscriptionId === null ? null : stringField(destination, 'subscriptionId', path)
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function identityMatches(left, right) {
|
|
761
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
762
|
+
}
|
|
763
|
+
export function validateApprovalEnvelope(value, options = {}) {
|
|
764
|
+
const envelope = exact(value, [
|
|
765
|
+
'schemaVersion',
|
|
766
|
+
'id',
|
|
767
|
+
'phaseId',
|
|
768
|
+
'gateKind',
|
|
769
|
+
'identity',
|
|
770
|
+
'baselineSha',
|
|
771
|
+
'planDigest',
|
|
772
|
+
'resources',
|
|
773
|
+
'destinations',
|
|
774
|
+
'permissions',
|
|
775
|
+
'costCeiling',
|
|
776
|
+
'policyExceptions',
|
|
777
|
+
'destructiveScope',
|
|
778
|
+
'expiresAt',
|
|
779
|
+
'approvedAt',
|
|
780
|
+
'approver'
|
|
781
|
+
], 'approvalEnvelope');
|
|
782
|
+
requireVersion(envelope.schemaVersion, approvalEnvelopeSchemaVersion, 'approvalEnvelope.schemaVersion');
|
|
783
|
+
const cost = exact(envelope.costCeiling, [
|
|
784
|
+
'currency',
|
|
785
|
+
'fixedMonthlyCents',
|
|
786
|
+
'usageMonthlyCents'
|
|
787
|
+
], 'approvalEnvelope.costCeiling');
|
|
788
|
+
const baselineSha = stringField(envelope, 'baselineSha', 'approvalEnvelope');
|
|
789
|
+
const planDigest = stringField(envelope, 'planDigest', 'approvalEnvelope');
|
|
790
|
+
if (!hex64Pattern.test(baselineSha) || !hex64Pattern.test(planDigest)) {
|
|
791
|
+
throw new Error('approvalEnvelope baselineSha and planDigest must be SHA-256 hex digests.');
|
|
792
|
+
}
|
|
793
|
+
const identity = validateActivationIdentity(envelope.identity);
|
|
794
|
+
if (options.expectedIdentity && !identityMatches(identity, options.expectedIdentity)) {
|
|
795
|
+
throw new Error('approvalEnvelope.identity does not match the active activation identity.');
|
|
796
|
+
}
|
|
797
|
+
const expiresAt = isoTimestamp(envelope.expiresAt, 'approvalEnvelope.expiresAt');
|
|
798
|
+
if (options.requireUnexpired) {
|
|
799
|
+
assertTimestampNotExpired(expiresAt, 'approvalEnvelope.expiresAt', options.now ?? new Date());
|
|
800
|
+
}
|
|
801
|
+
const resources = Array.isArray(envelope.resources)
|
|
802
|
+
? normalizeApprovalResources(envelope.resources.map((entry, index) => validateApprovalResource(entry, `approvalEnvelope.resources[${index}]`)))
|
|
803
|
+
: (() => { throw new Error('approvalEnvelope.resources must be an array.'); })();
|
|
804
|
+
const destinations = Array.isArray(envelope.destinations)
|
|
805
|
+
? normalizeApprovalDestinations(envelope.destinations.map((entry, index) => validateApprovalDestination(entry, `approvalEnvelope.destinations[${index}]`)))
|
|
806
|
+
: (() => { throw new Error('approvalEnvelope.destinations must be an array.'); })();
|
|
807
|
+
const permissions = normalizeApprovalPermissions(stringArray(envelope.permissions, 'approvalEnvelope.permissions'));
|
|
808
|
+
const policyExceptions = normalizeApprovalPolicyExceptions(stringArray(envelope.policyExceptions, 'approvalEnvelope.policyExceptions'));
|
|
809
|
+
const destructiveScope = normalizeApprovalDestructiveScope(stringArray(envelope.destructiveScope, 'approvalEnvelope.destructiveScope'));
|
|
810
|
+
const costCeiling = normalizeApprovalCostCeiling({
|
|
811
|
+
currency: stringField(cost, 'currency', 'approvalEnvelope.costCeiling'),
|
|
812
|
+
fixedMonthlyCents: integerField(cost, 'fixedMonthlyCents', 'approvalEnvelope.costCeiling'),
|
|
813
|
+
usageMonthlyCents: integerField(cost, 'usageMonthlyCents', 'approvalEnvelope.costCeiling')
|
|
814
|
+
});
|
|
815
|
+
const phaseId = enumValue(envelope.phaseId, phaseIdSet, 'approvalEnvelope.phaseId');
|
|
816
|
+
const gateKind = enumValue(envelope.gateKind, new Set(approvalGateKinds), 'approvalEnvelope.gateKind');
|
|
817
|
+
const expectedGateKind = canonicalPhaseGraph.phases.find((phase) => phase.id === phaseId)?.approvalGate.kind;
|
|
818
|
+
if (expectedGateKind !== gateKind) {
|
|
819
|
+
throw new Error(`approvalEnvelope.gateKind ${gateKind} does not match phase ${phaseId} gate ${expectedGateKind}.`);
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
schemaVersion: approvalEnvelopeSchemaVersion,
|
|
823
|
+
id: stringField(envelope, 'id', 'approvalEnvelope'),
|
|
824
|
+
phaseId,
|
|
825
|
+
gateKind,
|
|
826
|
+
identity,
|
|
827
|
+
baselineSha,
|
|
828
|
+
planDigest,
|
|
829
|
+
resources,
|
|
830
|
+
destinations,
|
|
831
|
+
permissions,
|
|
832
|
+
costCeiling,
|
|
833
|
+
policyExceptions,
|
|
834
|
+
destructiveScope,
|
|
835
|
+
expiresAt,
|
|
836
|
+
approvedAt: isoTimestamp(envelope.approvedAt, 'approvalEnvelope.approvedAt'),
|
|
837
|
+
approver: stringField(envelope, 'approver', 'approvalEnvelope')
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function validateOperationDestination(value, path) {
|
|
841
|
+
const destination = exactWithOptional(value, ['type', 'identity'], ['pathParts', 'repository', 'subscriptionId', 'ref'], path);
|
|
842
|
+
const normalized = {
|
|
843
|
+
type: enumValue(destination.type, new Set(['local', 'repository', 'subscription', 'environment', 'tenant', 'external']), `${path}.type`),
|
|
844
|
+
identity: stringField(destination, 'identity', path),
|
|
845
|
+
...(destination.pathParts !== undefined ? { pathParts: stringArray(destination.pathParts, `${path}.pathParts`) } : {}),
|
|
846
|
+
...(destination.repository !== undefined ? { repository: stringField(destination, 'repository', path) } : {}),
|
|
847
|
+
...(destination.subscriptionId !== undefined ? { subscriptionId: stringField(destination, 'subscriptionId', path) } : {}),
|
|
848
|
+
...(destination.ref !== undefined ? { ref: stringField(destination, 'ref', path) } : {})
|
|
849
|
+
};
|
|
850
|
+
return normalized;
|
|
851
|
+
}
|
|
852
|
+
function validateTransitionOperation(value, path) {
|
|
853
|
+
const operation = exact(value, [
|
|
854
|
+
'adapter',
|
|
855
|
+
'actionId',
|
|
856
|
+
'mutationClass',
|
|
857
|
+
'phaseId',
|
|
858
|
+
'inputs',
|
|
859
|
+
'destination',
|
|
860
|
+
'remote',
|
|
861
|
+
'destructive'
|
|
862
|
+
], path);
|
|
863
|
+
return {
|
|
864
|
+
adapter: enumValue(operation.adapter, transitionAdapterIds, `${path}.adapter`),
|
|
865
|
+
actionId: stringField(operation, 'actionId', path),
|
|
866
|
+
mutationClass: enumValue(operation.mutationClass, new Set(mutationClasses), `${path}.mutationClass`),
|
|
867
|
+
phaseId: enumValue(operation.phaseId, phaseIdSet, `${path}.phaseId`),
|
|
868
|
+
inputs: record(operation.inputs, `${path}.inputs`),
|
|
869
|
+
destination: validateOperationDestination(operation.destination, `${path}.destination`),
|
|
870
|
+
remote: booleanField(operation, 'remote', path),
|
|
871
|
+
destructive: booleanField(operation, 'destructive', path)
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
function validateApprovalEvaluation(value, path) {
|
|
875
|
+
const evaluation = exact(value, [
|
|
876
|
+
'phaseId',
|
|
877
|
+
'gateKind',
|
|
878
|
+
'questionKind',
|
|
879
|
+
'approvalRequired',
|
|
880
|
+
'status',
|
|
881
|
+
'envelopeId',
|
|
882
|
+
'envelopeHash',
|
|
883
|
+
'reasons',
|
|
884
|
+
'expansionReasons'
|
|
885
|
+
], path);
|
|
886
|
+
const question = evaluation.questionKind === null
|
|
887
|
+
? null
|
|
888
|
+
: enumValue(evaluation.questionKind, new Set([
|
|
889
|
+
'repository-creation-initial-commit-push',
|
|
890
|
+
'credential-enrollment',
|
|
891
|
+
'billed-infrastructure-policy-exception-cost-ceiling',
|
|
892
|
+
'final-enforcement',
|
|
893
|
+
'destructive-operation',
|
|
894
|
+
'external-blocker'
|
|
895
|
+
]), `${path}.questionKind`);
|
|
896
|
+
const envelopeHash = evaluation.envelopeHash === null
|
|
897
|
+
? null
|
|
898
|
+
: hexDigest(evaluation.envelopeHash, `${path}.envelopeHash`);
|
|
899
|
+
return {
|
|
900
|
+
phaseId: enumValue(evaluation.phaseId, phaseIdSet, `${path}.phaseId`),
|
|
901
|
+
gateKind: enumValue(evaluation.gateKind, new Set(approvalGateKinds), `${path}.gateKind`),
|
|
902
|
+
questionKind: question,
|
|
903
|
+
approvalRequired: booleanField(evaluation, 'approvalRequired', path),
|
|
904
|
+
status: enumValue(evaluation.status, new Set(['not-required', 'approval-required', 'reused', 'expired', 'invalidated']), `${path}.status`),
|
|
905
|
+
envelopeId: evaluation.envelopeId === null ? null : stringField(evaluation, 'envelopeId', path),
|
|
906
|
+
envelopeHash,
|
|
907
|
+
reasons: stringArray(evaluation.reasons, `${path}.reasons`),
|
|
908
|
+
expansionReasons: stringArray(evaluation.expansionReasons, `${path}.expansionReasons`)
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function validateRollbackPlan(value, path) {
|
|
912
|
+
const rollbackPlan = exact(value, [
|
|
913
|
+
'phaseId',
|
|
914
|
+
'strategy',
|
|
915
|
+
'target',
|
|
916
|
+
'operations',
|
|
917
|
+
'retained',
|
|
918
|
+
'cleanupWarnings'
|
|
919
|
+
], path);
|
|
920
|
+
return {
|
|
921
|
+
phaseId: enumValue(rollbackPlan.phaseId, phaseIdSet, `${path}.phaseId`),
|
|
922
|
+
strategy: enumValue(rollbackPlan.strategy, new Set(rollbackKinds), `${path}.strategy`),
|
|
923
|
+
target: rollbackPlan.target === null ? null : enumValue(rollbackPlan.target, phaseIdSet, `${path}.target`),
|
|
924
|
+
operations: Array.isArray(rollbackPlan.operations)
|
|
925
|
+
? rollbackPlan.operations.map((operation, index) => {
|
|
926
|
+
const validated = validateTransitionOperation(operation, `${path}.operations[${index}]`);
|
|
927
|
+
return {
|
|
928
|
+
adapter: validated.adapter,
|
|
929
|
+
actionId: validated.actionId,
|
|
930
|
+
mutationClass: validated.mutationClass,
|
|
931
|
+
phaseId: validated.phaseId,
|
|
932
|
+
inputs: validated.inputs,
|
|
933
|
+
destination: validated.destination,
|
|
934
|
+
remote: validated.remote,
|
|
935
|
+
destructive: validated.destructive
|
|
936
|
+
};
|
|
937
|
+
})
|
|
938
|
+
: (() => { throw new Error(`${path}.operations must be an array.`); })(),
|
|
939
|
+
retained: stringArray(rollbackPlan.retained, `${path}.retained`),
|
|
940
|
+
cleanupWarnings: stringArray(rollbackPlan.cleanupWarnings, `${path}.cleanupWarnings`)
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
export function validateSavedTransitionPlan(value) {
|
|
944
|
+
const plan = exact(value, [
|
|
945
|
+
'schemaVersion',
|
|
946
|
+
'phaseId',
|
|
947
|
+
'createdAt',
|
|
948
|
+
'expiresAt',
|
|
949
|
+
'identity',
|
|
950
|
+
'graphHash',
|
|
951
|
+
'stateHash',
|
|
952
|
+
'baselineDigest',
|
|
953
|
+
'inputDigest',
|
|
954
|
+
'transitionDigest',
|
|
955
|
+
'planDigest',
|
|
956
|
+
'mutationClasses',
|
|
957
|
+
'operations',
|
|
958
|
+
'approval',
|
|
959
|
+
'rollbackPlan',
|
|
960
|
+
'noSecrets'
|
|
961
|
+
], 'transitionPlan');
|
|
962
|
+
requireVersion(plan.schemaVersion, 1, 'transitionPlan.schemaVersion');
|
|
963
|
+
const identity = validateActivationIdentity(plan.identity);
|
|
964
|
+
const graphHash = hexDigest(plan.graphHash, 'transitionPlan.graphHash');
|
|
965
|
+
if (graphHash !== identity.phaseGraphHash) {
|
|
966
|
+
throw new Error('transitionPlan.graphHash must match identity.phaseGraphHash.');
|
|
967
|
+
}
|
|
968
|
+
const phaseId = enumValue(plan.phaseId, phaseIdSet, 'transitionPlan.phaseId');
|
|
969
|
+
const mutationClassesValue = exact(plan.mutationClasses, ['local', 'remote'], 'transitionPlan.mutationClasses');
|
|
970
|
+
const approval = exact(plan.approval, [
|
|
971
|
+
'gateKind',
|
|
972
|
+
'required',
|
|
973
|
+
'evaluation',
|
|
974
|
+
'envelopeId',
|
|
975
|
+
'envelopeHash'
|
|
976
|
+
], 'transitionPlan.approval');
|
|
977
|
+
const operations = Array.isArray(plan.operations)
|
|
978
|
+
? plan.operations.map((operation, index) => validateTransitionOperation(operation, `transitionPlan.operations[${index}]`))
|
|
979
|
+
: (() => { throw new Error('transitionPlan.operations must be an array.'); })();
|
|
980
|
+
for (const operation of operations) {
|
|
981
|
+
if (operation.phaseId !== phaseId) {
|
|
982
|
+
throw new Error(`transitionPlan operation ${operation.actionId} phaseId must match ${phaseId}.`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const evaluation = validateApprovalEvaluation(approval.evaluation, 'transitionPlan.approval.evaluation');
|
|
986
|
+
const envelopeHash = approval.envelopeHash === null
|
|
987
|
+
? null
|
|
988
|
+
: hexDigest(approval.envelopeHash, 'transitionPlan.approval.envelopeHash');
|
|
989
|
+
return {
|
|
990
|
+
schemaVersion: 1,
|
|
991
|
+
phaseId,
|
|
992
|
+
createdAt: isoTimestamp(plan.createdAt, 'transitionPlan.createdAt'),
|
|
993
|
+
expiresAt: isoTimestamp(plan.expiresAt, 'transitionPlan.expiresAt'),
|
|
994
|
+
identity,
|
|
995
|
+
graphHash,
|
|
996
|
+
stateHash: plan.stateHash === null ? null : hexDigest(plan.stateHash, 'transitionPlan.stateHash'),
|
|
997
|
+
baselineDigest: hexDigest(plan.baselineDigest, 'transitionPlan.baselineDigest'),
|
|
998
|
+
inputDigest: hexDigest(plan.inputDigest, 'transitionPlan.inputDigest'),
|
|
999
|
+
transitionDigest: hexDigest(plan.transitionDigest, 'transitionPlan.transitionDigest'),
|
|
1000
|
+
planDigest: hexDigest(plan.planDigest, 'transitionPlan.planDigest'),
|
|
1001
|
+
mutationClasses: {
|
|
1002
|
+
local: stringArray(mutationClassesValue.local, 'transitionPlan.mutationClasses.local').map((entry) => enumValue(entry, new Set(mutationClasses), 'transitionPlan.mutationClasses.local')),
|
|
1003
|
+
remote: stringArray(mutationClassesValue.remote, 'transitionPlan.mutationClasses.remote').map((entry) => enumValue(entry, new Set(mutationClasses), 'transitionPlan.mutationClasses.remote'))
|
|
1004
|
+
},
|
|
1005
|
+
operations,
|
|
1006
|
+
approval: {
|
|
1007
|
+
gateKind: enumValue(approval.gateKind, new Set(approvalGateKinds), 'transitionPlan.approval.gateKind'),
|
|
1008
|
+
required: booleanField(approval, 'required', 'transitionPlan.approval'),
|
|
1009
|
+
evaluation,
|
|
1010
|
+
envelopeId: approval.envelopeId === null ? null : stringField(approval, 'envelopeId', 'transitionPlan.approval'),
|
|
1011
|
+
envelopeHash
|
|
1012
|
+
},
|
|
1013
|
+
rollbackPlan: validateRollbackPlan(plan.rollbackPlan, 'transitionPlan.rollbackPlan'),
|
|
1014
|
+
noSecrets: plan.noSecrets === true ? true : (() => { throw new Error('transitionPlan.noSecrets must be true.'); })()
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
export function validateSupersessionRecord(value) {
|
|
1018
|
+
const supersession = exact(value, [
|
|
1019
|
+
'schemaVersion',
|
|
1020
|
+
'identity',
|
|
1021
|
+
'supersededChangeId',
|
|
1022
|
+
'supersedingChangeId',
|
|
1023
|
+
'reason',
|
|
1024
|
+
'approvedAt',
|
|
1025
|
+
'approver'
|
|
1026
|
+
], 'supersession');
|
|
1027
|
+
requireVersion(supersession.schemaVersion, supersessionSchemaVersion, 'supersession.schemaVersion');
|
|
1028
|
+
return {
|
|
1029
|
+
schemaVersion: supersessionSchemaVersion,
|
|
1030
|
+
identity: validateActivationIdentity(supersession.identity),
|
|
1031
|
+
supersededChangeId: stringField(supersession, 'supersededChangeId', 'supersession'),
|
|
1032
|
+
supersedingChangeId: stringField(supersession, 'supersedingChangeId', 'supersession'),
|
|
1033
|
+
reason: stringField(supersession, 'reason', 'supersession'),
|
|
1034
|
+
approvedAt: stringField(supersession, 'approvedAt', 'supersession'),
|
|
1035
|
+
approver: stringField(supersession, 'approver', 'supersession')
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
export function validateGraphReconciliationRecord(value, recognizedGraphHashes = new Set([canonicalPhaseGraphHash])) {
|
|
1039
|
+
const reconciliation = exact(value, [
|
|
1040
|
+
'schemaVersion',
|
|
1041
|
+
'fromGraphHash',
|
|
1042
|
+
'toGraphHash',
|
|
1043
|
+
'fromIdentity',
|
|
1044
|
+
'toIdentity',
|
|
1045
|
+
'phaseMappings',
|
|
1046
|
+
'reconciledAt',
|
|
1047
|
+
'producer'
|
|
1048
|
+
], 'graphReconciliation');
|
|
1049
|
+
requireVersion(reconciliation.schemaVersion, activationStateSchemaVersion, 'graphReconciliation.schemaVersion');
|
|
1050
|
+
const fromGraphHash = hexDigest(reconciliation.fromGraphHash, 'graphReconciliation.fromGraphHash');
|
|
1051
|
+
const toGraphHash = hexDigest(reconciliation.toGraphHash, 'graphReconciliation.toGraphHash');
|
|
1052
|
+
if (!recognizedGraphHashes.has(fromGraphHash)) {
|
|
1053
|
+
throw new Error(`graphReconciliation.fromGraphHash is not a recognized graph hash: ${fromGraphHash}.`);
|
|
1054
|
+
}
|
|
1055
|
+
if (!recognizedGraphHashes.has(toGraphHash)) {
|
|
1056
|
+
throw new Error(`graphReconciliation.toGraphHash is not a recognized graph hash: ${toGraphHash}.`);
|
|
1057
|
+
}
|
|
1058
|
+
const fromIdentity = validateActivationIdentityShape(reconciliation.fromIdentity, 'graphReconciliation.fromIdentity');
|
|
1059
|
+
const toIdentity = validateActivationIdentityShape(reconciliation.toIdentity, 'graphReconciliation.toIdentity');
|
|
1060
|
+
if (fromIdentity.phaseGraphHash !== fromGraphHash) {
|
|
1061
|
+
throw new Error('graphReconciliation.fromIdentity.phaseGraphHash must match fromGraphHash.');
|
|
1062
|
+
}
|
|
1063
|
+
if (toIdentity.phaseGraphHash !== toGraphHash) {
|
|
1064
|
+
throw new Error('graphReconciliation.toIdentity.phaseGraphHash must match toGraphHash.');
|
|
1065
|
+
}
|
|
1066
|
+
if (!Array.isArray(reconciliation.phaseMappings)) {
|
|
1067
|
+
throw new Error('graphReconciliation.phaseMappings must be an array.');
|
|
1068
|
+
}
|
|
1069
|
+
const seen = new Set();
|
|
1070
|
+
const phaseMappings = reconciliation.phaseMappings.map((entry, index) => {
|
|
1071
|
+
const mapping = exact(entry, [
|
|
1072
|
+
'phaseId',
|
|
1073
|
+
'fromContractDigest',
|
|
1074
|
+
'toContractDigest',
|
|
1075
|
+
'preserveEvidence'
|
|
1076
|
+
], `graphReconciliation.phaseMappings[${index}]`);
|
|
1077
|
+
const phaseId = enumValue(mapping.phaseId, phaseIdSet, `graphReconciliation.phaseMappings[${index}].phaseId`);
|
|
1078
|
+
if (seen.has(phaseId)) {
|
|
1079
|
+
throw new Error(`graphReconciliation.phaseMappings contains duplicate phase ${phaseId}.`);
|
|
1080
|
+
}
|
|
1081
|
+
seen.add(phaseId);
|
|
1082
|
+
const fromContractDigest = hexDigest(mapping.fromContractDigest, `graphReconciliation.phaseMappings[${index}].fromContractDigest`);
|
|
1083
|
+
const toContractDigest = hexDigest(mapping.toContractDigest, `graphReconciliation.phaseMappings[${index}].toContractDigest`);
|
|
1084
|
+
const preserveEvidence = booleanField(mapping, 'preserveEvidence', `graphReconciliation.phaseMappings[${index}]`);
|
|
1085
|
+
if (preserveEvidence && fromContractDigest !== toContractDigest) {
|
|
1086
|
+
throw new Error(`graphReconciliation.phaseMappings[${index}] cannot preserve evidence for changed phase ${phaseId}.`);
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
phaseId,
|
|
1090
|
+
fromContractDigest,
|
|
1091
|
+
toContractDigest,
|
|
1092
|
+
preserveEvidence
|
|
1093
|
+
};
|
|
1094
|
+
});
|
|
1095
|
+
for (const phaseId of phaseIds) {
|
|
1096
|
+
if (!seen.has(phaseId)) {
|
|
1097
|
+
throw new Error(`graphReconciliation.phaseMappings.${phaseId} is required.`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
schemaVersion: activationStateSchemaVersion,
|
|
1102
|
+
fromGraphHash,
|
|
1103
|
+
toGraphHash,
|
|
1104
|
+
fromIdentity,
|
|
1105
|
+
toIdentity,
|
|
1106
|
+
phaseMappings,
|
|
1107
|
+
reconciledAt: isoTimestamp(reconciliation.reconciledAt, 'graphReconciliation.reconciledAt'),
|
|
1108
|
+
producer: stringField(reconciliation, 'producer', 'graphReconciliation')
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
export function validateCredentialPolicy(value) {
|
|
1112
|
+
const policy = exact(value, [
|
|
1113
|
+
'schemaVersion',
|
|
1114
|
+
'identity',
|
|
1115
|
+
'repository',
|
|
1116
|
+
'owner',
|
|
1117
|
+
'authKind',
|
|
1118
|
+
'displayNameTemplate',
|
|
1119
|
+
'displayName',
|
|
1120
|
+
'secretName',
|
|
1121
|
+
'createdAt',
|
|
1122
|
+
'expiresAt',
|
|
1123
|
+
'rotationLeadDays',
|
|
1124
|
+
'rotationDueAt',
|
|
1125
|
+
'permissions',
|
|
1126
|
+
'allowedWorkflows',
|
|
1127
|
+
'nonForwarding',
|
|
1128
|
+
'status',
|
|
1129
|
+
'proof',
|
|
1130
|
+
'app',
|
|
1131
|
+
'pat'
|
|
1132
|
+
], 'credentialPolicy');
|
|
1133
|
+
requireVersion(policy.schemaVersion, credentialPolicySchemaVersion, 'credentialPolicy.schemaVersion');
|
|
1134
|
+
const permissions = exact(policy.permissions, ['repository', 'organization'], 'credentialPolicy.permissions');
|
|
1135
|
+
if (policy.nonForwarding !== true) {
|
|
1136
|
+
throw new Error('credentialPolicy.nonForwarding must be true.');
|
|
1137
|
+
}
|
|
1138
|
+
if (policy.secretName !== runnerPreflightSecretName) {
|
|
1139
|
+
throw new Error(`credentialPolicy.secretName must be ${runnerPreflightSecretName}.`);
|
|
1140
|
+
}
|
|
1141
|
+
if (policy.displayNameTemplate !== runnerPreflightDisplayNameTemplate) {
|
|
1142
|
+
throw new Error(`credentialPolicy.displayNameTemplate must be ${runnerPreflightDisplayNameTemplate}.`);
|
|
1143
|
+
}
|
|
1144
|
+
if (!Array.isArray(policy.allowedWorkflows)) {
|
|
1145
|
+
throw new Error('credentialPolicy.allowedWorkflows must be an array.');
|
|
1146
|
+
}
|
|
1147
|
+
if (policy.allowedWorkflows.length === 0) {
|
|
1148
|
+
throw new Error('credentialPolicy.allowedWorkflows must contain at least one workflow.');
|
|
1149
|
+
}
|
|
1150
|
+
const repository = validateCredentialRepositoryIdentity(policy.repository, 'credentialPolicy.repository');
|
|
1151
|
+
if (policy.owner !== repository.owner) {
|
|
1152
|
+
throw new Error('credentialPolicy.owner must match credentialPolicy.repository.owner.');
|
|
1153
|
+
}
|
|
1154
|
+
const displayName = stringField(policy, 'displayName', 'credentialPolicy');
|
|
1155
|
+
if (displayName !== `${repository.name.toLowerCase()}-runner-preflight-read`) {
|
|
1156
|
+
throw new Error('credentialPolicy.displayName must be derived from the canonical repository name.');
|
|
1157
|
+
}
|
|
1158
|
+
const createdAt = isoTimestamp(policy.createdAt, 'credentialPolicy.createdAt');
|
|
1159
|
+
const expiresAt = isoTimestamp(policy.expiresAt, 'credentialPolicy.expiresAt');
|
|
1160
|
+
const rotationLeadDays = integerField(policy, 'rotationLeadDays', 'credentialPolicy');
|
|
1161
|
+
requireVersion(rotationLeadDays, runnerPreflightRotationLeadDays, 'credentialPolicy.rotationLeadDays');
|
|
1162
|
+
const rotationDueAt = isoTimestamp(policy.rotationDueAt, 'credentialPolicy.rotationDueAt');
|
|
1163
|
+
if (rotationDueAt !== addDaysIso(expiresAt, -runnerPreflightRotationLeadDays)) {
|
|
1164
|
+
throw new Error('credentialPolicy.rotationDueAt must equal expiresAt minus the rotation lead.');
|
|
1165
|
+
}
|
|
1166
|
+
const repositoryPermissions = stringArray(permissions.repository, 'credentialPolicy.permissions.repository');
|
|
1167
|
+
const organizationPermissions = stringArray(permissions.organization, 'credentialPolicy.permissions.organization');
|
|
1168
|
+
assertNoDuplicateStrings(repositoryPermissions, 'credentialPolicy.permissions.repository');
|
|
1169
|
+
assertNoDuplicateStrings(organizationPermissions, 'credentialPolicy.permissions.organization');
|
|
1170
|
+
exactStringSet(repositoryPermissions, runnerPreflightRepositoryPermissions, 'credentialPolicy.permissions.repository');
|
|
1171
|
+
exactStringSet(organizationPermissions, runnerPreflightOrganizationPermissions, 'credentialPolicy.permissions.organization');
|
|
1172
|
+
const authKind = enumValue(policy.authKind, new Set(['github-app', 'fine-grained-pat']), 'credentialPolicy.authKind');
|
|
1173
|
+
const proof = exact(policy.proof, ['verifiedAt', 'readbackDigest', 'readbackProvider', 'payloadFree'], 'credentialPolicy.proof');
|
|
1174
|
+
if (proof.payloadFree !== true) {
|
|
1175
|
+
throw new Error('credentialPolicy.proof.payloadFree must be true.');
|
|
1176
|
+
}
|
|
1177
|
+
const app = policy.app === null
|
|
1178
|
+
? null
|
|
1179
|
+
: exact(policy.app, [
|
|
1180
|
+
'installationId',
|
|
1181
|
+
'appSlug',
|
|
1182
|
+
'selection',
|
|
1183
|
+
'repositoryFullName',
|
|
1184
|
+
'permissionsVerifiedAt',
|
|
1185
|
+
'token'
|
|
1186
|
+
], 'credentialPolicy.app');
|
|
1187
|
+
const pat = policy.pat === null
|
|
1188
|
+
? null
|
|
1189
|
+
: exact(policy.pat, [
|
|
1190
|
+
'lifetimeDays',
|
|
1191
|
+
'selectedRepositoryOnly',
|
|
1192
|
+
'createdBy'
|
|
1193
|
+
], 'credentialPolicy.pat');
|
|
1194
|
+
if (authKind === 'github-app') {
|
|
1195
|
+
if (app === null || pat !== null) {
|
|
1196
|
+
throw new Error('credentialPolicy.github-app requires app metadata and no PAT metadata.');
|
|
1197
|
+
}
|
|
1198
|
+
if (integerField(app, 'installationId', 'credentialPolicy.app') <= 0) {
|
|
1199
|
+
throw new Error('credentialPolicy.app.installationId must be positive.');
|
|
1200
|
+
}
|
|
1201
|
+
if (app.selection !== 'selected-repository') {
|
|
1202
|
+
throw new Error('credentialPolicy.app.selection must be selected-repository.');
|
|
1203
|
+
}
|
|
1204
|
+
if (stringField(app, 'repositoryFullName', 'credentialPolicy.app') !== repository.fullName) {
|
|
1205
|
+
throw new Error('credentialPolicy.app.repositoryFullName must match the policy repository.');
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
if (pat === null || app !== null) {
|
|
1210
|
+
throw new Error('credentialPolicy.fine-grained-pat requires PAT metadata and no App metadata.');
|
|
1211
|
+
}
|
|
1212
|
+
requireVersion(pat.lifetimeDays, runnerPreflightPatLifetimeDays, 'credentialPolicy.pat.lifetimeDays');
|
|
1213
|
+
if (dateDaysBetween(createdAt, expiresAt) !== runnerPreflightPatLifetimeDays) {
|
|
1214
|
+
throw new Error('credentialPolicy fine-grained PAT expiry must be exactly 30 days after creation.');
|
|
1215
|
+
}
|
|
1216
|
+
if (pat.selectedRepositoryOnly !== true) {
|
|
1217
|
+
throw new Error('credentialPolicy.pat.selectedRepositoryOnly must be true.');
|
|
1218
|
+
}
|
|
1219
|
+
if (pat.createdBy !== 'manual-masked-entry') {
|
|
1220
|
+
throw new Error('credentialPolicy.pat.createdBy must be manual-masked-entry.');
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const typedApp = app === null
|
|
1224
|
+
? null
|
|
1225
|
+
: {
|
|
1226
|
+
installationId: integerField(app, 'installationId', 'credentialPolicy.app'),
|
|
1227
|
+
appSlug: stringField(app, 'appSlug', 'credentialPolicy.app'),
|
|
1228
|
+
selection: enumValue(app.selection, new Set(['selected-repository']), 'credentialPolicy.app.selection'),
|
|
1229
|
+
repositoryFullName: stringField(app, 'repositoryFullName', 'credentialPolicy.app'),
|
|
1230
|
+
permissionsVerifiedAt: isoTimestamp(app.permissionsVerifiedAt, 'credentialPolicy.app.permissionsVerifiedAt'),
|
|
1231
|
+
token: (() => {
|
|
1232
|
+
const token = exact(app.token, ['strategy', 'ttlSeconds', 'generatedBy'], 'credentialPolicy.app.token');
|
|
1233
|
+
const ttlSeconds = integerField(token, 'ttlSeconds', 'credentialPolicy.app.token');
|
|
1234
|
+
if (ttlSeconds <= 0 || ttlSeconds > 3600) {
|
|
1235
|
+
throw new Error('credentialPolicy.app.token.ttlSeconds must be between 1 and 3600.');
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
strategy: enumValue(token.strategy, new Set(['installation-token']), 'credentialPolicy.app.token.strategy'),
|
|
1239
|
+
ttlSeconds,
|
|
1240
|
+
generatedBy: enumValue(token.generatedBy, new Set(['github-app']), 'credentialPolicy.app.token.generatedBy')
|
|
1241
|
+
};
|
|
1242
|
+
})()
|
|
1243
|
+
};
|
|
1244
|
+
return {
|
|
1245
|
+
schemaVersion: credentialPolicySchemaVersion,
|
|
1246
|
+
identity: validateActivationIdentity(policy.identity),
|
|
1247
|
+
repository,
|
|
1248
|
+
owner: stringField(policy, 'owner', 'credentialPolicy'),
|
|
1249
|
+
authKind,
|
|
1250
|
+
displayNameTemplate: runnerPreflightDisplayNameTemplate,
|
|
1251
|
+
displayName,
|
|
1252
|
+
secretName: runnerPreflightSecretName,
|
|
1253
|
+
createdAt,
|
|
1254
|
+
expiresAt,
|
|
1255
|
+
rotationLeadDays: runnerPreflightRotationLeadDays,
|
|
1256
|
+
rotationDueAt,
|
|
1257
|
+
permissions: {
|
|
1258
|
+
repository: [...runnerPreflightRepositoryPermissions],
|
|
1259
|
+
organization: [...runnerPreflightOrganizationPermissions]
|
|
1260
|
+
},
|
|
1261
|
+
allowedWorkflows: (() => {
|
|
1262
|
+
const workflowPaths = new Set();
|
|
1263
|
+
return policy.allowedWorkflows.map((entry, index) => {
|
|
1264
|
+
const workflow = exact(entry, ['path', 'jobs'], `credentialPolicy.allowedWorkflows[${index}]`);
|
|
1265
|
+
const jobs = stringArray(workflow.jobs, `credentialPolicy.allowedWorkflows[${index}].jobs`);
|
|
1266
|
+
if (jobs.length === 0) {
|
|
1267
|
+
throw new Error(`credentialPolicy.allowedWorkflows[${index}].jobs must contain at least one job.`);
|
|
1268
|
+
}
|
|
1269
|
+
assertNoDuplicateStrings(jobs, `credentialPolicy.allowedWorkflows[${index}].jobs`);
|
|
1270
|
+
const workflowPath = stringField(workflow, 'path', `credentialPolicy.allowedWorkflows[${index}]`);
|
|
1271
|
+
if (!workflowPath.startsWith('.github/workflows/')) {
|
|
1272
|
+
throw new Error(`credentialPolicy.allowedWorkflows[${index}].path must be a GitHub Actions workflow path.`);
|
|
1273
|
+
}
|
|
1274
|
+
if (workflowPaths.has(workflowPath)) {
|
|
1275
|
+
throw new Error(`credentialPolicy.allowedWorkflows contains duplicate workflow ${workflowPath}.`);
|
|
1276
|
+
}
|
|
1277
|
+
workflowPaths.add(workflowPath);
|
|
1278
|
+
return {
|
|
1279
|
+
path: workflowPath,
|
|
1280
|
+
jobs
|
|
1281
|
+
};
|
|
1282
|
+
});
|
|
1283
|
+
})(),
|
|
1284
|
+
nonForwarding: true,
|
|
1285
|
+
status: enumValue(policy.status, new Set(['active', 'expiring', 'expired', 'compromised']), 'credentialPolicy.status'),
|
|
1286
|
+
proof: {
|
|
1287
|
+
verifiedAt: isoTimestamp(proof.verifiedAt, 'credentialPolicy.proof.verifiedAt'),
|
|
1288
|
+
readbackDigest: hexDigest(proof.readbackDigest, 'credentialPolicy.proof.readbackDigest'),
|
|
1289
|
+
readbackProvider: enumValue(proof.readbackProvider, new Set(['github-api', 'adapter-fixture']), 'credentialPolicy.proof.readbackProvider'),
|
|
1290
|
+
payloadFree: true
|
|
1291
|
+
},
|
|
1292
|
+
app: typedApp,
|
|
1293
|
+
pat: pat === null
|
|
1294
|
+
? null
|
|
1295
|
+
: {
|
|
1296
|
+
lifetimeDays: runnerPreflightPatLifetimeDays,
|
|
1297
|
+
selectedRepositoryOnly: true,
|
|
1298
|
+
createdBy: 'manual-masked-entry'
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
export function validFixtureIdentity() {
|
|
1303
|
+
return currentActivationIdentity;
|
|
1304
|
+
}
|
|
1305
|
+
//# sourceMappingURL=validators.js.map
|