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
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue