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
This commit is contained in:
parent
d978bb7a8b
commit
304407e523
20 changed files with 590 additions and 60 deletions
|
|
@ -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;
|
||||
|
|
|
|||
84
cli/lib/console-apple-group-plan.ts
Normal file
84
cli/lib/console-apple-group-plan.ts
Normal file
|
|
@ -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<PlanStep, 'n'>): 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 <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/<resource id>)'] };
|
||||
|
||||
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/<resource id> (`console plan apple-app-group --bundle-id <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'],
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -534,7 +534,7 @@ function publishChanges(config: ShipConfig, p: PlanIds): Part {
|
|||
};
|
||||
}
|
||||
|
||||
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, p: PlanIds): Part {
|
||||
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, config: ShipConfig, p: PlanIds): Part {
|
||||
switch (form) {
|
||||
case 'play-production-countries': return productionCountries(config, p);
|
||||
case 'play-production-release': return productionRelease(config, p);
|
||||
|
|
|
|||
|
|
@ -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<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, text: string): VerifyResult {
|
||||
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, 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)');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,17 +110,20 @@ export function appGroupGaps(want: string[], profileEnt: Record<string, unknown>
|
|||
/** 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<string, unknown> | 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue