credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry
This commit is contained in:
parent
304407e523
commit
2c5e30090f
27 changed files with 1145 additions and 43 deletions
|
|
@ -40,6 +40,39 @@ import { creativeNote } from './shots/creative';
|
|||
import { creativeWaiting, localCreative } from './apple/creative-assets';
|
||||
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
|
||||
import { listingFileMentions } from './lib/listing-mentions';
|
||||
import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker';
|
||||
import { playCredentials } from './lib/google-auth';
|
||||
import { usesPlayGames } from './lib/game-services';
|
||||
|
||||
/** What the config's Play work needs from the service account: uploads and the listing always, products,
|
||||
* declarations and Play Games when the config has them. */
|
||||
export function playNeedsOf(config: Loaded['config']): PlayNeed[] {
|
||||
const needs: PlayNeed[] = ['upload', 'listing'];
|
||||
if ((config.products ?? []).some(p => sellsOn(config, p, 'play')) || allSubscriptions(config.subscriptions).some(x => sellsOn(config, x.sub, 'play'))) needs.push('iap');
|
||||
if (config.dataSafety) needs.push('declarations');
|
||||
if (usesPlayGames(config)) needs.push('games');
|
||||
return needs;
|
||||
}
|
||||
|
||||
/** With a credential broker: the Play permissions and enabled APIs it lists for the package, the gaps for what the
|
||||
* config needs (blockers: the commands would get a 403), and SDK config drift. Nothing without a broker. */
|
||||
export async function brokerFindings(loaded: Loaded): Promise<Finding[]> {
|
||||
const { config, root } = loaded;
|
||||
const pkg = config.play?.packageName;
|
||||
if (!pkg) return [];
|
||||
let cfg;
|
||||
try { cfg = brokerConfig(root); } catch (e) { return [{ store: 'play', level: 'warn', code: 'broker-config', msg: (e as Error).message, fix: '~/.storebound/broker.json: { "url", "apiKeyFile" }' }]; }
|
||||
if (!cfg) return [];
|
||||
const r = await brokerPlayInfo(cfg, pkg);
|
||||
if (r.kind === 'not-registered') return [{ store: 'play', level: 'warn', code: 'broker-not-registered', msg: r.message, fix: 'register the package on the credential broker (google_play_set)' }];
|
||||
if (r.kind !== 'ok') return [{ store: 'play', level: 'warn', code: 'broker-unavailable', msg: `${r.message}: permission checks skipped`, fix: 'check the credential broker url and API key (~/.storebound/broker.json)' }];
|
||||
const out: Finding[] = [];
|
||||
const grants = grantsOf(r.info), apis = enabledApisOf(r.info);
|
||||
out.push({ store: 'play', level: 'ok', code: 'broker-access', msg: `credential broker: ${r.info.account ?? 'service account'} on ${pkg}: Play permissions ${grants ? (grants.join(', ') || 'none') : 'not listed'}; enabled APIs ${apis ? (apis.join(', ') || 'none') : 'not listed'}` });
|
||||
for (const g of playGaps(r.info, playNeedsOf(config))) out.push({ store: 'play', level: 'blocker', code: g.kind === 'grant' ? 'broker-grant' : 'broker-api', msg: g.message, fix: g.fix, url: g.url });
|
||||
for (const m of sdkConfigGaps(r.info, root)) out.push({ store: 'play', level: 'warn', code: 'sdk-config', msg: m });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
|
||||
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
|
||||
|
|
@ -231,8 +264,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' });
|
||||
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
|
||||
if ((both || a.has('play')) && config.play) {
|
||||
// the broker's grants first: a missing permission is named before the edit reads hit the same 403
|
||||
const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]);
|
||||
findings.push(...access);
|
||||
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
|
||||
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
|
||||
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); }
|
||||
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
||||
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
|
||||
if (config.games || config.apple) {
|
||||
|
|
@ -259,7 +295,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' })));
|
||||
}
|
||||
const blockers = findings.filter(f => f.level === 'blocker').length;
|
||||
if (a.has('json')) printJson({ blockers, findings });
|
||||
// where Play tokens come from (MCP validate reads it): "broker" or "file", never a value
|
||||
const credentials = config.play ? { play: { source: playCredentials(ctx.loaded.root).source } } : {};
|
||||
if (a.has('json')) printJson({ blockers, findings, credentials });
|
||||
else {
|
||||
console.log(formatFindings(findings));
|
||||
log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue