@imagina-build/ias-cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -21
- package/package.json +1 -1
- package/schema/ias.schema.json +46 -1
- package/shared/vault/index.mjs +25 -0
- package/shared/vault/policy-vectors.mjs +237 -0
- package/shared/vault/policy.mjs +427 -0
- package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
- package/shared/vault/vectors.mjs +2 -0
- package/src/apple/capabilities.mjs +96 -0
- package/src/apple/inventory.mjs +75 -0
- package/src/apple/signing.mjs +134 -0
- package/src/applications.mjs +114 -0
- package/src/cli.mjs +28 -10
- package/src/commands/apps/index.mjs +61 -11
- package/src/commands/apps/store.mjs +367 -0
- package/src/commands/auth/index.mjs +3 -3
- package/src/commands/build/index.mjs +2 -0
- package/src/commands/builds/index.mjs +20 -0
- package/src/commands/credentials/apple.mjs +1118 -0
- package/src/commands/credentials/generate.mjs +122 -0
- package/src/commands/credentials/index.mjs +114 -44
- package/src/commands/credentials/seal.mjs +44 -0
- package/src/commands/devices/asc.mjs +50 -8
- package/src/commands/devices/index.mjs +2 -1
- package/src/commands/devices/sync.mjs +57 -4
- package/src/commands/dist/index.mjs +12 -0
- package/src/commands/github/index.mjs +13 -3
- package/src/commands/policy/index.mjs +583 -0
- package/src/commands/policy/policy.mjs +182 -0
- package/src/commands/policy/progress.mjs +20 -0
- package/src/commands/releases/index.mjs +225 -0
- package/src/commands/rollout/index.mjs +186 -0
- package/src/commands/runners/index.mjs +19 -0
- package/src/commands/sdk/index.mjs +31 -4
- package/src/commands/setup/index.mjs +184 -0
- package/src/commands/submit/index.mjs +135 -19
- package/src/commands/testflight/index.mjs +309 -0
- package/src/commands/tokens/index.mjs +99 -0
- package/src/commands/update/index.mjs +42 -15
- package/src/commands/vault/approvers.mjs +5 -1
- package/src/commands/vault/index.mjs +26 -6
- package/src/commands/vault/runners.mjs +48 -2
- package/src/commands/webhooks/index.mjs +19 -5
- package/src/config.mjs +16 -1
- package/src/errors.mjs +52 -6
- package/src/op.mjs +83 -0
- package/src/ota-key.mjs +53 -0
- package/src/provisioning.mjs +281 -0
- package/src/schema.mjs +3 -0
- package/src/setup-state.mjs +76 -0
- package/src/setup.mjs +698 -0
- package/src/team-vault.mjs +2 -2
- package/src/token-store.mjs +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The test vectors of ias-policy-1 (docs/plans/gaps/06-politica-aprobacion.md §5.1 and docs/technical/vault.md §8):
|
|
2
|
+
// test-vectors/ias-policy-1/policy.json, signed with the team's test key of ias-vault-2/keys.json. vectors.mjs writes it
|
|
3
|
+
// with the rest; the tests fail if the file differs. The member ids are the ones of the plan's Anexo A, and the keys are
|
|
4
|
+
// public on purpose: never use them for anything else.
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { POLICY_FORMAT, POLICY_LIMITS, policyHash, policyText, signPolicy, simulate, summary, validatePolicy, verifyPolicy } from './policy.mjs';
|
|
7
|
+
|
|
8
|
+
const OWNER_A = '01930000-0000-7000-8000-00000000a001';
|
|
9
|
+
const OWNER_B = '01930000-0000-7000-8000-00000000a002';
|
|
10
|
+
const OWNER_C = '01930000-0000-7000-8000-00000000a003';
|
|
11
|
+
const NOBODY = '01930000-0000-7000-8000-00000000a004';
|
|
12
|
+
|
|
13
|
+
const rule = (id, fields = {}) => ({
|
|
14
|
+
id,
|
|
15
|
+
app: '*',
|
|
16
|
+
platform: '*',
|
|
17
|
+
profile: '*',
|
|
18
|
+
type: '*',
|
|
19
|
+
target: '*',
|
|
20
|
+
track: '*',
|
|
21
|
+
require: { members: [OWNER_A, OWNER_B], count: 1 },
|
|
22
|
+
...fields,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Copies `document` and lets `change` alter the copy. */
|
|
26
|
+
const altered = (document, change) => {
|
|
27
|
+
const copy = structuredClone(document);
|
|
28
|
+
change(copy);
|
|
29
|
+
return copy;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The unsigned part of a document, as signPolicy takes it. */
|
|
33
|
+
const unsigned = ({ version, not_after: notAfter, rules }) => ({ version, ...(notAfter === undefined ? {} : { not_after: notAfter }), rules });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Everything that goes into test-vectors/ias-policy-1/policy.json. `signing` is the one of vectors.mjs: { team,
|
|
37
|
+
* 'other-team' }.
|
|
38
|
+
*/
|
|
39
|
+
export function policyVectors(signing) {
|
|
40
|
+
const team = signing.team;
|
|
41
|
+
const other = signing['other-team'];
|
|
42
|
+
const inputs = [
|
|
43
|
+
['heredado', 'The minimal policy of the plan\'s Anexo A: one * rule with every owner with a passkey, count 1. It decides nothing new.', { version: 1, not_after: '2027-04-28T00:00:00Z', rules: [rule('heredado', { require: { members: [OWNER_A, OWNER_B], count: 1 } })] }],
|
|
44
|
+
['two-rules', 'Two rules: iOS production builds need two owners, and the internal track of Google Play one.', {
|
|
45
|
+
version: 2,
|
|
46
|
+
not_after: '2027-04-28T00:00:00Z',
|
|
47
|
+
rules: [
|
|
48
|
+
rule('ios-production', { platform: 'ios', profile: 'production', type: 'build', require: { members: [OWNER_A, OWNER_B], count: 2 } }),
|
|
49
|
+
rule('play-internal', { platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'internal', require: { members: [OWNER_A, OWNER_C], count: 1 } }),
|
|
50
|
+
],
|
|
51
|
+
}],
|
|
52
|
+
['four-rules', 'Four rules with a date: one owner for everything, two for production, the client app to its owner, and an Android submission to production.', {
|
|
53
|
+
version: 4,
|
|
54
|
+
not_after: '2027-10-28T00:00:00Z',
|
|
55
|
+
rules: [
|
|
56
|
+
rule('todo', { require: { members: [OWNER_A], count: 1 } }),
|
|
57
|
+
rule('ios-production', { profile: 'production', type: 'build', require: { members: [OWNER_A, OWNER_B], count: 2 } }),
|
|
58
|
+
rule('cliente', { app: 'acme', require: { members: [OWNER_C], count: 1 } }),
|
|
59
|
+
rule('play-production', { platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'production', require: { members: [OWNER_B, OWNER_C], count: 1 } }),
|
|
60
|
+
],
|
|
61
|
+
}],
|
|
62
|
+
];
|
|
63
|
+
const documents = inputs.map(([name, description, input]) => {
|
|
64
|
+
const document = signPolicy(input, team.privateKey);
|
|
65
|
+
verifyPolicy(document, team.publicKey, { now: new Date('2026-10-28T00:00:00Z') });
|
|
66
|
+
return { name, description, signedText: policyText(document, team.kid).toString('utf8'), hash: policyHash(document), document };
|
|
67
|
+
});
|
|
68
|
+
const base = documents[0].document;
|
|
69
|
+
// Not one of the documents above: a policy whose submit rules cover TestFlight and a release of production, the jobs
|
|
70
|
+
// that name no type of their own in ias-policy-1 (they match as the submission they are, docs/technical/vault.md §2.5).
|
|
71
|
+
const testflightAndRelease = {
|
|
72
|
+
name: 'testflight-and-release',
|
|
73
|
+
description: 'A submit rule to App Store Connect covers TestFlight, and one to Google Play with track production covers a release of that track.',
|
|
74
|
+
document: signPolicy({
|
|
75
|
+
version: 5,
|
|
76
|
+
not_after: '2027-04-28T00:00:00Z',
|
|
77
|
+
rules: [
|
|
78
|
+
rule('asc-submit', { type: 'submit', target: 'app_store_connect' }),
|
|
79
|
+
rule('play-production-release', { platform: 'android', type: 'submit', target: 'google_play', track: 'production' }),
|
|
80
|
+
],
|
|
81
|
+
}, team.privateKey),
|
|
82
|
+
};
|
|
83
|
+
const ruleOf = (value, index = 0) => value.rules[index];
|
|
84
|
+
// A track of a form factor: `:` is a track character (wear:production, automotive:production), and a rule may name it.
|
|
85
|
+
const formFactor = {
|
|
86
|
+
name: 'form-factor',
|
|
87
|
+
description: 'A rule for the production track of a form factor (wear:production), which the track pattern accepts.',
|
|
88
|
+
document: signPolicy({
|
|
89
|
+
version: 6,
|
|
90
|
+
not_after: '2027-04-28T00:00:00Z',
|
|
91
|
+
rules: [rule('play-wear-production', { platform: 'android', type: 'submit', target: 'google_play', track: 'wear:production' })],
|
|
92
|
+
}, team.privateKey),
|
|
93
|
+
};
|
|
94
|
+
const malformed = [
|
|
95
|
+
['array', 'policy', 'A list instead of an object.', { document: [base] }],
|
|
96
|
+
['not-json', 'policy', 'Text that is not JSON.', { json: '{"format":"ias-policy-1",' }],
|
|
97
|
+
['too-large', 'policy.too_large', `More than ${POLICY_LIMITS.bytes} bytes of JSON.`, { json: `{"format":"ias-policy-1","padding":"${'x'.repeat(POLICY_LIMITS.bytes)}"}` }],
|
|
98
|
+
['other-format', 'policy.format', 'Another format.', { document: altered(base, (d) => (d.format = 'ias-policy-2')) }],
|
|
99
|
+
['missing-signature', 'policy.fields', 'No signature.', { document: altered(base, (d) => delete d.signature) }],
|
|
100
|
+
['extra-field', 'policy.fields', 'A field besides format, version, not_after, rules and signature.', { document: altered(base, (d) => (d.issued_at = '2026-10-28T00:00:00Z')) }],
|
|
101
|
+
['not-after-null', 'policy.not_after', 'not_after as null: it is left out or a timestamp.', { document: altered(base, (d) => (d.not_after = null)) }],
|
|
102
|
+
['version-zero', 'policy.version', 'Version 0.', { document: altered(base, (d) => (d.version = 0)) }],
|
|
103
|
+
['version-string', 'policy.version', 'The version as a string.', { document: altered(base, (d) => (d.version = '1')) }],
|
|
104
|
+
['version-decimal', 'policy.version', 'A version with decimals.', { document: altered(base, (d) => (d.version = 1.5)) }],
|
|
105
|
+
['not-after-date-only', 'policy.not_after', 'A date without the time, which the format does not take.', { document: altered(base, (d) => (d.not_after = '2027-04-28')) }],
|
|
106
|
+
['not-after-impossible-day', 'policy.not_after', 'The 30th of February.', { document: altered(base, (d) => (d.not_after = '2027-02-30T00:00:00Z')) }],
|
|
107
|
+
['rules-empty', 'policy.rules', 'No rules: a policy that denies everything is not a policy.', { document: altered(base, (d) => (d.rules = [])) }],
|
|
108
|
+
['rules-object', 'policy.rules', 'The rules as an object keyed by id.', { document: altered(base, (d) => (d.rules = Object.fromEntries(d.rules.map((entry) => [entry.id, entry])))) }],
|
|
109
|
+
['rules-too-many', 'policy.rules', `More than ${POLICY_LIMITS.rules} rules.`, { document: altered(base, (d) => (d.rules = Array.from({ length: POLICY_LIMITS.rules + 1 }, (_, index) => ({ ...rule(`r${index}`), id: `r${index}` })))) }],
|
|
110
|
+
['rule-extra-field', 'policy.rule', 'A rule with a comment: it takes exactly the eight fields.', { document: altered(base, (d) => (d.rules[0].note = 'producción')) }],
|
|
111
|
+
['rule-without-require', 'policy.rule', 'A rule without require.', { document: altered(base, (d) => delete d.rules[0].require) }],
|
|
112
|
+
['id-uppercase', 'policy.rule.id', 'An id in capitals.', { document: altered(base, (d) => (d.rules[0].id = 'Heredado')) }],
|
|
113
|
+
['id-empty', 'policy.rule.id', 'An empty id.', { document: altered(base, (d) => (d.rules[0].id = '')) }],
|
|
114
|
+
['id-too-long', 'policy.rule.id', 'An id of 33 characters.', { document: altered(base, (d) => (d.rules[0].id = 'a'.repeat(33))) }],
|
|
115
|
+
['id-duplicate', 'policy.rule.duplicate', 'Two rules with the same id.', { document: altered(base, (d) => d.rules.push(structuredClone(d.rules[0]))) }],
|
|
116
|
+
['app-uppercase', 'policy.rule.app', 'An application code in capitals.', { document: altered(base, (d) => (d.rules[0].app = 'Acme')) }],
|
|
117
|
+
['platform-windows', 'policy.rule.platform', 'A platform the format does not have.', { document: altered(base, (d) => (d.rules[0].platform = 'windows')) }],
|
|
118
|
+
['platform-uppercase', 'policy.rule.platform', 'iOS in capitals.', { document: altered(base, (d) => (d.rules[0].platform = 'iOS')) }],
|
|
119
|
+
['profile-with-slash', 'policy.rule.profile', 'A profile with a slash, which no profile may have.', { document: altered(base, (d) => (d.rules[0].profile = 'production/eu')) }],
|
|
120
|
+
['type-resign', 'policy.rule.type', 'type resign, reserved for a later format.', { document: altered(base, (d) => (d.rules[0].type = 'resign')) }],
|
|
121
|
+
['target-unknown', 'policy.rule.target', 'A store the format does not have.', { document: altered(base, (d) => (d.rules[0].target = 'huawei')) }],
|
|
122
|
+
['target-with-build', 'policy.rule.target', 'A target with type build.', { document: altered(base, (d) => Object.assign(d.rules[0], { type: 'build', target: 'app_store_connect' })) }],
|
|
123
|
+
['track-with-app-store-connect', 'policy.rule.track', 'A track with target app_store_connect.', { document: altered(base, (d) => Object.assign(d.rules[0], { type: 'submit', target: 'app_store_connect', track: 'internal' })) }],
|
|
124
|
+
['track-with-build', 'policy.rule.track', 'A track with type build.', { document: altered(base, (d) => Object.assign(d.rules[0], { type: 'build', track: 'internal' })) }],
|
|
125
|
+
['track-invalid-char', 'policy.rule.track', 'A track with a percentage, which no track may have.', { document: altered(base, (d) => Object.assign(d.rules[0], { type: 'submit', target: 'google_play', track: 'internal:50%' })) }],
|
|
126
|
+
['require-extra-field', 'policy.rule.require', 'require with a role: the document names member ids, not roles.', { document: altered(base, (d) => (d.rules[0].require.roles = ['owner'])) }],
|
|
127
|
+
['members-empty', 'policy.rule.members', 'A rule without members: nobody could approve it.', { document: altered(base, (d) => (d.rules[0].require.members = [])) }],
|
|
128
|
+
['members-too-many', 'policy.rule.members', `More than ${POLICY_LIMITS.members} members.`, { document: altered(base, (d) => (d.rules[0].require.members = Array.from({ length: POLICY_LIMITS.members + 1 }, (_, index) => `01930000-0000-7000-8000-${String(index).padStart(12, '0')}`))) }],
|
|
129
|
+
['member-empty', 'policy.rule.member', 'An empty member id.', { document: altered(base, (d) => (d.rules[0].require.members[0] = '')) }],
|
|
130
|
+
['members-duplicate', 'policy.rule.member', 'The same member twice.', { document: altered(base, (d) => d.rules[0].require.members.push(OWNER_A)) }],
|
|
131
|
+
['count-zero', 'policy.rule.count', 'count 0, which would approve without a passkey.', { document: altered(base, (d) => (d.rules[0].require.count = 0)) }],
|
|
132
|
+
['count-nine', 'policy.rule.count', `count ${POLICY_LIMITS.count + 1}, more than a policy may ask for.`, { document: altered(base, (d) => (d.rules[0].require.count = POLICY_LIMITS.count + 1)) }],
|
|
133
|
+
['count-more-than-members', 'policy.rule.count', 'count 3 with 2 members: nobody could ever approve it.', { document: altered(base, (d) => (d.rules[0].require.count = 3)) }],
|
|
134
|
+
['signature-sig-63-bytes', 'signature.sig', 'A 63-byte signature.', { document: altered(base, (d) => (d.signature.sig = Buffer.from(d.signature.sig, 'base64url').subarray(1).toString('base64url'))) }],
|
|
135
|
+
['signature-kid-padded', 'signature.kid', 'Padding after the kid of the signature.', { document: altered(base, (d) => (d.signature.kid = `${d.signature.kid}=`)) }],
|
|
136
|
+
['signed-by-another-key', 'signature.unknown', 'A policy signed by another team\'s key with its own kid (options.signingKid is the team\'s).', { document: signPolicy(unsigned(base), other.privateKey), options: { signingKid: team.kid } }],
|
|
137
|
+
['ambiguous-tie', 'policy.rule.ambiguous', 'Two rules that match the same job with the same specificity: no rule wins.', {
|
|
138
|
+
document: altered(documents[1].document, (d) => (d.rules = [rule('ios-a', { platform: 'ios', type: 'build' }), rule('ios-b', { platform: 'ios', type: 'build', profile: '*' })])),
|
|
139
|
+
}],
|
|
140
|
+
].map(([name, code, description, value]) => {
|
|
141
|
+
const result = validatePolicy('json' in value ? value.json : value.document, value.options ?? {});
|
|
142
|
+
if (result.valid || result.code !== code) throw new Error(`malformed policy ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
|
|
143
|
+
return { name, code, description, ...value };
|
|
144
|
+
});
|
|
145
|
+
const tampered = [
|
|
146
|
+
['rule-changed', 'A profile changed without signing.', altered(base, (d) => (d.rules[0].profile = 'production'))],
|
|
147
|
+
['member-added', 'A member added by the server, who could then approve.', altered(base, (d) => d.rules[0].require.members.push(NOBODY))],
|
|
148
|
+
['count-lowered', 'The count lowered to one, so one approval is enough.', altered(documents[1].document, (d) => (d.rules[0].require.count = 1))],
|
|
149
|
+
['rule-removed', 'A rule removed: its jobs would be denied or fall to a * rule.', altered(documents[1].document, (d) => d.rules.pop())],
|
|
150
|
+
['not-after-extended', 'not_after moved further away, to keep an expired policy alive.', altered(base, (d) => (d.not_after = '2030-01-01T00:00:00Z'))],
|
|
151
|
+
['signature-of-another-team', 'Signed by another key under the team\'s kid.', (() => {
|
|
152
|
+
const forged = signPolicy(unsigned(base), other.privateKey);
|
|
153
|
+
return { ...forged, signature: { kid: team.kid, sig: forged.signature.sig } };
|
|
154
|
+
})()],
|
|
155
|
+
['signature-bit', 'One bit of the signature changed.', altered(base, (d) => {
|
|
156
|
+
const bytes = Buffer.from(d.signature.sig, 'base64url');
|
|
157
|
+
bytes[0] ^= 1;
|
|
158
|
+
d.signature.sig = bytes.toString('base64url');
|
|
159
|
+
})],
|
|
160
|
+
['rules-reordered', 'The rules in another order, which the signature covers.', altered(documents[2].document, (d) => d.rules.reverse())],
|
|
161
|
+
['version-raised', 'The version raised without signing, to look newer than the one a runner has.', altered(base, (d) => (d.version = 9))],
|
|
162
|
+
].map(([name, description, document]) => {
|
|
163
|
+
if (!validatePolicy(document, { signingKid: team.kid }).valid) throw new Error(`tampered policy ${name} is not well formed`);
|
|
164
|
+
try {
|
|
165
|
+
verifyPolicy(document, team.publicKey);
|
|
166
|
+
throw new Error(`tampered policy ${name} verifies`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== 'policy.signature') throw new Error(`tampered policy ${name} fails with ${error.code ?? error.message}`);
|
|
169
|
+
}
|
|
170
|
+
return { name, description, error: 'policy.signature', document };
|
|
171
|
+
});
|
|
172
|
+
const expired = [
|
|
173
|
+
{
|
|
174
|
+
name: 'expired',
|
|
175
|
+
description: 'A policy that was valid when it was signed and expired on 2026-10-01: the runner rejects it with policy.expired, the server only if it is already past when it is uploaded.',
|
|
176
|
+
now: '2026-10-28T00:00:00Z',
|
|
177
|
+
error: 'policy.expired',
|
|
178
|
+
document: signPolicy({ version: 1, not_after: '2026-10-01T00:00:00Z', rules: [rule('heredado')] }, team.privateKey),
|
|
179
|
+
},
|
|
180
|
+
].map((vector) => {
|
|
181
|
+
verifyPolicy(vector.document, team.publicKey, { now: new Date('2026-09-01T00:00:00Z') });
|
|
182
|
+
try {
|
|
183
|
+
verifyPolicy(vector.document, team.publicKey, { now: new Date(vector.now) });
|
|
184
|
+
throw new Error(`expired policy ${vector.name} verifies`);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code !== vector.error) throw new Error(`expired policy ${vector.name} fails with ${error.code ?? error.message}`);
|
|
187
|
+
}
|
|
188
|
+
return vector;
|
|
189
|
+
});
|
|
190
|
+
const matching = [
|
|
191
|
+
['exact-beats-wildcard', documents[2].document, { app: 'jscheduler', platform: 'ios', profile: 'production', type: 'build' }, 'ios-production'],
|
|
192
|
+
[ 'play-internal', documents[1].document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'internal' }, 'play-internal'],
|
|
193
|
+
['play-production-is-not-internal', documents[1].document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'production' }, null, 'policy.unmatched'],
|
|
194
|
+
['ios-submit-is-not-a-build', documents[1].document, { app: 'jscheduler', platform: 'ios', profile: 'production', type: 'submit', target: 'app_store_connect' }, null, 'policy.unmatched'],
|
|
195
|
+
['another-profile', documents[1].document, { app: 'jscheduler', platform: 'ios', profile: 'preview', type: 'build' }, null, 'policy.unmatched'],
|
|
196
|
+
['wildcard-only', documents[0].document, { app: 'acme', platform: 'android', profile: 'preview', type: 'build' }, 'heredado'],
|
|
197
|
+
['four-rules-client', documents[2].document, { app: 'acme', platform: 'ios', profile: 'preview', type: 'build' }, 'cliente'],
|
|
198
|
+
// A TestFlight action is a submission to App Store Connect without a track, and a release a submission to Google Play with its track.
|
|
199
|
+
['testflight-is-an-app-store-submission', testflightAndRelease.document, { app: 'jscheduler', platform: 'ios', profile: 'production', type: 'testflight' }, 'asc-submit'],
|
|
200
|
+
['testflight-is-not-a-build', documents[1].document, { app: 'jscheduler', platform: 'ios', profile: 'production', type: 'testflight' }, null, 'policy.unmatched'],
|
|
201
|
+
['release-is-a-play-submission-with-its-track', testflightAndRelease.document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'release', track: 'production' }, 'play-production-release'],
|
|
202
|
+
['release-another-track-is-denied', testflightAndRelease.document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'release', track: 'beta' }, null, 'policy.unmatched'],
|
|
203
|
+
['wildcard-covers-testflight', documents[0].document, { app: 'jscheduler', platform: 'ios', profile: 'production', type: 'testflight' }, 'heredado'],
|
|
204
|
+
['wildcard-covers-release', documents[0].document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'release', track: 'production' }, 'heredado'],
|
|
205
|
+
// A form factor's track: `:` is part of the name, and the rule that names it is the one that decides.
|
|
206
|
+
['wear-production-is-a-track', formFactor.document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'wear:production' }, 'play-wear-production'],
|
|
207
|
+
['production-is-not-wear-production', formFactor.document, { app: 'jscheduler', platform: 'android', profile: 'production', type: 'submit', target: 'google_play', track: 'production' }, null, 'policy.unmatched'],
|
|
208
|
+
].map(([name, policy, job, id, error = null]) => {
|
|
209
|
+
const result = simulate(policy, job, { now: new Date('2026-10-28T00:00:00Z') });
|
|
210
|
+
if (id === null ? result.allowed || result.code !== error : !result.allowed || result.rule.id !== id) throw new Error(`matching vector ${name} gives ${result.allowed ? result.rule.id : result.code}`);
|
|
211
|
+
return { name, job, rule: id, error, document: policy, specificity: id === null ? null : result.specificity };
|
|
212
|
+
});
|
|
213
|
+
const summaries = [
|
|
214
|
+
{
|
|
215
|
+
name: 'two-rules',
|
|
216
|
+
description: 'The two-rule document over one app and two profiles: the only owner with a passkey cannot reach the two approvals of ios-production, and the combinations no rule covers.',
|
|
217
|
+
document: documents[1].document,
|
|
218
|
+
options: { apps: ['jscheduler'], profiles: ['preview', 'production'], members: [OWNER_A], now: '2026-10-28T00:00:00Z' },
|
|
219
|
+
},
|
|
220
|
+
].map((vector) => {
|
|
221
|
+
const result = summary(vector.document, { ...vector.options, now: new Date(vector.options.now) });
|
|
222
|
+
return { ...vector, summary: result, uncovered: result.uncovered.map((entry) => `${entry.app}/${entry.platform}/${entry.profile}/${entry.type}/${entry.target}/${entry.track}`), unsatisfiable: result.unsatisfiable.map((entry) => entry.id) };
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
format: POLICY_FORMAT,
|
|
226
|
+
description: `The signed approval policy (${POLICY_FORMAT}, docs/technical/vault.md §2.5): documents are good ones signed with the team key of ias-vault-2/keys.json (signing.team; other-team makes the forgeries), each with the signedText the signature covers and the hash of that text. malformed must be rejected with code (validatePolicy, with options); tampered are well formed but do not verify (verifyPolicy: policy.signature); expired verifies before its date and gives policy.expired after it; matching gives the rule simulate picks for a job (or the code of its denial), and summaries what summary reports.`,
|
|
227
|
+
warning: 'Test vectors: the keys are public. Never use them for anything else.',
|
|
228
|
+
signing: { kid: team.kid, publicKey: team.publicKey },
|
|
229
|
+
members: { 'owner-a': OWNER_A, 'owner-b': OWNER_B, 'owner-c': OWNER_C, 'without-passkey': NOBODY },
|
|
230
|
+
documents,
|
|
231
|
+
malformed,
|
|
232
|
+
tampered,
|
|
233
|
+
expired,
|
|
234
|
+
matching,
|
|
235
|
+
summaries,
|
|
236
|
+
};
|
|
237
|
+
}
|