From 304407e5232332339e4e4b796abd4022dd2469fa Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 25 Oct 2026 22:34:14 +0700 Subject: [PATCH] apple signing: App Group association fails early under automatic signing too (API capability + newest profile, steps and deep link, exit before the archive); console plan/verify apple-app-group --- CHANGELOG.md | 3 + cli/api/wrapped.gen.ts | 2 +- cli/apple/build.ts | 35 ++-- cli/apple/signing.ts | 75 +++++++-- cli/console/plan.ts | 27 +++- cli/console/verify.ts | 16 ++ cli/data/flags.json | 18 ++- cli/lib/console-answers.ts | 5 +- cli/lib/console-apple-group-plan.ts | 84 ++++++++++ cli/lib/console-plan.ts | 8 +- cli/lib/console-play-plans.ts | 2 +- cli/lib/console-verify.ts | 25 ++- cli/lib/targets.ts | 62 +++++++- cli/mcp-tools.ts | 18 ++- docs/api-coverage.md | 2 +- skills/build-and-sign/SKILL.md | 7 +- skills/console-autofill/SKILL.md | 2 +- skills/store-pitfalls/SKILL.md | 18 ++- tests/console-play-plans.test.ts | 2 +- tests/signing-targets.test.ts | 239 +++++++++++++++++++++++++++- 20 files changed, 590 insertions(+), 60 deletions(-) create mode 100644 cli/lib/console-apple-group-plan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b029e3f..e4d8c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased +- App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides. +- New `storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup]` (MCP `console_plan`: `bundleId`, `groups`, `lookup`): the agent-first developer portal plan to assign an App Group to an extension's App ID (start URL per bundle id resource id, portal labels, answer sheet per extension, never-press list: Remove / Delete / Revoke). Its final Save is the agent's own (`buttons.finalByAgent`: a capability edit, not a store submission). `console verify apple-app-group` (MCP `console_verify`, API only) re-reads the App ID and the newest profile and fails while a group is missing or cannot be proven. + ## 0.8.49 · 2026-10-25 - `play listing` for a language Play does not have yet uploads, in the same edit as the text, everything Play requires to commit it: icon, feature graphic, 2+ phone screenshots from `store/renders//` and the tablet sets when the other languages have them (a set the folder lacks falls back to the default language's). The dry run lists the uploads or refuses with the missing file, instead of a 403 "too few screenshots" at commit. `play images` creates a Play listing that does not exist yet (text from the listing file) together with its images instead of skipping the language (pitfall 102). diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 20dc859..eced4ef 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -136,7 +136,7 @@ export const WRAPPED: { apple: Record; play: Record] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap] // Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in // STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain signs with a build -// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its -// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps). +// keychain instead of the login keychain. App Groups: stops before the archive when a target's App Group is not proven (manual: its +// installed profile; automatic: the App ID's capability and the newest profile the API lists; the portal assigns groups: console plan apple-app-group). // First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a // Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops. // Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/, Flutter ios/Runner, @@ -30,13 +30,13 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; import { exportOptions, profileDirs } from '../lib/signing'; import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain'; -import { appGroupGapsFor, appGroupSteps } from '../lib/targets'; +import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets'; import { capacitorProject, webStep } from '../lib/web-assets'; import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { parsePlatform } from './platform'; -import { discoverTargets, findBundleId } from './signing'; +import { discoverTargets, findBundleId, readGroupEvidence } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; @@ -179,12 +179,27 @@ export async function run(ctx: Ctx): Promise { if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`); else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`); } - // App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id - const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : []; - for (const g of groupGaps) { - const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`; - if (a.has('allow-app-group-gap') || dry) warn(TAG, msg); - else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`); + // App Groups: a profile carries a group only when the portal assigned it to the bundle id (the API cannot). Manual + // signing: the installed profile; automatic: the App ID's capability and the newest profile the API lists. A gap stops + // the build before the archive; an association nothing can prove is warned with the same steps. + const groupTargets = [...(scan.main ? [scan.main] : []), ...scan.extensions]; + let groupEvidence: ((id: string) => GroupEvidence | null) | null = null; + if (groupTargets.some(t => t.appGroups.length)) { + if (style === 'manual') groupEvidence = id => { const p = installedProfiles(id, teamId)[0]; return p ? { resourceId: null, capabilityOn: null, profileEnt: p.entitlements } : null; }; + else { + try { groupEvidence = await readGroupEvidence(await ascClient(root, bundleId, config.apple?.appId), groupTargets, 'automatic', signing.profileType ?? 'IOS_APP_STORE'); } + catch (e) { warn(TAG, `App Group association not read from App Store Connect (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); } + } + } + const groupVerdicts = appGroupVerdicts(groupTargets, groupEvidence ?? (() => null)); + for (const g of groupVerdicts.unproven) for (const l of appGroupSteps(g)) warn(TAG, l); + for (const g of groupVerdicts.gaps) { + const msg = `${g.bundleId}: ${g.why}`; + if (a.has('allow-app-group-gap') || dry) { warn(TAG, msg); for (const l of appGroupSteps(g).slice(1)) warn(TAG, l); } + else { + const auto = style === 'automatic' ? 'Automatic signing: Xcode renews its managed profile at archive time, so if you just ticked the group and the API still lists the old profile, build with --allow-app-group-gap.' : ''; + throw new Error([`${msg}; codesign would refuse the build (stopped before the archive).`, ...appGroupSteps(g).slice(1), ...(auto ? [auto] : []), '(--allow-app-group-gap builds anyway)'].join(String.fromCharCode(10))); + } } const opts = exportOptions({ teamId: teamId ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : undefined }); const plist = toXmlPlist(opts); diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 09fb9b0..22330b4 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -34,7 +34,7 @@ import { import { isBuildCopyPath } from '../lib/privacy-manifest'; import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode'; import { parseMobileprovision, parsePlist } from '../lib/plist'; -import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets'; +import { appGroupSteps, appGroupVerdicts, type AppGroupGap, type GroupEvidence, type GroupVerdicts, pbxTargets, type TargetKind } from '../lib/targets'; import { zipEntries, zipRead } from '../lib/zip'; import type { ShipConfig } from '../lib/config'; import { textTable } from '../lib/table'; @@ -71,6 +71,7 @@ export async function findBundleId(c: Asc, identifier: string): Promise b.attributes.identifier === identifier) ?? null; if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)) .map((x: J) => x.attributes.capabilityType as string); + if (hit) hit.capabilityObjects = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)).map((x: J) => x.attributes); return hit; } @@ -173,10 +174,51 @@ export function profileEntitlements(list: ApiProfile[]): Record try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } } +/** The entitlements of the newest ACTIVE profile of a bundle id with content, an Xcode-managed one too (automatic + * signing: the list serves the content even where /v1/profiles/{id} answers 404). null = none readable. */ +export function newestProfileEntitlements(list: ApiProfile[]): Record | null { + const p = list.filter(x => x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent) + .sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0]; + if (!p) return null; + try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } +} + +/** What the API shows about the App Group association of one bundle id: the APP_GROUPS capability (and its settings) + * and the entitlements of its newest readable profile (manual signing: storebound's own; automatic: any, managed too). */ +export function groupEvidence(b: J | null | undefined, list: ApiProfile[], style: 'automatic' | 'manual'): GroupEvidence | null { + if (!b) return null; + const caps = (b.capabilities as string[] | undefined) ?? []; + const obj = ((b.capabilityObjects as { capabilityType?: string; settings?: unknown }[] | undefined) ?? []).find(x => x.capabilityType === 'APP_GROUPS'); + return { resourceId: b.id ?? null, capabilityOn: caps.includes('APP_GROUPS'), capabilitySettings: obj?.settings ?? null, + profileEnt: style === 'manual' ? profileEntitlements(list) : newestProfileEntitlements(list) }; +} + +/** Reads the evidence for every target with App Groups (API only, no writes): the bundle id with its capabilities and + * the profiles listed under it. */ +export async function readGroupEvidence(c: Asc, targets: { bundleId: string; appGroups: string[] }[], style: 'automatic' | 'manual', type: ProfileType): Promise<(bundleId: string) => GroupEvidence | null> { + const out = new Map(); + for (const t of targets.filter(x => x.appGroups.length)) { + const b = await findBundleId(c, t.bundleId); + let list: ApiProfile[] = []; + if (b) { + try { list = (await c.all(`/v1/bundleIds/${b.id}/profiles?limit=200`) as J[]).filter(p => p.attributes?.profileType === type).map((p): ApiProfile => ({ id: p.id, attributes: p.attributes })); } catch { /* not listable: unproven */ } + } + out.set(t.bundleId, groupEvidence(b, list, style)); + } + return id => out.get(id) ?? null; +} + export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void { for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l); } +/** Early exit text of a proven App Group gap (before the archive / at the end of ensure): the gap, the exact portal + * steps with the deep link, the plan command and the refresh. */ +export function appGroupError(gaps: AppGroupGap[], what: string): string { + const nl = String.fromCharCode(10); + return `${gaps.map(g => `${g.bundleId}: ${g.why ?? `lacks ${g.groups.join(', ')}`}`).join('; ')}; ${what}.${nl}${gaps.map(g => appGroupSteps(g).slice(1).join(nl)).join(nl)}`; +} + const git = (root: string, args: string[]): string | null => { try { const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' }); @@ -303,6 +345,8 @@ export interface SigningState { /** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */ targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[]; appGroupGaps: AppGroupGap[]; + /** targets whose App Group association the API cannot show (warned, not blocked) */ + appGroupUnproven: AppGroupGap[]; } export async function run(ctx: Ctx): Promise { @@ -351,7 +395,7 @@ export async function run(ctx: Ctx): Promise { } const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities); const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts]; - const gapsOf = (): AppGroupGap[] => appGroupGapsFor(targetList, id => { const b = bundles.get(id); return b ? profileEntitlements(profiles.get(id)?.list ?? []) : null; }, id => bundles.get(id)?.id ?? null); + const verdictsOf = (): GroupVerdicts => appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], style)); const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, @@ -370,7 +414,7 @@ export async function run(ctx: Ctx): Promise { 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' }; }), - targets: [], appGroupGaps: [], + targets: [], appGroupGaps: [], appGroupUnproven: [], }; state.targets = ids.map(id => { @@ -379,7 +423,7 @@ export async function run(ctx: Ctx): Promise { return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want, missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] }; }); - state.appGroupGaps = style === 'manual' ? gapsOf() : []; + { const v = verdictsOf(); state.appGroupGaps = v.gaps; state.appGroupUnproven = v.unproven; } // 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) : []); @@ -400,7 +444,7 @@ export async function run(ctx: Ctx): Promise { 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)`); for (const n of scan.notes) log(TAG, n); console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-']))); - warnAppGroupGaps(state.appGroupGaps, TAG); + warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG); 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(); @@ -443,6 +487,7 @@ export async function run(ctx: Ctx): Promise { 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); + warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG); cleanup(); done(TAG, `signing checked (dry run): ${plan.length} write(s)`); return; @@ -469,7 +514,13 @@ export async function run(ctx: Ctx): Promise { if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); - if (json) printJson({ ...state, style, scope, plan, done: true }); + // automatic signing: Xcode makes the profile at archive time, so the App Group association is read from the App ID + // and the newest profile the API lists now (the writes above may have added capabilities: read the App IDs again) + for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null); + const v = appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], 'automatic')); + if (json) printJson({ ...state, style, scope, plan, appGroupGaps: v.gaps, appGroupUnproven: v.unproven, done: !v.gaps.length }); + warnAppGroupGaps(v.unproven, TAG); + if (v.gaps.length) throw new Error(`capabilities in step, but ${appGroupError(v.gaps, 'the archive would fail at codesign (empty application-groups in the profile)')}`); done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`); return; } @@ -539,12 +590,12 @@ export async function run(ctx: Ctx): Promise { } cleanup(); // App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal - const gaps = appGroupGapsFor(targetList, id => finalEnt.get(id) ?? null, id => bundles.get(id)?.id ?? null); - if (json) printJson({ ...state, plan, appGroupGaps: gaps, done: !gaps.length }); - if (gaps.length) { - warnAppGroupGaps(gaps, TAG); - throw new Error(`profile(s) installed, but ${gaps.map(g => `${g.bundleId} lacks ${g.groups.join(', ')}${g.url ? ` (${g.url})` : ''}`).join('; ')}: the build would fail at codesign. Tick the App Group(s) in the developer portal (steps above), then run storebound apple signing ensure --refresh-profile.`); - } + for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null); + const v = appGroupVerdicts(targetList, id => { const ent = finalEnt.get(id); const b = bundles.get(id); return ent ? { ...groupEvidence(b, [], 'manual')!, profileEnt: ent } : groupEvidence(b, [], 'manual'); }); + const gaps = v.gaps; + if (json) printJson({ ...state, plan, appGroupGaps: gaps, appGroupUnproven: v.unproven, done: !gaps.length }); + warnAppGroupGaps(v.unproven, TAG); + if (gaps.length) throw new Error(`profile(s) installed, but ${appGroupError(gaps, 'the build would fail at codesign')}`); done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); } diff --git a/cli/console/plan.ts b/cli/console/plan.ts index be96c28..2c68c40 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -2,6 +2,7 @@ // storebound console plan lists the forms // storebound console plan
[--json] [--lookup] // storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list +// storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup] [--json] the developer portal App Group assignment the API cannot do // Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask // owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use // apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json, @@ -12,8 +13,31 @@ import type { Ctx } from '../lib/ctx'; import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers'; import { reviewDemoAccount } from '../lib/credentials'; import { consolePlan, type PlanIds, planText } from '../lib/console-plan'; +import type { ShipConfig } from '../lib/config'; import { printJson } from '../lib/log'; +/** apple-app-group: the targets whose entitlements (or apple.signing.extensions) list App Groups, narrowed by --bundle-id; + * --groups g1,g2 names the groups of a bundle id the project does not show; --lookup reads the bundle id's resource id (a GET). */ +async function appGroupTargets(ctx: Ctx, cfg: ShipConfig, bundleId: string | undefined, groups: string | undefined, lookup: boolean): Promise> { + const { discoverTargets } = await import('../apple/signing'); + const scan = discoverTargets(ctx.loaded.root, cfg); + const all = [...(scan.main ? [scan.main] : []), ...scan.extensions]; + const listed = groups ? groups.split(',').map(g => g.trim()).filter(Boolean) : []; + for (const g of listed) if (!/^group.[A-Za-z0-9.-]+$/.test(g)) throw new Error(`console plan apple-app-group: --groups "${g}" is not an App Group id (group.)`); + let found = all.filter(t => t.appGroups.length && (!bundleId || t.bundleId === bundleId)).map(t => ({ bundleId: t.bundleId, name: t.name, groups: listed.length ? listed : t.appGroups, source: t.entitlements ? `entitlements ${t.entitlements}` : 'apple.signing.extensions' })); + if (bundleId && !found.length) { + if (!listed.length) throw new Error(`console plan apple-app-group: no target "${bundleId}" with App Groups in the project (targets with groups: ${all.filter(t => t.appGroups.length).map(t => t.bundleId).join(', ') || 'none'}); pass --groups group.a,group.b`); + found = [{ bundleId, name: bundleId, groups: listed, source: '--groups' }]; + } + if (!lookup || !found.length) return found; + const bundle = cfg.apple?.bundleId; + if (!bundle) throw new Error('console plan: apple.bundleId is not set (needed for --lookup)'); + const { ascClient } = await import('../apple/asc'); + const { findBundleId } = await import('../apple/signing'); + const c = await ascClient(ctx.loaded.root, bundle, cfg.apple?.appId); + return Promise.all(found.map(async t => ({ ...t, resourceId: (await findBundleId(c, t.bundleId))?.id ?? null }))); +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const args = ctx.args, json = args.has('json'); @@ -21,7 +45,7 @@ export async function run(ctx: Ctx): Promise { if (!form) { const forms = PLAN_FORM_IDS.map(id => { if (isForm(id)) { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable, scripted: m.fillable }; } - return { id, store: 'play' as const, title: consolePlan(id, config).title, fillable: true, scripted: false }; + return { id, store: id.startsWith('apple-') ? 'apple' as const : 'play' as const, title: consolePlan(id, config).title, fillable: true, scripted: false }; }); if (json) return printJson({ forms }); console.log('storebound console plan [--json] [--lookup] (then drive the page yourself; `storebound console verify ` checks it)\n'); @@ -53,6 +77,7 @@ export async function run(ctx: Ctx): Promise { ids.readyChanges = await readyFromApi(root, cfg); } catch { /* not read: the plan says so */ } } + if (form === 'apple-app-group') ids.appGroupTargets = await appGroupTargets(ctx, cfg, args.get('bundle-id'), args.get('groups'), args.has('lookup')); if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)'); const plan = consolePlan(form, cfg, ids, { all: args.has('all') }); if (json) return printJson(plan); diff --git a/cli/console/verify.ts b/cli/console/verify.ts index 5310af6..f513920 100644 --- a/cli/console/verify.ts +++ b/cli/console/verify.ts @@ -2,6 +2,7 @@ // storebound console verify [--from ] [--from-text ] [--json] // --from takes the JSON of the plan's verify.capture snippet (input states: aria-checked, checked, aria-selected) or page text. // storebound console verify play-app-record --url [--write] +// storebound console verify apple-app-group [--bundle-id ] (API: the App Groups on the App ID's newest readable profile) // apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store // Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or // the page text. play-content-rating, play-target-audience: the summary page text. play-app-content: the plan's @@ -43,6 +44,21 @@ export async function run(ctx: Ctx): Promise { const { readyFromApi } = await import('../play/publish-ready'); const { verifyPublishChangesApi } = await import('../lib/console-verify'); r = verifyPublishChangesApi(await readyFromApi(root, config)); + } else if (form === 'apple-app-group') { + const { discoverTargets, readGroupEvidence } = await import('../apple/signing'); + const { appGroupVerdicts } = await import('../lib/targets'); + const { verifyAppGroupsApi } = await import('../lib/console-verify'); + const want = args.get('bundle-id'); + const scan = discoverTargets(root, config); + const targets = [...(scan.main ? [scan.main] : []), ...scan.extensions].filter(t => t.appGroups.length && (!want || t.bundleId === want)); + if (!targets.length) throw new Error(`console verify apple-app-group: no target${want ? ` "${want}"` : ''} with App Groups in the project's entitlements or apple.signing.extensions`); + const bundleId = config.apple?.bundleId; + if (!bundleId) throw new Error('console verify: apple.bundleId is not set'); + const { ascClient } = await import('../apple/asc'); + const c = await ascClient(root, bundleId, config.apple?.appId); + const style = config.apple?.signing?.style ?? 'automatic'; + const evidence = await readGroupEvidence(c, targets, style, config.apple?.signing?.profileType ?? 'IOS_APP_STORE'); + r = verifyAppGroupsApi(targets, appGroupVerdicts(targets, evidence)); } else if (form === 'apple-app-record' || form === 'apple-age-rating') { const bundleId = config.apple?.bundleId; if (!bundleId) throw new Error('console verify: apple.bundleId is not set'); diff --git a/cli/data/flags.json b/cli/data/flags.json index 2e3f8ba..88b6faf 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -499,6 +499,7 @@ "all", "allow-availability-changes", "allow-price-changes", + "bundle-id", "capacitor", "cdp", "chrome-profile", @@ -510,10 +511,15 @@ "flutter", "fresh-profile", "godot", + "groups", + "keychain", + "locale-map", "lookup", "name", + "no-install", "profile", "react-native", + "refresh-profile", "sku", "start-url", "step-timeout", @@ -524,6 +530,7 @@ "console verify": [ "allow-availability-changes", "allow-price-changes", + "bundle-id", "capacitor", "cordova", "cordova-android", @@ -533,8 +540,12 @@ "from", "from-text", "godot", + "keychain", + "locale-map", "name", + "no-install", "react-native", + "refresh-profile", "sku", "text", "unity", @@ -1204,6 +1215,7 @@ "from-text", "godot", "group", + "groups", "halt", "headless", "home", @@ -1578,11 +1590,13 @@ "console plan": [ "storebound console plan lists the forms", "storebound console plan [--json] [--lookup]", - "storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list" + "storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list", + "storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup] [--json] the developer portal App Group assignment the API cannot do" ], "console verify": [ "storebound console verify [--from ] [--from-text ] [--json]", - "storebound console verify play-app-record --url [--write]" + "storebound console verify play-app-record --url [--write]", + "storebound console verify apple-app-group [--bundle-id ] (API: the App Groups on the App ID's newest readable profile)" ], "desktop": [ "storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which", diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index 8294abb..036fc47 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -25,11 +25,12 @@ import type { ProjectSignals } from './project-signals'; export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const; export type FormId = typeof FORM_IDS[number]; -/** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the +/** Forms with a console plan only (the agent drives them; no scripted fill): apple-app-group (the developer portal App Group + * assignment the API cannot do, cli/lib/console-apple-group-plan.ts), Play Store settings (the App category the * API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */ export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', - 'play-games-services', 'play-publish-changes'] as const; + 'play-games-services', 'play-publish-changes', 'apple-app-group'] as const; export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number]; /** Every form `console plan` / `console verify` know. */ export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const; diff --git a/cli/lib/console-apple-group-plan.ts b/cli/lib/console-apple-group-plan.ts new file mode 100644 index 0000000..c54d404 --- /dev/null +++ b/cli/lib/console-apple-group-plan.ts @@ -0,0 +1,84 @@ +// `storebound console plan apple-app-group`: the App Store Connect API can switch the APP_GROUPS capability on for a +// bundle id but cannot assign a group to it, so an app extension (a packet-tunnel extension, a share extension, a +// widget) whose entitlements list `com.apple.security.application-groups` gets a profile with an empty group list until +// the group is ticked in the developer portal. Agent-first like the other plans: the agent drives the signed-in +// developer.apple.com page with its own browser tool, presses Save itself (it is a capability edit on the owner's own +// App ID, not a store submission: finalByAgent), then `storebound apple signing ensure --refresh-profile` and +// `storebound console verify apple-app-group` (read-only, API). Pure. +// Modeled on Apple's help ("Configure App Groups", "Register an App Group"), not yet recorded on a real portal page: the +// labels are where to start, the agent adapts. +import { bundleIdPortalUrl, PORTAL_IDENTIFIERS_URL } from './targets'; +import { NEVER_PRESS } from './console-forms'; +import type { ConsolePlan, PlanAnswer, PlanIds, PlanStep } from './console-plan'; + +export const PORTAL_REMOVE = ['Remove', 'Remove App ID', 'Delete', 'Delete identifier', 'Revoke', 'Revoke certificate', 'Download']; +const CHECK = 'document.querySelector("main")?.innerText ?? document.body.innerText'; + +export function appleAppGroupPlan(p: PlanIds): ConsolePlan { + const targets = p.appGroupTargets ?? []; + const one = targets.length === 1 ? targets[0]! : null; + const answers: PlanAnswer[] = []; + const steps: PlanStep[] = []; + const add = (s: Omit): void => { steps.push({ n: steps.length + 1, ...s }); }; + const a = (x: PlanAnswer): void => { answers.push(x); }; + + if (!targets.length) { + a({ id: 'targets', question: 'Which bundle id needs which App Group(s)?', value: null, text: '(none found)', status: 'ask-owner', source: 'entitlements of the Xcode targets', + note: 'No target of the project lists com.apple.security.application-groups. Pass --bundle-id --groups group.a,group.b, or list the extension in apple.signing.extensions with appGroups.' }); + } + for (const t of targets) { + a({ id: `${t.bundleId}/capability`, question: `${t.bundleId}: App Groups capability`, value: true, text: 'on', status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: 'App Groups', action: 'tick', where: 'Capabilities list of the App ID page' }); + for (const g of t.groups) a({ id: `${t.bundleId}/${g}`, question: `${t.bundleId}: App Group`, value: g, text: g, status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: g, action: 'tick', where: 'the App Group assignment dialog (App Groups > Configure)' }); + } + + const start = one?.resourceId + ? { url: bundleIdPortalUrl(one.resourceId), deepLink: true, reach: [] as string[] } + : { url: PORTAL_IDENTIFIERS_URL, deepLink: false, reach: ['Certificates, Identifiers & Profiles > Identifiers', targets.length ? `search the bundle id (${targets.map(t => t.bundleId).join(', ')}) and open it` : 'search the bundle id and open it', 'with --lookup the plan gets the deep link (https://developer.apple.com/account/resources/identifiers/bundleId/edit/)'] }; + + add({ text: `Open ${start.url} in the signed-in browser (never sign in, never type the account password: if the page asks to sign in, tell the owner).${start.deepLink ? '' : ' Identifiers > search the bundle id > open it.'}` }); + for (const t of targets) { + const url = t.resourceId ? bundleIdPortalUrl(t.resourceId) : null; + const gs = t.groups.join(', '); + add({ text: `${t.bundleId}${url ? ` (${url})` : ''}: on the "Edit Your App ID Configuration" page, find App Groups in the Capabilities list and tick its checkbox if it is not ticked.`, answers: [`${t.bundleId}/capability`] }); + add({ text: `${t.bundleId}: press "Configure" next to App Groups. In the dialog that lists the registered App Groups tick ${gs} (exact identifiers; tick nothing else), then press "Continue". A group that is not listed must be registered first: Identifiers > the blue + > App Groups > Continue > Description and Identifier (the group id of the sheet) > Continue > Register; then reopen this page.`, click: ['Configure', 'Continue'], answers: t.groups.map(g => `${t.bundleId}/${g}`) }); + add({ text: `${t.bundleId}: press "Save" at the top right. If a dialog says the provisioning profiles of this App ID become invalid, press "Confirm" (a new profile is made by \`storebound apple signing ensure --refresh-profile\`). Read the page back: App Groups shows the configured count.`, click: ['Save', 'Confirm'] }); + } + add({ text: `Then run \`storebound apple signing ensure --refresh-profile\` (a new profile that carries the group), and \`storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}\` (read-only: reads the App ID and the newest profile through the API and checks every group).` }); + + return { + form: 'apple-app-group', store: 'apple', title: 'Apple Developer portal: assign App Groups to an App ID (the API cannot)', + ownerBatch: [], start, steps, answers, + askOwner: answers.filter(x => x.status !== 'certain').map(x => `${x.question}: ask the owner${x.note ? ` (${x.note})` : ''}`), + buttons: { + neverPress: [...new Set([...PORTAL_REMOVE, 'Revoke', 'Account', 'Certificates', 'Keys', ...NEVER_PRESS])], + final: 'Save', finalByAgent: true, + allowed: [ + { button: 'Configure', class: 'navigation', where: 'App Groups row: opens the group assignment dialog' }, + { button: 'Continue', class: 'navigation', where: 'the group assignment dialog and the App Group registration form' }, + { button: 'Register', class: 'portal-register', where: 'App Groups registration form only: registers the group id of the answer sheet (a new group identifier, nothing public)' }, + { button: 'Save', class: 'portal-save', where: 'top right of the App ID page: saves the capability edit (the agent presses it; not a store submission)' }, + { button: 'Confirm', class: 'portal-save', where: 'the dialog that warns the profiles become invalid after Save' }, + ], + rule: 'The agent presses everything it needs here, including Save (a capability edit on the owner\'s own App ID; no store submission, nothing public). Never press Remove / Delete / Revoke, never untick or edit another capability, never touch Certificates, Keys or Account pages; stop and tell the owner if the page asks to sign in.', + }, + uiFacts: [ + 'Modeled on Apple\'s help pages (not recorded on a real portal page yet): Certificates, Identifiers & Profiles > Identifiers > the App ID > Capabilities list > App Groups (checkbox) > Configure > the group list > Continue > Save (top right) > Confirm.', + 'The App Store Connect API can enable the APP_GROUPS capability (POST /v1/bundleIdCapabilities) but has no field that assigns a group: an extension\'s profile has an empty application-groups list until the group is ticked here, and the archive then fails at codesign.', + 'Saving the App ID invalidates its provisioning profiles; manual signing needs `storebound apple signing ensure --refresh-profile`, automatic signing lets Xcode renew its managed profile at archive time.', + 'The deep link uses the bundle id\'s App Store Connect API resource id: https://developer.apple.com/account/resources/identifiers/bundleId/edit/ (`console plan apple-app-group --bundle-id --lookup` reads it).', + 'Group identifiers start with group. and are registered once per team under Identifiers > App Groups.', + ], + driving: [ + 'Use your own browser tool (Claude in Chrome with the owner\'s signed-in session). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps, but never press anything in buttons.neverPress.', + 'Tick boxes by clicking the checkbox, then read its checked state back. After Save read the App ID page again.', + 'Press Save yourself (buttons.finalByAgent): it is not a store submission. Never sign in, never type passwords or 2FA codes.', + ], + inPageLoop: '', + verify: { + command: `storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}`, source: 'api', capture: CHECK, + expect: ['Every App Group of the answer sheet is on the App ID\'s newest readable profile (re-download after `storebound apple signing ensure --refresh-profile`) or named by the App Groups capability settings.', 'Exit code 1 while a group is missing or cannot be proven.'], + }, + fillable: true, + guidance: ['Open: developer.apple.com > Account > Certificates, Identifiers & Profiles > Identifiers > the App ID'], + }; +} diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index ff11b8c..bd0142b 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -12,6 +12,7 @@ import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, i import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SAVE_EACH_PAGE, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans'; import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms'; import { PLAY_CREATE_CAPTURE } from './console-verify'; +import { appleAppGroupPlan } from './console-apple-group-plan'; import { APPLE_PURPOSES, IARC_DIGITAL_GOODS, IARC_DIGITAL_PURCHASES } from './console-answers'; export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement'; @@ -87,6 +88,8 @@ export interface ConsolePlan { allowed: { button: string; class: string; where: string }[]; /** The rule in one line. */ rule: string; + /** true: the final button is the agent's own (a portal capability edit such as Save on an App ID, not a store submission or a public change). */ + finalByAgent?: boolean; }; /** Known UI facts of this form (verified ones say so). */ uiFacts: string[]; @@ -110,6 +113,8 @@ export interface PlanIds { signals?: ProjectSignals; /** play-publish-changes: the production releases ready to publish per the API (null/undefined: not read). */ readyChanges?: { track: string; version: string | null; build: string | null; rollout: string }[] | null; + /** apple-app-group: the targets whose entitlements list App Groups (their bundle id, groups and, with --lookup, the App Store Connect resource id for the portal deep link). */ + appGroupTargets?: { bundleId: string; name?: string; groups: string[]; resourceId?: string | null; source?: string }[]; } const ASC = 'https://appstoreconnect.apple.com'; @@ -538,6 +543,7 @@ export interface PlanOpts { all?: boolean } export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan { const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config; if (form === 'play-app-content') return appContentPlan(cfg, p, o.all); + if (form === 'apple-app-group') return appleAppGroupPlan(p); if (isPlanOnly(form)) { const part = playOnlyPart(form, cfg, p); return { @@ -583,7 +589,7 @@ export function planText(pl: ConsolePlan): string { L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`); if (a.status !== 'certain' && a.note) L.push(` ${a.note}`); } - L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), + L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (${pl.buttons.finalByAgent ? "the agent's own, not a store submission" : "the owner's"}): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`)); if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`)); diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index 51978b7..9fe47ac 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -534,7 +534,7 @@ function publishChanges(config: ShipConfig, p: PlanIds): Part { }; } -export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { +export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { switch (form) { case 'play-production-countries': return productionCountries(config, p); case 'play-production-release': return productionRelease(config, p); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index 730997d..5db26ec 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -19,6 +19,7 @@ import { APPLE_PRIVACY_TYPES } from './app-privacy'; import { typeLabels } from './console-forms'; import { playAppIdInText } from './play-app-id'; import { APPLE_PURPOSES } from './console-answers'; +import type { AppGroupGap } from './targets'; export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner'; export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string } @@ -360,7 +361,7 @@ export function parseStates(raw: string): { states: InputState[]; text: string } * the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No * radios cannot be told apart in page text: reported, never counted as a match. */ -export function verifyPlayDeclaration(form: Exclude, config: ShipConfig, text: string): VerifyResult { +export function verifyPlayDeclaration(form: Exclude, config: ShipConfig, text: string): VerifyResult { const part = playOnlyPart(form, config, {}); const st = parseStates(text); const blob = fold(st ? st.text : text); @@ -444,6 +445,27 @@ export function verifyPublishChangesApi(ready: { track: string; version: string ...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]); } +/** + * apple-app-group (read-only, API): one row per (bundle id, App Group). match: the group is on the newest readable + * profile or named by the capability settings; mismatch: the evidence shows it missing; not-found: nothing readable + * proves it (after the portal Save the old profiles are invalid: run `storebound apple signing ensure --refresh-profile`, + * then verify again). ok only when every row matches. + */ +export function verifyAppGroupsApi(targets: { bundleId: string; appGroups: string[] }[], verdicts: { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }): VerifyResult { + const rows: VerifyRow[] = []; + for (const t of targets.filter(x => x.appGroups.length)) { + const gap = verdicts.gaps.find(g => g.bundleId === t.bundleId), un = verdicts.unproven.find(g => g.bundleId === t.bundleId); + for (const g of t.appGroups) { + const item = `${t.bundleId}: ${g}`; + if (gap?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'missing', status: 'mismatch', ...(gap.why ? { note: gap.why } : {}) }); + else if (un?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'cannot be proven', status: 'not-found', note: 'no ACTIVE profile shows it: run storebound apple signing ensure --refresh-profile, then verify again' }); + else rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'present', status: 'match' }); + } + } + const r = result('apple-app-group', 'api', rows, ['Reads the App Store Connect API: the App Groups capability and the newest readable provisioning profile of each bundle id.']); + return rows.some(x => x.status === 'not-found') ? { ...r, ok: false } : r; +} + /** Page-text verification of any form that has one. */ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult { if (form === 'play-app-content') return verifyAppContent(text); @@ -460,6 +482,7 @@ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): case 'play-target-audience': return verifyTargetAudience(config, text); case 'apple-app-record': return verifyAppRecordText(config, text); case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)'); + case 'apple-app-group': throw new Error('console verify apple-app-group reads the App Store Connect API (no --from-text)'); } } diff --git a/cli/lib/targets.ts b/cli/lib/targets.ts index fc8e8b9..227d3b4 100644 --- a/cli/lib/targets.ts +++ b/cli/lib/targets.ts @@ -110,17 +110,20 @@ export function appGroupGaps(want: string[], profileEnt: Record /** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */ export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`; -export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null } +export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null; /** why it is a gap / not proven (one line) */ why?: string } -/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */ +export const PORTAL_IDENTIFIERS_URL = 'https://developer.apple.com/account/resources/identifiers/list'; + +/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. + * The first line is the headline, the rest are the steps. */ export function appGroupSteps(g: AppGroupGap): string[] { - const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list'; + const url = g.url ?? PORTAL_IDENTIFIERS_URL; return [ - `${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`, - `Open ${url} in the signed-in browser (agent-first: you drive it).`, - 'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).', - 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.', - `Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`, + `${g.bundleId}: ${g.why ?? `the profile lacks App Group(s) ${g.groups.join(', ')}`} (the App Store Connect API can enable the App Groups capability but cannot assign a group to a bundle id).`, + `Open ${url} in the signed-in browser (agent-first: you drive it; plan: storebound console plan apple-app-group --bundle-id ${g.bundleId}${g.url ? '' : ' --lookup'}).`, + `Certificates, Identifiers & Profiles > Identifiers > ${g.bundleId} > App Groups (tick it if it is not ticked) > Configure > tick ${g.groups.join(', ')} (register a new group first under Identifiers > App Groups) > Continue > Save (and Confirm in the dialog that says the profiles get invalidated).`, + 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button; change no other capability.', + 'Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.', ]; } @@ -136,3 +139,46 @@ export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] } return out; } + +/** What the API (or an installed profile) shows about one App ID's App Group association. */ +export interface GroupEvidence { + /** App Store Connect API resource id of the bundle id (the portal deep link); null: not registered / unknown */ + resourceId: string | null; + /** the APP_GROUPS capability is on the App ID (null: unknown) */ + capabilityOn: boolean | null; + /** the raw settings of the APP_GROUPS capability (any field that names a group proves it) */ + capabilitySettings?: unknown; + /** Entitlements of the newest readable profile of the bundle id (a managed one too); null: none readable */ + profileEnt: Record | null; +} + +/** Groups of `want` that the capability's settings name (the API has no documented App Group field; if a field ever + * lists them, this proves the association without a profile). */ +export const capabilityGroups = (settings: unknown, want: string[]): string[] => { + if (settings === null || settings === undefined) return []; + const text = JSON.stringify(settings); + return want.filter(g => text.includes(g)); +}; + +export interface GroupVerdicts { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] } + +/** + * Every target whose entitlements list App Groups: proven (a readable profile carries each group, or the capability + * settings name it), a gap (the evidence shows the group missing: a readable profile without it, or the capability + * off) or unproven (nothing readable: the API cannot show the association). Pure. + */ +export function appGroupVerdicts(targets: { bundleId: string; appGroups: string[] }[], evidence: (bundleId: string) => GroupEvidence | null): GroupVerdicts { + const out: GroupVerdicts = { gaps: [], unproven: [], ok: [] }; + for (const t of targets) { + if (!t.appGroups.length) continue; + const ev = evidence(t.bundleId); + const rid = ev?.resourceId ?? null, url = rid ? bundleIdPortalUrl(rid) : null; + const viaCap = new Set(capabilityGroups(ev?.capabilitySettings, t.appGroups)); + const missing = (ev?.profileEnt ? appGroupGaps(t.appGroups, ev.profileEnt) : t.appGroups).filter(g => !viaCap.has(g)); + if (!missing.length) { out.ok.push(t.bundleId); continue; } + if (ev?.capabilityOn === false) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the App Groups capability is not enabled on the App ID, so ${missing.join(', ')} is not associated` }); + else if (ev?.profileEnt) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the newest profile lacks App Group(s) ${missing.join(', ')} (an empty application-groups list means the group is not ticked on the App ID)` }); + else out.unproven.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: ev ? `App Group(s) ${missing.join(', ')} cannot be proven: the API shows no group on the App ID's capability and no readable profile` : `App Group(s) ${missing.join(', ')} cannot be proven: the App ID is not registered or could not be read` }); + } + return out; +} diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index c7972ae..deda4cf 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -40,7 +40,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm /** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */ export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience', 'play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps', - 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes']; + 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes', 'apple-app-group']; export const TOOLS: ToolDef[] = [ { name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false, @@ -458,15 +458,19 @@ export const TOOLS: ToolDef[] = [ description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.', props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) }, { name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false, - description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.', + description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s); and apple-app-group (the developer portal step the App Store Connect API cannot do: assign App Groups to an extension\'s App ID; its final Save is the agent\'s own, buttons.finalByAgent; bundleId narrows it, lookup adds the portal deep link). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.', props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS }, - all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' } }, - required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), '--json'] }, + all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' }, + bundleId: { type: 'string', description: 'apple-app-group only: the bundle id (an extension) whose App Group to assign' }, + groups: { type: 'string', description: 'apple-app-group only: comma-separated group ids when the project does not show them (group.com.example.app)' }, + lookup: { type: 'boolean', description: 'apple-app-group only: read the bundle id\'s resource id from the App Store Connect API (a GET) for the portal deep link' } }, + required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), ...opt('bundle-id', i.bundleId), ...opt('groups', i.groups), ...flag('lookup', i.lookup), '--json'] }, { name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true, - description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the privacy policy: the page text after Save; the other App content cards (ads, sign-in details, advertising ID, government, financial, health): the JSON of the plan\'s verify.capture snippet, which reads radios and boxes from the DOM: aria-checked, checked, aria-selected). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.', + description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the privacy policy: the page text after Save; the other App content cards (ads, sign-in details, advertising ID, government, financial, health): the JSON of the plan\'s verify.capture snippet, which reads radios and boxes from the DOM: aria-checked, checked, aria-selected). apple-app-record and apple-age-rating without text read the App Store Connect API, and so does apple-app-group (the App Groups on the App ID\'s newest readable profile). Returns a per-item match / mismatch table; exit code 1 on a mismatch.', props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS }, - text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } }, - required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] }, + text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' }, + bundleId: { type: 'string', description: 'apple-app-group only: check this bundle id (default: every target with App Groups); it reads the API, no text' } }, + required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), ...opt('bundle-id', i.bundleId), '--json'] }, { name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true, description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] }, ]; diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 2e79da5..3c3e0e4 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -676,7 +676,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `bundleIds_deleteInstance` | DELETE | `/v1/bundleIds/{id}` | owner only (typed confirm) | | | `bundleIds_getCollection` | GET | `/v1/bundleIds` | read | apple/adhoc, apple/bundle, apple/signing | | `bundleIds_getInstance` | GET | `/v1/bundleIds/{id}` | read | | -| `bundleIds_profiles_getToManyRelated` | GET | `/v1/bundleIds/{id}/profiles` | read | apple/adhoc, lib/signing | +| `bundleIds_profiles_getToManyRelated` | GET | `/v1/bundleIds/{id}/profiles` | read | apple/adhoc, apple/signing, lib/signing | | `bundleIds_profiles_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/profiles` | read | | | `bundleIds_updateInstance` | PATCH | `/v1/bundleIds/{id}` | write (dry by default) | | | **certificates** (7) | | | | | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 9845d21..77c0e52 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ 1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store - profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group the profile lacks is reported with the developer portal link and steps (the API cannot assign a group; then `signing ensure --refresh-profile`, store-pitfalls 121). + profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group not proven on the App ID (manual: the profile; automatic: the capability and the newest profile the API lists) is reported with the developer portal link and steps (the API cannot assign a group): `storebound console plan apple-app-group --bundle-id --lookup`, drive it, then `signing ensure --refresh-profile` and `console verify apple-app-group` (store-pitfalls 121). It also compares three places a capability must be and names what is missing where: the App ID, the config's `apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS, `com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...; @@ -61,8 +61,9 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ `apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build --keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the environment and never printed); `--keychain ` signs with a build keychain (`--keychain --keychain-unlock` - unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). With manual signing it - also stops when a target's App Group is missing from its installed profile (store-pitfalls 121). + unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). It + also stops before the archive when a target's App Group is missing (manual: its installed profile; automatic: the profile + the API lists; store-pitfalls 121; `--allow-app-group-gap` overrides). - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build` run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 251dac8..29a198d 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -45,7 +45,7 @@ the UI shows. This is the primary mode. `--all` / MCP `all: true` puts every card's steps inline in fix order and ends with the one owner list: tick the IARC Terms of Use box (before), Send changes for review (after)), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`, and the first production release: - `play-production-countries`, `play-production-release` and `play-publish-changes` (managed publishing). `storebound console plan` lists them. + `play-production-countries`, `play-production-release` and `play-publish-changes` (managed publishing), and the Apple developer portal step `apple-app-group` (assign an App Group to an extension's App ID, which the API cannot do; its final Save is yours, `buttons.finalByAgent`; `console verify apple-app-group` reads the API). `storebound console plan` lists them. 2. Send the owner the `ownerBatch` of every form you will fill in ONE message before you start (AGENTS.md rule 5): the `ask-owner` answers (write them into the config, so the plan has them; never guess them) and the `before` steps (Play content rating: tick the IARC Terms of Use box on the questionnaire's page 1 first; page 1's Next does diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 95f7ac0..967824b 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1465,7 +1465,6 @@ that fails without the fix (see CONTRIBUTING.md). attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and names the MISS with the size and every candidate's match count. - **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. -||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) ## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile @@ -1481,14 +1480,21 @@ that fails without the fix (see CONTRIBUTING.md). and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without `--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to- capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per - target. It then decodes each profile and compares its App Groups with the target's entitlements: a gap fails with + target. It then compares each target's App Groups with what the API shows (manual signing: the decoded profile; automatic + signing: the App ID's APP_GROUPS capability settings and the newest profile the API lists for the bundle id, an + Xcode-managed one too; a profile that carries no group, or the capability off, is a gap; nothing readable is + "cannot be proven" and only warns with the same steps): a gap fails with the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/` and the steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that `storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it - does not pile up profiles while the group is unassigned). `apple build` (manual signing) stops before the archive on the - same gap (`--allow-app-group-gap` overrides). A variable bundle id (set in an xcconfig) is not resolvable: list the + does not pile up profiles while the group is unassigned). `apple build` stops before the archive on the same gap in both + signing styles (manual: the installed profile; automatic: read through the API; `--allow-app-group-gap` overrides, e.g. + when the group was just ticked and Xcode will renew its managed profile at archive time). The agent-first plan is + `storebound console plan apple-app-group --bundle-id ` (`--lookup` adds the deep link; its Save is the agent's own, not a + store submission) and `storebound console verify apple-app-group` re-reads the App ID and the newest profile (after the + portal Save the old profiles are invalid: run `signing ensure --refresh-profile` first). A variable bundle id (set in an xcconfig) is not resolvable: list the extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones. - **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in `cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group - entitlements, a decoded profile plist, a fake App Store Connect). There is no `console plan` form for the portal page: - the steps are in the error and the warning. + entitlements, a decoded profile plist, a fake App Store Connect), `cli/lib/console-apple-group-plan.ts` (the plan), + `verifyAppGroupsApi` in `cli/lib/console-verify.ts`, `appGroupVerdicts` in `cli/lib/targets.ts` (gap / unproven / ok). diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index c65df5d..1210897 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -30,7 +30,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup', 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing', }; - for (const form of PLAN_ONLY_FORM_IDS) { + for (const form of PLAN_ONLY_FORM_IDS.filter(f => f.startsWith('play-'))) { test(form, () => { const p = consolePlan(form, base); expect(p).toMatchObject({ form, store: 'play', fillable: true }); diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts index 0104925..b3c6bc3 100644 --- a/tests/signing-targets.test.ts +++ b/tests/signing-targets.test.ts @@ -9,7 +9,11 @@ import { decide, NOT_A_TTY } from '../cli/api/gate'; import { USAGE } from '../cli/api'; import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain'; import { planProfile, validateSigning } from '../cli/lib/signing'; -import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets'; +import { appGroupGaps, appGroupGapsFor, appGroupSteps, appGroupVerdicts, bundleIdPortalUrl, capabilityGroups, pbxTargets, targetKind } from '../cli/lib/targets'; +import { consolePlan, planText } from '../cli/lib/console-plan'; +import { verifyAppGroupsApi, verifyText } from '../cli/lib/console-verify'; +import { PLAN_FORM_IDS } from '../cli/lib/console-answers'; +import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; import { upsertTiming } from '../cli/lib/upsert'; import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; @@ -280,7 +284,8 @@ describe('signing ensure with an extension (fake App Store Connect)', () => { const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); const all = r.out + r.err; expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2); - expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes'); + expect(all).toContain('com.example.notes.ShareExt: the newest profile lacks App Group(s) group.com.example.notes'); + expect(all).toContain('console plan apple-app-group --bundle-id com.example.notes'); expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); expect(all).toContain('signing ensure --refresh-profile'); }); @@ -296,6 +301,236 @@ describe('signing ensure with an extension (fake App Store Connect)', () => { }); }); +describe('App Group association: verdicts (pure)', () => { + const t = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes'] }]; + const ent = (g: string[]) => ({ 'com.apple.security.application-groups': g }); + + test('a readable profile with the group proves it; one without it is a gap with the deep link', () => { + expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: ent(['group.com.example.notes']) }))).toEqual({ gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); + const v = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: {} })); + expect(v.gaps).toHaveLength(1); + expect(v.gaps[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }); + expect(v.gaps[0]!.why).toContain('empty application-groups'); + }); + + test('capability settings that name the group prove it without a profile', () => { + const settings = [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }]; + expect(capabilityGroups(settings, ['group.com.example.notes', 'group.com.example.other'])).toEqual(['group.com.example.notes']); + expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: settings, profileEnt: null })).ok).toEqual(['com.example.notes.ShareExt']); + }); + + test('nothing readable is unproven (not a gap); the capability off is a gap even without a profile', () => { + const u = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: null, profileEnt: null })); + expect([u.gaps.length, u.unproven.length]).toEqual([0, 1]); + expect(u.unproven[0]!.why).toContain('cannot be proven'); + expect(appGroupVerdicts(t, () => null).unproven).toHaveLength(1); + const off = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: false, profileEnt: null })); + expect(off.gaps[0]!.why).toContain('capability is not enabled'); + }); + + test('the steps name the portal path, the deep link, the plan command and the refresh', () => { + const s = appGroupSteps({ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }).join('\n'); + expect(s).toContain('Certificates, Identifiers & Profiles > Identifiers > com.example.notes.ShareExt > App Groups'); + expect(s).toContain('Configure > tick group.com.example.notes'); + expect(s).toContain('Continue > Save'); + expect(s).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(s).toContain('storebound apple signing ensure --refresh-profile'); + }); +}); + +describe('App Group association under automatic signing (fake App Store Connect)', () => { + beforeEach(() => { upsertTiming.waitMs = 0; }); + afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); + + const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; + const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style: 'automatic' as const, teamId: 'ABCDE12345', capabilities: ['IN_APP_PURCHASE' as const] } } }; + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + const managed = (id: string, ent: Record) => ({ type: 'profiles', id: `MANAGED_${id}`, attributes: { name: `iOS Team Store Provisioning Profile: ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); + const GROUP = { 'com.apple.security.application-groups': ['group.com.example.notes'] }; + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + + function api(o: { ext?: unknown[]; main?: unknown[]; extSettings?: unknown; extCaps?: string[] }) { + const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: o.extCaps ?? ['APP_GROUPS', 'ASSOCIATED_DOMAINS'] }; + const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' }, relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } }); + const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: id === EXT && t === 'APP_GROUPS' ? (o.extSettings ?? null) : null } })); + return [ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) }, + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, + { match: /^\/v1\/certificates\?/, answer: page([]) }, + { match: new RegExp(`^/v1/bundleIds/${EXT}/profiles`), answer: () => page(o.ext ?? []) }, + { match: new RegExp(`^/v1/bundleIds/${MAIN}/profiles`), answer: () => page(o.main ?? [managed('com.example.notes', GROUP)]) }, + { match: /^\/v1\/profiles\/MANAGED_[^/]+\/relationships\/certificates/, answer: { errors: [{ status: '404' }] }, status: 404 }, + { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + ] as never; + } + const ensure = async (argv: string[] = ['ensure', '--yes']) => { + const p = project(cfg, files); + const calls = mockFetch(api(current)); + const r = await capture(() => signing(p.ctx(argv))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + return { r, all: r.out + r.err, calls }; + }; + let current: Parameters[0] = {}; + + test('the extension\'s newest (Xcode-managed) profile has an empty group list: ensure stops with the steps, the deep link and the plan command', async () => { + ascEnv(); + current = { ext: [managed('com.example.notes.ShareExt', {})] }; + const { r, all, calls } = await ensure(); + expect(r.exit).toBe(1); + expect(all).toContain('com.example.notes.ShareExt: the newest profile lacks App Group(s) group.com.example.notes'); + expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); + expect(all).toContain('Configure > tick group.com.example.notes'); + expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(all).toContain('storebound apple signing ensure --refresh-profile'); + expect(calls.filter(c => c.method !== 'GET')).toEqual([]); + }); + + test('the managed profile carries the group: ensure finishes without a warning', async () => { + ascEnv(); + current = { ext: [managed('com.example.notes.ShareExt', GROUP)] }; + const { r, all } = await ensure(); + expect(r.exit).toBeNull(); + expect(all).not.toContain('cannot be proven'); + expect(all).toContain('capabilities in step'); + }); + + test('no profile and no capability field to read: ensure warns with the portal steps instead of passing silently', async () => { + ascEnv(); + current = { ext: [] }; + const { r, all } = await ensure(); + expect(r.exit).toBeNull(); + expect(all).toContain('cannot be proven'); + expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + }); + + test('a capability field that names the group proves it', async () => { + ascEnv(); + current = { ext: [], extSettings: [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }] }; + const { all } = await ensure(); + expect(all).not.toContain('cannot be proven'); + }); + + test('status --json lists the gap and the unproven targets; the main app (profile without groups) is a gap too', async () => { + ascEnv(); + current = { ext: [], main: [managed('com.example.notes', {})] }; + const p = project(cfg, files); + mockFetch(api(current)); + const r = await capture(() => signing(p.ctx(['status', '--json']))); + const j = JSON.parse(r.out.slice(r.out.indexOf('{'))); + expect(j.appGroupGaps.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes']); + expect(j.appGroupUnproven.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes.ShareExt']); + }); + + test('apple build stops before the archive on a proven gap, builds on --allow-app-group-gap, and only warns on an unproven one', async () => { + ascEnv(); + const { run: build } = await import('../cli/apple/build'); + const argv = ['--build-number', '5', '--no-prebuild', '--dry']; + // dry: warns (never throws) + current = { ext: [managed('com.example.notes.ShareExt', {})] }; + let p = project(cfg, files); + mockFetch(api(current)); + let r = await capture(() => build(p.ctx(argv))); + expect(r.err).toContain('the newest profile lacks App Group(s)'); + // not dry: early exit, before any xcodebuild step + p = project(cfg, files); + mockFetch(api(current)); + r = await capture(() => build(p.ctx(['--build-number', '5', '--no-prebuild']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + expect(r.exit).toBe(1); + expect(r.err).toContain('stopped before the archive'); + expect(r.err).toContain('console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(r.err).toContain('--allow-app-group-gap'); + // unproven: a warning with the same steps, no throw at the group check + current = { ext: [] }; + p = project(cfg, files); + mockFetch(api(current)); + r = await capture(() => build(p.ctx(argv))); + expect(r.err).toContain('cannot be proven'); + }); +}); + +describe('console plan / verify apple-app-group', () => { + afterEach(() => { restore(); }); + + test('the plan: deep link per resource id, the portal labels, the answer sheet per extension, never-press list, Save is the agent\'s', () => { + const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], resourceId: 'EXT0001' }] }); + expect(p).toMatchObject({ form: 'apple-app-group', store: 'apple', fillable: true }); + expect(p.start).toEqual({ url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001', deepLink: true, reach: [] }); + const text = p.steps.map(s => s.text).join('\n'); + for (const label of ['App Groups', 'Configure', 'Continue', 'Save', 'Confirm']) expect(text).toContain(label); + expect(p.answers.map(a => a.id)).toEqual(['com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); + expect(p.answers[1]).toMatchObject({ label: 'group.com.example.notes', action: 'tick', control: 'checkbox', status: 'certain' }); + const inSteps = new Set(p.steps.flatMap(s => s.answers ?? [])); + for (const a of p.answers) expect(inSteps.has(a.id)).toBe(true); + for (const b of ['Remove', 'Delete', 'Revoke', 'Publish', 'Submit']) expect(p.buttons.neverPress).toContain(b); + expect(p.buttons.neverPress).not.toContain('Save'); + expect(p.buttons).toMatchObject({ final: 'Save', finalByAgent: true }); + for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); + expect(p.ownerBatch).toEqual([]); + expect(p.verify.command).toBe('storebound console verify apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(planText(p)).toContain('the agent\'s own, not a store submission'); + p.steps.forEach((s, i) => expect(s.n).toBe(i + 1)); + }); + + test('without a resource id the plan starts at the Identifiers list and says --lookup; without targets the group ids are asked', () => { + const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'] }] }); + expect(p.start).toMatchObject({ url: 'https://developer.apple.com/account/resources/identifiers/list', deepLink: false }); + expect(p.start.reach.join(' ')).toContain('--lookup'); + const none = consolePlan('apple-app-group', BASE); + expect(none.answers[0]).toMatchObject({ status: 'ask-owner', value: null }); + expect(none.askOwner).toHaveLength(1); + }); + + test('the plan is in the form list and the MCP tools, and the plan command works from the CLI', async () => { + expect(PLAN_FORM_IDS).toContain('apple-app-group'); + expect(CONSOLE_PLAN_FORMS).toContain('apple-app-group'); + const t = TOOLS.find(x => x.name === 'console_plan')!; + expect(validateInput(t, { form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', lookup: true })).toEqual([]); + expect(t.args!({ form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', groups: 'group.com.example.notes' })).toEqual(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--groups', 'group.com.example.notes', '--json']); + const { run: plan } = await import('../cli/console/plan'); + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes' } }, files); + const r = await capture(() => plan(p.ctx(['apple-app-group', '--json']))); + const j = JSON.parse(r.out); + expect(j.answers.map((a: { id: string }) => a.id)).toEqual(['com.example.notes/capability', 'com.example.notes/group.com.example.notes', 'com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); + const one = await capture(() => plan(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--json']))); + expect(JSON.parse(one.out).answers).toHaveLength(2); + }); + + test('verify reads the API: present groups match, a missing one mismatches, an unprovable one fails', async () => { + const targets = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes', 'group.com.example.other'] }]; + const ok = verifyAppGroupsApi(targets, { gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); + expect([ok.ok, ok.matched, ok.mismatches]).toEqual([true, 2, 0]); + const gap = verifyAppGroupsApi(targets, { gaps: [{ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.other'], url: null, why: 'the newest profile lacks it' }], unproven: [], ok: [] }); + expect([gap.ok, gap.matched, gap.mismatches]).toEqual([false, 1, 1]); + expect(gap.rows.find(r => r.status === 'mismatch')!.item).toContain('group.com.example.other'); + const un = verifyAppGroupsApi(targets, { gaps: [], unproven: [{ bundleId: 'com.example.notes.ShareExt', resourceId: null, groups: ['group.com.example.notes', 'group.com.example.other'], url: null }], ok: [] }); + expect(un.ok).toBe(false); + expect(un.rows[0]!.note).toContain('signing ensure --refresh-profile'); + expect(() => verifyText('apple-app-group', BASE, 'x')).toThrow('reads the App Store Connect API'); + }); + + test('console verify apple-app-group end to end with a fake API: missing then present after the refreshed profile', async () => { + ascEnv(); + const { run: verify } = await import('../cli/console/verify'); + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + const prof = (ent: Record) => ({ type: 'profiles', id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: 'u', expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + const run = async (ent: Record) => { + mockFetch([ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: page([{ type: 'bundleIds', id: 'EXT0001', attributes: { identifier: 'com.example.notes.ShareExt', platform: 'IOS' }, relationships: { bundleIdCapabilities: { data: [{ type: 'bundleIdCapabilities', id: 'c1' }] } } }], [{ type: 'bundleIdCapabilities', id: 'c1', attributes: { capabilityType: 'APP_GROUPS', settings: null } }]) }, + { match: /^\/v1\/bundleIds\/EXT0001\/profiles/, answer: page([prof(ent)]) }, + ] as never); + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); + return capture(() => verify(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt']))); + }; + const before = await run({}); + expect(before.exit).toBe(1); + expect(before.out).toContain('mismatch'); + restore(); ascEnv(); + const after = await run({ 'com.apple.security.application-groups': ['group.com.example.notes'] }); + expect(after.exit).toBeNull(); + expect(after.out).toContain('match'); + }); +}); + describe('apple build over SSH: the keychain fixes', () => { test('the refusal names the GUI session, the CI runner and the keychain flags', () => { const m = sshBuildRefusal('this is an SSH session');