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:
CRTkafa 2026-10-25 22:50:52 +07:00
parent 304407e523
commit 2c5e30090f
27 changed files with 1145 additions and 43 deletions

View file

@ -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)');