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

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