@imagina-build/ias-cli 0.2.2 → 0.3.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.
Files changed (58) hide show
  1. package/README.md +188 -22
  2. package/package.json +1 -1
  3. package/schema/ias.schema.json +46 -1
  4. package/shared/vault/index.mjs +25 -0
  5. package/shared/vault/policy-vectors.mjs +237 -0
  6. package/shared/vault/policy.mjs +427 -0
  7. package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
  8. package/shared/vault/vectors.mjs +2 -0
  9. package/src/apple/capabilities.mjs +96 -0
  10. package/src/apple/inventory.mjs +75 -0
  11. package/src/apple/signing.mjs +134 -0
  12. package/src/applications.mjs +114 -0
  13. package/src/cli.mjs +28 -10
  14. package/src/commands/apps/index.mjs +61 -11
  15. package/src/commands/apps/store.mjs +367 -0
  16. package/src/commands/auth/index.mjs +3 -3
  17. package/src/commands/build/index.mjs +2 -0
  18. package/src/commands/builds/index.mjs +20 -0
  19. package/src/commands/credentials/apple.mjs +1118 -0
  20. package/src/commands/credentials/generate.mjs +122 -0
  21. package/src/commands/credentials/index.mjs +114 -44
  22. package/src/commands/credentials/seal.mjs +44 -0
  23. package/src/commands/devices/asc.mjs +50 -8
  24. package/src/commands/devices/index.mjs +2 -1
  25. package/src/commands/devices/sync.mjs +57 -4
  26. package/src/commands/dist/index.mjs +12 -0
  27. package/src/commands/doctor/index.mjs +3 -0
  28. package/src/commands/github/index.mjs +13 -3
  29. package/src/commands/init/index.mjs +6 -3
  30. package/src/commands/policy/index.mjs +583 -0
  31. package/src/commands/policy/policy.mjs +182 -0
  32. package/src/commands/policy/progress.mjs +20 -0
  33. package/src/commands/releases/index.mjs +225 -0
  34. package/src/commands/rollout/index.mjs +186 -0
  35. package/src/commands/runners/index.mjs +19 -0
  36. package/src/commands/sdk/index.mjs +31 -4
  37. package/src/commands/setup/index.mjs +184 -0
  38. package/src/commands/skill/index.mjs +43 -0
  39. package/src/commands/submit/index.mjs +135 -19
  40. package/src/commands/testflight/index.mjs +309 -0
  41. package/src/commands/tokens/index.mjs +99 -0
  42. package/src/commands/update/index.mjs +26 -5
  43. package/src/commands/vault/approvers.mjs +5 -1
  44. package/src/commands/vault/index.mjs +26 -6
  45. package/src/commands/vault/runners.mjs +48 -2
  46. package/src/commands/webhooks/index.mjs +19 -5
  47. package/src/config.mjs +16 -1
  48. package/src/errors.mjs +52 -6
  49. package/src/op.mjs +83 -0
  50. package/src/ota-key.mjs +53 -0
  51. package/src/provisioning.mjs +281 -0
  52. package/src/schema.mjs +3 -0
  53. package/src/setup-state.mjs +76 -0
  54. package/src/setup.mjs +701 -0
  55. package/src/skill-files.generated.mjs +10 -0
  56. package/src/skill.mjs +256 -0
  57. package/src/team-vault.mjs +2 -2
  58. package/src/token-store.mjs +1 -1
@@ -0,0 +1,427 @@
1
+ // The signed approval policy of the team (ias-policy-1, docs/plans/gaps/06-politica-aprobacion.md §3.2–§3.5 and
2
+ // docs/technical/vault.md §2.5): which passkeys may approve which store job and how many of them, for every combination
3
+ // of application, platform, build profile, job type and, for a submission, target and Google Play track. The team signs
4
+ // it with the same Ed25519 key as the envelopes, the runner list and the approvers; the server stores it, verifies its
5
+ // signature (the one deliberate difference with the other documents) and serves it, and the runner verifies it again.
6
+ //
7
+ // This module only defines the document, its canonical text, its signature and the matching: it does not approve jobs
8
+ // nor gate anything. Without a policy the platform behaves as before (one passkey of an owner). Only node:crypto.
9
+ import { Buffer } from 'node:buffer';
10
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
11
+ import { KEY_BYTES, LIMITS, SIGNATURE_BYTES, VaultError, decode, encode, keyId, signingKeyFromPrivateKey } from './index.mjs';
12
+
13
+ export const POLICY_FORMAT = 'ias-policy-1';
14
+
15
+ /** The limits of the format. The server enforces the same ones; changing them changes the format. */
16
+ export const POLICY_LIMITS = Object.freeze({
17
+ /** The JSON text of the whole document, signature included. */
18
+ bytes: 64 * 1024,
19
+ rules: 64,
20
+ members: 64,
21
+ /** The most approvals a rule may ask for: more is a sign that the rule is wrong. */
22
+ count: 8,
23
+ });
24
+
25
+ /** What a job has to match: the only fields of the canonical text of a job that choose a rule (§3.3). */
26
+ export const POLICY_FIELDS = Object.freeze(['app', 'platform', 'profile', 'type', 'target', 'track']);
27
+
28
+ export const POLICY_PLATFORMS = Object.freeze(['ios', 'android', '*']);
29
+ export const POLICY_TYPES = Object.freeze(['build', 'submit', '*']);
30
+ export const POLICY_TARGETS = Object.freeze(['app_store_connect', 'google_play', '*']);
31
+
32
+ const DOCUMENT_FIELDS = ['format', 'version', 'rules', 'signature'];
33
+ const RULE_FIELDS = ['id', 'app', 'platform', 'profile', 'type', 'target', 'track', 'require'];
34
+ const REQUIRE_FIELDS = ['members', 'count'];
35
+ const RULE_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
36
+ const APP = RULE_ID;
37
+ /**
38
+ * A Google Play track as Google names it: production, beta, alpha, internal, a closed one or a form factor's
39
+ * (wear:production, automotive:production). One pattern for the policy document, the CLI and the runner, so the `:` of
40
+ * a form factor is accepted everywhere the identifier travels.
41
+ */
42
+ export const PLAY_TRACK = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
43
+ const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
44
+ const TRACK = PLAY_TRACK;
45
+ const MEMBER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
46
+ const NOT_AFTER = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
47
+
48
+ const DAY_MS = 24 * 60 * 60 * 1000;
49
+
50
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
51
+ const sameKeys = (object, keys) => {
52
+ const own = Object.keys(object).sort();
53
+ return own.length === keys.length && [...keys].sort().every((key, index) => own[index] === key);
54
+ };
55
+
56
+ /** A UTC timestamp exactly as the format writes it: YYYY-MM-DDTHH:MM:SSZ, and a day that exists. */
57
+ export function isNotAfter(value) {
58
+ if (typeof value !== 'string' || !NOT_AFTER.test(value)) return false;
59
+ const time = Date.parse(value);
60
+ return Number.isFinite(time) && new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z') === value;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------------------------------------------------
64
+ // Shape and semantics (§3.2 and §3.3).
65
+
66
+ /** The first problem of one rule, as [code, message], or null. */
67
+ function ruleProblem(rule) {
68
+ if (!isRecord(rule) || !sameKeys(rule, RULE_FIELDS)) return ['policy.rule', `each rule must be an object with exactly ${RULE_FIELDS.join(', ')}`];
69
+ if (typeof rule.id !== 'string' || !RULE_ID.test(rule.id)) return ['policy.rule.id', 'rule.id must be 1 to 32 characters of a-z 0-9 - and start with a letter or digit'];
70
+ if (rule.app !== '*' && (typeof rule.app !== 'string' || !APP.test(rule.app))) return ['policy.rule.app', 'rule.app must be * or the code of an application: 1 to 32 characters of a-z 0-9 - that start with a letter or digit'];
71
+ if (!POLICY_PLATFORMS.includes(rule.platform)) return ['policy.rule.platform', 'rule.platform must be ios, android or *'];
72
+ if (rule.profile !== '*' && (typeof rule.profile !== 'string' || !PROFILE.test(rule.profile))) return ['policy.rule.profile', 'rule.profile must be * or the name of a profile: 1 to 50 characters of A-Z a-z 0-9 . _ - that start with a letter or digit'];
73
+ if (!POLICY_TYPES.includes(rule.type)) return ['policy.rule.type', 'rule.type must be build, submit or * (resign is reserved: no rule may name it yet)'];
74
+ if (!POLICY_TARGETS.includes(rule.target)) return ['policy.rule.target', 'rule.target must be app_store_connect, google_play or *'];
75
+ if (rule.target !== '*' && rule.type !== 'submit') return ['policy.rule.target', 'rule.target must be * unless rule.type is submit: a build does not go to a store'];
76
+ if (rule.track !== '*' && (typeof rule.track !== 'string' || !TRACK.test(rule.track))) return ['policy.rule.track', 'rule.track must be * or a Google Play track: 1 to 64 characters of A-Z a-z 0-9 . _ : - that start with a letter or digit'];
77
+ if (rule.track !== '*' && (rule.type !== 'submit' || rule.target !== 'google_play')) return ['policy.rule.track', 'rule.track must be * unless rule.type is submit and rule.target is google_play'];
78
+ if (!isRecord(rule.require) || !sameKeys(rule.require, REQUIRE_FIELDS)) return ['policy.rule.require', `each rule.require must be an object with exactly ${REQUIRE_FIELDS.join(', ')}`];
79
+ const members = rule.require.members;
80
+ if (!Array.isArray(members) || members.length === 0 || members.length > POLICY_LIMITS.members) return ['policy.rule.members', `rule.require.members must be a list of 1 to ${POLICY_LIMITS.members} member ids`];
81
+ const seen = new Set();
82
+ for (const member of members) {
83
+ if (typeof member !== 'string' || !MEMBER.test(member)) return ['policy.rule.member', 'each member must be the id of a member: 1 to 128 characters of A-Z a-z 0-9 . _ - that start with a letter or digit'];
84
+ if (seen.has(member)) return ['policy.rule.member', `${member} is in the members of a rule twice`];
85
+ seen.add(member);
86
+ }
87
+ const { count } = rule.require;
88
+ if (!Number.isInteger(count) || count < 1 || count > POLICY_LIMITS.count) return ['policy.rule.count', `rule.require.count must be an integer from 1 to ${POLICY_LIMITS.count}: never 0`];
89
+ if (count > members.length) return ['policy.rule.count', `rule.require.count (${count}) is more than the members of the rule (${members.length})`];
90
+ return null;
91
+ }
92
+
93
+ /** How many fields of a rule are not *: 0 to 6 (§3.3). */
94
+ export function specificityOf(rule) {
95
+ return POLICY_FIELDS.filter((field) => rule[field] !== '*').length;
96
+ }
97
+
98
+ /** Whether two rules can match the same job: no field has a different concrete value in each one. */
99
+ function compatible(first, second) {
100
+ return POLICY_FIELDS.every((field) => first[field] === second[field] || first[field] === '*' || second[field] === '*');
101
+ }
102
+
103
+ /** The two rules that tie, as [code, message], or null: the document would not be deterministic (§3.1, rule 5). */
104
+ function ambiguityProblem(rules) {
105
+ for (let index = 0; index < rules.length; index += 1) {
106
+ for (let other = index + 1; other < rules.length; other += 1) {
107
+ if (!compatible(rules[index], rules[other])) continue;
108
+ if (specificityOf(rules[index]) !== specificityOf(rules[other])) continue;
109
+ return ['policy.rule.ambiguous', `the rules ${rules[index].id} and ${rules[other].id} both match the same job with specificity ${specificityOf(rules[index])}: no rule wins, so the document is invalid`];
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** The first problem of a policy document, as [code, message], or null. The signature is checked last, as in the others. */
116
+ function documentProblem(document, { signingKid } = {}) {
117
+ if (!isRecord(document)) return ['policy', 'the policy must be a JSON object'];
118
+ if (document.format !== POLICY_FORMAT) return ['policy.format', `the format must be ${POLICY_FORMAT}`];
119
+ const fields = [...DOCUMENT_FIELDS, ...('not_after' in document ? ['not_after'] : [])];
120
+ if (!sameKeys(document, fields)) return ['policy.fields', `a policy must have exactly ${DOCUMENT_FIELDS.join(', ')} and, optionally, not_after`];
121
+ if (!Number.isInteger(document.version) || document.version < 1 || document.version > LIMITS.version) return ['policy.version', `version must be an integer from 1 to ${LIMITS.version}`];
122
+ if ('not_after' in document && !isNotAfter(document.not_after)) return ['policy.not_after', 'not_after must be a UTC timestamp as YYYY-MM-DDTHH:MM:SSZ, such as 2027-04-28T00:00:00Z'];
123
+ const { rules } = document;
124
+ if (!Array.isArray(rules) || rules.length === 0 || rules.length > POLICY_LIMITS.rules) return ['policy.rules', `rules must be a list of 1 to ${POLICY_LIMITS.rules} rules`];
125
+ const ids = new Set();
126
+ for (const rule of rules) {
127
+ const problem = ruleProblem(rule);
128
+ if (problem) return problem;
129
+ if (ids.has(rule.id)) return ['policy.rule.duplicate', `${rule.id} is the id of two rules`];
130
+ ids.add(rule.id);
131
+ }
132
+ const ambiguity = ambiguityProblem(rules);
133
+ if (ambiguity) return ambiguity;
134
+ const { signature } = document;
135
+ if (!isRecord(signature) || !sameKeys(signature, ['kid', 'sig'])) return ['signature', 'signature must be an object with exactly kid and sig'];
136
+ if (decode(signature.kid)?.length !== KEY_BYTES) return ['signature.kid', `signature.kid must be ${KEY_BYTES} bytes in unpadded base64url`];
137
+ if (decode(signature.sig)?.length !== SIGNATURE_BYTES) return ['signature.sig', `signature.sig must be ${SIGNATURE_BYTES} bytes in unpadded base64url`];
138
+ if (signingKid !== undefined && signature.kid !== signingKid) return ['signature.unknown', `the policy must be signed with the team's key ${signingKid}`];
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Checks the shape and the semantics of a policy (an object or its JSON) without verifying its signature:
144
+ * { valid: true } or { valid: false, code, message } for the first problem. With `signingKid`, the signature must carry
145
+ * it. The codes are the ones of docs/technical/vault.md §6.5, and PHP gives the same ones.
146
+ */
147
+ export function validatePolicy(document, { signingKid } = {}) {
148
+ let value = document;
149
+ if (typeof document === 'string') {
150
+ if (Buffer.byteLength(document, 'utf8') > POLICY_LIMITS.bytes) return { valid: false, code: 'policy.too_large', message: `the policy must not exceed ${POLICY_LIMITS.bytes} bytes` };
151
+ try {
152
+ value = JSON.parse(document);
153
+ } catch {
154
+ return { valid: false, code: 'policy', message: 'the policy is not JSON' };
155
+ }
156
+ }
157
+ const problem = documentProblem(value, { signingKid });
158
+ return problem ? { valid: false, code: problem[0], message: problem[1] } : { valid: true };
159
+ }
160
+
161
+ /** The placeholder signature of a document that is not signed yet: 32 bytes of kid and 64 of signature. */
162
+ export const DRAFT_SIGNATURE = Object.freeze({ kid: 'A'.repeat(43), sig: 'A'.repeat(86) });
163
+
164
+ /**
165
+ * Checks the shape and the semantics of a policy that has not been signed yet (the file ias policy init
166
+ * writes and ias policy check reads): the same checks as validatePolicy, with a placeholder signature.
167
+ * It does not verify any signature.
168
+ */
169
+ export function validatePolicyDraft(document) {
170
+ return validatePolicy({ format: POLICY_FORMAT, ...document, signature: { ...DRAFT_SIGNATURE } });
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------------------------------------------------
174
+ // Canonical text and signature (§3.2 and docs/technical/vault.md §5.4).
175
+
176
+ /**
177
+ * What the team signs in a policy: the format, the purpose word `policy`, the kid and the version; the optional
178
+ * not_after or `-`; the number of rules; and then nine lines per rule in order (id, app, platform, profile, type,
179
+ * target, track, count and the number of members) plus one line per member, in the order written. There is no rule
180
+ * encoding beyond that: every value is one line exactly as the JSON has it, and no value can hold a newline (the
181
+ * patterns forbid it), so the text reads back one way only.
182
+ */
183
+ export function policyText(document, signingKid) {
184
+ const lines = [POLICY_FORMAT, 'policy', signingKid, String(document.version), document.not_after ?? '-', String(document.rules.length)];
185
+ for (const rule of document.rules) {
186
+ lines.push(rule.id, rule.app, rule.platform, rule.profile, rule.type, rule.target, rule.track, String(rule.require.count), String(rule.require.members.length), ...rule.require.members);
187
+ }
188
+ return Buffer.from(lines.join('\n'), 'utf8');
189
+ }
190
+
191
+ /** The SHA-256 of the canonical text, in base64url: what the panel and the CLI show as the hash of a policy. */
192
+ export function policyHash(document) {
193
+ const kid = document?.signature?.kid ?? '-';
194
+ return encode(createHash('sha256').update(policyText(withoutSignature(document), kid)).digest());
195
+ }
196
+
197
+ /** The team's Ed25519 key from its seed or pair, for signing: { kid, object }. */
198
+ function signerOf(signingKey) {
199
+ const seed = typeof signingKey === 'string' ? signingKey : signingKey?.privateKey;
200
+ if (typeof seed !== 'string') throw new VaultError('signing-key', 'the policy is signed with the team\'s signing key (its private key)');
201
+ const pair = signingKeyFromPrivateKey(seed);
202
+ return { kid: pair.kid, object: createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d: seed, x: pair.publicKey }, format: 'jwk' }) };
203
+ }
204
+
205
+ /** The team's public key the CLI or the runner pinned (base64url or { publicKey, kid? }): { kid, object }. */
206
+ function verifierOf(signedBy) {
207
+ const publicKey = typeof signedBy === 'string' ? signedBy : signedBy?.publicKey;
208
+ const raw = typeof publicKey === 'string' ? decode(publicKey) : null;
209
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('signing-key', 'the policy is checked against the team\'s signing public key (32 bytes in base64url)');
210
+ if (isRecord(signedBy) && signedBy.kid !== undefined && signedBy.kid !== keyId(raw)) throw new VaultError('signing-key', 'the kid does not match the signing public key');
211
+ let object = null;
212
+ try {
213
+ object = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey }, format: 'jwk' });
214
+ } catch {
215
+ object = null; // bytes that are no point of the curve: nothing verifies against them
216
+ }
217
+ return { kid: keyId(raw), object };
218
+ }
219
+
220
+ /** The fields of a document in their order, without the signature: format, version, not_after (if any) and rules. */
221
+ function withoutSignature(document) {
222
+ const unsigned = { format: document.format, version: document.version, ...('not_after' in document ? { not_after: document.not_after } : {}), rules: document.rules };
223
+ return unsigned;
224
+ }
225
+
226
+ /** A rule with its fields in their order, and its require with members and count. */
227
+ function normalizedRule(rule) {
228
+ const require = isRecord(rule.require)
229
+ ? { members: Array.isArray(rule.require.members) ? [...rule.require.members] : rule.require.members, count: rule.require.count }
230
+ : rule.require;
231
+
232
+ return {
233
+ id: rule.id,
234
+ app: rule.app,
235
+ platform: rule.platform,
236
+ profile: rule.profile,
237
+ type: rule.type,
238
+ target: rule.target,
239
+ track: rule.track,
240
+ require,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * The policy of `version` and `rules`, signed with the team's `signingKey` (its seed or pair):
246
+ * { format, version, not_after?, rules, signature }. The order of the rules and of their members is kept and covered by
247
+ * the signature. Throws a VaultError with the code of validatePolicy for the first problem.
248
+ */
249
+ export function signPolicy({ version, not_after: notAfter, rules }, signingKey) {
250
+ const signer = signerOf(signingKey);
251
+ const unsigned = {
252
+ format: POLICY_FORMAT,
253
+ version,
254
+ ...(notAfter === undefined ? {} : { not_after: notAfter }),
255
+ rules: Array.isArray(rules) ? rules.map((rule) => (isRecord(rule) ? normalizedRule(rule) : rule)) : rules,
256
+ };
257
+ const problem = documentProblem({ ...unsigned, signature: { kid: signer.kid, sig: encode(Buffer.alloc(SIGNATURE_BYTES)) } }, {});
258
+ if (problem) throw new VaultError(...problem);
259
+ return { ...unsigned, signature: { kid: signer.kid, sig: encode(sign(null, policyText(unsigned, signer.kid), signer.object)) } };
260
+ }
261
+
262
+ /**
263
+ * The policy checked against `signedBy`, the team's public signing key (base64url or { kid, publicKey }): its shape and
264
+ * semantics, that it is signed with that key, that the signature verifies, that it has not expired with `now` (a Date,
265
+ * by default now; `checkExpiry: false` checks only the signature, which is what reading an expired policy to replace
266
+ * it needs) and, with `minimumVersion` (the highest version a runner has seen), that it is not older. Returns the
267
+ * document parsed; throws a VaultError otherwise (policy.signature, policy.expired, policy.version, or a code of
268
+ * validatePolicy). It does not approve anything: the runner does that in its step 1b.
269
+ */
270
+ export function verifyPolicy(document, signedBy, { minimumVersion, now = new Date(), checkExpiry = true } = {}) {
271
+ const verifier = verifierOf(signedBy);
272
+ let value = document;
273
+ if (typeof document === 'string') {
274
+ if (Buffer.byteLength(document, 'utf8') > POLICY_LIMITS.bytes) throw new VaultError('policy.too_large', `the policy must not exceed ${POLICY_LIMITS.bytes} bytes`);
275
+ try {
276
+ value = JSON.parse(document);
277
+ } catch {
278
+ throw new VaultError('policy', 'the policy is not JSON');
279
+ }
280
+ }
281
+ const problem = documentProblem(value, { signingKid: verifier.kid });
282
+ if (problem) throw new VaultError(...problem);
283
+ let verified = false;
284
+ try {
285
+ verified = verifier.object !== null && verify(null, policyText(value, value.signature.kid), verifier.object, decode(value.signature.sig));
286
+ } catch {
287
+ verified = false;
288
+ }
289
+ if (!verified) throw new VaultError('policy.signature', `the policy does not verify with the team's signing key ${verifier.kid}: it was altered, or made by someone without that key`);
290
+ if (checkExpiry && 'not_after' in value && Date.parse(value.not_after) <= now.getTime()) throw new VaultError('policy.expired', `the policy expired on ${value.not_after}`);
291
+ if (minimumVersion !== undefined && value.version < minimumVersion) throw new VaultError('policy.version', `the policy is version ${value.version}, older than version ${minimumVersion}, which this machine has already seen`);
292
+ return structuredClone(value);
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------------------------------------------------
296
+ // Matching (§3.3).
297
+
298
+ /**
299
+ * What a job gives the matching (§3.3): its type decides target and track. A build has neither. A TestFlight action is
300
+ * a submission to App Store Connect with no track, and a release is a submission to Google Play with the track it moves:
301
+ * the rule types stay build, submit and *, so a submit rule covers them with its target and track.
302
+ */
303
+ export function jobOf({ app, platform, profile, type = 'build', target, track } = {}) {
304
+ if (typeof type !== 'string' || type === '') throw new VaultError('policy.type', 'a job names its type: build, submit, testflight or release');
305
+ if (type === 'testflight') return { app, platform, profile, type: 'submit', target: 'app_store_connect', track: '*' };
306
+ if (type === 'release') {
307
+ if (typeof track !== 'string' || track === '') throw new VaultError('policy.track', 'a release names the Google Play track it moves: production, beta or a closed one');
308
+ return { app, platform, profile, type: 'submit', target: 'google_play', track };
309
+ }
310
+ const submit = type === 'submit';
311
+ if (submit && target !== 'app_store_connect' && target !== 'google_play') throw new VaultError('policy.target', 'a submit names its target: app_store_connect or google_play');
312
+ if (submit && target === 'google_play' && (typeof track !== 'string' || !TRACK.test(track))) throw new VaultError('policy.track', 'a submit to Google Play names its track: internal, alpha, beta, production or a closed one');
313
+ return { app, platform, profile, type, target: submit ? target : '*', track: submit && target === 'google_play' ? track : '*' };
314
+ }
315
+
316
+ /** Whether one rule matches a job: each of its six fields is * or exactly the job's. */
317
+ function matches(rule, job) {
318
+ return POLICY_FIELDS.every((field) => rule[field] === '*' || rule[field] === job[field]);
319
+ }
320
+
321
+ /**
322
+ * The rule that decides a job: the one that matches with the greatest specificity (the number of fields that are not
323
+ * *). Throws VaultError policy.unmatched when none matches and policy.rule.ambiguous when two match with the same
324
+ * specificity: there is no implicit rule and no first one wins.
325
+ */
326
+ export function ruleFor(policy, job) {
327
+ const wanted = jobOf(job);
328
+ const candidates = policy.rules.filter((rule) => matches(rule, wanted));
329
+ if (candidates.length === 0) throw new VaultError('policy.unmatched', `no rule of the policy matches ${POLICY_FIELDS.map((field) => `${field}:${wanted[field]}`).join(' ')}`);
330
+ const best = Math.max(...candidates.map(specificityOf));
331
+ const winners = candidates.filter((rule) => specificityOf(rule) === best);
332
+ if (winners.length > 1) throw new VaultError('policy.rule.ambiguous', `the rules ${winners.map((rule) => rule.id).join(' and ')} all match this job with specificity ${best}`);
333
+ return winners[0];
334
+ }
335
+
336
+ /** The specificity of the rule that decides a job, with the rule: { rule, specificity }. */
337
+ export function ruleAndSpecificityFor(policy, job) {
338
+ const rule = ruleFor(policy, job);
339
+ return { rule, specificity: specificityOf(rule) };
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------------------------------------------------
343
+ // Summary and simulation: what a policy covers, what it leaves out and what it would decide (§5 and §3.8).
344
+
345
+ /** The destinations to check for uncovered rules: the stores each job type and platform can reach. */
346
+ function destinationsOf(apps, profiles) {
347
+ if (apps.length === 0 || profiles.length === 0) return [];
348
+ const destinations = [];
349
+ for (const app of apps) {
350
+ for (const platform of ['ios', 'android']) {
351
+ for (const profile of profiles) {
352
+ destinations.push({ app, platform, profile, type: 'build', target: '*', track: '*' });
353
+ if (platform === 'ios') {
354
+ destinations.push({ app, platform, profile, type: 'submit', target: 'app_store_connect', track: '*' });
355
+ } else {
356
+ for (const track of ['internal', 'production']) destinations.push({ app, platform, profile, type: 'submit', target: 'google_play', track });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ return destinations;
362
+ }
363
+
364
+ const appCodeOf = (app) => (typeof app === 'string' ? app : app?.code);
365
+ const profileNameOf = (profile) => (typeof profile === 'string' ? profile : profile?.name);
366
+
367
+ /**
368
+ * What a policy is and what it leaves out. `apps` are application codes (or { code }), `profiles` profile names (or
369
+ * { name }) and `members` the ids of the members with a passkey in the current ias-approvers-1 (null to skip the check:
370
+ * with an empty list every rule is unsatisfiable). Returns:
371
+ * rules: every rule, with its specificity and whether it has enough members with a passkey;
372
+ * uncovered: the app · platform · profile · type · target · track combinations of the default matrix that no rule
373
+ * matches (a build, and the submit of each platform to its store; Google Play on internal and
374
+ * production). Empty without apps or profiles;
375
+ * unsatisfiable: the rules whose count is more than their members with a passkey;
376
+ * expiring: null without not_after, or { not_after, days, expired, warning } (warning inside warnDays).
377
+ */
378
+ export function summary(policy, { apps = [], profiles = [], members = null, now = new Date(), warnDays = 30 } = {}) {
379
+ const holders = members === null ? null : new Set(members);
380
+ const rules = policy.rules.map((rule) => {
381
+ const available = holders === null ? null : rule.require.members.filter((member) => holders.has(member)).length;
382
+ return { ...normalizedRule(rule), specificity: specificityOf(rule), unsatisfiable: available !== null && available < rule.require.count };
383
+ });
384
+ const uncovered = [];
385
+ for (const destination of destinationsOf(apps.map(appCodeOf).filter(Boolean), profiles.map(profileNameOf).filter(Boolean))) {
386
+ try {
387
+ ruleFor(policy, destination);
388
+ } catch (error) {
389
+ uncovered.push({ ...destination, code: error.code });
390
+ }
391
+ }
392
+ const unsatisfiable = rules.filter((rule) => rule.unsatisfiable).map((rule) => ({ id: rule.id, reason: 'policy.member.passkey', count: rule.require.count, members: rule.require.members }));
393
+ const expiring = 'not_after' in policy && policy.not_after !== undefined
394
+ ? (() => {
395
+ const time = Date.parse(policy.not_after);
396
+ const expired = time <= now.getTime();
397
+ const days = Math.ceil((time - now.getTime()) / DAY_MS);
398
+ return { not_after: policy.not_after, days: Math.max(days, 0), expired, warning: !expired && days <= warnDays };
399
+ })()
400
+ : null;
401
+ return { rules, uncovered, unsatisfiable, expiring };
402
+ }
403
+
404
+ /**
405
+ * What a policy would decide for a job, without approving or denying anything yet: { allowed, code, message, rule,
406
+ * specificity, version }. `allowed` is false when no rule matches (policy.unmatched), when two tie
407
+ * (policy.rule.ambiguous), when the policy expired (policy.expired) or, with `members`, when the rule cannot reach its
408
+ * count (policy.unsatisfiable). A job of type testflight or release matches as the submission it is: to App Store
409
+ * Connect without a track, or to Google Play with the track it moves.
410
+ */
411
+ export function simulate(policy, job, { now = new Date(), members = null } = {}) {
412
+ const result = { allowed: false, code: null, message: null, rule: null, specificity: null, version: policy.version };
413
+ let matched;
414
+ try {
415
+ matched = ruleAndSpecificityFor(policy, job);
416
+ } catch (error) {
417
+ return { ...result, code: error.code, message: error.message };
418
+ }
419
+ result.rule = matched.rule;
420
+ result.specificity = matched.specificity;
421
+ if ('not_after' in policy && Date.parse(policy.not_after) <= now.getTime()) return { ...result, code: 'policy.expired', message: `the policy expired on ${policy.not_after}` };
422
+ if (members !== null) {
423
+ const available = matched.rule.require.members.filter((member) => members.includes(member)).length;
424
+ if (available < matched.rule.require.count) return { ...result, code: 'policy.unsatisfiable', message: `the rule ${matched.rule.id} needs ${matched.rule.require.count} approvals and only ${available} of its members have a passkey` };
425
+ }
426
+ return { ...result, allowed: true };
427
+ }