@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.
Files changed (53) hide show
  1. package/README.md +184 -21
  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/github/index.mjs +13 -3
  28. package/src/commands/policy/index.mjs +583 -0
  29. package/src/commands/policy/policy.mjs +182 -0
  30. package/src/commands/policy/progress.mjs +20 -0
  31. package/src/commands/releases/index.mjs +225 -0
  32. package/src/commands/rollout/index.mjs +186 -0
  33. package/src/commands/runners/index.mjs +19 -0
  34. package/src/commands/sdk/index.mjs +31 -4
  35. package/src/commands/setup/index.mjs +184 -0
  36. package/src/commands/submit/index.mjs +135 -19
  37. package/src/commands/testflight/index.mjs +309 -0
  38. package/src/commands/tokens/index.mjs +99 -0
  39. package/src/commands/update/index.mjs +42 -15
  40. package/src/commands/vault/approvers.mjs +5 -1
  41. package/src/commands/vault/index.mjs +26 -6
  42. package/src/commands/vault/runners.mjs +48 -2
  43. package/src/commands/webhooks/index.mjs +19 -5
  44. package/src/config.mjs +16 -1
  45. package/src/errors.mjs +52 -6
  46. package/src/op.mjs +83 -0
  47. package/src/ota-key.mjs +53 -0
  48. package/src/provisioning.mjs +281 -0
  49. package/src/schema.mjs +3 -0
  50. package/src/setup-state.mjs +76 -0
  51. package/src/setup.mjs +698 -0
  52. package/src/team-vault.mjs +2 -2
  53. package/src/token-store.mjs +1 -1
@@ -0,0 +1,1118 @@
1
+ // ias credentials generate ios, apple_distribution_certificate and apple_provisioning_profile (docs/plans/gaps/01-certificado-ios.md
2
+ // and 02-perfil-app-store.md): the iOS signing material, made on this Mac because the server never sees a private key.
3
+ // The App Store Connect key of the vault is opened in memory here, the RSA-2048 key and the CSR are made here, and only
4
+ // the CSR travels to Apple; the certificate Apple answers comes back as the .p12 of one identity (inspectP12), is sealed
5
+ // in ias-vault-2 for the recovery key and the signed runners, and the App Store profile of the bundle id is made or
6
+ // reused with it. Order matters:
7
+ // 1. read the vault's certificates and profiles and Apple's, and show the inventory before touching anything;
8
+ // 2. reuse the certificate whose private key is in the vault and Apple still lists, unless --rotate or it expires
9
+ // within --if-expiring days; several usable ones need --certificate, never a silent pick;
10
+ // 3. make the certificate (POST /v1/certificates), keep a recovery copy in 1Password and seal it in the vault first, so
11
+ // a failure later leaves it usable and the next run only makes what is missing;
12
+ // 4. reuse a valid App Store profile of the bundle id that holds the certificate, or make one (IOS_APP_STORE, one
13
+ // certificate, no devices relationship at all) and seal it as the next version of the profile credential;
14
+ // 5. nothing here revokes a certificate or deletes a profile: Apple's limit stops it and the message says so.
15
+ // --dry-run makes no POST/DELETE, no 1Password item and no vault write. Nothing prints a password or a token.
16
+ import { randomUUID } from 'node:crypto';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join, resolve } from 'node:path';
19
+ import { VERSION } from '../../cli.mjs';
20
+ import { loadProject, readExpo } from '../../config.mjs';
21
+ import { ApiError, IasError } from '../../errors.mjs';
22
+ import { applicationOf, connect, openUrl, sleep, waitForApproval } from '../../manage.mjs';
23
+ import { createItem, itemOf, readField, setAttachment, setField } from '../../op.mjs';
24
+ import { credentials as inspect } from '../../shared.mjs';
25
+ import { signedRunners, vaultItem } from '../../team-vault.mjs';
26
+ import { OPENSSL, appleSigningMaterial } from '../../apple/signing.mjs';
27
+ import { capabilityPlan, currentCapabilities, missingCapabilities, portalUrl } from '../../apple/capabilities.mjs';
28
+ import { certificateSummary, daysUntil, expiredAt, profileSummary } from '../../apple/inventory.mjs';
29
+ import { ascClient } from '../devices/asc.mjs';
30
+ import { openAscKey, sync } from '../devices/sync.mjs';
31
+ import { confirm, teamKeys } from '../vault/team.mjs';
32
+ import { expiryWarning, metadataOf, plaintextOf, sealCredential } from './seal.mjs';
33
+
34
+ const PROFILE_TYPE = { 'app-store': 'IOS_APP_STORE', 'ad-hoc': 'IOS_APP_ADHOC' };
35
+
36
+ /** The date Apple's name of a profile carries: 20261028-101500. */
37
+ const stamp = (now = new Date()) => now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
38
+
39
+ const latestExpiring = (list) => [...list].sort((a, b) => (Date.parse(b.expires_at ?? b.metadata?.expires ?? '9999') || 0) - (Date.parse(a.expires_at ?? a.metadata?.expires ?? '9999') || 0))[0] ?? null;
40
+
41
+ const sameSha1 = (first, second) => String(first ?? '').toUpperCase() === String(second ?? '').toUpperCase();
42
+
43
+ const sameUuid = (first, second) => String(first ?? '').toUpperCase() === String(second ?? '').toUpperCase();
44
+
45
+ /** The old material of an approval of a certificate change, from the metadata of the stored one. */
46
+ export function oldCertificate(metadata = {}) {
47
+ return {
48
+ ...(metadata.appleCertificateId ? { apple_certificate_id: metadata.appleCertificateId } : {}),
49
+ ...(metadata.serial ? { serial: metadata.serial } : {}),
50
+ sha1: String(metadata.sha1 ?? '').toUpperCase(),
51
+ ...(metadata.expires ? { expires: metadata.expires } : {}),
52
+ };
53
+ }
54
+
55
+ /** What the person compares with the panel before the passkey: the old certificate and the new material. */
56
+ export function approvalLines({ what, old, team, material, applications }) {
57
+ return [
58
+ `${what}: certificate serial ${old.serial ?? '—'}, SHA-1 ${old.sha1}${old.expires ? `, until ${String(old.expires).slice(0, 10)}` : ''}`,
59
+ material ? `new material: ${material}` : 'no new certificate: the team is left without a distribution certificate',
60
+ `Apple team ${team}${applications.length > 0 ? `; applications affected: ${applications.join(', ')}` : ''}`,
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * Asks an owner for the approval of a change of the team's certificate and waits for it: the passkey is the
66
+ * authorization the server keeps. A rejection or an expiry throws before the caller touches Apple, so nothing is
67
+ * deleted or replaced. Returns the id of the approval, which the PUT of the new material names.
68
+ */
69
+ export async function approveCertificateChange(context, client, payload, lines) {
70
+ const { output } = context;
71
+ for (const line of lines) output.progress(` · ${line}`);
72
+ const { status, data } = await client.request('POST', '/team/apple-certificates/authorize', { json: payload });
73
+ const approval = data?.approval ?? null;
74
+ if (status !== 202 || approval === null) throw new IasError('the server did not ask for an owner\'s approval of the certificate change', { fix: 'nothing was done in Apple: ias credentials show <id> checks the credential, then run it again', code: 'approval_missing' });
75
+ const opened = (context.openUrl ?? ((url) => openUrl(url, context.env)))(approval.url);
76
+ output.progress(`${opened ? 'Opened' : 'Open'} ${approval.url}: an owner compares the serial, the SHA-1 and the new material there and approves with a passkey (approval ${approval.id}, until ${approval.expires_at}).`);
77
+ await waitForApproval(client, approval, { wait: context.sleep ?? sleep, output });
78
+ output.progress('The owner approved the certificate change: the passkey is the authorization the server keeps.');
79
+ return approval.id;
80
+ }
81
+
82
+ /**
83
+ * The applications whose App Store profile holds `sha1`: the one of this app when its inventory says so, and the rest
84
+ * by the profile credentials the server has. Informative only, for the approval's summary.
85
+ */
86
+ async function applicationsUsing(environment, sha1, inventory) {
87
+ const codes = new Set();
88
+ if ((inventory?.profiles ?? []).some((profile) => (profile.certificates ?? []).some((value) => sameSha1(value, sha1)))) codes.add(environment.code);
89
+ try {
90
+ const { data } = await environment.client.request('GET', '/apps');
91
+ for (const app of data.data ?? []) {
92
+ if (codes.has(app.code) || !app.ios?.bundle_id) continue;
93
+ const { data: found } = await environment.client.request('GET', `/apps/${encodeURIComponent(app.code)}/credentials`);
94
+ const holds = (found.data ?? []).some((credential) => !credential.retired_at && credential.type === 'apple_provisioning_profile' && (credential.metadata?.certificates ?? []).some((value) => sameSha1(value, sha1)));
95
+ if (holds) codes.add(app.code);
96
+ }
97
+ } catch (error) {
98
+ if (!(error instanceof ApiError) || error.status !== 404) throw error;
99
+ }
100
+ return [...codes];
101
+ }
102
+
103
+ /** `--type` of the profile, checked: app-store (default) or ad-hoc. */
104
+ function profileTypeOf(values) {
105
+ const type = values.type ?? 'app-store';
106
+ if (!(type in PROFILE_TYPE)) throw new IasError(`«${type}» is not a profile type`, { fix: 'use --type app-store (the default) or --type ad-hoc (which runs ias devices sync)', code: 'profile_type_invalid' });
107
+ return type;
108
+ }
109
+
110
+ /** `--if-expiring`: the days within which the certificate is renewed (30 by default; 0 never). */
111
+ function ifExpiringOf(values) {
112
+ if (values['if-expiring'] === undefined) return 30;
113
+ const days = Number(values['if-expiring']);
114
+ if (!Number.isInteger(days) || days < 0 || days > 3650) throw new IasError(`--if-expiring «${values['if-expiring']}» is not a number of days`, { fix: '0 to 3650; 30 by default, 0 never renews' });
115
+ return days;
116
+ }
117
+
118
+ const expired = (credential, now = Date.now()) => Boolean(credential.expires_at) && !(Date.parse(credential.expires_at) > now);
119
+
120
+ /** The team's App Store Connect key: the newest one that is not retired nor expired. */
121
+ function ascKeyOf(team) {
122
+ return team
123
+ .filter((credential) => credential.type === 'asc_api_key' && !credential.retired_at && !expired(credential))
124
+ .sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))[0] ?? null;
125
+ }
126
+
127
+ /** The ad hoc profile is ias devices sync's: it owns the device list, and it shows and asks before writing. */
128
+ function adHocProfile(context, project) {
129
+ for (const flag of ['profile', 'delete-previous', 'sync-capabilities']) {
130
+ if (context.values[flag] !== undefined) throw new IasError(`--${flag} is for the App Store profile`, { fix: 'the ad hoc profile and its devices are ias devices sync\'s: run it without that flag', code: 'option_not_supported' });
131
+ }
132
+ if (context.values['dry-run']) throw new IasError('--dry-run is not available for the ad hoc profile', { fix: 'ias devices sync prints the device list and asks before writing; run it to decide', code: 'dry_run_unsupported' });
133
+ context.output.progress('--type ad-hoc: the ad hoc profile and its devices are ias devices sync\'s job');
134
+ return sync(context, project);
135
+ }
136
+
137
+ /** Everything a command of this file reads: the app, the vault's keys and credentials, and the client of Apple. */
138
+ async function appleEnvironment(context) {
139
+ const { env, output } = context;
140
+ const project = loadProject(resolve(context.cwd, context.values.project ?? '.'));
141
+ const client = connect(context, project);
142
+ const code = applicationOf(context, project);
143
+ const { data: app } = await client.request('GET', `/apps/${encodeURIComponent(code)}`);
144
+ const bundleId = String(context.values['bundle-id'] ?? app.ios?.bundle_id ?? '').trim();
145
+ if (!bundleId) throw new IasError(`${code} has no iOS bundle id`, { fix: 'set it in the panel (the application › Settings), or pass --bundle-id', code: 'application_without_ios' });
146
+ if (bundleId.includes('*')) throw new IasError(`«${bundleId}» is a wildcard App ID`, { fix: 'an App Store profile needs an explicit App ID: pass --bundle-id with the one of the app', code: 'bundle_id_wildcard' });
147
+
148
+ const item = vaultItem(context, project, client.server);
149
+ const { vault, keys, signingKey, recoveryKey } = await teamKeys(client, item, env, { recovery: true });
150
+ const team = (await client.request('GET', '/team/credentials')).data.data.filter((credential) => !credential.retired_at);
151
+ const own = (await client.request('GET', `/apps/${encodeURIComponent(code)}/credentials`)).data.data.filter((credential) => !credential.retired_at);
152
+ const ascKey = ascKeyOf(team);
153
+ if (!ascKey) {
154
+ throw new IasError('the vault has no App Store Connect API key', { fix: 'import the team\'s with ias credentials import asc_api_key --p8 <AuthKey_….p8> --key-id <id> --issuer-id <uuid>', code: 'credentials_missing' });
155
+ }
156
+ const { runners } = signedRunners(vault, keys.signing, output);
157
+ const key = await openAscKey(client, ascKey, { recoveryKey, keys });
158
+ return {
159
+ project,
160
+ client,
161
+ code,
162
+ app,
163
+ bundleId,
164
+ item,
165
+ vault,
166
+ keys,
167
+ signingKey,
168
+ team,
169
+ own,
170
+ ascKey,
171
+ key,
172
+ asc: ascClient(key, { env, fetch: context.fetch ?? fetch }),
173
+ teamId: String(context.values['team-id'] ?? app.ios?.team_id ?? ascKey.metadata?.teamId ?? '').trim() || null,
174
+ sealing: { recipients: [keys.recovery, ...runners], signingKey, keys },
175
+ base: `ias ${VERSION}`.slice(0, 64),
176
+ output,
177
+ };
178
+ }
179
+
180
+ /** Runs `work` with the environment and wipes the key however it ends. */
181
+ async function withApple(context, work) {
182
+ const environment = await appleEnvironment(context);
183
+ try {
184
+ return await work(environment);
185
+ } finally {
186
+ environment.key.p8?.fill?.(0);
187
+ }
188
+ }
189
+
190
+ /** The distribution certificates of the team that the vault holds (the caller leaves retired ones out). */
191
+ const vaultCertificates = (environment) =>
192
+ environment.team.filter((credential) => credential.type === 'apple_distribution_certificate' && credential.metadata?.kind === 'distribution' && (!environment.teamId || credential.metadata?.team === environment.teamId));
193
+
194
+ /** What Apple has now, and the bundle id of the app: { bundle, certificates, profiles, bySha1, resources }. */
195
+ async function inventoryOf(environment) {
196
+ const certificates = (await environment.asc.certificatesOfTeam()).map((certificate) => certificateSummary(certificate));
197
+ const bundle = await environment.asc.bundleId(environment.bundleId);
198
+ const resources = bundle ? await environment.asc.profilesOf(bundle.id) : [];
199
+ const profiles = resources.map((profile) => profileSummary(profile));
200
+ return { bundle, certificates, profiles, resources, profilesOf: new Map(resources.map((resource) => [String(resource.attributes?.uuid ?? '').toUpperCase(), resource])), bySha1: new Map(certificates.filter((entry) => entry.sha1).map((entry) => [entry.sha1, entry])) };
201
+ }
202
+
203
+ /** A vault certificate with what Apple says of it: { credential, sha1, apple, expires, problem }. */
204
+ function vaultEntry(credential, inventory, now = Date.now()) {
205
+ const sha1 = String(credential.metadata?.sha1 ?? '').toUpperCase();
206
+ const apple = inventory.bySha1.get(sha1) ?? null;
207
+ const expires = credential.metadata?.expires ?? credential.expires_at ?? null;
208
+ let problem = null;
209
+ if (!apple) problem = 'Apple does not have it: it was revoked, or it is of another team';
210
+ else if (apple.status === 'inactive') problem = 'Apple does not have it active';
211
+ else if (apple.status === 'expired' || expiredAt(expires, now)) problem = `it expired on ${apple.expires ?? expires}`;
212
+ return { credential, sha1, apple, expires: apple?.expires ?? expires, problem };
213
+ }
214
+
215
+ /**
216
+ * Picks the certificate among the usable ones: --certificate (SHA-1, credential id or Apple's id) when there are
217
+ * several; the only one otherwise. Never the first of a list. Throws when --certificate names one that cannot be used.
218
+ */
219
+ function selectCertificate(environment, inventory, { certificate, output }) {
220
+ const entries = vaultCertificates(environment).map((credential) => vaultEntry(credential, inventory));
221
+ if (certificate !== undefined) {
222
+ const wanted = String(certificate).trim();
223
+ const found = entries.find((entry) => entry.credential.id === wanted || sameSha1(entry.sha1, wanted) || entry.credential.metadata?.appleCertificateId === wanted || entry.apple?.id === wanted);
224
+ if (!found) {
225
+ throw new IasError(`there is no distribution certificate «${wanted}» in the vault of team ${environment.teamId ?? '—'} and in Apple`, {
226
+ fix: 'ias credentials list shows the team\'s certificates; ias credentials generate ios lists the ones Apple has and how to choose',
227
+ code: 'certificate_not_found',
228
+ details: { certificates: inventory.certificates },
229
+ });
230
+ }
231
+ if (!found.apple) throw new IasError(`the certificate ${found.sha1} of the vault is not in Apple any more: ${found.problem}`, { fix: 'make a new one with ias credentials generate ios --rotate, or import the current one with ias credentials import apple_distribution_certificate', code: 'certificate_not_in_apple', details: { certificate: found.sha1 } });
232
+ if (found.problem) throw new IasError(`the certificate ${found.sha1} cannot be used: ${found.problem}`, { fix: 'make a new one with ias credentials generate ios --rotate (revoking the current one needs the approval of gap 06)', code: 'certificate_unusable', details: { certificate: found.sha1 } });
233
+ return found;
234
+ }
235
+ const usable = entries.filter((entry) => !entry.problem);
236
+ if (usable.length > 1) {
237
+ for (const entry of usable) output.log(` ${entry.sha1} ${entry.credential.name} serial ${entry.credential.metadata?.serial ?? '—'} until ${entry.expires ?? '—'} (credential ${entry.credential.id})`);
238
+ throw new IasError(`there are ${usable.length} usable distribution certificates of the team`, {
239
+ fix: 'choose one with --certificate <sha1|credential id>; ias never picks one of several for you',
240
+ code: 'certificate_ambiguous',
241
+ details: { certificates: usable.map((entry) => ({ id: entry.credential.id, sha1: entry.sha1, name: entry.credential.name, serial: entry.credential.metadata?.serial ?? null, expires: entry.expires })) },
242
+ });
243
+ }
244
+ return usable[0] ?? null;
245
+ }
246
+
247
+ /** The inventory for people: what Apple has, which of it the vault can sign with, and the state of each one. */
248
+ function printInventory(environment, inventory, output) {
249
+ output.log(`${environment.app.name} (${environment.bundleId}), Apple team ${environment.teamId ?? '—'}`);
250
+ output.log(` distribution certificates in Apple${inventory.certificates.length === 0 ? ': none' : ':'}`);
251
+ const vault = new Map(vaultCertificates(environment).map((credential) => [String(credential.metadata?.sha1 ?? '').toUpperCase(), credential]));
252
+ for (const certificate of inventory.certificates) {
253
+ const held = vault.get(certificate.sha1);
254
+ output.log(` ${certificate.id} ${certificate.name ?? '—'} serial ${certificate.serial ?? '—'} SHA-1 ${certificate.sha1 ?? '—'} until ${certificate.expires?.slice(0, 10) ?? '—'} ${certificate.type ?? '—'} ${certificate.status} ${held ? `in the vault (${held.id})` : 'no private key in the vault'}`);
255
+ }
256
+ for (const credential of vault.values()) {
257
+ if (!inventory.bySha1.has(String(credential.metadata?.sha1 ?? '').toUpperCase())) output.log(` — ${credential.name} SHA-1 ${credential.metadata?.sha1 ?? '—'} not in Apple any more (credential ${credential.id})`);
258
+ }
259
+ output.log(` profiles of ${environment.bundleId}${inventory.profiles.length === 0 ? ': none' : ':'}`);
260
+ for (const profile of inventory.profiles) {
261
+ output.log(` ${profile.uuid ?? profile.id} ${profile.name ?? '—'} ${profile.type ?? '—'} ${profile.state ?? '—'} until ${profile.expires?.slice(0, 10) ?? '—'} certificate ${profile.certificates.join(', ') || '—'} ${profile.devices === null ? 'no content' : `${profile.devices} device${profile.devices === 1 ? '' : 's'}`} ${profile.status}`);
262
+ }
263
+ if (!inventory.bundle) output.log(` Apple has no App ID ${environment.bundleId}: register it with ias apps store ${environment.code} --register-bundle-id`);
264
+ }
265
+
266
+ /**
267
+ * The iOS entitlements of the project: app.json › expo › ios › entitlements, which is the source `ios.entitlements` names
268
+ * (packages/sdk writes them into the build). An app.config.* is code, so ias says it cannot read it instead of guessing.
269
+ */
270
+ function projectEntitlements(environment) {
271
+ const { output, project } = environment;
272
+ const expo = readExpo(project.dir);
273
+ if (!expo) {
274
+ output.log(`no app.json in ${project.dir}: ias reads the iOS entitlements from app.json › expo › ios › entitlements, and an app.config.* is code it cannot read; nothing is synced`);
275
+ return null;
276
+ }
277
+ const entitlements = expo.ios?.entitlements;
278
+ if (!entitlements || typeof entitlements !== 'object' || Array.isArray(entitlements)) {
279
+ output.log('app.json has no expo.ios.entitlements: nothing to sync with the App ID capabilities');
280
+ return null;
281
+ }
282
+ return entitlements;
283
+ }
284
+
285
+ /**
286
+ * --sync-capabilities: enables in the App ID the capabilities the app's entitlements ask for and Apple lacks, one by one,
287
+ * and re-reads them. It never disables one. Apple invalidates every profile of the App ID when one changes, so the caller
288
+ * makes the App Store profile again and tells the person to run ias devices sync --force. A dry run enables nothing.
289
+ * Returns { requested, missing, enabled }; the unsupported entitlements stop it before any write, with the portal link.
290
+ */
291
+ async function syncCapabilities(environment, inventory, context) {
292
+ if (context.values['sync-capabilities'] !== true) return { requested: [], missing: [], enabled: [] };
293
+ const { output } = environment;
294
+ const entitlements = projectEntitlements(environment);
295
+ if (!entitlements) return { requested: [], missing: [], enabled: [] };
296
+ const portal = portalUrl(inventory.bundle.id);
297
+ const plan = capabilityPlan(entitlements);
298
+ if (plan.unsupported.length > 0) {
299
+ const listed = plan.unsupported.map((entry) => `${entry.entitlement}: ${entry.why}`).join('; ');
300
+ throw new IasError(`capabilities of the App ID ${environment.bundleId} that the App Store Connect API does not enable: ${listed}`, {
301
+ fix: `enable them by hand for the App ID in Apple Developer › Identifiers (${portal}) and run it again; ias did not change the App ID`,
302
+ code: 'capability_unsupported',
303
+ details: { unsupported: plan.unsupported, portal },
304
+ });
305
+ }
306
+ const current = currentCapabilities(await environment.asc.bundleIdCapabilities(inventory.bundle.id));
307
+ const missing = missingCapabilities(plan.requested, current);
308
+ output.progress(`App ID ${inventory.bundle.id} of ${environment.bundleId}: ${current.size} capabilit${current.size === 1 ? 'y' : 'ies'}, ${missing.length === 0 ? 'nothing to enable' : `${missing.length} to enable (${missing.map((entry) => entry.capabilityType).join(', ')})`}`);
309
+ if (missing.length === 0) {
310
+ output.log('the App ID has every capability the entitlements of app.json ask for: nothing to sync');
311
+ return { requested: plan.requested, missing, enabled: [] };
312
+ }
313
+ if (context.values.profile !== undefined) {
314
+ throw new IasError('--profile reuses an App Store profile, and --sync-capabilities invalidates every profile of the App ID', { fix: 'leave --profile out so ias makes a new App Store profile after enabling the capabilities', code: 'profile_unusable' });
315
+ }
316
+ output.warn(`Enabling a capability invalidates EVERY provisioning profile of the App ID ${environment.bundleId}, including the ad hoc one that ias devices sync keeps: Apple asks for them to be made again.`);
317
+ for (const entry of missing) output.progress(` enable ${entry.capabilityType} (${entry.entitlements.join(', ')})`);
318
+ if (context.values['dry-run'] === true) {
319
+ output.progress('dry run: nothing was enabled in Apple');
320
+ return { requested: plan.requested, missing, enabled: [] };
321
+ }
322
+ await confirm(context, `Enable ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} in the App ID ${environment.bundleId} (every profile of it is invalidated)?`);
323
+ for (const entry of missing) {
324
+ try {
325
+ await environment.asc.enableCapability(inventory.bundle.id, entry.capabilityType);
326
+ } catch (error) {
327
+ if (!(error instanceof IasError) || error.code !== 'apple_api_error') throw error;
328
+ throw new IasError(`Apple did not enable ${entry.capabilityType}: ${error.message}`, {
329
+ fix: `nothing else was enabled: the App Store Connect key needs the Admin role (or App Manager with Certificates) and the account the capability; enable it by hand in ${portal}`,
330
+ code: 'capability_refused',
331
+ details: { capability: entry.capabilityType, portal, status: error.details?.status ?? null },
332
+ });
333
+ }
334
+ output.progress(` enabled ${entry.capabilityType}`);
335
+ }
336
+ const after = currentCapabilities(await environment.asc.bundleIdCapabilities(inventory.bundle.id));
337
+ const left = missingCapabilities(plan.requested, after);
338
+ if (left.length > 0) {
339
+ throw new IasError(`Apple does not list ${left.map((entry) => entry.capabilityType).join(', ')} in the App ID after enabling it`, { fix: `check the App ID in ${portal}; the App Store profile was not made`, code: 'capability_refused', details: { capabilities: left.map((entry) => entry.capabilityType), portal } });
340
+ }
341
+ const enabled = missing.map((entry) => entry.capabilityType);
342
+ output.log(`enabled ${enabled.join(', ')} in the App ID ${inventory.bundle.id}: the App Store profile is made again with them`);
343
+ output.warn('every profile of the App ID is invalid now: run ias devices sync --force so the internal builds get a valid ad hoc profile');
344
+ return { requested: plan.requested, missing, enabled };
345
+ }
346
+
347
+ /** The report of --json, with the same keys with and without changes. Never a secret. */
348
+ function reportOf(environment, { changed, dryRun = false, certificate = null, profile = null, inventory, action = 'none', next = undefined, capabilities = null }) {
349
+ return {
350
+ status: dryRun ? 'dry_run' : changed ? 'changed' : 'unchanged',
351
+ changed,
352
+ app: environment.code,
353
+ bundle_id: environment.bundleId,
354
+ team: environment.teamId,
355
+ action: changed || dryRun ? action : 'none',
356
+ certificate: certificate
357
+ ? {
358
+ source: certificate.source ?? null,
359
+ credential: certificate.credential?.id ?? null,
360
+ id: certificate.apple?.id ?? null,
361
+ sha1: certificate.sha1 ?? null,
362
+ serial: certificate.apple?.serial ?? certificate.metadata?.serial ?? null,
363
+ name: certificate.credential?.name ?? null,
364
+ expires: certificate.apple?.expires ?? certificate.metadata?.expires ?? null,
365
+ }
366
+ : null,
367
+ profile: profile
368
+ ? { action: profile.action ?? null, credential: profile.credential?.id ?? null, version: profile.credential?.version ?? null, uuid: profile.summary?.uuid ?? null, name: profile.summary?.name ?? null, expires: profile.summary?.expires ?? null, devices: profile.summary?.devices ?? null, previous: profile.previous ?? null }
369
+ : null,
370
+ ...(capabilities ? { capabilities: { enabled: capabilities.enabled ?? [], missing: (capabilities.missing ?? []).map((entry) => entry.capabilityType) } } : {}),
371
+ certificates: inventory.certificates,
372
+ profiles: inventory.profiles,
373
+ next: next ?? (changed ? 'ias build --platform ios --profile production' : null),
374
+ };
375
+ }
376
+
377
+ /** Whether Apple's refusal is its certificate limit, in the words it uses. */
378
+ function limitReached(error) {
379
+ const text = (error.details?.errors ?? []).map((entry) => [entry.code, entry.title, entry.detail].filter(Boolean).join(' ')).join(' ');
380
+ return /already have|already exists|maximum|limit|too many|current .*certificate|pending certificate/i.test(text);
381
+ }
382
+
383
+ /** The body of POST /v1/certificates: PEM first (and base64 once if Apple refuses the text). No revocation, ever. */
384
+ async function requestCertificate(environment, material, inventory) {
385
+ const csr = material.csrPem;
386
+ const map = (error) => {
387
+ if (error.code !== 'apple_api_error') return error;
388
+ const status = error.details?.status;
389
+ if (status === 403) return new IasError('the App Store Connect key cannot make certificates', { fix: 'the Team API key needs the Admin role in App Store Connect (or App Manager with Access to Certificates, Identifiers & Profiles); an Individual Key does not work for provisioning', code: 'asc_key_forbidden', details: error.details });
390
+ if (status === 401) return new IasError('App Store Connect did not take the key of the vault', { fix: 'import a valid team key with ias credentials import asc_api_key', code: 'asc_key_invalid' });
391
+ if (status === 409 || (status === 422 && limitReached(error))) {
392
+ return new IasError(`Apple did not make the certificate: the team already has one, or the limit is reached (${status})`, {
393
+ fix: 'ias did not revoke anything: look at the certificates above, revoke the one to replace in Apple Developer › Certificates, Identifiers & Profiles, and run this again',
394
+ code: 'certificate_limit_reached',
395
+ details: { certificates: inventory.certificates },
396
+ });
397
+ }
398
+ return new IasError(`Apple did not make the certificate: ${error.message}`, { fix: 'nothing was stored: check the role of the key and the CSR, and run it again', code: 'certificate_refused', details: error.details });
399
+ };
400
+ try {
401
+ return await environment.asc.createCertificate({ certificateType: 'DISTRIBUTION', csr });
402
+ } catch (first) {
403
+ if (first.code === 'apple_api_error' && first.details?.status === 422 && !limitReached(first) && !/^[A-Za-z0-9+/=\s]+$/.test(csr)) {
404
+ environment.output.warn('Apple did not take the CSR as PEM text: trying it in base64 once');
405
+ try {
406
+ const bytes = Buffer.from(csr.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64');
407
+ return await environment.asc.createCertificate({ certificateType: 'DISTRIBUTION', csr: bytes.toString('base64') });
408
+ } catch (second) {
409
+ throw map(second);
410
+ }
411
+ }
412
+ throw map(first);
413
+ }
414
+ }
415
+
416
+ /** Keeps the .p12 and its password in 1Password; never replaces another item without --force. */
417
+ function copyToOnePassword(context, item, material, done) {
418
+ const existing = readField(item, 'serial', context.env);
419
+ const fields = [
420
+ { label: 'p12-password', value: done.password, type: 'CONCEALED' },
421
+ { label: 'serial', value: done.metadata.serial, type: 'CONCEALED' },
422
+ { label: 'sha1', value: done.metadata.sha1, type: 'CONCEALED' },
423
+ { label: 'sha256', value: done.metadata.sha256, type: 'CONCEALED' },
424
+ { label: 'expires', value: done.metadata.expires, type: 'CONCEALED' },
425
+ { label: 'csr-sha256', value: material.csrSha256, type: 'CONCEALED' },
426
+ { label: 'team', value: done.metadata.team ?? '' },
427
+ ];
428
+ if (existing !== undefined) {
429
+ if (context.values.force !== true) throw new IasError(`${item} already has a certificate`, { fix: 'the old .p12 is the plan B if the new one fails: pass --item with another item, or --force to replace it' });
430
+ for (const field of fields) setField(item, field.label, field.value, context.env);
431
+ setAttachment(item, 'certificate.p12', done.p12, context.env);
432
+ return;
433
+ }
434
+ createItem(item, {
435
+ notes: `The distribution certificate ${done.metadata.commonName} (${done.metadata.team}) and its private key, made on this Mac by ias credentials generate. The .p12 is the attachment; the password is the concealed field. It is sealed in the IAS vault too; this item is the recovery copy.`,
436
+ fields,
437
+ attachments: [{ label: 'certificate.p12', path: done.p12 }],
438
+ }, context.env);
439
+ }
440
+
441
+ /**
442
+ * Makes the certificate: the CSR (already made) to Apple, the .p12 here, its recovery copy in 1Password, and the sealed
443
+ * credential of the team. Returns it as the server answers it, with what Apple said.
444
+ */
445
+ async function makeCertificate(environment, material, inventory, context, { replaceWith = null, approval = null } = {}) {
446
+ const { output } = environment;
447
+ if (context.values.item) itemOf(context.values.item);
448
+ const issued = await requestCertificate(environment, material, inventory);
449
+ output.progress(` Apple issued ${issued.id}: ${issued.attributes?.serialNumber ?? 'no serial'}, until ${String(issued.attributes?.expirationDate ?? '').slice(0, 10)}`);
450
+ let safe = false;
451
+ try {
452
+ const done = material.complete(issued.attributes?.certificateContent);
453
+ const metadata = {
454
+ ...done.metadata,
455
+ appleCertificateId: issued.id,
456
+ csrSha256: material.csrSha256,
457
+ generatedAt: new Date().toISOString(),
458
+ generatedBy: environment.base,
459
+ };
460
+ const item = context.values.item ?? `op://IAS/${environment.teamId ?? 'Apple'} Apple Distribution ${done.metadata.serial}`;
461
+ itemOf(item);
462
+ try {
463
+ copyToOnePassword(context, item, material, done);
464
+ safe = true;
465
+ output.progress(` 1Password: ${item} has the .p12 and its password (recovery copy)`);
466
+ } catch (error) {
467
+ output.warn(`no recovery copy in 1Password (${error.message}): the .p12 goes to the vault, which is the source of truth`);
468
+ }
469
+ // A rotation keeps the id of the credential and seals the next version: the owner's approval names that new
470
+ // material, and the server only takes the changed SHA-1 with it.
471
+ const id = replaceWith?.id ?? randomUUID();
472
+ const version = (replaceWith?.version ?? 0) + 1;
473
+ const plaintext = plaintextOf('apple_distribution_certificate', { fields: { p12: readFileSync(done.p12), password: done.password } }, metadata, environment.code);
474
+ const envelope = sealCredential(plaintext, { id, type: 'apple_distribution_certificate', version }, environment.sealing);
475
+ const { data: stored } = replaceWith
476
+ ? await environment.client.request('PUT', `/credentials/${id}`, { json: { metadata, envelope, ...(approval ? { approval } : {}) } })
477
+ : await environment.client.request('POST', '/team/credentials', { json: { id, type: 'apple_distribution_certificate', name: metadata.commonName ?? `Apple certificate ${metadata.serial}`, metadata, envelope } });
478
+ safe = true;
479
+ output.log(`${replaceWith ? 'replaced' : 'stored'} «${stored.name}» (${stored.id}) version ${stored.version}, sealed for ${envelope.recipients.length} key${envelope.recipients.length === 1 ? '' : 's'}`);
480
+ expiryWarning(`«${stored.name}»`, metadata, output);
481
+ return { source: 'generated', credential: stored, apple: certificateSummary(issued), metadata, sha1: metadata.sha1 };
482
+ } catch (error) {
483
+ if (!safe) {
484
+ // Apple has the certificate: deleting the key now would leave it unusable and counting against the limit.
485
+ const kept = material.preserve();
486
+ output.warn(`the certificate ${issued.id} is in Apple but nothing stored its key: the key and the CSR stay in ${kept} (0700, 0600); its CSR SHA-256 is ${material.csrSha256}`);
487
+ if (material.password) output.warn(`if the .p12 was made, its password is in ${join(kept, 'password')}: seal it with ias credentials import apple_distribution_certificate --p12 ${join(kept, 'certificate.p12')} --password - < ${join(kept, 'password')}`);
488
+ }
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ /** The app's App Store profile credential to replace, if any: the one of this bundle that expires last. */
494
+ const storeProfileCredential = (environment) =>
495
+ latestExpiring(environment.own.filter((credential) => credential.type === 'apple_provisioning_profile' && credential.metadata?.type === 'app-store' && credential.metadata?.bundleId === environment.bundleId));
496
+
497
+ /** The App Store profiles Apple lists for the bundle id: the ones that --profile may name. */
498
+ const storeProfiles = (environment, inventory) =>
499
+ inventory.profiles.filter((profile) => profile.type === 'app-store' && (!profile.bundleId || profile.bundleId === environment.bundleId));
500
+
501
+ /** A profile of the inventory in one line: UUID, name, type, state, expiry, certificate, bundle id and devices. */
502
+ export function profileLine(profile) {
503
+ const devices = profile.devices === null || profile.devices === undefined ? 'no content' : `${profile.devices} device${profile.devices === 1 ? '' : 's'}`;
504
+ return `${profile.uuid ?? profile.id ?? '—'} «${profile.name ?? '—'}» ${profile.type ?? '—'} ${profile.state ?? '—'} until ${String(profile.expires ?? '—').slice(0, 10)} certificate ${(profile.certificates ?? []).join(', ') || '—'} ${profile.bundleId ?? '—'} ${devices} ${profile.status}`;
505
+ }
506
+
507
+ /**
508
+ * Why the App Store profile `profile` cannot be used with `certificate`, or null when it can: its type, bundle id, state,
509
+ * expiry, devices and the certificate it holds. The fix names the exact option or command.
510
+ */
511
+ function storeProfileProblem(environment, certificate, profile, now = Date.now()) {
512
+ const held = (profile.certificates ?? []).map((value) => String(value).toUpperCase());
513
+ if (profile.type !== 'app-store') {
514
+ return { problem: `it is ${profile.type ? `a ${profile.type}` : 'an unknown type of'} profile, not an App Store one`, fix: 'an ad-hoc or development profile lists devices: leave --profile out so ias makes the App Store one, and keep ias devices sync for the ad hoc' };
515
+ }
516
+ if (profile.bundleId && profile.bundleId !== environment.bundleId) {
517
+ return { problem: `it is for ${profile.bundleId}, not ${environment.bundleId}`, fix: 'choose a profile of this bundle id: ias credentials generate apple_provisioning_profile --bundle-id <id> works on another one' };
518
+ }
519
+ if (profile.state && profile.state !== 'ACTIVE') {
520
+ return { problem: `Apple has it ${profile.state}`, fix: `its App ID changed (capabilities or identifiers) and Apple invalidated it: leave --profile out so ias makes a new one${profile.state === 'INVALID' ? '; if an entitlement is behind it, run it with --sync-capabilities' : ''}` };
521
+ }
522
+ if (expiredAt(profile.expires, now)) {
523
+ return { problem: `it expired on ${String(profile.expires).slice(0, 10)}`, fix: 'leave --profile out so ias makes a new one with the certificate of the vault' };
524
+ }
525
+ if (profile.devices !== null && profile.devices !== undefined && profile.devices !== 0) {
526
+ return { problem: `it lists ${profile.devices} device${profile.devices === 1 ? '' : 's'}`, fix: 'an App Store profile lists none: leave --profile out so ias makes the right one, and ias devices sync keeps the ad hoc profile' };
527
+ }
528
+ if (held.length > 0 && !held.includes(certificate.sha1)) {
529
+ return { problem: `it holds the certificate ${held.join(', ')}, not ${certificate.sha1}`, fix: `reuse it with the certificate it holds (--certificate ${held[0]}) if that one is in the vault, or leave --profile out so ias makes a new profile with ${certificate.sha1}` };
530
+ }
531
+ return null;
532
+ }
533
+
534
+ /**
535
+ * What to do with the App Store profile: unchanged (Apple's and the vault's are the same), seal (reuse Apple's) or create.
536
+ * `--profile` (a UUID or an exact name) chooses one and validates it; without it, several usable profiles are an error
537
+ * and never a silent pick, and one usable one is reused as always. `invalidated` leaves every profile of the App ID out,
538
+ * because the capabilities are about to change and Apple invalidates them all.
539
+ */
540
+ function profileDecision(environment, certificate, inventory, { profile, invalidated = false, output = environment.output } = {}) {
541
+ const existing = storeProfileCredential(environment);
542
+ const candidates = invalidated ? [] : storeProfiles(environment, inventory);
543
+ if (profile !== undefined) {
544
+ const wanted = String(profile).trim();
545
+ const named = candidates.filter((candidate) => sameUuid(candidate.uuid, wanted) || String(candidate.name ?? '') === wanted);
546
+ if (named.length === 0) {
547
+ for (const candidate of candidates) output.log(` ${profileLine(candidate)}`);
548
+ throw new IasError(`Apple has no App Store profile «${wanted}» for ${environment.bundleId}`, {
549
+ fix: candidates.length === 0 ? 'make one without --profile (or enable the missing capability first, with --sync-capabilities)' : 'pass its UUID or its exact name, from the list above',
550
+ code: 'profile_not_found',
551
+ details: { profiles: candidates },
552
+ });
553
+ }
554
+ if (named.length > 1) {
555
+ for (const candidate of named) output.log(` ${profileLine(candidate)}`);
556
+ throw new IasError(`«${wanted}» names ${named.length} App Store profiles of ${environment.bundleId}`, { fix: 'use the UUID of one: --profile <uuid>', code: 'profile_ambiguous', details: { profiles: named } });
557
+ }
558
+ const [chosen] = named;
559
+ const problem = storeProfileProblem(environment, certificate, chosen);
560
+ if (problem) throw new IasError(`the App Store profile ${chosen.uuid ?? chosen.id} cannot be used: ${problem.problem}`, { fix: problem.fix, code: 'profile_unusable', details: { profile: chosen } });
561
+ return { decision: existing && sameUuid(existing.metadata?.uuid, chosen.uuid) ? 'unchanged' : 'seal', reused: chosen, existing, candidates };
562
+ }
563
+ const usable = candidates.filter((candidate) => !storeProfileProblem(environment, certificate, candidate));
564
+ if (usable.length > 1) {
565
+ for (const candidate of candidates) output.log(` ${profileLine(candidate)}`);
566
+ throw new IasError(`Apple has ${usable.length} usable App Store profiles for ${environment.bundleId}`, {
567
+ fix: 'choose one with --profile <uuid|name>; ias never picks one of several for you',
568
+ code: 'profile_ambiguous',
569
+ details: { profiles: candidates },
570
+ });
571
+ }
572
+ const reused = usable[0] ?? null;
573
+ return { decision: reused && existing && sameUuid(existing.metadata?.uuid, reused.uuid) ? 'unchanged' : reused ? 'seal' : 'create', reused, existing, candidates };
574
+ }
575
+
576
+ /** Whether another IAS credential still holds the profile `uuid`: one of those is never deleted in Apple. */
577
+ async function profileReferencedElsewhere(environment, uuid, { except = [] } = {}) {
578
+ const holds = (credential) => credential.type === 'apple_provisioning_profile' && !credential.retired_at && sameUuid(credential.metadata?.uuid, uuid) && !except.includes(credential.id);
579
+ const local = [...environment.team, ...environment.own].filter(holds);
580
+ if (local.length > 0) return local.map((credential) => credential.id);
581
+ const found = [];
582
+ try {
583
+ const { data } = await environment.client.request('GET', '/apps');
584
+ for (const app of data.data ?? []) {
585
+ if (app.code === environment.code) continue;
586
+ const { data: credentials } = await environment.client.request('GET', `/apps/${encodeURIComponent(app.code)}/credentials`);
587
+ for (const credential of credentials.data ?? []) if (holds(credential)) found.push(`${credential.id} of ${app.code}`);
588
+ }
589
+ } catch (error) {
590
+ if (!(error instanceof ApiError) || error.status !== 404) throw error;
591
+ }
592
+ return found;
593
+ }
594
+
595
+ /**
596
+ * --delete-previous: deletes in Apple the App Store profile the new one replaces, and only that one. After the new one
597
+ * is sealed and uploaded (the caller only gets here then) and only with the flag: the UUID comes from the credential of
598
+ * the vault that was just replaced, so a profile of another tool is never touched; an ad hoc profile, a profile another
599
+ * IAS credential still holds and a dry run are left alone. The default is to leave the old profile in Apple.
600
+ */
601
+ async function deletePreviousProfile(environment, inventory, context, { previousUuid, newUuid, existing }) {
602
+ const { output } = environment;
603
+ if (context.values['delete-previous'] !== true) return null;
604
+ if (!previousUuid || sameUuid(previousUuid, newUuid)) {
605
+ output.log('--delete-previous: there is no previous App Store profile of the vault to delete');
606
+ return null;
607
+ }
608
+ const profile = inventory.profiles.find((candidate) => sameUuid(candidate.uuid, previousUuid)) ?? null;
609
+ if (!profile) {
610
+ output.log(`--delete-previous: Apple does not list the previous profile ${previousUuid} any more: already gone`);
611
+ return { uuid: previousUuid, deleted: false, reason: 'absent' };
612
+ }
613
+ if (profile.type !== 'app-store' || (profile.bundleId && profile.bundleId !== environment.bundleId)) {
614
+ output.warn(`--delete-previous: ${previousUuid} is not an App Store profile of ${environment.bundleId}: it stays in Apple`);
615
+ return { uuid: previousUuid, deleted: false, reason: 'not-app-store' };
616
+ }
617
+ const holders = await profileReferencedElsewhere(environment, previousUuid, { except: existing ? [existing.id] : [] });
618
+ if (holders.length > 0) {
619
+ output.warn(`--delete-previous: ${previousUuid} is still in the vault as ${holders.join(', ')}: it stays in Apple`);
620
+ return { uuid: previousUuid, deleted: false, reason: 'referenced' };
621
+ }
622
+ output.progress(`--delete-previous: DELETE /v1/profiles/${profile.id} removes «${profile.name ?? '—'}» (${previousUuid}) of ${environment.bundleId} in Apple. Nothing uses it now, and Apple cannot give it back.`);
623
+ try {
624
+ await confirm(context, `Delete the previous App Store profile ${previousUuid} in Apple?`);
625
+ } catch (error) {
626
+ if (error instanceof IasError && ['not_confirmed', 'confirmation_required'].includes(error.code)) {
627
+ output.warn(`the previous profile ${previousUuid} stays in Apple`);
628
+ return { uuid: previousUuid, deleted: false, reason: 'not-confirmed' };
629
+ }
630
+ throw error;
631
+ }
632
+ try {
633
+ await environment.asc.deleteProfile(profile.id);
634
+ output.log(`deleted the previous App Store profile ${previousUuid} (${profile.id}) in Apple`);
635
+ return { id: profile.id, uuid: previousUuid, deleted: true };
636
+ } catch (error) {
637
+ if (error instanceof IasError && error.code === 'apple_api_error' && error.details?.status === 404) {
638
+ output.warn(`Apple does not have ${profile.id} any more: it was already gone, so nothing was deleted`);
639
+ return { id: profile.id, uuid: previousUuid, deleted: false, reason: 'absent' };
640
+ }
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * The App Store profile: reuses the valid one of Apple that holds the certificate (the one --profile names), or makes one
647
+ * (IOS_APP_STORE, that one certificate, no devices), and seals it as the next version of the profile credential. In a dry
648
+ * run it says what it would do and writes nothing. --delete-previous removes the replaced one in Apple at the end.
649
+ */
650
+ async function ensureStoreProfile(environment, certificate, inventory, context, { invalidated = false } = {}) {
651
+ const { output } = environment;
652
+ const { decision, reused, existing } = profileDecision(environment, certificate, inventory, { profile: context.values.profile, invalidated, output });
653
+ const dryRun = context.values['dry-run'] === true;
654
+ if (decision === 'unchanged') {
655
+ output.log(`the App Store profile ${reused.uuid} of ${environment.bundleId} is valid and holds the certificate: nothing to do with it`);
656
+ return { action: 'unchanged', summary: reused, credential: existing, made: false };
657
+ }
658
+ if (dryRun) {
659
+ output.log(decision === 'seal'
660
+ ? ` would reuse the App Store profile ${reused.uuid} of Apple and seal it as version ${(existing?.version ?? 0) + 1} of its credential`
661
+ : ` would make the App Store profile «IAS tienda ${environment.bundleId} ${stamp()}» in Apple: one certificate (${certificate.sha1 ?? 'the new one'}), no devices, and seal it as version ${(existing?.version ?? 0) + 1}`);
662
+ return { action: decision, summary: reused, credential: existing, made: false };
663
+ }
664
+ let summary = reused;
665
+ let resource = reused ? inventory.profilesOf.get(String(reused.uuid).toUpperCase()) : null;
666
+ if (reused && !resource?.attributes?.profileContent) resource = await environment.asc.profile(reused.id);
667
+ if (reused) {
668
+ output.progress(` reusing the App Store profile ${reused.uuid} of Apple`);
669
+ } else {
670
+ const certificateId = certificate.apple?.id ?? certificate.metadata?.appleCertificateId;
671
+ if (!certificateId) throw new IasError('Apple did not say the id of the certificate to make the profile with', { fix: 'run it again: the certificate of the vault may lack its Apple id; import it again or make a new one', code: 'certificate_not_in_apple' });
672
+ resource = await environment.asc.createProfile({ name: `IAS tienda ${environment.bundleId} ${stamp()}`, type: 'IOS_APP_STORE', bundleIdId: inventory.bundle.id, certificateId, deviceIds: [] });
673
+ summary = profileSummary(resource);
674
+ output.progress(` Apple made the App Store profile ${summary.uuid ?? resource.id}`);
675
+ }
676
+ const bytes = Buffer.from(resource?.attributes?.profileContent ?? '', 'base64');
677
+ if (bytes.length === 0) throw new IasError(`Apple did not give the content of the profile ${summary?.uuid ?? ''}`.trim(), { fix: 'nothing was stored: run it again, and if it goes on check the profile in Apple Developer', code: 'profile_unreadable' });
678
+ const metadata = metadataOf('apple_provisioning_profile', { inspect: { mobileprovision: bytes } });
679
+ const problems = inspect.checkIos({ certificate: certificate.metadata, profile: metadata }, { type: 'app-store', teamId: environment.teamId ?? undefined, bundleId: environment.bundleId });
680
+ if (metadata.devices !== 0) problems.push(`it lists ${metadata.devices} devices: an App Store profile lists none`);
681
+ if (metadata.bundleId !== environment.bundleId) problems.push(`it is for ${metadata.bundleId}, not ${environment.bundleId}`);
682
+ if (problems.length > 0) throw new IasError(`the profile Apple gave is not the one asked for: ${problems.join('; ')}`, { fix: 'nothing was stored: check the App ID and the certificate in Apple Developer', code: 'profile_unexpected' });
683
+ const id = existing?.id ?? randomUUID();
684
+ const version = (existing?.version ?? 0) + 1;
685
+ const plaintext = plaintextOf('apple_provisioning_profile', { fields: { mobileprovision: bytes } }, metadata, environment.code);
686
+ const envelope = sealCredential(plaintext, { id, type: 'apple_provisioning_profile', version }, environment.sealing);
687
+ const { data: stored } = existing
688
+ ? await environment.client.request('PUT', `/credentials/${id}`, { json: { metadata, envelope } })
689
+ : await environment.client.request('POST', `/apps/${encodeURIComponent(environment.code)}/credentials`, { json: { id, type: 'apple_provisioning_profile', name: `Perfil de tienda de ${environment.app.name}`, metadata, envelope } });
690
+ output.log(`stored «${stored.name}» (${stored.id}) version ${stored.version}, profile ${metadata.uuid} until ${metadata.expires?.slice(0, 10) ?? '—'}, sealed for ${envelope.recipients.length} key${envelope.recipients.length === 1 ? '' : 's'}`);
691
+ if (existing && existing.metadata?.uuid && !sameSha1(existing.metadata.uuid, metadata.uuid)) {
692
+ if (context.values['delete-previous'] === true) output.progress(`the profile of the vault was ${existing.metadata.uuid}: --delete-previous removes it in Apple after this`);
693
+ else output.warn(`the profile of the vault was ${existing.metadata.uuid}: it stays in Apple and nothing uses it now`);
694
+ }
695
+ expiryWarning(`«${stored.name}»`, metadata, output);
696
+ const made = !reused;
697
+ const previous = made ? await deletePreviousProfile(environment, inventory, context, { previousUuid: existing?.metadata?.uuid ?? null, newUuid: metadata.uuid, existing }) : null;
698
+ return { action: made ? (existing ? 'updated' : 'created') : 'sealed', summary: profileSummary({ id: resource.id, attributes: { ...resource.attributes, profileContent: bytes.toString('base64') } }), credential: stored, made, previous };
699
+ }
700
+
701
+ /** A profile that was not touched: either it was already the right one, or --no-profiles left it for later. */
702
+ function skippedProfile(environment, certificate, inventory, output, { invalidated = false } = {}) {
703
+ const { decision, reused, existing } = profileDecision(environment, certificate, inventory, { profile: undefined, invalidated, output });
704
+ if (decision === 'unchanged') {
705
+ output.log(`the App Store profile ${reused.uuid} of ${environment.bundleId} is valid and holds the certificate: nothing to do with it`);
706
+ return { action: 'unchanged', summary: reused, credential: existing, made: false };
707
+ }
708
+ output.log(`--no-profiles: the App Store profile of ${environment.bundleId} is not ${decision === 'seal' ? 'sealed' : 'made'}. A store build needs it: run ias credentials generate ios (or ias credentials generate apple_provisioning_profile) before the next store build.`);
709
+ return { action: 'skipped', summary: reused, credential: existing, made: false };
710
+ }
711
+
712
+ /** Says what is going to happen in Apple and the vault, then asks (--yes skips the question, never the API errors). */
713
+ async function confirmMutations(context, plan) {
714
+ const { output } = context;
715
+ output.progress('What this will do:');
716
+ for (const line of plan) output.progress(` · ${line}`);
717
+ output.progress('Affected: the next store build of this app uses the new material; nothing already published in the App Store changes, and the internal ad hoc builds keep their own profile (run ias devices sync --force after a new certificate).');
718
+ output.progress('To undo it: revoke the certificate in Apple Developer › Certificates, Identifiers & Profiles and retire the credential in the panel; the previous .p12 stays in 1Password.');
719
+ await confirm(context, 'Make these changes in Apple and the vault?');
720
+ }
721
+
722
+ /** The plan lines of making a certificate. */
723
+ const certificatePlan = (material, environment) => [
724
+ `POST /v1/certificates in App Store Connect with the CSR ${material.csrSha256} (subject ${material.subject}), team ${environment.teamId}`,
725
+ 'seal the new .p12 and its password in the vault as the team\'s apple_distribution_certificate',
726
+ `keep a recovery copy in 1Password (op://IAS/${environment.teamId ?? 'Apple'} Apple Distribution <serial>)`,
727
+ ];
728
+
729
+ /** The plan lines of the profile part, from what has to be done with it. */
730
+ const profilePlan = (decision, environment, certificate) => [
731
+ decision === 'seal'
732
+ ? 'seal the App Store profile Apple already has in the vault as the next version of the app\'s profile'
733
+ : `make the App Store profile «IAS tienda ${environment.bundleId} ${stamp()}» in Apple (one certificate ${certificate.sha1 ?? '— the new one —'}, no devices) and seal it`,
734
+ ];
735
+
736
+ /**
737
+ * ias credentials generate ios: the certificate and the App Store profile in one command. It reuses what is valid and
738
+ * makes only what is missing, so running it again after a failure finishes the work and never duplicates anything.
739
+ */
740
+ export async function generateIos(context) {
741
+ const type = profileTypeOf(context.values);
742
+ if (type === 'ad-hoc') return adHocProfile(context, loadProject(resolve(context.cwd, context.values.project ?? '.')));
743
+ if (context.values.reuse && context.values.rotate) throw new IasError('--reuse and --rotate ask for different things', { fix: 'leave one out: --reuse never makes a certificate, --rotate always makes one' });
744
+ const days = ifExpiringOf(context.values);
745
+ const noProfiles = context.values['no-profiles'] === true;
746
+ return withApple(context, async (environment) => {
747
+ const { output } = environment;
748
+ output.progress(`App Store Connect: key ${environment.key.key_id}, from the vault (${environment.ascKey.id} v${environment.ascKey.version})`);
749
+ let inventory = await inventoryOf(environment);
750
+ printInventory(environment, inventory, output);
751
+ if (!inventory.bundle) throw new IasError(`Apple has no App ID ${environment.bundleId}`, { fix: `register it with ias apps store ${environment.code} --register-bundle-id, or in Apple Developer › Identifiers; after that: ias credentials generate ios`, code: 'bundle_id_not_in_apple' });
752
+ const capabilities = await syncCapabilities(environment, inventory, context);
753
+ if (capabilities.enabled.length > 0) inventory = await inventoryOf(environment);
754
+ const invalidated = capabilities.missing.length > 0;
755
+ const chosen = selectCertificate(environment, inventory, { certificate: context.values.certificate, output });
756
+ if (context.values.reuse && !chosen) throw new IasError('--reuse needs a usable distribution certificate of the team in the vault and in Apple', { fix: 'import the current one with ias credentials import apple_distribution_certificate, or leave --reuse out so ias makes one', code: 'certificate_not_found' });
757
+ const renew = !context.values.reuse && (context.values.rotate === true || !chosen || (days > 0 && daysUntil(chosen.expires) < days));
758
+ if (renew && context.values.profile !== undefined) throw new IasError('--profile reuses the App Store profile of the current certificate, and this run makes a new certificate', { fix: 'leave --profile out: the App Store profile of the new certificate is made from scratch', code: 'option_not_supported' });
759
+ if (chosen && !renew) output.log(`the certificate ${chosen.sha1} is in the vault and Apple still lists it: reusing it (it expires on ${String(chosen.expires).slice(0, 10)})`);
760
+ if (renew && !environment.teamId) throw new IasError('the Apple team of the app is unknown', { fix: 'give it in the panel (the application › Settings) or with --team-id', code: 'team_missing' });
761
+ if (noProfiles && context.values.profile !== undefined) throw new IasError('--profile names an App Store profile and --no-profiles leaves it for later', { fix: 'leave one out: --profile reuses the profile of Apple, --no-profiles does not touch it', code: 'option_not_supported' });
762
+ if (noProfiles && context.values['sync-capabilities'] === true) throw new IasError('--sync-capabilities invalidates every profile of the App ID and --no-profiles would leave the App Store one unmade', { fix: 'run it without --no-profiles, or enable the capabilities in Apple Developer › Identifiers first', code: 'option_not_supported' });
763
+ const dryRun = context.values['dry-run'] === true;
764
+ const material = renew ? appleSigningMaterial({ name: context.values.name ?? environment.app.name, team: environment.teamId, openssl: context.env.IAS_OPENSSL ?? OPENSSL }) : null;
765
+ const profileStep = (certificate) => (noProfiles
766
+ ? skippedProfile(environment, certificate, inventory, output, { invalidated })
767
+ : ensureStoreProfile(environment, certificate, inventory, context, { invalidated }));
768
+ try {
769
+ const certificate = renew ? { source: 'generated', sha1: material ? null : '—', metadata: chosen?.metadata ?? {} } : { source: 'vault', sha1: chosen.sha1, apple: chosen.apple, credential: chosen.credential, metadata: chosen.credential.metadata };
770
+ const { decision } = renew ? { decision: 'create' } : profileDecision(environment, certificate, inventory, { profile: context.values.profile, invalidated, output });
771
+ if (dryRun && renew) output.log(` would make the certificate from the CSR ${material.csrSha256} (subject ${material.subject}) and seal its .p12: no POST /v1/certificates in a dry run`);
772
+ if (!renew && (decision === 'unchanged' || noProfiles)) {
773
+ output.log(decision === 'unchanged'
774
+ ? 'nothing to do: the distribution certificate and the App Store profile are valid and in the vault. --rotate makes a new certificate.'
775
+ : 'nothing to do with the certificate: --no-profiles leaves the App Store profile for later (ias credentials generate ios finishes it).');
776
+ const profile = await profileStep(certificate);
777
+ return reportOf(environment, { changed: false, dryRun, certificate, profile, inventory, capabilities, next: noProfiles && decision !== 'unchanged' ? 'ias credentials generate ios' : undefined });
778
+ }
779
+ if (dryRun) {
780
+ const profile = await profileStep(certificate);
781
+ return reportOf(environment, { changed: false, dryRun: true, action: renew ? 'certificate' : 'profile', certificate, profile, inventory, capabilities, next: noProfiles ? 'ias credentials generate ios' : undefined });
782
+ }
783
+ const willCreate = renew || decision === 'create';
784
+ await confirmMutations(context, [...(renew ? certificatePlan(material, environment) : []), ...(noProfiles && decision !== 'unchanged' ? ['leave the App Store profile for later (--no-profiles)'] : profilePlan(decision, environment, certificate)), ...(context.values['delete-previous'] === true && willCreate ? ['delete in Apple the App Store profile the new one replaces, once it is sealed and uploaded (--delete-previous asks again)'] : [])]);
785
+ let made = certificate;
786
+ if (renew) {
787
+ // The new material replaces the stored one: an owner approves the change first, and the approval names the
788
+ // old SHA-1, the Apple team and the CSR. A rejection or an expiry stops before Apple is touched.
789
+ const replaces = chosen?.credential ?? null;
790
+ const applications = replaces ? await applicationsUsing(environment, chosen.sha1, inventory) : [];
791
+ const old = replaces ? oldCertificate(replaces.metadata) : null;
792
+ const approval = replaces
793
+ ? await approveCertificateChange(
794
+ context,
795
+ environment.client,
796
+ {
797
+ action: 'replace',
798
+ credential: replaces.id,
799
+ team: environment.teamId,
800
+ old,
801
+ new: { csr_sha256: material.csrSha256, key: 'RSA-2048' },
802
+ revoke_before_create: false,
803
+ applications,
804
+ },
805
+ approvalLines({ what: 'replace the distribution certificate', old, team: environment.teamId, material: `SHA-256 of the CSR ${material.csrSha256} · RSA-2048 key`, applications }),
806
+ )
807
+ : null;
808
+ made = await makeCertificate(environment, material, inventory, context, { replaceWith: replaces, approval });
809
+ }
810
+ const profile = await profileStep(made);
811
+ if (renew) output.warn('a new distribution certificate: the next store build uses the new profile; run ias devices sync --force so the internal builds have an ad hoc profile with it too');
812
+ return reportOf(environment, { changed: true, action: renew ? 'certificate' : 'profile', certificate: made, profile, inventory, capabilities, next: capabilities.enabled.length > 0 ? 'ias devices sync --force' : noProfiles && profile.action !== 'unchanged' ? 'ias credentials generate ios' : undefined });
813
+ } finally {
814
+ material?.cleanup();
815
+ }
816
+ });
817
+ }
818
+
819
+ /**
820
+ * ias credentials generate apple_distribution_certificate: only the certificate, for repeating or resolving a case.
821
+ * It refuses when a usable one exists (that is what ios reuses) unless --rotate.
822
+ */
823
+ export async function generateDistributionCertificate(context) {
824
+ if (context.values.reuse) throw new IasError('--reuse is for ias credentials generate ios', { fix: 'this command only makes a certificate; ias credentials generate ios reuses the valid one' });
825
+ return withApple(context, async (environment) => {
826
+ const { output } = environment;
827
+ output.progress(`App Store Connect: key ${environment.key.key_id}, from the vault (${environment.ascKey.id} v${environment.ascKey.version})`);
828
+ const inventory = await inventoryOf(environment);
829
+ printInventory(environment, inventory, output);
830
+ const chosen = selectCertificate(environment, inventory, { certificate: context.values.certificate, output });
831
+ if (chosen && !context.values.rotate) {
832
+ throw new IasError(`there is already a usable distribution certificate (${chosen.sha1}, until ${String(chosen.expires).slice(0, 10)})`, { fix: 'ias credentials generate ios reuses it and makes the App Store profile; --rotate makes a new one (Apple may refuse until the current one is revoked)', code: 'certificate_exists', details: { certificate: chosen.sha1 } });
833
+ }
834
+ if (!environment.teamId) throw new IasError('the Apple team of the app is unknown', { fix: 'give it in the panel (the application › Settings) or with --team-id', code: 'team_missing' });
835
+ const material = appleSigningMaterial({ name: context.values.name ?? environment.app.name, team: environment.teamId, openssl: context.env.IAS_OPENSSL ?? OPENSSL });
836
+ try {
837
+ if (context.values['dry-run'] === true) {
838
+ output.log(`dry run: would POST /v1/certificates with the CSR ${material.csrSha256} (subject ${material.subject}), seal the .p12 in the vault and keep its recovery copy in 1Password; nothing sent`);
839
+ return { status: 'dry_run', changed: false, action: 'certificate', certificate: { source: 'generated', sha1: null, csr_sha256: material.csrSha256 }, certificates: inventory.certificates, next: null };
840
+ }
841
+ await confirmMutations(context, [...certificatePlan(material, environment), 'no App Store profile is made here: ias credentials generate ios or apple_provisioning_profile does that']);
842
+ const replaces = chosen?.credential ?? null;
843
+ const applications = replaces ? await applicationsUsing(environment, chosen.sha1, inventory) : [];
844
+ const old = replaces ? oldCertificate(replaces.metadata) : null;
845
+ const approval = replaces
846
+ ? await approveCertificateChange(
847
+ context,
848
+ environment.client,
849
+ { action: 'replace', credential: replaces.id, team: environment.teamId, old, new: { csr_sha256: material.csrSha256, key: 'RSA-2048' }, revoke_before_create: false, applications },
850
+ approvalLines({ what: 'replace the distribution certificate', old, team: environment.teamId, material: `SHA-256 of the CSR ${material.csrSha256} · RSA-2048 key`, applications }),
851
+ )
852
+ : null;
853
+ const certificate = await makeCertificate(environment, material, inventory, context, { replaceWith: replaces, approval });
854
+ output.log(`the App Store profile is next: ias credentials generate apple_provisioning_profile --certificate ${certificate.sha1} (or ias credentials generate ios)`);
855
+ return { status: 'changed', changed: true, action: 'certificate', certificate: { source: 'generated', credential: certificate.credential.id, id: certificate.apple.id, sha1: certificate.sha1, serial: certificate.apple.serial, name: certificate.credential.name, expires: certificate.apple.expires }, certificates: inventory.certificates, next: 'ias credentials generate ios' };
856
+ } finally {
857
+ material.cleanup();
858
+ }
859
+ });
860
+ }
861
+
862
+ /**
863
+ * ias credentials generate apple_provisioning_profile: only the App Store profile, with the certificate the vault has
864
+ * (--certificate picks among several). --type ad-hoc delegates to ias devices sync, which owns the device list.
865
+ */
866
+ export async function generateProvisioningProfile(context) {
867
+ const type = profileTypeOf(context.values);
868
+ if (type === 'ad-hoc') return adHocProfile(context, loadProject(resolve(context.cwd, context.values.project ?? '.')));
869
+ return withApple(context, async (environment) => {
870
+ const { output } = environment;
871
+ output.progress(`App Store Connect: key ${environment.key.key_id}, from the vault (${environment.ascKey.id} v${environment.ascKey.version})`);
872
+ let inventory = await inventoryOf(environment);
873
+ printInventory(environment, inventory, output);
874
+ if (!inventory.bundle) throw new IasError(`Apple has no App ID ${environment.bundleId}`, { fix: `register it with ias apps store ${environment.code} --register-bundle-id, or in Apple Developer › Identifiers; after that: ias credentials generate apple_provisioning_profile`, code: 'bundle_id_not_in_apple' });
875
+ const chosen = selectCertificate(environment, inventory, { certificate: context.values.certificate, output });
876
+ if (!chosen) throw new IasError(`the vault has no usable distribution certificate of the team ${environment.teamId ?? ''}`.trim(), { fix: 'make one with ias credentials generate ios (certificate and profile) or import the current one with ias credentials import apple_distribution_certificate', code: 'credentials_missing' });
877
+ const capabilities = await syncCapabilities(environment, inventory, context);
878
+ if (capabilities.enabled.length > 0) inventory = await inventoryOf(environment);
879
+ const invalidated = capabilities.missing.length > 0;
880
+ const certificate = { source: 'vault', sha1: chosen.sha1, apple: chosen.apple, credential: chosen.credential, metadata: chosen.credential.metadata };
881
+ output.log(`the profile will hold the certificate ${chosen.sha1} (${chosen.credential.name}, until ${String(chosen.expires).slice(0, 10)})`);
882
+ const dryRun = context.values['dry-run'] === true;
883
+ if (!dryRun) {
884
+ const { decision } = profileDecision(environment, certificate, inventory, { profile: context.values.profile, invalidated, output });
885
+ if (decision !== 'unchanged') {
886
+ await confirmMutations(context, [
887
+ ...profilePlan(decision, environment, certificate),
888
+ ...(context.values['delete-previous'] === true && decision === 'create' ? ['delete in Apple the App Store profile the new one replaces, once it is sealed and uploaded (--delete-previous asks again)'] : []),
889
+ ]);
890
+ }
891
+ }
892
+ const profile = await ensureStoreProfile(environment, certificate, inventory, context, { invalidated });
893
+ return reportOf(environment, { changed: !dryRun && profile.action !== 'unchanged', dryRun, action: 'profile', certificate, profile, inventory, capabilities, next: capabilities.enabled.length > 0 ? 'ias devices sync --force' : undefined });
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Everything ias credentials revoke reads: the server's applications, the vault's keys and signing key, the team's
899
+ * App Store Connect key (opened in memory) and the Apple team. It does not need the bundle id of the app.
900
+ */
901
+ async function teamAppleEnvironment(context) {
902
+ const { env, output } = context;
903
+ const project = loadProject(resolve(context.cwd, context.values.project ?? '.'));
904
+ const client = connect(context, project);
905
+ const apps = (await client.request('GET', '/apps')).data.data;
906
+ const code = context.values.app ?? project?.config?.app ?? null;
907
+ const app = code ? apps.find((application) => application.code === code) ?? null : null;
908
+ const item = vaultItem(context, project, client.server);
909
+ const { vault, keys, signingKey, recoveryKey } = await teamKeys(client, item, env, { recovery: true });
910
+ const team = (await client.request('GET', '/team/credentials')).data.data;
911
+ const ascKey = ascKeyOf(team);
912
+ if (!ascKey) {
913
+ throw new IasError('the vault has no App Store Connect API key', { fix: 'import the team\'s with ias credentials import asc_api_key --p8 <AuthKey_….p8> --key-id <id> --issuer-id <uuid>', code: 'credentials_missing' });
914
+ }
915
+ const { runners } = signedRunners(vault, keys.signing, output);
916
+ const key = await openAscKey(client, ascKey, { recoveryKey, keys });
917
+ return {
918
+ project,
919
+ client,
920
+ apps,
921
+ app,
922
+ code,
923
+ item,
924
+ vault,
925
+ keys,
926
+ signingKey,
927
+ team,
928
+ ascKey,
929
+ key,
930
+ asc: ascClient(key, { env, fetch: context.fetch ?? fetch }),
931
+ teamId: String(context.values['team-id'] ?? app?.ios?.team_id ?? ascKey.metadata?.teamId ?? '').trim() || null,
932
+ sealing: { recipients: [keys.recovery, ...runners], signingKey, keys },
933
+ base: `ias ${VERSION}`.slice(0, 64),
934
+ output,
935
+ };
936
+ }
937
+
938
+ /** Runs `work` with the team's environment and wipes the Apple key however it ends. */
939
+ async function withTeamApple(context, work) {
940
+ const environment = await teamAppleEnvironment(context);
941
+ try {
942
+ return await work(environment);
943
+ } finally {
944
+ environment.key.p8?.fill?.(0);
945
+ }
946
+ }
947
+
948
+ /** The profiles of the team's apps that hold `sha1`, with their application: { …profileSummary, app, bundleId }. */
949
+ async function profilesUsing(environment, sha1) {
950
+ const found = new Map();
951
+ for (const app of environment.apps) {
952
+ const bundleId = app.ios?.bundle_id;
953
+ if (!bundleId) continue;
954
+ if (environment.teamId && app.ios?.team_id && app.ios.team_id !== environment.teamId) continue;
955
+ const bundle = await environment.asc.bundleId(bundleId);
956
+ if (!bundle) continue;
957
+ for (const resource of await environment.asc.profilesOf(bundle.id)) {
958
+ let summary = profileSummary(resource);
959
+ if ((summary.certificates ?? []).length === 0) {
960
+ // Apple's list may not carry the profileContent: the certificate of the profile comes from it.
961
+ try {
962
+ summary = profileSummary(await environment.asc.profile(resource.id));
963
+ } catch {
964
+ summary = profileSummary(resource);
965
+ }
966
+ }
967
+ if (!(summary.certificates ?? []).some((value) => sameSha1(value, sha1))) continue;
968
+ if (environment.teamId && summary.team && summary.team !== environment.teamId) continue;
969
+ found.set(summary.uuid ?? summary.id, { ...summary, bundleId: summary.bundleId ?? bundleId, app: app.code });
970
+ }
971
+ }
972
+ return [...found.values()];
973
+ }
974
+
975
+ /** The report of ias credentials revoke: the same keys with and without a change, never a secret. */
976
+ function revokeReport(environment, { changed, action = 'revoke', credential, sha1, appleId, apple, retired, profiles, applications, approval = null, dryRun = false }) {
977
+ return {
978
+ status: dryRun ? 'dry_run' : changed ? 'changed' : 'unchanged',
979
+ changed,
980
+ action,
981
+ app: environment.code,
982
+ team: environment.teamId,
983
+ credential: credential.id,
984
+ certificate: { id: appleId, serial: credential.metadata?.serial ?? null, sha1, expires: credential.metadata?.expires ?? null },
985
+ apple,
986
+ retired,
987
+ profiles: profiles.map((profile) => ({ app: profile.app, bundle_id: profile.bundleId, uuid: profile.uuid, name: profile.name, type: profile.type })),
988
+ applications,
989
+ approval,
990
+ next: 'ias credentials generate ios',
991
+ };
992
+ }
993
+
994
+ /**
995
+ * ias credentials revoke: the owner-authorized way to withdraw the team's distribution certificate without a
996
+ * substitute (a compromised certificate). It lists what Apple has and which profiles and apps use it, asks an owner to
997
+ * authorize it with a passkey, then deletes it in Apple with the key of the vault and retires the credential. A
998
+ * rejected or expired approval stops before Apple is touched; a certificate Apple no longer has is still retired.
999
+ */
1000
+ export async function revokeAppleCertificate(context) {
1001
+ const id = context.positionals[0];
1002
+ return withTeamApple(context, async (environment) => {
1003
+ const { output, client } = environment;
1004
+ const credential = environment.team.find((candidate) => candidate.id === id);
1005
+ if (!credential) {
1006
+ throw new IasError(`there is no active credential ${id} in the team`, { fix: 'ias credentials list --all shows them; a retired one is already out of use', code: 'credential_not_found' });
1007
+ }
1008
+ if (credential.type !== 'apple_distribution_certificate') {
1009
+ throw new IasError(`${id} is a ${credential.type}, not an Apple distribution certificate`, { fix: 'ias credentials show <id> says what it is; only an apple_distribution_certificate is revoked in Apple', code: 'credential_type_invalid' });
1010
+ }
1011
+ const sha1 = String(credential.metadata?.sha1 ?? '').toUpperCase();
1012
+ if (!/^[0-9A-F]{40}$/.test(sha1)) {
1013
+ throw new IasError(`${id} has no SHA-1 in its metadata`, { fix: 'ias credentials show <id> checks it; import the certificate again with ias credentials import apple_distribution_certificate', code: 'credential_unreadable' });
1014
+ }
1015
+ if (environment.teamId && credential.metadata?.team && credential.metadata.team !== environment.teamId) {
1016
+ throw new IasError(`the certificate ${sha1} is of team ${credential.metadata.team}, not of ${environment.teamId}`, { fix: 'nothing was done in Apple: pass --team-id with the team of the certificate, or run it in the folder of that app', code: 'certificate_not_in_apple' });
1017
+ }
1018
+ if (credential.retired_at) {
1019
+ output.log(`nothing to do: «${credential.name}» (${credential.id}) is retired since ${credential.retired_at}`);
1020
+ return revokeReport(environment, { changed: false, credential, sha1, appleId: credential.metadata?.appleCertificateId ?? null, apple: 'unknown', retired: true, profiles: [], applications: [] });
1021
+ }
1022
+
1023
+ const appleCertificates = (await environment.asc.certificatesOfTeam()).map((certificate) => certificateSummary(certificate));
1024
+ const storedId = credential.metadata?.appleCertificateId ?? null;
1025
+ // The SHA-1 is what the vault has and what the approval names: it is the
1026
+ // certificate to revoke. The id in the metadata only corroborates it; if it
1027
+ // points at another certificate, nothing is asked and nothing is deleted.
1028
+ const bySha1 = appleCertificates.find((certificate) => sameSha1(certificate.sha1, sha1)) ?? null;
1029
+ const byId = storedId === null ? null : appleCertificates.find((certificate) => certificate.id === storedId) ?? null;
1030
+ if (byId && !sameSha1(byId.sha1, sha1)) {
1031
+ throw new IasError(`the credential names the Apple certificate ${storedId} in its metadata, but Apple has it with SHA-1 ${byId.sha1}, not ${sha1}`, {
1032
+ fix: 'nothing was asked nor deleted: ias credentials show <id> shows the metadata; import the certificate again with its right id (ias credentials import apple_distribution_certificate), or generate a new one with ias credentials generate ios --rotate',
1033
+ code: 'approval_mismatch',
1034
+ details: { credential: credential.id, apple_certificate_id: storedId, stored_sha1: sha1, apple_sha1: byId.sha1 ?? null },
1035
+ });
1036
+ }
1037
+ const found = bySha1 ?? byId;
1038
+ const appleId = bySha1?.id ?? storedId ?? null;
1039
+ const profiles = await profilesUsing(environment, sha1);
1040
+ const applications = [...new Set(profiles.map((profile) => profile.app))];
1041
+ output.log(`«${credential.name}» (${credential.id}), Apple team ${credential.metadata?.team ?? environment.teamId ?? '—'}`);
1042
+ output.log(` certificate ${appleId ?? '—'}${found?.serial ?? credential.metadata?.serial ? ` serial ${found?.serial ?? credential.metadata?.serial}` : ''} SHA-1 ${sha1} until ${String(found?.expires ?? credential.metadata?.expires ?? '—').slice(0, 10)} ${found ? found.status : 'not in Apple'}`);
1043
+ output.log(` profiles that hold it${profiles.length === 0 ? ': none in Apple' : ''}`);
1044
+ for (const profile of profiles) output.log(` ${profile.app} ${profile.uuid ?? profile.id} ${profile.type ?? '—'} ${profile.name ?? '—'} ${profile.state ?? '—'} until ${String(profile.expires ?? '—').slice(0, 10)}`);
1045
+ output.log(` applications affected: ${applications.length === 0 ? 'none' : applications.join(', ')}`);
1046
+ output.progress('Impact: what is already published in the App Store keeps working. Internal (ad hoc) builds signed with it stop installing, and builds uploaded to App Store Connect but not sent to review may become Invalid Binary.');
1047
+ output.progress('The App Store profile of every application above has to be made again with another certificate: ias credentials generate ios (or apple_provisioning_profile).');
1048
+ output.progress('This does not create a substitute: the team is left without a distribution certificate until you generate a new one.');
1049
+ if (context.values['dry-run'] === true) {
1050
+ output.log('dry run: nothing was asked, deleted in Apple nor retired; run it without --dry-run to do it.');
1051
+ return revokeReport(environment, { changed: false, dryRun: true, credential, sha1, appleId, apple: found ? 'present' : 'absent', retired: false, profiles, applications });
1052
+ }
1053
+ await confirm(context, `Revoke the certificate ${sha1} in Apple and retire «${credential.name}»?`);
1054
+
1055
+ const team = String(credential.metadata?.team ?? environment.teamId ?? '').trim().toUpperCase();
1056
+ if (!/^[A-Z0-9]{10}$/.test(team)) {
1057
+ throw new IasError(`the certificate ${sha1} has no Apple team in its metadata`, { fix: 'nothing was done in Apple: ias credentials show <id> shows the metadata; import it again with the team in the .p12, or run in the folder of its app with --team-id', code: 'team_missing' });
1058
+ }
1059
+ const old = oldCertificate(credential.metadata);
1060
+ const approval = await approveCertificateChange(
1061
+ context,
1062
+ client,
1063
+ { action: 'revoke', credential: credential.id, team, old, new: null, revoke_before_create: false, applications },
1064
+ approvalLines({ what: 'revoke the distribution certificate', old, team, material: null, applications }),
1065
+ );
1066
+
1067
+ let apple = 'revoked';
1068
+ if (appleId === null) {
1069
+ apple = 'absent';
1070
+ output.warn('Apple does not list it: there is nothing to delete');
1071
+ } else {
1072
+ // The approval names a SHA-1. Re-read the certificate by id right before
1073
+ // deleting it: if Apple now has another one under that id, the approval
1074
+ // does not cover it and nothing is deleted.
1075
+ let current = null;
1076
+ try {
1077
+ current = certificateSummary(await environment.asc.certificate(appleId));
1078
+ } catch (error) {
1079
+ if (!(error instanceof IasError) || error.code !== 'apple_api_error' || error.details?.status !== 404) throw error;
1080
+ }
1081
+ if (current === null) {
1082
+ apple = 'absent';
1083
+ output.warn(`Apple does not have ${appleId} any more: it was already gone, so nothing was deleted`);
1084
+ } else if (!sameSha1(current.sha1, sha1)) {
1085
+ throw new IasError(`the certificate ${appleId} in Apple is now ${current.sha1}, not the approved ${sha1}`, {
1086
+ fix: 'nothing was deleted and the credential is still active: read Apple again with ias credentials show <id>; if another certificate replaced it, renew with ias credentials generate ios --rotate instead of revoking this one',
1087
+ code: 'approval_mismatch',
1088
+ details: { credential: credential.id, apple_certificate_id: appleId, approved_sha1: sha1, apple_sha1: current.sha1 ?? null },
1089
+ });
1090
+ } else {
1091
+ try {
1092
+ await environment.asc.revokeCertificate(appleId);
1093
+ output.log(`revoked ${appleId} in Apple (SHA-1 ${sha1})`);
1094
+ } catch (error) {
1095
+ if (error instanceof IasError && error.code === 'apple_api_error' && error.details?.status === 404) {
1096
+ apple = 'absent';
1097
+ output.warn(`Apple does not have ${appleId} any more: it was already gone, so nothing was deleted`);
1098
+ } else {
1099
+ throw error;
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ let retired
1105
+ try {
1106
+ ({ data: retired } = await client.request('POST', `/credentials/${credential.id}/retire`))
1107
+ } catch (error) {
1108
+ throw new IasError(`the certificate ${appleId ?? sha1} is already deleted in Apple (or was already gone), but the credential was not retired: ${error.message}`, {
1109
+ fix: `the certificate is gone, so do not generate a second one: run ias credentials revoke ${credential.id} again; Apple answers 404 for a certificate that no longer exists (ias treats it as already absent) and the command only retires the credential`,
1110
+ code: 'retire_failed',
1111
+ details: { credential: credential.id, sha1, apple_certificate_id: appleId, apple },
1112
+ });
1113
+ }
1114
+ output.log(`retired «${retired.name}» (${retired.id}) on ${retired.retired_at}: builds no longer get it, and it stays in the inventory`);
1115
+ output.warn('the profiles that held it are invalid now: make them again with ias credentials generate ios, and run ias devices sync --force for the internal builds');
1116
+ return revokeReport(environment, { changed: true, credential: retired, sha1, appleId, apple, retired: true, profiles, applications, approval: { id: approval, status: 'approved' } });
1117
+ });
1118
+ }