apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests

This commit is contained in:
CRTkafa 2026-10-14 19:10:01 +08:00
parent 357319a775
commit daae3715bf
12 changed files with 463 additions and 23 deletions

View file

@ -13,7 +13,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client';
import { decide, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
import { decide, dedicatedCommand, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
import { APPLE_SPEC } from './api/apple.gen';
import { PLAY_APIS } from './api/play.gen';
import { WRAPPED } from './api/wrapped.gen';
@ -147,7 +147,9 @@ export async function run(args: Args): Promise<void> {
});
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
if (decision.action === 'dry') {
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`);
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, ...(instead ? { instead } : {}), request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`);
return;
}

View file

@ -94,6 +94,28 @@ export function decide(g: GateInput): Decision {
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: writes through `storebound api` are only sent from one' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: by design, writes through `storebound api` are sent only from an interactive terminal (--yes alone is not enough)' };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}
/** storebound commands that do the common writes of a resource the safe way (dry run, read-back, idempotent):
* named when a raw `api` write is held, so an agent uses them instead of a terminal. First match wins. */
const DEDICATED: [Store, RegExp, string][] = [
['apple', /^bundleIdCapabilities_/, 'storebound apple signing ensure --dry, then without --dry (adds apple.signing.capabilities to the App ID and reads them back)'],
['apple', /^bundleIds_/, 'storebound apple bundle --dry, then without --dry'],
['apple', /^(certificates|profiles)_/, 'storebound apple signing ensure (manual signing; automatic signing leaves certificates and profiles to Xcode)'],
['apple', /^devices_/, 'storebound apple adhoc register --dry (adhoc-distribution skill)'],
['apple', /^(inAppPurchases|inAppPurchaseLocalizations|inAppPurchasePriceSchedules)/, 'storebound apple iap --dry'],
['apple', /^(subscriptions|subscriptionGroups|subscriptionLocalizations|subscriptionPrices)/, 'storebound apple subscriptions --dry'],
['apple', /^(appStoreVersionLocalizations|appInfoLocalizations)_/, 'storebound apple listing --dry'],
['apple', /^(appScreenshotSets|appScreenshots)_/, 'storebound apple screenshots --dry'],
['apple', /^betaGroups_|^betaTesters_/, 'storebound apple testflight --dry'],
['play', /\.(listings|details)\./, 'storebound play listing --dry'],
['play', /\.images\./, 'storebound play images --dry'],
['play', /\.(inappproducts|onetimeproducts)\./, 'storebound play products --dry'],
['play', /\.subscriptions\./, 'storebound play subscriptions --dry'],
];
export function dedicatedCommand(store: Store, opId: string): string | null {
return DEDICATED.find(([s, re]) => s === store && re.test(opId))?.[2] ?? null;
}

View file

@ -3,23 +3,29 @@
// storebound apple signing status [--json] read-only: capabilities, certificates (with / without a
// private key on this machine), profiles, what is installed
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]
// adds missing capabilities (apple.signing.capabilities), creates a distribution certificate when this machine
// adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick).
// style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also
// creates a distribution certificate when this machine
// holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed;
// --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an
// App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode.
// Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID
// certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts.
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dirname, join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr';
import { done, log, printJson, warn } from '../lib/log';
import {
type ApiCert, type ApiProfile, type AppleSigning, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, readProfiles,
type ApiCert, type ApiProfile, type AppleSigning, type CapMismatch, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs,
type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities,
} from '../lib/signing';
import { isBuildCopyPath } from '../lib/privacy-manifest';
import { scanXcode } from '../lib/xcode';
import type { ShipConfig } from '../lib/config';
import { textTable } from '../lib/table';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi';
@ -57,6 +63,54 @@ export async function findBundleId(c: Asc, identifier: string): Promise<J | null
return hit;
}
/** The iOS project's .entitlements files (next to each .xcodeproj, build copies skipped) with the capabilities they
* need and whether the project references them (CODE_SIGN_ENTITLEMENTS). null when there is no Xcode project. */
export function readProjectEntitlements(root: string): ProjectEntitlements[] | null {
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
if (!projects.length) return null;
const out: ProjectEntitlements[] = [];
for (const proj of projects) {
let pbx = '';
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { /* unreadable */ }
const refs = [...pbx.matchAll(/CODE_SIGN_ENTITLEMENTS\s*=\s*"?([^";]+)"?;/g)].map(m => m[1]!.trim().replace(/^\$\(SRCROOT\)\//, ''));
const walk = (dir: string, d: number): void => {
let names: string[] = [];
try { names = readdirSync(dir); } catch { return; }
for (const n of names) {
const f = join(dir, n);
if (n.endsWith('.entitlements')) {
const rel = relative(root, f);
if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue;
out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : null));
} else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) {
try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ }
}
}
};
walk(dirname(join(root, proj)), 3);
}
return out;
}
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; mismatches: CapMismatch[] }
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements. */
export async function capabilityReport(c: Asc, config: ShipConfig, root: string): Promise<CapabilityReport> {
const bundleId = config.apple!.bundleId;
const b = await findBundleId(c, bundleId);
const appId = b ? (b.capabilities as string[]) : null;
const cfg = capabilitySpecs(config.apple?.signing).map(x => x.type);
const project = readProjectEntitlements(root);
return { bundleId, appId, config: cfg, project, mismatches: compareCapabilities({ appId, config: cfg, project }) };
}
export function printCapabilityReport(r: CapabilityReport, tag: string): void {
const proj = r.project === null ? 'no Xcode project found' : r.project.length ? r.project.map(p => `${p.file}: ${p.caps.map(c => c.cap).join(', ') || 'none'}${p.referenced === false ? ' (NOT referenced by CODE_SIGN_ENTITLEMENTS)' : ''}`).join('; ') : 'no .entitlements file';
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj}`);
for (const m of r.mismatches) warn(tag, `${m.msg}. Fix: ${m.fix}`);
if (!r.mismatches.length) log(tag, 'capabilities match (App ID, config, project)');
}
const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise<ApiProfile[]> => readProfiles(p => c.all(p), bundleIdId, type);
const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []);
@ -103,6 +157,8 @@ export async function run(ctx: Ctx): Promise<void> {
const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE';
const dry = a.has('dry');
const json = a.has('json');
const style = s.style ?? 'automatic';
const scope = ensureScope(style);
const c = await ascClient(root, bundleId, config.apple?.appId);
// --- read everything first (read-only)
@ -138,21 +194,36 @@ export async function run(ctx: Ctx): Promise<void> {
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' };
const r = p.plan?.reuse;
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
if (!r && managed && (s.style ?? 'automatic') === 'automatic')
return { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'})` };
if (style === 'automatic')
return managed
? { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'}; renewed with the App ID's current capabilities when they change)` }
: { bundleId: id, name: null, uuid: null, state: 'Xcode-managed', installed: false, why: 'automatic signing: xcodebuild -allowProvisioningUpdates makes it at archive time' };
return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile',
installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' };
}),
};
// storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one)
const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []);
const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse));
const cleanup = (): void => {
if (!unused.length) return;
log(TAG, `${unused.length} provisioning profile(s) storebound made that nothing uses${style === 'automatic' ? ' (automatic signing never needs them)' : ''}: ${unused.map(p => `"${p.attributes.name}"`).join(', ')}`);
log(TAG, 'deleting them is your call; not run by storebound:');
for (const p of unused) log(TAG, ` ${deleteProfileCmd(p)}`);
};
const report = await capabilityReport(c, config, root);
if (sub === 'status') {
if (json) { printJson(state); return; }
if (json) { printJson({ ...state, style, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`);
log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`);
log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`);
for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`);
console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why])));
printCapabilityReport(report, TAG);
cleanup();
done(TAG, 'status read');
return;
}
@ -161,28 +232,40 @@ export async function run(ctx: Ctx): Promise<void> {
if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`);
const missingExtra = ids.filter(id => !bundles.get(id));
if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`);
const needCert = !choice.cert;
const needCert = scope.certificates && !choice.cert;
const plan: string[] = [
...capPlan.create.map(r => describeReq(r)),
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
...ids.flatMap(id => {
...(!scope.profiles ? [] : ids.flatMap(id => {
const p = profiles.get(id)!.plan;
return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : [];
}),
})),
];
log(TAG, scope.why);
if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit)
warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`);
if (json && dry) { printJson({ ...state, plan }); return; }
if (json && dry) { printJson({ ...state, style, scope, plan, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`);
if (!plan.length) log(TAG, 'nothing to create on App Store Connect');
if (dry) {
for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`);
printCapabilityReport(report, TAG);
cleanup();
done(TAG, `signing checked (dry run): ${plan.length} write(s)`);
return;
}
if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return;
await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s).map(w => w.type), responses);
if (!scope.profiles) {
printCapabilityReport(await capabilityReport(c, config, root), TAG);
cleanup();
if (json) printJson({ ...state, style, scope, plan, done: true });
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
return;
}
let cert = choice.cert;
if (needCert) {
const dir = signingDir(root);
@ -245,7 +328,21 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
}
cleanup();
if (json) printJson({ ...state, plan, done: true });
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
}
/** After a capability write: read the App ID back (App Store Connect lags a little) and fail, with the API's answers,
* when a wanted capability is still not there. Seen live: an add that "succeeded" while the App ID kept only
* IN_APP_PURCHASE. */
export async function checkCapabilitiesStuck(c: Asc, bundleId: string, want: string[], responses: unknown[], o: { waitMs?: number; timeoutMs?: number } = {}): Promise<void> {
const r = await verifyCapabilities(async () => ((await findBundleId(c, bundleId))?.capabilities as string[] | undefined) ?? [], want,
{ waitMs: o.waitMs ?? upsertTiming.waitMs, timeoutMs: o.timeoutMs, sleep: upsertTiming.sleep, onWait: (m, n) => log(TAG, `read-back ${n}: ${m.join(', ')} not on the App ID yet, checking again`) });
if (!r.missing.length) { log(TAG, `read back: the App ID has ${want.join(', ')}`); return; }
throw new Error(`capability write did not stick: ${bundleId} still lacks ${r.missing.join(', ')} after ${r.reads} read(s) (it has: ${r.have.join(', ') || 'none'}).\n`
+ `API answers to the writes: ${JSON.stringify(responses).slice(0, 1500)}\n`
+ 'Check the bundle id in the config (apple.bundleId), that the API key has the Admin role, and add the capability in '
+ 'developer.apple.com > Certificates, Identifiers & Profiles > Identifiers if the API keeps refusing it; then run storebound apple signing status.');
}

View file

@ -9,6 +9,8 @@
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
// Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns,
// naming what is missing where.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { log, printJson } from '../lib/log';
@ -16,6 +18,7 @@ import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '..
import { EMPTY_DRAFT } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc';
import { notCancellable, readItems, type SubItem } from './submission';
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
import * as rt from '../lib/rt';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
@ -68,6 +71,8 @@ export async function run(ctx: Ctx): Promise<void> {
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
let caps: CapabilityReport | { error: string };
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
// a submission back from App Review: its items and their states, and how to resubmit it
const unresolved: { id: string; items: SubItem[] }[] = [];
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
@ -97,7 +102,7 @@ export async function run(ctx: Ctx): Promise<void> {
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
if (v) buildVersions[b.id] = v;
}
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
return;
}
@ -113,4 +118,6 @@ export async function run(ctx: Ctx): Promise<void> {
const newest = builds.data[0]?.attributes?.version;
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
else printCapabilityReport(caps, 'apple');
}

View file

@ -188,6 +188,8 @@
],
"apple status": [
"cancel-submission",
"keychain",
"no-install",
"timeout",
"version",
"wait-review"
@ -817,8 +819,10 @@
"include-drafts",
"issue",
"jobs",
"keychain",
"lang",
"no-health",
"no-install",
"no-local",
"no-scan",
"out",

View file

@ -199,3 +199,114 @@ export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue>
}
return o;
}
// --- capabilities: App ID vs config vs the iOS project's entitlements
/** Entitlement keys (*.entitlements) -> the App ID capability they need. In-App Purchase has no entitlement. */
export const ENTITLEMENT_CAPABILITIES: Record<string, CapabilityType> = {
'aps-environment': 'PUSH_NOTIFICATIONS',
'com.apple.developer.aps-environment': 'PUSH_NOTIFICATIONS',
'com.apple.developer.game-center': 'GAME_CENTER',
'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS',
'com.apple.developer.applesignin': 'APPLE_ID_AUTH',
'com.apple.developer.icloud-container-identifiers': 'ICLOUD',
'com.apple.developer.icloud-services': 'ICLOUD',
'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD',
'com.apple.developer.ubiquity-container-identifiers': 'ICLOUD',
'com.apple.security.application-groups': 'APP_GROUPS',
'com.apple.developer.healthkit': 'HEALTHKIT',
'com.apple.developer.homekit': 'HOMEKIT',
'com.apple.developer.in-app-payments': 'APPLE_PAY',
'com.apple.developer.pass-type-identifiers': 'WALLET',
'com.apple.developer.siri': 'SIRIKIT',
'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN',
'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION',
'com.apple.developer.default-data-protection': 'DATA_PROTECTION',
'com.apple.developer.ClassKit-environment': 'CLASSKIT',
'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER',
'com.apple.developer.networking.multipath': 'MULTIPATH',
'com.apple.developer.networking.HotspotConfiguration': 'HOT_SPOT',
'inter-app-audio': 'INTER_APP_AUDIO',
'com.apple.external-accessory.wireless-configuration': 'WIRELESS_ACCESSORY_CONFIGURATION',
'com.apple.developer.coremedia.hls.low-latency': 'COREMEDIA_HLS_LOW_LATENCY',
'com.apple.developer.user-management': 'USER_MANAGEMENT',
};
/** Top-level entitlement keys switched on in an .entitlements plist (a key whose value is <false/> is off). */
export function entitlementKeys(xml: string): string[] {
const body = xml.replace(/<!--[\s\S]*?-->/g, '');
return [...body.matchAll(/<key>\s*([^<]+?)\s*<\/key>\s*(<false\s*\/>)?/g)].filter(m => !m[2]).map(m => m[1]!);
}
export interface ProjectEntitlements { file: string; caps: { cap: CapabilityType; key: string }[]; referenced: boolean | null }
export function projectEntitlements(file: string, xml: string, referenced: boolean | null): ProjectEntitlements {
const caps: { cap: CapabilityType; key: string }[] = [];
for (const key of entitlementKeys(xml)) {
const cap = ENTITLEMENT_CAPABILITIES[key];
if (cap && !caps.some(c => c.cap === cap)) caps.push({ cap, key });
}
return { file, caps, referenced };
}
export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project'; msg: string; fix: string }
/** Compares the three places a capability has to be: the App ID (Certificates, Identifiers & Profiles), the config's
* apple.signing.capabilities and the iOS project's entitlements. `appId` null = not read; `project` null = no iOS
* project found (not compared). Names exactly what is missing where. */
export function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null }): CapMismatch[] {
const out: CapMismatch[] = [];
const has = new Set(i.appId ?? []);
const projCaps = new Map<string, { file: string; key: string }>();
for (const p of i.project ?? []) for (const c of p.caps) if (!projCaps.has(c.cap)) projCaps.set(c.cap, { file: p.file, key: c.key });
const needsEntitlement = new Set<string>(Object.values(ENTITLEMENT_CAPABILITIES));
if (i.appId) {
for (const c of i.config) if (!has.has(c))
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in apple.signing.capabilities but not on the App ID`, fix: 'storebound apple signing ensure --dry, then without --dry' });
for (const [c, w] of projCaps) if (!has.has(c) && !i.config.includes(c))
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in ${w.file} (${w.key}) but not on the App ID: the archive's profile will not allow it`, fix: `add '${c}' to apple.signing.capabilities, then storebound apple signing ensure` });
}
for (const [c, w] of projCaps) if (!i.config.includes(c))
out.push({ cap: c, missingIn: 'config', msg: `${c} is in ${w.file} (${w.key}) but not in apple.signing.capabilities`, fix: `add '${c}' to apple.signing.capabilities so signing ensure keeps the App ID in step` });
if (i.project) {
for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) {
const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0];
out.push({ cap: c, missingIn: 'project', msg: `${c} is in apple.signing.capabilities but no .entitlements file of the iOS project has ${key}: builds ship without it`,
fix: `add ${key} to the app target's .entitlements (Xcode > Signing & Capabilities > + ${c.toLowerCase().replace(/_/g, ' ')}) and set CODE_SIGN_ENTITLEMENTS` });
}
for (const p of i.project) if (p.referenced === false && p.caps.length)
out.push({ cap: p.caps.map(c => c.cap).join(', '), missingIn: 'project', msg: `${p.file} is not referenced by CODE_SIGN_ENTITLEMENTS in the Xcode project: builds ignore it`, fix: `set CODE_SIGN_ENTITLEMENTS = ${p.file.split('/').slice(-2).join('/')} for the app target (Release and Debug)` });
}
return out;
}
/** Reads the App ID's capabilities until every wanted one is there (App Store Connect lags right after a write) or the
* time is up. `missing` empty = all there. */
export async function verifyCapabilities(read: () => Promise<string[]>, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void>; onWait?: (missing: string[], n: number) => void } = {}): Promise<{ have: string[]; missing: string[]; reads: number }> {
const tries = o.tries ?? 5, waitMs = o.waitMs ?? 3000, deadline = Date.now() + (o.timeoutMs ?? 45_000);
const sleep = o.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
let have: string[] = [], missing = want, reads = 0;
for (let n = 0; n < tries; n++) {
if (n) { o.onWait?.(missing, n); if (Date.now() + waitMs * n > deadline) break; await sleep(waitMs * n); }
have = await read(); reads++;
missing = want.filter(w => !have.includes(w));
if (!missing.length) break;
}
return { have, missing, reads };
}
/** Provisioning profiles storebound made ("storebound <bundle id> ..."), not in `inUse`: unused leftovers whose
* deletion is the owner's call. */
export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set<string>): ApiProfile[] =>
list.filter(p => /^storebound /.test(p.attributes.name ?? '') && !inUse.has(p.id));
export const deleteProfileCmd = (p: ApiProfile): string => `storebound api apple profiles_deleteInstance --param id=${p.id} --yes # "${p.attributes.name}" (in a terminal; asks you to type the operation id)`;
/** What `signing ensure` writes: automatic signing only adds capabilities (Xcode owns certificates and profiles). */
export function ensureScope(style: 'automatic' | 'manual' | undefined): { certificates: boolean; profiles: boolean; why: string } {
return (style ?? 'automatic') === 'automatic'
? { certificates: false, profiles: false, why: 'automatic signing: ensure only keeps the App ID and its capabilities in step; certificates and provisioning profiles are Xcode\'s (xcodebuild -allowProvisioningUpdates with the API key makes them at archive time)' }
: { certificates: true, profiles: true, why: 'manual signing: ensure also keeps the distribution certificate and an App Store profile per bundle id' };
}

View file

@ -59,6 +59,8 @@ export interface AppleSnapshot {
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
renders?: RendersCheck;
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
@ -218,6 +220,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
f.push(...rendersFindings('apple', s.renders));

View file

@ -15,6 +15,7 @@
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { templateBlockers } from './lib/template-answers';
import { ascClient, type J } from './apple/asc';
import { capabilityReport } from './apple/signing';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
import { reviewDemoAccount } from './lib/credentials';
@ -112,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
availability: !!avail?.data,
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),
};
}