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
|
|
@ -22,6 +22,7 @@ import { resolve } from 'node:path';
|
|||
import type { Args } from './lib/args';
|
||||
import { applyImport, describeProfiles, planImport, setPlayDeveloperId } from './lib/accounts';
|
||||
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
|
||||
import { brokerConfig } from './lib/broker';
|
||||
import { confirm, type Ctx } from './lib/ctx';
|
||||
import { textTable } from './lib/table';
|
||||
import { printJson } from './lib/log';
|
||||
|
|
@ -42,9 +43,10 @@ export async function run(args: Args): Promise<void> {
|
|||
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
|
||||
return;
|
||||
}
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
|
||||
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play via', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playSource === 'broker' ? 'credential broker' : r.playSource === 'file' ? 'key file' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
|
||||
if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default <profile>)`);
|
||||
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
|
||||
try { const b = brokerConfig(); if (b) console.log(`credential broker: ${b.url} (${b.from === 'env' ? 'STOREBOUND_BROKER_URL' : tilde(b.from)}); a profile with "broker": false keeps its key file`); } catch (e) { console.log(`credential broker: not usable: ${(e as Error).message}`); }
|
||||
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default <profile>, or accounts use <profile> in the project)');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,13 +354,12 @@ export async function authFor(store: Store, root: string): Promise<Auth> {
|
|||
const { jwt } = ascJwt(root);
|
||||
return () => 'Bearer ' + jwt();
|
||||
}
|
||||
const { playServiceAccount } = await import('../lib/credentials');
|
||||
const { tokenSource } = await import('../lib/google-auth');
|
||||
const sa = playServiceAccount(root);
|
||||
const sources = new Map<string, () => Promise<string>>();
|
||||
const { PLAY_SCOPE, playToken } = await import('../lib/google-auth');
|
||||
// the credential broker when it has the scope, else the local key; throws now when neither is there
|
||||
const sources = new Map<string, () => Promise<string>>([[PLAY_SCOPE, playToken(root, PLAY_SCOPE)]]);
|
||||
return async req => {
|
||||
const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope;
|
||||
if (!sources.has(scope)) sources.set(scope, tokenSource(sa, scope));
|
||||
if (!sources.has(scope)) sources.set(scope, playToken(root, scope));
|
||||
return 'Bearer ' + (await sources.get(scope)!());
|
||||
};
|
||||
}
|
||||
|
|
|
|||
75
cli/apps.ts
75
cli/apps.ts
|
|
@ -14,9 +14,10 @@
|
|||
// desktop shows it read-only ("no local project");
|
||||
// apps remove apple:<id> | <entry id> takes it out
|
||||
import type { Args } from './lib/args';
|
||||
import { ascKey, playServiceAccount } from './lib/credentials';
|
||||
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
||||
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
|
||||
import { ascKey } from './lib/credentials';
|
||||
import type { AccountCheck, AppsListResult, BrokerGrant } from './lib/desktop-protocol';
|
||||
import { PLAY_SCOPE, playCredentials, playToken, REPORTING_SCOPE } from './lib/google-auth';
|
||||
import { brokerConfig, brokerPlayInfo, BrokerRefused, BrokerUnavailable, BrokerUnknownAccount, enabledApisOf, grantsOf, type PlayNeed, playGaps } from './lib/broker';
|
||||
import { errorBody } from './lib/http';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
||||
|
|
@ -39,8 +40,8 @@ function stores(args: Args, root: string): Store[] {
|
|||
// every store whose credentials are set (env, or .storebound/credentials.json in this folder)
|
||||
const out: Store[] = [];
|
||||
try { ascKey(root); out.push('apple'); } catch { /* not set */ }
|
||||
try { playServiceAccount(root); out.push('play'); } catch { /* not set */ }
|
||||
if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json)');
|
||||
if (playCredentials(root).source) out.push('play');
|
||||
if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), or a credential broker (~/.storebound/broker.json)');
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ function googleGet(token: () => Promise<string>, base: string): Get {
|
|||
|
||||
/** Play icon of one package: an edit is opened, the default language's icon listed, and the edit deleted (never committed). */
|
||||
function playIcon(root: string): (pkg: string) => Promise<string | null> {
|
||||
const token = tokenSource(playServiceAccount(root), PLAY_SCOPE);
|
||||
const token = playToken(root, PLAY_SCOPE);
|
||||
const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/';
|
||||
const call = async (method: string, url: string) => {
|
||||
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()) } });
|
||||
|
|
@ -96,7 +97,7 @@ function playIcon(root: string): (pkg: string) => Promise<string | null> {
|
|||
};
|
||||
}
|
||||
|
||||
const principalOf = (root: string): string | null => { try { return playServiceAccount(root).client_email; } catch { return null; } };
|
||||
const principalOf = (root: string): string | null => playCredentials(root).principal;
|
||||
|
||||
export async function listApps(store: Store, root: string, icons: boolean): Promise<AppsListResult> {
|
||||
const at = new Date().toISOString();
|
||||
|
|
@ -104,15 +105,15 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom
|
|||
const r = await listAppleApps(await appleGet(root), { icons, bytes });
|
||||
return { store: 'ios', ...r, at };
|
||||
}
|
||||
const sa = playServiceAccount(root);
|
||||
const token = playToken(root, REPORTING_SCOPE);
|
||||
try {
|
||||
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
const r = await listPlayApps(googleGet(token, REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
||||
return { store: 'android', ...r, at };
|
||||
} catch (e) {
|
||||
// the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the
|
||||
// Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list
|
||||
if (!isServiceDisabled(e)) throw e;
|
||||
const c = playCheck(sa.client_email, { error: e });
|
||||
const c = playCheck(token.principal(), { error: e });
|
||||
const known = knownPlayPackages(root);
|
||||
return { store: 'android', apps: localPlayApps(known), at,
|
||||
warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] };
|
||||
|
|
@ -152,12 +153,56 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
|
|||
} catch (e) { return appleCheck({ error: e }); }
|
||||
}
|
||||
let principal: string | null = null;
|
||||
let source: AccountCheck['source'] = null;
|
||||
try {
|
||||
const sa = playServiceAccount(root);
|
||||
principal = sa.client_email;
|
||||
const page = await googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API)('/apps:search?pageSize=1000');
|
||||
return playCheck(principal, { apps: (page.apps ?? []).length });
|
||||
} catch (e) { return playCheck(principal, { error: e }); }
|
||||
const creds = playCredentials(root);
|
||||
principal = creds.principal;
|
||||
source = creds.source;
|
||||
if (creds.broker?.error) throw new BrokerRefused(creds.broker.error, 0);
|
||||
if (source === 'broker') {
|
||||
// proves the broker: one androidpublisher token (it may fall back to the local key, then source says "file")
|
||||
const t = playToken(root, PLAY_SCOPE);
|
||||
await t();
|
||||
source = t.source();
|
||||
principal = t.principal() ?? principal;
|
||||
}
|
||||
let apps: number | null = null;
|
||||
let reporting: ReturnType<typeof playToken> | null = null;
|
||||
try { reporting = playToken(root, REPORTING_SCOPE); } catch (e) { if (source !== 'broker') throw e; }
|
||||
if (reporting) apps = ((await googleGet(reporting, REPORTING_API)('/apps:search?pageSize=1000')).apps ?? []).length;
|
||||
const c = apps === null
|
||||
? { store: 'android' as const, status: 'ok' as const, principal, apps: null, message: `The credential broker hands out Play tokens for ${principal ?? 'the service account'} (the app count needs the Reporting API scope, which the broker does not hand out).`, fix: null, docs: null }
|
||||
: playCheck(principal, { apps });
|
||||
return { ...c, source, ...(await brokerGrants(root, principal)) };
|
||||
} catch (e) {
|
||||
if (e instanceof BrokerRefused || e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)
|
||||
return { store: 'android', status: 'error', principal, apps: null, source, message: (e as Error).message, fix: 'Check the credential broker: ~/.storebound/broker.json (url, apiKeyFile) and the scopes of the API key (google:read, google:token).', docs: null };
|
||||
return { ...playCheck(principal, { error: e }), source };
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_NEEDS: PlayNeed[] = ['upload', 'listing', 'iap', 'declarations', 'reviews'];
|
||||
|
||||
/** With a credential broker: the Play permissions and enabled APIs it lists for the Play apps this machine knows (at
|
||||
* most 25, 4 reads at a time), for the account check (CLI and the desktop's Accounts view). */
|
||||
async function brokerGrants(root: string, principal: string | null): Promise<{ grants?: BrokerGrant[] }> {
|
||||
let cfg;
|
||||
try { cfg = brokerConfig(root); } catch { return {}; }
|
||||
if (!cfg) return {};
|
||||
const pkgs = knownPlayPackages(root).slice(0, 25).map(k => k.packageName);
|
||||
const out: (BrokerGrant | null)[] = new Array(pkgs.length).fill(null);
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(4, pkgs.length) }, async () => {
|
||||
while (next < pkgs.length) {
|
||||
const i = next++, pkg = pkgs[i]!;
|
||||
const r = await brokerPlayInfo(cfg, pkg);
|
||||
if (r.kind === 'ok') {
|
||||
if (principal && r.info.account && r.info.account !== principal) continue;
|
||||
out[i] = { packageName: pkg, registered: true, grants: grantsOf(r.info), enabledApis: enabledApisOf(r.info), missing: playGaps(r.info, ALL_NEEDS).map(g => g.name) };
|
||||
} else if (r.kind === 'not-registered') out[i] = { packageName: pkg, registered: false, grants: null, enabledApis: null, missing: [] };
|
||||
}
|
||||
}));
|
||||
return { grants: out.filter((g): g is BrokerGrant => !!g) };
|
||||
}
|
||||
|
||||
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { join, resolve } from 'node:path';
|
|||
import pkg from '../package.json' with { type: 'json' };
|
||||
import type { Args } from './lib/args';
|
||||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { credentialStatus } from './lib/credentials';
|
||||
import { credentialStatus, maskUser } from './lib/credentials';
|
||||
import { type PlayCredentials, playCredentials } from './lib/google-auth';
|
||||
import { log, printJson } from './lib/log';
|
||||
import { textTable } from './lib/table';
|
||||
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
|
||||
|
|
@ -38,6 +39,9 @@ export interface DoctorResult {
|
|||
/** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or
|
||||
* placeholder (a template value, treated as missing). Never the values. */
|
||||
credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[];
|
||||
/** Where Play tokens come from: the credential broker (~/.storebound/broker.json) or the local key file; the
|
||||
* e-mail masked, never a key. */
|
||||
play: PlayCredentials;
|
||||
/** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */
|
||||
foreignRsync: string | null;
|
||||
}
|
||||
|
|
@ -50,10 +54,24 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
|
|||
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
|
||||
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
|
||||
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd),
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), play: maskedPlay(cwd),
|
||||
foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null };
|
||||
}
|
||||
|
||||
function maskedPlay(cwd: string): PlayCredentials {
|
||||
const p = playCredentials(cwd);
|
||||
return { ...p, principal: p.principal ? maskUser(p.principal) : null };
|
||||
}
|
||||
|
||||
/** The one line about Play access: which source, and the broker's state. */
|
||||
export function playLine(p: PlayCredentials): string {
|
||||
const who = p.principal ? ` for ${p.principal}` : '';
|
||||
if (p.source === 'broker') return `Play: tokens from the credential broker ${p.broker!.url}${who}${p.local ? ' (local key as fallback)' : ''}`;
|
||||
const broken = p.broker?.error ? ` · credential broker not usable: ${p.broker.error}` : '';
|
||||
if (p.source === 'file') return `Play: local service account key${who}${broken}`;
|
||||
return `Play: no service account key and no credential broker${broken}`;
|
||||
}
|
||||
|
||||
/** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */
|
||||
export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } {
|
||||
const tool = r.tools.find(t => t.id === id);
|
||||
|
|
@ -95,6 +113,7 @@ export async function run(args: Args): Promise<void> {
|
|||
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
|
||||
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
|
||||
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
|
||||
log('doctor', playLine(r.play));
|
||||
if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`);
|
||||
const missing = r.tools.filter(t => !t.found || t.status === 'outdated');
|
||||
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }
|
||||
|
|
|
|||
13
cli/index.ts
13
cli/index.ts
|
|
@ -105,6 +105,7 @@ async function main(): Promise<void> {
|
|||
const loaded = await loadConfig(args.get('config'));
|
||||
const { acquireLock, lockKeys } = await import('./lib/lock');
|
||||
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
|
||||
await playPreflight(key, loaded, args);
|
||||
await (await COMMANDS[key]![0]()).run({ loaded, args });
|
||||
};
|
||||
await (args.has('json') ? withJsonOutput(go) : go());
|
||||
|
|
@ -119,8 +120,20 @@ async function main(): Promise<void> {
|
|||
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
|
||||
const { acquireLock, lockKeys } = await import('./lib/lock');
|
||||
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
|
||||
await playPreflight(key, loaded, args);
|
||||
await (await COMMANDS[key]![0]()).run({ loaded, args });
|
||||
});
|
||||
}
|
||||
|
||||
/** Play commands with a credential broker: the package's grants and APIs are read first, and a missing permission or a
|
||||
* disabled API stops the command before any Play call (a dry run or a read only warns). cli/lib/broker.ts. */
|
||||
async function playPreflight(key: string, loaded: { root: string; config: { play?: { packageName: string } } }, args: ReturnType<typeof parseArgs>): Promise<void> {
|
||||
if (!key.startsWith('play ')) return;
|
||||
const b = await import('./lib/broker');
|
||||
if (!b.COMMAND_NEEDS[key]) return;
|
||||
const { warn } = await import('./lib/log');
|
||||
const read = args.has('dry') || ['status', 'pull'].includes(String(args._[0] ?? ''));
|
||||
await b.playPreflight(key, loaded.root, loaded.config.play?.packageName, { dry: read, warn: m => warn('preflight', m) });
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSyn
|
|||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials';
|
||||
import { brokerConfig, profileBrokerOff } from './broker';
|
||||
|
||||
export type Kind = 'apple' | 'play';
|
||||
/** The store keys that move to a profile (one developer account's), never the app's own ones. */
|
||||
|
|
@ -36,12 +37,19 @@ export function profileIdentity(name: string): Identity {
|
|||
}
|
||||
|
||||
/** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */
|
||||
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; error?: string }[] {
|
||||
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null;
|
||||
/** Where Play tokens come from: the credential broker (~/.storebound/broker.json), the profile's key file, or none. */
|
||||
playSource: 'broker' | 'file' | null; error?: string }[] {
|
||||
let broker = false;
|
||||
try { broker = !!brokerConfig(); } catch { /* a broken broker.json: doctor reports it */ }
|
||||
return profileNames().map(profile => {
|
||||
try {
|
||||
const id = profileIdentity(profile);
|
||||
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId };
|
||||
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; }
|
||||
const email = id.playEmail ?? profileEnv(profile).PLAY_SERVICE_ACCOUNT_EMAIL ?? null;
|
||||
const viaBroker = broker && !profileBrokerOff(profile) && !!email;
|
||||
const playSource = viaBroker ? 'broker' as const : id.playEmail ? 'file' as const : null;
|
||||
return { profile, apple: !!id.appleKeyId, play: !!playSource, playAccount: email && playSource ? maskUser(email) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId, playSource };
|
||||
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, playSource: null, error: (e as Error).message }; }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
327
cli/lib/broker.ts
Normal file
327
cli/lib/broker.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// Credential broker: a server of the owner's that holds the Google service-account keys, the Cloud project facts and
|
||||
// the Play permission grants, so this machine needs no Google key of its own. storebound asks it for short-lived
|
||||
// Google access tokens (POST /api/google/token) and for the Play facts of one package (GET
|
||||
// /api/google/play/<package>/storebound: the service account's Play permissions, the Cloud projects' enabled APIs, the
|
||||
// SDK config files). docs/credential-broker.md.
|
||||
//
|
||||
// Config: ~/.storebound/broker.json (file: env STOREBOUND_BROKER_CONFIG)
|
||||
// { "url": "https://broker.example.com", "apiKeyFile": "~/.storebound/broker.key", "account"?: "<service account e-mail>" }
|
||||
// env STOREBOUND_BROKER_URL / STOREBOUND_BROKER_KEY_FILE override the file. Which Google account to mint for:
|
||||
// PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile, .storebound/credentials.json), else "account" in broker.json,
|
||||
// else the client_email of a local key, else the account the broker names for the package. Off: env
|
||||
// STOREBOUND_BROKER=off, or "broker": false in a credential profile (that profile keeps its local key).
|
||||
// The key file holds one scoped API key (google:read google:token); on macOS / Linux it must be mode 600 (a group or
|
||||
// world readable file is refused). Tokens live in memory only, until 5 minutes before they expire, one request per
|
||||
// account + scope at a time; they are never written or logged, and every error text has ya29. tokens and the API key
|
||||
// redacted.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { activeProfile, credentialValue, PROFILE_RE, profileDir } from './credentials';
|
||||
import { enableUrl, PLAY_USERS_URL } from './google-errors';
|
||||
|
||||
export type BrokerScope = 'androidpublisher' | 'cloud-platform.read-only' | 'firebase.readonly';
|
||||
export const BROKER_SCOPES: readonly BrokerScope[] = ['androidpublisher', 'cloud-platform.read-only', 'firebase.readonly'];
|
||||
|
||||
export interface BrokerConfig {
|
||||
url: string;
|
||||
keyFile: string;
|
||||
/** The Google account (service account e-mail) to mint tokens for, when configured. */
|
||||
account: string | null;
|
||||
/** Where the URL came from: "env" or the broker.json path. */
|
||||
from: string;
|
||||
}
|
||||
|
||||
/** The broker did not answer (network error, timeout, 5xx): callers fall back to a local key when there is one. */
|
||||
export class BrokerUnavailable extends Error {}
|
||||
/** The broker refused this machine's API key (401 / 403): no fallback, no retry; the key needs its scopes. */
|
||||
export class BrokerRefused extends Error { constructor(msg: string, readonly status: number) { super(msg); } }
|
||||
/** The broker has no such Google account (400 / 404 on a token request): a local key may still serve it. */
|
||||
export class BrokerUnknownAccount extends Error {}
|
||||
|
||||
const OFF = /^(off|0|false|no)$/i;
|
||||
export const brokerConfigFile = (): string => process.env.STOREBOUND_BROKER_CONFIG || join(homedir(), '.storebound', 'broker.json');
|
||||
const expand = (p: string, base: string): string => { const x = p.replace(/^~(?=$|[/\\])/, homedir()); return isAbsolute(x) ? x : resolve(base, x); };
|
||||
|
||||
/** A profile that says "broker": false keeps its local key (also env STOREBOUND_PROFILE_<NAME>_BROKER=off). */
|
||||
export function profileBrokerOff(name: string): boolean {
|
||||
if (!PROFILE_RE.test(name)) return false;
|
||||
if (OFF.test(process.env[`STOREBOUND_PROFILE_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BROKER`] ?? '')) return true;
|
||||
try {
|
||||
const f = join(profileDir(), `${name}.json`);
|
||||
if (!existsSync(f)) return false;
|
||||
const v = (JSON.parse(readFileSync(f, 'utf8')) as Record<string, unknown>).broker;
|
||||
return v === false || (typeof v === 'string' && OFF.test(v));
|
||||
} catch { return false; }
|
||||
}
|
||||
function profileOptsOut(root: string): boolean {
|
||||
try { const p = activeProfile(root); return !!p && profileBrokerOff(p.name); } catch { return false; }
|
||||
}
|
||||
|
||||
/** Only https, or http to this machine (a local test broker): the API key never travels in clear text. */
|
||||
export function checkBrokerUrl(raw: string): string {
|
||||
let u: URL;
|
||||
try { u = new URL(raw); } catch { throw new Error(`credential broker url "${raw}" is not a URL (https://broker.example.com)`); }
|
||||
const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
||||
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`credential broker url ${u.origin}: https only (http only for localhost)`);
|
||||
return u.origin + u.pathname.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker config for a project (null: not configured, or turned off for it). Throws on an unreadable broker.json or
|
||||
* a bad URL: a broken config is reported, never silently skipped.
|
||||
*/
|
||||
export function brokerConfig(root?: string): BrokerConfig | null {
|
||||
if (OFF.test(process.env.STOREBOUND_BROKER ?? '')) return null;
|
||||
if (root && profileOptsOut(root)) return null;
|
||||
const f = brokerConfigFile();
|
||||
let file: { url?: unknown; apiKeyFile?: unknown; account?: unknown } = {};
|
||||
if (existsSync(f)) {
|
||||
try { file = JSON.parse(readFileSync(f, 'utf8')) as typeof file; } catch (e) { throw new Error(`${f}: ${(e as Error).message}`); }
|
||||
if (!file || typeof file !== 'object' || Array.isArray(file)) throw new Error(`${f}: an object { "url", "apiKeyFile" }`);
|
||||
}
|
||||
const envUrl = process.env.STOREBOUND_BROKER_URL;
|
||||
const raw = envUrl || (typeof file.url === 'string' ? file.url : '');
|
||||
if (!raw) return null;
|
||||
const keyRaw = process.env.STOREBOUND_BROKER_KEY_FILE || (typeof file.apiKeyFile === 'string' && file.apiKeyFile ? file.apiKeyFile : join(dirname(f), 'broker.key'));
|
||||
let account: string | null = null;
|
||||
try { account = (root ? credentialValue(root, 'PLAY_SERVICE_ACCOUNT_EMAIL') : process.env.PLAY_SERVICE_ACCOUNT_EMAIL) || null; } catch { /* unreadable project file */ }
|
||||
if (!account && typeof file.account === 'string' && file.account) account = file.account;
|
||||
return { url: checkBrokerUrl(raw), keyFile: expand(keyRaw, dirname(f)), account, from: envUrl ? 'env' : f };
|
||||
}
|
||||
|
||||
/** The API key from the key file. Refuses a group / world readable file on POSIX. Never printed. */
|
||||
export function brokerKey(cfg: BrokerConfig, platform: NodeJS.Platform = process.platform): string {
|
||||
let mode: number;
|
||||
try { mode = statSync(cfg.keyFile).mode; } catch { throw new Error(`credential broker key file ${cfg.keyFile} not found: put the API key there (one line, chmod 600)`); }
|
||||
if (platform !== 'win32' && (mode & 0o077)) throw new Error(`refusing the credential broker key file ${cfg.keyFile}: it is readable by group or others (mode ${(mode & 0o777).toString(8)}); run chmod 600 ${cfg.keyFile}`);
|
||||
const k = readFileSync(cfg.keyFile, 'utf8').trim();
|
||||
if (!k || /\s/.test(k)) throw new Error(`credential broker key file ${cfg.keyFile}: one API key on one line`);
|
||||
return k;
|
||||
}
|
||||
|
||||
/** An error text without Google access tokens, bearer values or the given secrets. */
|
||||
export function redact(text: string, ...secrets: (string | null | undefined)[]): string {
|
||||
let s = String(text ?? '').replace(/ya29\.[A-Za-z0-9._~+/=-]+/g, 'ya29.***').replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1***');
|
||||
for (const x of secrets) if (x && x.length >= 6) s = s.split(x).join('***');
|
||||
return s;
|
||||
}
|
||||
|
||||
const host = (cfg: BrokerConfig): string => { try { return new URL(cfg.url).host; } catch { return cfg.url; } };
|
||||
export const brokerTimeoutMs = (): number => Number(process.env.STOREBOUND_BROKER_TIMEOUT_MS) || 15_000;
|
||||
|
||||
interface Answer { status: number; json: unknown; text: string }
|
||||
|
||||
async function request(cfg: BrokerConfig, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Answer> {
|
||||
const key = brokerKey(cfg);
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(cfg.url + path, {
|
||||
method, signal: AbortSignal.timeout(brokerTimeoutMs()),
|
||||
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (e) { throw new BrokerUnavailable(`credential broker ${host(cfg)} did not answer: ${redact((e as Error).message, key)}`); }
|
||||
const raw = await r.text().catch(() => '');
|
||||
let json: unknown = null;
|
||||
try { json = raw ? JSON.parse(raw) : null; } catch { /* not JSON */ }
|
||||
const text = redact(raw, key).slice(0, 300);
|
||||
if (r.status >= 500) throw new BrokerUnavailable(`credential broker ${host(cfg)} -> ${r.status} ${text}`.trim());
|
||||
return { status: r.status, json, text };
|
||||
}
|
||||
|
||||
const errOf = (a: Answer): string => {
|
||||
const j = a.json as { error?: unknown; message?: unknown } | null;
|
||||
const m = j && typeof j === 'object' ? [j.error, j.message].filter(x => typeof x === 'string').join(': ') : '';
|
||||
return m || a.text;
|
||||
};
|
||||
|
||||
const tokens = new Map<string, { token: string; until: number }>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const infos = new Map<string, Promise<PlayInfoResult>>();
|
||||
|
||||
/** Forgets every cached token and package answer (tests; a long-running process after a key change). */
|
||||
export function clearBrokerCache(): void { tokens.clear(); inflight.clear(); infos.clear(); }
|
||||
|
||||
/**
|
||||
* A Google access token for `account` with one broker scope. Cached in memory until 5 minutes before `expiresAt`;
|
||||
* parallel callers share one request. 401 / 403 throw BrokerRefused (no retry), 400 / 404 BrokerUnknownAccount, no
|
||||
* answer BrokerUnavailable.
|
||||
*/
|
||||
export async function brokerToken(cfg: BrokerConfig, account: string, scope: BrokerScope, now: () => number = Date.now): Promise<string> {
|
||||
const k = `${cfg.url}\n${account}\n${scope}`;
|
||||
const hit = tokens.get(k);
|
||||
if (hit && now() < hit.until) return hit.token;
|
||||
const running = inflight.get(k);
|
||||
if (running) return running;
|
||||
const p = (async () => {
|
||||
const a = await request(cfg, 'POST', '/api/google/token', { account, scopes: [scope] });
|
||||
if (a.status === 401 || a.status === 403)
|
||||
throw new BrokerRefused(`the credential broker ${host(cfg)} refused the token request (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): its API key lacks the google:token scope or was revoked. Give the key google:read and google:token on the broker; storebound does not retry.`, a.status);
|
||||
if (a.status === 400 || a.status === 404)
|
||||
throw new BrokerUnknownAccount(`the credential broker ${host(cfg)} has no Google account ${account} for scope ${scope} (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): import its key on the broker, or set PLAY_SERVICE_ACCOUNT_EMAIL to an account it holds`);
|
||||
const j = a.json as { accessToken?: unknown; expiresAt?: unknown } | null;
|
||||
if (a.status !== 200 || typeof j?.accessToken !== 'string' || !j.accessToken || typeof j.expiresAt !== 'number')
|
||||
throw new Error(`credential broker ${host(cfg)} token -> ${a.status}: no accessToken / expiresAt in the answer`);
|
||||
const exp = j.expiresAt > 1e12 ? j.expiresAt : j.expiresAt * 1000;
|
||||
tokens.set(k, { token: j.accessToken, until: exp - 300_000 });
|
||||
return j.accessToken;
|
||||
})().finally(() => inflight.delete(k));
|
||||
inflight.set(k, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface BrokerPlayInfo {
|
||||
packageName: string;
|
||||
developerId?: string | null;
|
||||
account?: string | null;
|
||||
accountPermissions?: { developer?: string[]; app?: string[] } | null;
|
||||
grantsCheckedAt?: number | null;
|
||||
projects?: { projectId: string; projectNumber?: string; enabledApis?: string[]; billing?: string }[];
|
||||
sdkConfigs?: { platform: string; sha256?: string; projectId?: string; firebaseAppId?: string; bundle?: string; drift?: boolean }[];
|
||||
/** Optional fix links the broker may hand out. */
|
||||
grantUrl?: string;
|
||||
}
|
||||
|
||||
export type PlayInfoResult =
|
||||
| { kind: 'ok'; info: BrokerPlayInfo }
|
||||
/** 404: the package is not registered on the broker (not "no access"). */
|
||||
| { kind: 'not-registered'; message: string }
|
||||
| { kind: 'refused'; message: string }
|
||||
| { kind: 'unavailable'; message: string };
|
||||
|
||||
/** What the broker knows about one Play package (once per run and package). Never throws. */
|
||||
export function brokerPlayInfo(cfg: BrokerConfig, pkg: string): Promise<PlayInfoResult> {
|
||||
const k = `${cfg.url}\n${pkg}`;
|
||||
const cached = infos.get(k);
|
||||
if (cached) return cached;
|
||||
const p = (async (): Promise<PlayInfoResult> => {
|
||||
try {
|
||||
const a = await request(cfg, 'GET', `/api/google/play/${encodeURIComponent(pkg)}/storebound`);
|
||||
if (a.status === 404) return { kind: 'not-registered', message: `${pkg} is not registered on the credential broker ${host(cfg)}: register the package there (google_play_set) to get its permission checks; this is not a missing Play access` };
|
||||
if (a.status === 401 || a.status === 403) return { kind: 'refused', message: `the credential broker ${host(cfg)} refused the package read (${a.status}): its API key lacks the google:read scope` };
|
||||
const j = a.json as BrokerPlayInfo | null;
|
||||
if (a.status !== 200 || !j || typeof j !== 'object') return { kind: 'unavailable', message: `credential broker ${host(cfg)} -> ${a.status} ${a.text}`.trim() };
|
||||
return { kind: 'ok', info: { ...j, packageName: typeof j.packageName === 'string' ? j.packageName : pkg } };
|
||||
} catch (e) {
|
||||
if (!(e instanceof BrokerUnavailable)) infos.delete(k);
|
||||
return { kind: 'unavailable', message: (e as Error).message };
|
||||
}
|
||||
})();
|
||||
infos.set(k, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** The Play permission each kind of work needs (Play Console's app permission names, as the broker reports them). */
|
||||
export const PLAY_GRANTS = {
|
||||
upload: 'CAN_MANAGE_TRACK_APKS',
|
||||
listing: 'CAN_MANAGE_PUBLIC_LISTING',
|
||||
iap: 'CAN_MANAGE_ORDERS',
|
||||
declarations: 'CAN_MANAGE_APP_CONTENT',
|
||||
reviews: 'CAN_REPLY_TO_REVIEWS',
|
||||
} as const;
|
||||
export type PlayNeed = keyof typeof PLAY_GRANTS | 'games';
|
||||
const NEED_WORDS: Record<PlayNeed, string> = { upload: 'uploads and releases', listing: 'the store listing and images', iap: 'in-app products and subscriptions', declarations: 'Data safety and app content', reviews: 'review replies', games: 'Play Games Services configuration' };
|
||||
export const ANDROIDPUBLISHER_API = 'androidpublisher.googleapis.com';
|
||||
export const GAMES_CONFIG_SERVICE = 'gamesconfiguration.googleapis.com';
|
||||
|
||||
/** What each storebound command needs from Play (the preflight before it runs). */
|
||||
export const COMMAND_NEEDS: Record<string, PlayNeed[]> = {
|
||||
'play upload': ['upload'], 'play release': ['upload'], 'play rollout': ['upload'], 'play share': ['upload'],
|
||||
'play listing': ['listing'], 'play images': ['listing'], 'play products': ['iap'], 'play subscriptions': ['iap'],
|
||||
'play data-safety': ['declarations'], 'play games': ['games'],
|
||||
};
|
||||
|
||||
export interface AccessGap {
|
||||
kind: 'grant' | 'api';
|
||||
need: PlayNeed;
|
||||
/** The missing permission (CAN_MANAGE_TRACK_APKS) or API (gamesconfiguration.googleapis.com). */
|
||||
name: string;
|
||||
message: string;
|
||||
fix: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** The grants of a package as one list (developer-level permissions count for every app). */
|
||||
export const grantsOf = (info: BrokerPlayInfo): string[] | null => {
|
||||
const p = info.accountPermissions;
|
||||
if (!p || (!Array.isArray(p.app) && !Array.isArray(p.developer))) return null;
|
||||
return [...new Set([...(p.developer ?? []), ...(p.app ?? [])])].sort();
|
||||
};
|
||||
/** Every API the broker saw enabled in the package's Cloud projects; null when it reported none. */
|
||||
export const enabledApisOf = (info: BrokerPlayInfo): string[] | null => {
|
||||
const ps = (info.projects ?? []).filter(p => Array.isArray(p.enabledApis));
|
||||
return ps.length ? [...new Set(ps.flatMap(p => p.enabledApis!))].sort() : null;
|
||||
};
|
||||
|
||||
/** The missing Play permissions and disabled APIs for `needs`, per the broker's answer. Unknown facts are no gap. */
|
||||
export function playGaps(info: BrokerPlayInfo, needs: PlayNeed[]): AccessGap[] {
|
||||
const pkg = info.packageName, who = info.account ?? 'the service account';
|
||||
const gaps: AccessGap[] = [];
|
||||
const grants = grantsOf(info);
|
||||
const usersUrl = info.grantUrl ?? (info.developerId && /^\d+$/.test(info.developerId) ? `https://play.google.com/console/u/0/developers/${info.developerId}/users-and-permissions` : PLAY_USERS_URL);
|
||||
for (const need of new Set(needs)) {
|
||||
if (need === 'games' || !grants) continue;
|
||||
const perm = PLAY_GRANTS[need];
|
||||
if (grants.includes(perm)) continue;
|
||||
gaps.push({ kind: 'grant', need, name: perm, url: usersUrl,
|
||||
message: `${who} lacks the Play permission ${perm} for ${pkg} (needed for ${NEED_WORDS[need]})`,
|
||||
fix: `grant ${perm} for ${pkg} on the credential broker (google_play_grant), or in Play Console > Users and permissions > ${who} > App permissions: ${usersUrl}` });
|
||||
}
|
||||
const apis = enabledApisOf(info);
|
||||
if (apis) {
|
||||
const project = (info.projects ?? []).find(p => Array.isArray(p.enabledApis))?.projectId ?? null;
|
||||
const want = new Set<string>([ANDROIDPUBLISHER_API, ...(needs.includes('games') ? [GAMES_CONFIG_SERVICE] : [])]);
|
||||
for (const api of want) {
|
||||
if (apis.includes(api)) continue;
|
||||
const url = enableUrl(api, project);
|
||||
gaps.push({ kind: 'api', need: api === GAMES_CONFIG_SERVICE ? 'games' : needs[0] ?? 'upload', name: api, url,
|
||||
message: `${api} is not enabled in Google Cloud project ${project ?? '(the service account\'s project)'} (needed for ${api === GAMES_CONFIG_SERVICE ? NEED_WORDS.games : 'every Play API call'})`,
|
||||
fix: `enable ${api} on the credential broker (google_api_enable), or open ${url} and press Enable` });
|
||||
}
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
const SDK_FILES: Record<string, string[]> = {
|
||||
android: ['android/app/google-services.json', 'app/google-services.json', 'google-services.json', 'android/google-services.json'],
|
||||
ios: ['ios/App/App/GoogleService-Info.plist', 'ios/Runner/GoogleService-Info.plist', 'ios/GoogleService-Info.plist', 'GoogleService-Info.plist'],
|
||||
};
|
||||
|
||||
/** SDK config files the broker says drifted, or whose sha256 differs from the repo's copy. */
|
||||
export function sdkConfigGaps(info: BrokerPlayInfo, root: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const c of info.sdkConfigs ?? []) {
|
||||
const label = `${c.platform} SDK config${c.bundle ? ` (${c.bundle})` : ''}`;
|
||||
if (c.drift) out.push(`${label}: the credential broker reports drift from the Firebase project ${c.projectId ?? ''}; download it again on the broker`.replace(/ ;/, ';'));
|
||||
const file = (SDK_FILES[c.platform] ?? []).map(f => join(root, f)).find(f => existsSync(f));
|
||||
if (file && c.sha256 && /^[0-9a-f]{64}$/i.test(c.sha256)) {
|
||||
const sha = createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
if (sha.toLowerCase() !== c.sha256.toLowerCase()) out.push(`${label}: ${file} differs from the broker's copy (sha256 ${sha.slice(0, 12)}... vs ${c.sha256.slice(0, 12)}...): take the broker's file`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One error text for the gaps that stop a command. */
|
||||
export const gapsText = (gaps: AccessGap[]): string => gaps.map(g => `${g.message}. Fix: ${g.fix}`).join('\n');
|
||||
|
||||
/**
|
||||
* Before a Play command runs (cli/index.ts): with a broker, read the package's grants and APIs and stop early with the
|
||||
* exact missing permission / API and its link. A dry run only warns. No broker, an unreachable one, or a package the
|
||||
* broker does not know: a note, then the command runs (Google answers for itself).
|
||||
*/
|
||||
export async function playPreflight(command: string, root: string, pkg: string | undefined, o: { dry: boolean; warn: (msg: string) => void }): Promise<AccessGap[]> {
|
||||
const needs = COMMAND_NEEDS[command];
|
||||
if (!needs || !pkg) return [];
|
||||
let cfg: BrokerConfig | null;
|
||||
try { cfg = brokerConfig(root); } catch (e) { o.warn((e as Error).message); return []; }
|
||||
if (!cfg) return [];
|
||||
const r = await brokerPlayInfo(cfg, pkg);
|
||||
if (r.kind !== 'ok') { o.warn(r.message); return []; }
|
||||
const gaps = playGaps(r.info, needs);
|
||||
if (!gaps.length) return [];
|
||||
if (o.dry) { for (const g of gaps) o.warn(`${g.message}. Fix: ${g.fix}`); return gaps; }
|
||||
throw new Error(`${command} stopped before any call to Google Play:\n${gapsText(gaps)}`);
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
// 4. the default profile (none named): store keys only
|
||||
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account
|
||||
// the broker mints tokens for, and "broker": false keeps a profile on its local key.
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -44,7 +46,7 @@ function fileStore(root: string): Store {
|
|||
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
|
||||
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
|
||||
* for reports too; an app's Android upload keystore stays in the project. */
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const;
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL'] as const;
|
||||
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
|
||||
|
|
@ -158,7 +160,7 @@ export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean
|
|||
}
|
||||
|
||||
/** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */
|
||||
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
|
||||
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
|
||||
'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const;
|
||||
|
||||
/** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never
|
||||
|
|
@ -206,7 +208,7 @@ export function parseServiceAccount(text: string): ServiceAccount {
|
|||
|
||||
export function playServiceAccount(root: string): ServiceAccount {
|
||||
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
|
||||
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles');
|
||||
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key, or a credential broker in ~/.storebound/broker.json; `storebound accounts` lists the profiles');
|
||||
return parseServiceAccount(inline(v, /^\s*\{/, root));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -315,6 +315,22 @@ export interface AccountCheck {
|
|||
/** What to do, in words. */
|
||||
fix: string | null;
|
||||
docs: string | null;
|
||||
/** Play: where the tokens come from, the credential broker or the local key file (no values). */
|
||||
source?: 'broker' | 'file' | null;
|
||||
/** Play with a credential broker: the grants and APIs it lists per Play app of this machine. */
|
||||
grants?: BrokerGrant[];
|
||||
}
|
||||
|
||||
/** One Play app's access as the credential broker lists it (GET /api/google/play/<package>/storebound). */
|
||||
export interface BrokerGrant {
|
||||
packageName: string;
|
||||
/** false: the package is not registered on the broker (no data; not a missing access). */
|
||||
registered: boolean;
|
||||
/** Play permissions of the service account (developer-level ones included); null when the broker has none. */
|
||||
grants: string[] | null;
|
||||
enabledApis: string[] | null;
|
||||
/** Permissions / APIs storebound's commands need that are not there. */
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
34
cli/lib/google-access.ts
Normal file
34
cli/lib/google-access.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// A Google 401 / 403 that says what is missing (SERVICE_DISABLED, PERMISSION_DENIED, a refused key) becomes one
|
||||
// GoogleAccessError with the exact API or grant and the link to fix it. It is never retried: the same call fails the
|
||||
// same way until the owner (or the credential broker) changes something. With the broker as the token source the text
|
||||
// also names the Play permissions the broker lists for the package and the broker-side fix.
|
||||
import { brokerConfig, brokerPlayInfo, grantsOf, redact } from './broker';
|
||||
import { findingText, type GoogleFinding, parseGoogleError } from './google-errors';
|
||||
|
||||
export class GoogleAccessError extends Error {
|
||||
/** Never sent again: retry loops and the upsert layer check this. */
|
||||
readonly noRetry = true;
|
||||
constructor(msg: string, readonly finding: GoogleFinding) { super(msg); }
|
||||
}
|
||||
|
||||
/** The GoogleAccessError for a failed call's text, or null when the text is another error. */
|
||||
export async function accessError(text: string, o: { app?: string | null; root?: string; source?: 'broker' | 'file' } = {}): Promise<GoogleAccessError | null> {
|
||||
const f = parseGoogleError(text, { app: o.app ?? null });
|
||||
if (!f || f.kind === 'quota') return null;
|
||||
const lines = [redact(text), findingText(f, { withUrl: true })];
|
||||
if (o.source === 'broker') {
|
||||
if (f.kind === 'auth-revoked') lines.push('The token came from the credential broker: its key for this service account was revoked or deleted at Google; rotate the key on the broker.');
|
||||
if (f.kind === 'api-disabled') lines.push(`Or enable it on the credential broker (google_api_enable ${f.projectId ?? '<project>'} ${f.service}).`);
|
||||
if (f.kind === 'no-app-access' && o.root && o.app) {
|
||||
try {
|
||||
const cfg = brokerConfig(o.root);
|
||||
const r = cfg ? await brokerPlayInfo(cfg, o.app) : null;
|
||||
if (r?.kind === 'ok') {
|
||||
const g = grantsOf(r.info);
|
||||
lines.push(`The credential broker lists ${g ? (g.length ? g.join(', ') : 'no Play permissions') : 'no permission data'} for ${r.info.account ?? 'the service account'} on ${o.app}: grant the missing one there (google_play_grant).`);
|
||||
} else if (r) lines.push(r.message);
|
||||
} catch { /* the Google error stands on its own */ }
|
||||
}
|
||||
}
|
||||
return new GoogleAccessError(lines.join('\n'), f);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency.
|
||||
import type { ServiceAccount } from './credentials';
|
||||
import { playServiceAccount, type ServiceAccount } from './credentials';
|
||||
import { BROKER_SCOPES, type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, type BrokerScope, brokerToken, BrokerUnavailable, BrokerUnknownAccount, redact } from './broker';
|
||||
import { warn } from './log';
|
||||
|
||||
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
||||
/** Play Developer Reporting API (vitals): https://developers.google.com/play/developer/reporting/reference/rest */
|
||||
|
|
@ -42,3 +44,97 @@ export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promi
|
|||
return token;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Play access through a credential broker (cli/lib/broker.ts) or a local key ----
|
||||
|
||||
/** The broker scope a Google scope maps to; null: the broker hands out no such scope (a local key is needed). */
|
||||
export const BROKER_SCOPE_OF: Record<string, BrokerScope> = { [PLAY_SCOPE]: 'androidpublisher', [STORAGE_READ_SCOPE]: 'cloud-platform.read-only' };
|
||||
|
||||
export type PlaySource = 'broker' | 'file';
|
||||
|
||||
/** A Play access token getter that also says where its tokens come from (no values). */
|
||||
export interface PlayToken {
|
||||
(): Promise<string>;
|
||||
/** "broker" until the broker failed over to the local key (then "file"). */
|
||||
source(): PlaySource;
|
||||
/** The service account e-mail the tokens are for, when known. */
|
||||
principal(): string | null;
|
||||
}
|
||||
|
||||
const fellBack = new Set<string>();
|
||||
|
||||
/**
|
||||
* The Play token getter of a project: the credential broker when it is configured and has the scope (POST
|
||||
* /api/google/token), else the local service account key (env, profile, .storebound/credentials.json). The broker not
|
||||
* answering, or not knowing the account, falls back to the local key once with a warning; a broker that refuses the
|
||||
* API key (401 / 403) fails. `brokerScope: null` forces the local key (writes the broker has no scope for).
|
||||
* Throws at once when neither is there.
|
||||
*/
|
||||
export function playToken(root: string, scope = PLAY_SCOPE, o: { packageName?: string; brokerScope?: BrokerScope | null; warn?: (msg: string) => void } = {}): PlayToken {
|
||||
let sa: ServiceAccount | null = null, saErr: Error | null = null;
|
||||
try { sa = playServiceAccount(root); } catch (e) { saErr = e as Error; }
|
||||
const cfg = brokerConfig(root);
|
||||
const bscope = o.brokerScope === undefined ? BROKER_SCOPE_OF[scope] ?? null : o.brokerScope;
|
||||
const local = sa ? tokenSource(sa, scope) : null;
|
||||
if (!cfg || !bscope) {
|
||||
if (!local) {
|
||||
if (cfg && !bscope) throw new Error(`${scope.replace('https://www.googleapis.com/auth/', '')}: the credential broker hands out no such scope (${BROKER_SCOPES.join(', ')}), and there is no local service account key (PLAY_SERVICE_ACCOUNT) for it`);
|
||||
throw saErr!;
|
||||
}
|
||||
return Object.assign(() => local(), { source: (): PlaySource => 'file', principal: () => sa!.client_email });
|
||||
}
|
||||
let account = cfg.account ?? sa?.client_email ?? null;
|
||||
let mode: PlaySource = 'broker';
|
||||
const say = o.warn ?? ((m: string) => warn('credentials', m));
|
||||
const get = async (): Promise<string> => {
|
||||
if (mode === 'file') return local!();
|
||||
try {
|
||||
if (!account && o.packageName) {
|
||||
const r = await brokerPlayInfo(cfg, o.packageName);
|
||||
if (r.kind === 'unavailable') throw new BrokerUnavailable(r.message);
|
||||
if (r.kind === 'ok' && r.info.account) account = r.info.account;
|
||||
}
|
||||
if (!account) throw new Error('credential broker: which Google account? Set PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile or .storebound/credentials.json) or "account" in broker.json');
|
||||
return await brokerToken(cfg, account, bscope);
|
||||
} catch (e) {
|
||||
if (local && (e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)) {
|
||||
const once = `${cfg.url}\n${sa!.client_email}`;
|
||||
if (!fellBack.has(once)) { fellBack.add(once); say(`${redact((e as Error).message)}; using the local service account key instead`); }
|
||||
mode = 'file';
|
||||
account = sa!.client_email;
|
||||
return local();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return Object.assign(get, { source: () => mode, principal: () => account });
|
||||
}
|
||||
|
||||
export interface PlayCredentials {
|
||||
/** Where Play tokens come from for this project: the broker, the local key file, or nowhere. */
|
||||
source: PlaySource | null;
|
||||
/** The service account e-mail when known (masked by the callers that print it). */
|
||||
principal: string | null;
|
||||
/** A local key is there too (the broker's fallback). */
|
||||
local: boolean;
|
||||
/** The broker config (its URL and key file path, never the key), and what is wrong with it. */
|
||||
broker: { url: string; keyFile: string; from: string; error?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Where this project's Play access comes from, without network calls and without values. */
|
||||
export function playCredentials(root: string): PlayCredentials {
|
||||
let sa: ServiceAccount | null = null, error: string | undefined;
|
||||
try { sa = playServiceAccount(root); } catch (e) { error = (e as Error).message; }
|
||||
let cfg: BrokerConfig | null = null, brokerError: string | undefined;
|
||||
try { cfg = brokerConfig(root); } catch (e) { brokerError = (e as Error).message; }
|
||||
if (cfg) { try { brokerKey(cfg); } catch (e) { brokerError = (e as Error).message; } }
|
||||
const broker = cfg ? { url: cfg.url, keyFile: cfg.keyFile, from: cfg.from, ...(brokerError ? { error: brokerError } : {}) } : null;
|
||||
const usable = !!cfg && !brokerError;
|
||||
return {
|
||||
source: usable ? 'broker' : sa ? 'file' : null,
|
||||
principal: (usable ? cfg!.account : null) ?? sa?.client_email ?? null,
|
||||
local: !!sa, broker,
|
||||
...(brokerError || !(usable || sa) ? { error: brokerError ?? error } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle.
|
||||
import { playServiceAccount } from '../lib/credentials';
|
||||
import { tokenSource } from '../lib/google-auth';
|
||||
import { PLAY_SCOPE, playToken } from '../lib/google-auth';
|
||||
import { accessError } from '../lib/google-access';
|
||||
import { log, warn } from '../lib/log';
|
||||
import { errorBody } from '../lib/http';
|
||||
import { noteGoogle } from '../lib/quota';
|
||||
|
|
@ -15,7 +15,8 @@ export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already bee
|
|||
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
|
||||
|
||||
export async function playClient(root: string, packageName: string) {
|
||||
const token = tokenSource(playServiceAccount(root));
|
||||
// the credential broker's token when one is configured, else the local service account key (cli/lib/google-auth.ts)
|
||||
const token = playToken(root, PLAY_SCOPE, { packageName });
|
||||
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`;
|
||||
const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`;
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ export async function playClient(root: string, packageName: string) {
|
|||
const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text);
|
||||
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status);
|
||||
if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T;
|
||||
// a disabled API or a missing Play permission: the exact API / grant and its link, never retried (pitfall 122)
|
||||
if (r.status === 403 || r.status === 401) { const g = await accessError(`${method} ${short()} -> ${r.status} ${msg}`, { app: packageName, root, source: token.source() }); if (g) throw g; }
|
||||
throw new Error(`${method} ${short()} -> ${r.status} ${msg}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +144,8 @@ export async function playClient(root: string, packageName: string) {
|
|||
|
||||
return {
|
||||
API, UPLOAD, packageName, call,
|
||||
/** Where this client's tokens come from: "broker" or "file" (no values). */
|
||||
credentialSource: () => token.source(),
|
||||
/**
|
||||
* Read-only edit: opens one, runs fn (GETs only), then deletes it, also on error. Never validated or committed.
|
||||
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@
|
|||
// configuration with the fields Play has and we do not manage (icon, sort rank) kept. Nothing is published: the
|
||||
// owner's Publish in Play Console makes the draft live.
|
||||
import { playServiceAccount } from '../lib/credentials';
|
||||
import type { PlayToken } from '../lib/google-auth';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { dryPlan } from '../lib/dry';
|
||||
import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services';
|
||||
import { PLAY_SCOPE, tokenSource } from '../lib/google-auth';
|
||||
import { PLAY_SCOPE, playToken } from '../lib/google-auth';
|
||||
import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors';
|
||||
import { errorBody } from '../lib/http';
|
||||
import { done, log, warn } from '../lib/log';
|
||||
|
|
@ -36,13 +37,24 @@ API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run s
|
|||
}
|
||||
|
||||
export function gamesConfigClient(root: string) {
|
||||
const account = playServiceAccount(root);
|
||||
const token = tokenSource(account, PLAY_SCOPE);
|
||||
// reads: the credential broker's cloud-platform.read-only token (androidpublisher does not cover this API), else the
|
||||
// local key; writes stay on the local key (the broker has no write scope for it): made only when a write is sent
|
||||
let projectId: string | null = null;
|
||||
try { projectId = playServiceAccount(root).project_id ?? null; } catch { /* broker only */ }
|
||||
const readToken = playToken(root, PLAY_SCOPE, { brokerScope: 'cloud-platform.read-only' });
|
||||
let writeToken: PlayToken | null = null;
|
||||
const token = (method: string): Promise<string> => {
|
||||
if (method === 'GET') return readToken();
|
||||
try { writeToken ??= playToken(root, PLAY_SCOPE, { brokerScope: null }); } catch (e) {
|
||||
throw new Error(`Play Games configuration writes need a local service account key (PLAY_SERVICE_ACCOUNT): the credential broker hands out read scopes only for this API. ${(e as Error).message}`);
|
||||
}
|
||||
return writeToken();
|
||||
};
|
||||
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = GAMES_CONFIG_API + path;
|
||||
const safe = method !== 'POST' && retrySafe(method, url);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token(method)), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
if (r.status === 429 && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
|
||||
if (r.status >= 500) {
|
||||
if (safe && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
|
||||
|
|
@ -53,7 +65,7 @@ export function gamesConfigClient(root: string) {
|
|||
const t = await r.text();
|
||||
if (!r.ok) {
|
||||
const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`;
|
||||
const off = r.status === 403 ? gamesApiDisabledText(msg, account.project_id ?? null) : null;
|
||||
const off = r.status === 403 ? gamesApiDisabledText(msg, projectId) : null;
|
||||
throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg);
|
||||
}
|
||||
return (t ? JSON.parse(t) : {}) as T;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Small client for the other Google APIs storebound reads with the same Play service account: the Play Developer
|
||||
// Reporting API (vitals) and Cloud Storage (Play statistics exports). Read-only callers; retries 429 / 5xx.
|
||||
import { playServiceAccount } from '../lib/credentials';
|
||||
import { tokenSource } from '../lib/google-auth';
|
||||
import { playToken } from '../lib/google-auth';
|
||||
import { errorBody } from '../lib/http';
|
||||
import { noteGoogle } from '../lib/quota';
|
||||
import * as rt from '../lib/rt';
|
||||
|
|
@ -9,7 +8,8 @@ import * as rt from '../lib/rt';
|
|||
export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1';
|
||||
|
||||
export function googleClient(root: string, scope: string) {
|
||||
const token = tokenSource(playServiceAccount(root), scope);
|
||||
// the credential broker when it hands out this scope, else the local service account key
|
||||
const token = playToken(root, scope);
|
||||
async function send(method: string, url: string, body?: unknown): Promise<Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
|
|
|
|||
|
|
@ -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