From 2c5e30090ff1ba11f82ef3befd52f5347b846049 Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 25 Oct 2026 22:50:52 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/tr.ts | 7 + apps/desktop/src/screens/Accounts.tsx | 17 ++ apps/desktop/src/styles/accounts.css | 2 + cli/accounts.ts | 4 +- cli/api/client.ts | 9 +- cli/apps.ts | 75 ++++-- cli/doctor.ts | 23 +- cli/index.ts | 13 + cli/lib/accounts.ts | 14 +- cli/lib/broker.ts | 327 +++++++++++++++++++++++++ cli/lib/credentials.ts | 8 +- cli/lib/desktop-protocol.ts | 16 ++ cli/lib/google-access.ts | 34 +++ cli/lib/google-auth.ts | 98 +++++++- cli/play/client.ts | 11 +- cli/play/games.ts | 22 +- cli/play/google.ts | 6 +- cli/validate.ts | 42 +++- docs/README.md | 2 + docs/credential-broker.md | 85 +++++++ docs/getting-started.md | 4 + docs/pitfalls.md | 1 + skills/store-pitfalls/SKILL.md | 17 ++ tests/credential-broker.test.ts | 339 ++++++++++++++++++++++++++ tests/helpers/preload.ts | 4 + 27 files changed, 1145 insertions(+), 43 deletions(-) create mode 100644 cli/lib/broker.ts create mode 100644 cli/lib/google-access.ts create mode 100644 docs/credential-broker.md create mode 100644 tests/credential-broker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d8c7c..2b7f477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides. - New `storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup]` (MCP `console_plan`: `bundleId`, `groups`, `lookup`): the agent-first developer portal plan to assign an App Group to an extension's App ID (start URL per bundle id resource id, portal labels, answer sheet per extension, never-press list: Remove / Delete / Revoke). Its final Save is the agent's own (`buttons.finalByAgent`: a capability edit, not a store submission). `console verify apple-app-group` (MCP `console_verify`, API only) re-reads the App ID and the newest profile and fails while a group is missing or cannot be proven. +- Google access through a credential broker (`~/.storebound/broker.json`: `url`, `apiKeyFile`; env `STOREBOUND_BROKER_URL` / `STOREBOUND_BROKER_KEY_FILE`): Play tokens come from the broker's `POST /api/google/token` (in memory until 5 minutes before expiry, one request per account and scope, never logged; ya29. tokens and the API key redacted in errors), the local service account key stays the fallback when the broker does not answer or does not know the account; a broker that refuses the API key stops without retry. `PLAY_SERVICE_ACCOUNT_EMAIL` names the account (env, profile, project file); a profile opts out with `"broker": false`. The key file must be mode 600 on macOS / Linux. Play write commands read the package's Play permissions and enabled APIs from the broker first and stop before any Play call on a missing one (`CAN_MANAGE_TRACK_APKS`, `CAN_MANAGE_PUBLIC_LISTING`, `CAN_MANAGE_ORDERS`, `CAN_MANAGE_APP_CONTENT`, the Games Configuration API) with the exact permission or API and its link; `validate` lists them (gaps are blockers, SDK config drift a warning) and `--json` carries `credentials.play.source`; `doctor`, `accounts` and `apps check` / the desktop's Accounts view say whether tokens come from the broker or the key file. A Google 403 SERVICE_DISABLED / PERMISSION_DENIED in the Play client names the API or grant with its fix link and is never retried (pitfall 122, docs/credential-broker.md). ## 0.8.49 · 2026-10-25 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b93dd8c..b34f6bb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2085,6 +2085,13 @@ export const en = { 'cm.ignoredNoReads': "Ignored apps are not read", 'cm.ignore': "Ignore", 'acct.devId': 'Play developer id {id}', + 'acct.viaBroker': 'tokens from the credential broker', + 'acct.viaFile': 'local key file', + 'acct.grantsTitle': 'Play permissions on the credential broker', + 'acct.grantsOk': 'everything storebound needs', + 'acct.grantsMissing': 'missing: {list}', + 'acct.grantsNone': 'no permission data', + 'acct.notRegistered': 'not registered on the broker', 'acct.noDevId': 'no Play developer id', 'acct.devIdLabel': 'Play developer account id', 'acct.devIdHint': "The digits after /developers/ in the Play Console address. With it, Play Console links open each app's own page; the CLI uses it too.", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index ec1d1ac..03dc74d 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2087,6 +2087,13 @@ export const tr: Record = { 'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz", 'cm.ignore': "Yok say", 'acct.devId': 'Play geliştirici kimliği {id}', + 'acct.viaBroker': 'jetonlar kimlik aracısından', + 'acct.viaFile': 'yerel anahtar dosyası', + 'acct.grantsTitle': 'Kimlik aracısındaki Play izinleri', + 'acct.grantsOk': 'storebound için gereken her şey var', + 'acct.grantsMissing': 'eksik: {list}', + 'acct.grantsNone': 'izin bilgisi yok', + 'acct.notRegistered': 'aracıda kayıtlı değil', 'acct.noDevId': 'Play geliştirici kimliği yok', 'acct.devIdLabel': 'Play geliştirici hesabı kimliği', 'acct.devIdHint': 'Play Console adresinde /developers/ sonrasındaki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.', diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index 7dc2f89..9a897b0 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -92,6 +92,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {used ? ` · ${t('acct.folders', { count: used })}` : ''} {a.kind === 'play' && (a.developerId ? <> · {t('acct.devId', { id: a.developerId })} : <> · {t('acct.noDevId')})} {check?.principal ? <> · {check.principal} : null} + {a.kind === 'play' && check?.source ? ` · ${check.source === 'broker' ? t('acct.viaBroker') : t('acct.viaFile')}` : ''}
@@ -122,6 +123,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
)} + {check?.grants && check.grants.length > 0 && ( +
+
{t('acct.grantsTitle')}
+
    + {check.grants.map(g => ( +
  • + {g.packageName}{': '} + {!g.registered ? {t('acct.notRegistered')} + : g.missing.length ? {t('acct.grantsMissing', { list: g.missing.join(', ') })} + : g.grants ? {t('acct.grantsOk')} : {t('acct.grantsNone')}} + {g.grants && g.grants.length > 0 && {` ${g.grants.join(', ')}`}} +
  • + ))} +
+
+ )} {h?.error &&
{h.error}
} ); diff --git a/apps/desktop/src/styles/accounts.css b/apps/desktop/src/styles/accounts.css index 251f916..38059de 100644 --- a/apps/desktop/src/styles/accounts.css +++ b/apps/desktop/src/styles/accounts.css @@ -24,3 +24,5 @@ .scr-accounts .acct-fix { padding: 0 16px 16px; } .scr-accounts .acct-add { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px; align-items: start; } .scr-accounts .acct-add .form { gap: 14px; } +.scr-accounts .acct-grants ul { margin: 4px 0 0; padding-left: 18px; display: grid; gap: 2px; } +.scr-accounts .acct-grants li { overflow-wrap: anywhere; } diff --git a/cli/accounts.ts b/cli/accounts.ts index e95199a..d5b8403 100644 --- a/cli/accounts.ts +++ b/cli/accounts.ts @@ -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 { 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 )`); 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 , or accounts use in the project)'); return; } diff --git a/cli/api/client.ts b/cli/api/client.ts index 17da151..b5d9584 100644 --- a/cli/api/client.ts +++ b/cli/api/client.ts @@ -354,13 +354,12 @@ export async function authFor(store: Store, root: string): Promise { 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 Promise>(); + 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 Promise>([[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)!()); }; } diff --git a/cli/apps.ts b/cli/apps.ts index 3260a47..ec0c4fb 100644 --- a/cli/apps.ts +++ b/cli/apps.ts @@ -14,9 +14,10 @@ // desktop shows it read-only ("no local project"); // apps remove apple: | 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, 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 { - 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 { }; } -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 { 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 | 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); diff --git a/cli/doctor.ts b/cli/doctor.ts index 612d04f..cfd4830 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -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 = 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 { : `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; } diff --git a/cli/index.ts b/cli/index.ts index b1e1386..fe3c5a7 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -105,6 +105,7 @@ async function main(): Promise { 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 { // 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): Promise { + 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); }); diff --git a/cli/lib/accounts.ts b/cli/lib/accounts.ts index 940e340..2c57242 100644 --- a/cli/lib/accounts.ts +++ b/cli/lib/accounts.ts @@ -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 }; } }); } diff --git a/cli/lib/broker.ts b/cli/lib/broker.ts new file mode 100644 index 0000000..41e171b --- /dev/null +++ b/cli/lib/broker.ts @@ -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//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"?: "" } +// 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__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).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 { + 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(); +const inflight = new Map>(); +const infos = new Map>(); + +/** 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 { + 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 { + const k = `${cfg.url}\n${pkg}`; + const cached = infos.get(k); + if (cached) return cached; + const p = (async (): Promise => { + 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 = { 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 = { + '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([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 = { + 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 { + 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)}`); +} diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 186a8fe..ddbbe09 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -21,6 +21,8 @@ // 4. the default profile (none named): store keys only // 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_.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 /, mode 0600), or env // STOREBOUND_PROFILE__ (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)); } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 1a174d5..3c86210 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -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//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[]; } /** diff --git a/cli/lib/google-access.ts b/cli/lib/google-access.ts new file mode 100644 index 0000000..b73f275 --- /dev/null +++ b/cli/lib/google-access.ts @@ -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 { + 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 ?? ''} ${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); +} diff --git a/cli/lib/google-auth.ts b/cli/lib/google-auth.ts index 106f798..aaec4f4 100644 --- a/cli/lib/google-auth.ts +++ b/cli/lib/google-auth.ts @@ -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 = { [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; + /** "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(); + +/** + * 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 => { + 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 } : {}), + }; +} diff --git a/cli/play/client.ts b/cli/play/client.ts index 647b893..81239dd 100644 --- a/cli/play/client.ts +++ b/cli/play/client.ts @@ -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>; 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*= 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). diff --git a/cli/play/games.ts b/cli/play/games.ts index 009683f..edee088 100644 --- a/cli/play/games.ts +++ b/cli/play/games.ts @@ -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 => { + 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(method: string, path: string, body?: unknown): Promise { 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; diff --git a/cli/play/google.ts b/cli/play/google.ts index 9691763..cc61886 100644 --- a/cli/play/google.ts +++ b/cli/play/google.ts @@ -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 { for (let attempt = 0; ; attempt++) { const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, diff --git a/cli/validate.ts b/cli/validate.ts index 4d43db3..c91fdac 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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 { + 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 { @@ -231,8 +264,11 @@ export async function run(ctx: Ctx): Promise { 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 { 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)'); diff --git a/docs/README.md b/docs/README.md index 0d6d79d..9efa6f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,8 @@ The [README](../README.md) is the short tour; everything in detail lives here. - [Skills](skills.md) - [MCP server and the desktop machine interface](mcp.md) - [Safety model](safety.md) +- [Google access through a credential broker](credential-broker.md): Play tokens and permission checks from a server of + yours, no Google key on the machine - [Idempotency: every write converges](idempotency.md) - [Pitfalls: lessons from real use](pitfalls.md) diff --git a/docs/credential-broker.md b/docs/credential-broker.md new file mode 100644 index 0000000..749b6c0 --- /dev/null +++ b/docs/credential-broker.md @@ -0,0 +1,85 @@ +# Google access through a credential broker + +A credential broker is a server of yours that holds the Google service-account keys, the Cloud project facts and the +Play permission grants. With one configured, storebound keeps no Google key on the machine: it asks the broker for a +short-lived access token for each Google API call and reads the package's Play permissions from it before a command +writes anything. The local key (`PLAY_SERVICE_ACCOUNT`) keeps working and is the fallback. + +## Setup + +`~/.storebound/broker.json`: + +```json +{ + "url": "https://broker.example.com", + "apiKeyFile": "~/.storebound/broker.key", + "account": "play-publisher@example.com" +} +``` + +- `url`: the broker's base URL. Only `https://` (plain `http://` only for `localhost`, a local test broker). +- `apiKeyFile`: a file with one scoped API key on one line, with the scopes `google:read` and `google:token`. On macOS + and Linux it must be `chmod 600`: storebound refuses a key file that the group or others can read. Default: + `broker.key` next to `broker.json`. The key is never printed, never written into a config or a log. +- `account` (optional): the service account e-mail the broker mints tokens for. A credential profile or the project + can name its own with `PLAY_SERVICE_ACCOUNT_EMAIL` (env, `~/.storebound/credentials/.json`, + `.storebound/credentials.json`); without either, the `client_email` of a local key is used, and for Play commands + the account the broker names for the package. + +Overrides: `STOREBOUND_BROKER_URL`, `STOREBOUND_BROKER_KEY_FILE` (env wins over the file), `STOREBOUND_BROKER_CONFIG` +(another broker.json). Off: `STOREBOUND_BROKER=off` for one run, `"broker": false` in a credential profile (that profile +keeps its key file), or env `STOREBOUND_PROFILE__BROKER=off`. + +## What storebound calls + +| call | when | +|---|---| +| `POST /api/google/token` `{ "account", "scopes": ["androidpublisher"] }` -> `{ "accessToken", "expiresAt" }` | before Google calls. Kept in memory until 5 minutes before `expiresAt`; parallel calls share one request | +| `GET /api/google/play//storebound` | `storebound validate`, the preflight of Play write commands, `apps check` and the desktop's Accounts view | + +Scopes: `androidpublisher` for the Play Developer API, `cloud-platform.read-only` for Play statistics exports and Play +Games configuration reads. The broker hands out no scope for the Play Developer Reporting API (vitals, the app list of +`apps list`) or for Play Games configuration writes: those use the local key, and say so when there is none. + +## Fallback and failures + +- Broker not configured: the local key, as before. +- Broker not answering (network error, timeout, 5xx) or not knowing the account (400 / 404 on a token): the local key, + with one warning per run. No local key: the error names the broker. +- Broker refusing the API key (401 / 403): storebound stops with "the key lacks google:token" and does not retry or + fall back. Give the key its scopes on the broker. +- A package the broker does not know (404 on `/storebound`): a warning to register it there. It is not a missing + Play access; the command still runs and Google answers for itself. + +## Permission checks + +The broker's answer lists the service account's Play permissions for the package and the APIs enabled in its Cloud +projects. Before a Play command writes, storebound compares them with what the command needs and stops before any +Play call, naming the exact permission or API and the link to fix it: + +| work | Play permission | commands | +|---|---|---| +| uploads, releases, rollouts | `CAN_MANAGE_TRACK_APKS` | `play upload`, `play release`, `play rollout`, `play share` | +| store listing and images | `CAN_MANAGE_PUBLIC_LISTING` | `play listing`, `play images` | +| products and subscriptions | `CAN_MANAGE_ORDERS` | `play products`, `play subscriptions` | +| Data safety, app content | `CAN_MANAGE_APP_CONTENT` | `play data-safety` | +| review replies | `CAN_REPLY_TO_REVIEWS` | (listed in the checks) | +| Play Games Services | API `gamesconfiguration.googleapis.com` | `play games` | + +Every Play call also needs `androidpublisher.googleapis.com` enabled. A dry run (`--dry`) and reads only warn. +`storebound validate` reports the same gaps as blockers, the permissions and APIs the broker lists, and SDK config +files (`google-services.json`, `GoogleService-Info.plist`) the broker reports as drifted or whose sha256 differs from +the repo's copy. + +When Google itself answers 403 `SERVICE_DISABLED` or `PERMISSION_DENIED`, the error names the API (with its enable +link) or the missing app access (with the Users and permissions link), adds what the broker lists for the package, and +is never retried. + +## Where it shows + +- `storebound doctor`: `Play: tokens from the credential broker for p***@...` (or the local key, and why the + broker is not usable). `--json`: `play.source` is `"broker"` or `"file"`. +- `storebound accounts`: a "Play via" column (credential broker / key file) per profile. +- `storebound validate --json` (and MCP `validate`): `credentials.play.source`. +- `storebound apps check --store play --json` and the desktop's Accounts view: `source` and, per Play app of the + machine, the permissions and APIs the broker lists and what is missing. diff --git a/docs/getting-started.md b/docs/getting-started.md index ea6c825..6301f0f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -115,12 +115,16 @@ Store accounts belong to the machine, everything else to the project: `.storebound/credentials.json`. - **Order:** environment variables win over everything, then a profile the project names, then the project file, then the default profile. +- **Google keys on a server of yours:** with a credential broker in `~/.storebound/broker.json`, Play tokens come from + it and the machine needs no Google key; the local key stays the fallback. See + [Google access through a credential broker](credential-broker.md). The names (env, profile and project file alike; the project file is gitignored by `init`): | name | what | |---|---| | `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console | +| `PLAY_SERVICE_ACCOUNT_EMAIL` | with a [credential broker](credential-broker.md): the service account the broker mints tokens for (no key file needed) | | `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) | | `ASC_P8` | path to `AuthKey_.p8` (default `~/.appstoreconnect/private_keys/AuthKey_.p8`) | | `ASC_VENDOR_NUMBER` | vendor number for `reports apple-sales` / `apple-finance` (Payments and Financial Reports) | diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 4f58aed..55cc3a4 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 95 | 2026-10 | The Google service-account keys moved to the owner's credential broker, but storebound still wanted a local `PLAY_SERVICE_ACCOUNT`, and a Play write with a missing permission ran its edit before Google's bare 403. | `cli/lib/broker.ts` (broker.json, key file mode 600, token cache and single flight, grants, `playPreflight` before Play write commands), `playToken` in `cli/lib/google-auth.ts` (broker first, local key fallback when it does not answer), `cli/lib/google-access.ts` (403 SERVICE_DISABLED / PERMISSION_DENIED -> exact API or grant with its link, never retried), `validate` blockers; `tests/credential-broker.test.ts` against a local fake broker. Skill pitfall 122, docs/credential-broker.md. | | 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). | | 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). | | 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 967824b..7392b6d 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1498,3 +1498,20 @@ that fails without the fix (see CONTRIBUTING.md). `cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group entitlements, a decoded profile plist, a fake App Store Connect), `cli/lib/console-apple-group-plan.ts` (the plan), `verifyAppGroupsApi` in `cli/lib/console-verify.ts`, `appGroupVerdicts` in `cli/lib/targets.ts` (gap / unproven / ok). + +## 122. Google keys moved to a credential broker: tokens, fallback and missing grants + +- **Symptom:** after the Google service-account keys moved to the owner's credential broker, Play commands either + still wanted `PLAY_SERVICE_ACCOUNT`, or an upload ran its edit, uploaded and only then died on a bare 403 for a + permission the broker could have named; an error dump could carry the bearer token. +- **Do:** configure `~/.storebound/broker.json` (`url`, `apiKeyFile`, key file mode 600) and, when the account is not + the local key's, `PLAY_SERVICE_ACCOUNT_EMAIL` in the profile. `storebound doctor` says which source Play uses. + Before Play writes, the preflight reads `GET /api/google/play//storebound` and stops on a missing + permission or API with the exact name and link; grant it on the broker (`google_play_grant`) or enable the API + (`google_api_enable`), never by retrying. A 404 there means "register the package on the broker", not "no access". + A broker that refuses the API key (401 / 403) needs `google:read google:token` on the key; storebound neither retries + nor falls back then. The broker has no scope for the Reporting API or Play Games writes: those keep the local key. +- **Never:** write an access token to disk or into a log, put the broker key into a config or docs, or loop on a 403. +- **Guard:** `cli/lib/broker.ts` (config, key file mode, token cache and single flight, grants, preflight, redaction), + `cli/lib/google-auth.ts` `playToken` (broker first, local key fallback), `cli/lib/google-access.ts` (403 -> + GoogleAccessError, no retry), `tests/credential-broker.test.ts` (a local fake broker). diff --git a/tests/credential-broker.test.ts b/tests/credential-broker.test.ts new file mode 100644 index 0000000..e99e8e2 --- /dev/null +++ b/tests/credential-broker.test.ts @@ -0,0 +1,339 @@ +// Google access through a credential broker (cli/lib/broker.ts, playToken in cli/lib/google-auth.ts), against a local +// Bun.serve fake broker: token minting, caching, single flight and expiry, fallback to the local key, the package's +// Play permissions, the missing-grant guardrail (preflight and Google's own 403), key file permissions, and that no +// key or token shows up in any output. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, BrokerRefused, brokerToken, checkBrokerUrl, clearBrokerCache, playGaps, playPreflight, redact } from '../cli/lib/broker'; +import { playCredentials, playToken } from '../cli/lib/google-auth'; +import { accessError, GoogleAccessError } from '../cli/lib/google-access'; +import { playClient } from '../cli/play/client'; +import { doctor, playLine } from '../cli/doctor'; + +const PKG = 'com.example.game'; +const ACCOUNT = 'publisher@sb-test.example.com'; +const API_KEY = 'brokerkey_0123456789abcdef'; +const ENV = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'STOREBOUND_BROKER', 'STOREBOUND_BROKER_URL', 'STOREBOUND_BROKER_KEY_FILE', 'STOREBOUND_BROKER_CONFIG', + 'STOREBOUND_CREDENTIALS_DIR', 'STOREBOUND_PROFILE', 'STOREBOUND_BROKER_TIMEOUT_MS'] as const; +const saved = Object.fromEntries(ENV.map(k => [k, process.env[k]])); + +interface Seen { method: string; path: string; auth: string | null; body: unknown } +let seen: Seen[] = []; +let mints = 0; +/** What the fake broker answers; tests change it. */ +let route: (s: Seen) => Response | Promise; +const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); +const nowS = () => Math.floor(Date.now() / 1000); +const defaultRoute = (s: Seen): Response => { + if (s.path === '/api/google/token') { mints++; return json({ accessToken: `ya29.fake-${mints}`, expiresAt: nowS() + 3600, clientEmail: ACCOUNT, scopes: ['androidpublisher'] }); } + if (s.path === `/api/google/play/${PKG}/storebound`) return json(INFO); + return json({ error: 'err_not_found' }, 404); +}; +const INFO = { + packageName: PKG, developerId: '5550000000000000001', account: ACCOUNT, + accountPermissions: { developer: [], app: ['CAN_MANAGE_PUBLIC_LISTING', 'CAN_MANAGE_ORDERS'] }, + grantsCheckedAt: 1761390000, + projects: [{ projectId: 'sb-test-project', projectNumber: '100000000001', enabledApis: ['androidpublisher.googleapis.com'], billing: 'linked' }], + sdkConfigs: [], +}; + +let server: ReturnType; +let tmp: string, root: string, keyFile: string; +const realFetch = globalThis.fetch; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const u = new URL(req.url); + const text = await req.text(); + const s: Seen = { method: req.method, path: decodeURIComponent(u.pathname), auth: req.headers.get('authorization'), body: text ? JSON.parse(text) : null }; + seen.push(s); + return route(s); + }, + }); +}); +afterAll(() => { server.stop(true); }); + +function writeBroker(o: Record = {}): void { + writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, apiKeyFile: keyFile, account: ACCOUNT, ...o })); +} +function localKey(): void { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: ACCOUNT, private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); +} +/** fetch double for Google only (the local key's token exchange, the Play API); the fake broker is reached for real. */ +function fakeGoogle(api: (url: string, init?: RequestInit) => Response = () => json({})): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'local-file-token', expires_in: 3600 }); + if (/googleapis\.com/.test(url)) { calls.push(`${init?.method ?? 'GET'} ${url}`); return api(url, init); } + return realFetch(input as Request, init); + }) as typeof fetch; + return calls; +} + +beforeEach(() => { + for (const k of ENV) delete process.env[k]; + tmp = mkdtempSync(join(tmpdir(), 'sb-broker-')); + root = join(tmp, 'project'); + mkdirSync(root, { recursive: true }); + keyFile = join(tmp, 'broker.key'); + writeFileSync(keyFile, API_KEY + '\n', { mode: 0o600 }); + process.env.STOREBOUND_BROKER_CONFIG = join(tmp, 'broker.json'); + process.env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'credentials'); + process.env.STOREBOUND_PROFILE = ''; + seen = []; mints = 0; route = defaultRoute; + clearBrokerCache(); +}); +afterEach(() => { + globalThis.fetch = realFetch; + rmSync(tmp, { recursive: true, force: true }); + for (const k of ENV) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } +}); + +describe('config', () => { + test('broker.json, env overrides, https only, per-profile opt-out', () => { + expect(brokerConfig(root)).toBeNull(); + writeBroker(); + const c = brokerConfig(root)!; + expect(c.url).toBe(`http://127.0.0.1:${server.port}`); + expect(c.keyFile).toBe(keyFile); + expect(c.account).toBe(ACCOUNT); + // PLAY_SERVICE_ACCOUNT_EMAIL (here a broker credential id) wins over broker.json + process.env.PLAY_SERVICE_ACCOUNT_EMAIL = 'cred-play-1'; + expect(brokerConfig(root)!.account).toBe('cred-play-1'); + delete process.env.PLAY_SERVICE_ACCOUNT_EMAIL; + process.env.STOREBOUND_BROKER_URL = 'https://broker.example.com/'; + expect(brokerConfig(root)!.url).toBe('https://broker.example.com'); + expect(brokerConfig(root)!.from).toBe('env'); + process.env.STOREBOUND_BROKER = 'off'; + expect(brokerConfig(root)).toBeNull(); + delete process.env.STOREBOUND_BROKER; + expect(() => checkBrokerUrl('http://broker.example.com')).toThrow(/https only/); + // a profile that keeps its own key + const creds = join(tmp, 'credentials'); + mkdirSync(creds, { recursive: true }); + writeFileSync(join(creds, 'p1.json'), JSON.stringify({ broker: false })); + process.env.STOREBOUND_PROFILE = 'p1'; + expect(brokerConfig(root)).toBeNull(); + }); + + test('a broken broker.json is an error, not a silent skip', () => { + writeFileSync(join(tmp, 'broker.json'), '{ not json'); + expect(() => brokerConfig(root)).toThrow(/broker\.json/); + }); +}); + +describe('key file permissions', () => { + test('group / world readable refused on POSIX, accepted on Windows; the key is never in the message', () => { + writeBroker(); + const cfg = brokerConfig(root)!; + if (process.platform !== 'win32') { + chmodSync(keyFile, 0o644); + expect(() => brokerKey(cfg)).toThrow(/chmod 600/); + chmodSync(keyFile, 0o600); + expect(brokerKey(cfg)).toBe(API_KEY); + } else { + // Windows reports 0o666 for every writable file: the POSIX rule is checked by naming the platform + let msg = ''; + try { brokerKey(cfg, 'linux'); } catch (e) { msg = (e as Error).message; } + expect(msg).toMatch(/readable by group or others .*chmod 600/); + expect(msg).not.toContain(API_KEY); + expect(brokerKey(cfg, 'win32')).toBe(API_KEY); + } + rmSync(keyFile); + expect(() => brokerKey(cfg)).toThrow(/not found/); + }); +}); + +describe('token minting', () => { + test('one mint, cached until 5 minutes before expiry, scoped request with the bearer key', async () => { + writeBroker(); + const t = playToken(root); + expect(await t()).toBe('ya29.fake-1'); + expect(await t()).toBe('ya29.fake-1'); + expect(mints).toBe(1); + expect(t.source()).toBe('broker'); + expect(t.principal()).toBe(ACCOUNT); + const mint = seen.find(s => s.path === '/api/google/token')!; + expect(mint.method).toBe('POST'); + expect(mint.auth).toBe(`Bearer ${API_KEY}`); + expect(mint.body).toEqual({ account: ACCOUNT, scopes: ['androidpublisher'] }); + }); + + test('parallel callers share one request (single flight)', async () => { + writeBroker(); + route = async s => { await Bun.sleep(50); return defaultRoute(s); }; + const t = playToken(root); + const all = await Promise.all(Array.from({ length: 6 }, () => t())); + expect(new Set(all)).toEqual(new Set(['ya29.fake-1'])); + expect(mints).toBe(1); + }); + + test('a token close to expiry is minted again', async () => { + writeBroker(); + const cfg = brokerConfig(root)!; + let clock = Date.now(); + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); + clock += 3000_000; // 50 min later: 10 min left, still cached + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); + clock += 400_000; // under 5 min left + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-2'); + expect(mints).toBe(2); + }); + + test('the account comes from the broker for the package when nothing names it', async () => { + writeBroker({ account: undefined }); + const t = playToken(root, undefined, { packageName: PKG }); + expect(await t()).toBe('ya29.fake-1'); + expect((seen.find(s => s.path === '/api/google/token')!.body as { account: string }).account).toBe(ACCOUNT); + }); + + test('Reporting has no broker scope: the local key serves it, and without one the error says so', () => { + writeBroker(); + expect(() => playToken(root, 'https://www.googleapis.com/auth/playdeveloperreporting')).toThrow(/hands out no such scope/); + }); +}); + +describe('fallback to the local key', () => { + test('broker not answering: the local key, one warning, source "file"', async () => { + localKey(); + fakeGoogle(); + writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: 'http://127.0.0.1:9', apiKeyFile: keyFile })); + process.env.STOREBOUND_BROKER_TIMEOUT_MS = '2000'; + const warnings: string[] = []; + const t = playToken(root, undefined, { warn: m => warnings.push(m) }); + expect(await t()).toBe('local-file-token'); + expect(await t()).toBe('local-file-token'); + expect(t.source()).toBe('file'); + expect(warnings.length).toBe(1); + expect(warnings[0]).toMatch(/did not answer.*local service account key/); + }); + + test('broker 503: the local key', async () => { + localKey(); + fakeGoogle(); + writeBroker(); + route = () => json({ error: 'down' }, 503); + const t = playToken(root, undefined, { warn: () => {} }); + expect(await t()).toBe('local-file-token'); + }); + + test('broker refusing the API key (401): no fallback, no retry, the key lacks google:token', async () => { + localKey(); + fakeGoogle(); + writeBroker(); + route = () => json({ error: 'err_scope', echo: `Bearer ${API_KEY}` }, 401); + const t = playToken(root, undefined, { warn: () => {} }); + const e = await t().then(() => null, x => x as Error); + expect(e).toBeInstanceOf(BrokerRefused); + expect(e!.message).toMatch(/lacks the google:token scope/); + expect(e!.message).not.toContain(API_KEY); + expect(seen.filter(s => s.path === '/api/google/token').length).toBe(1); + }); + + test('no broker: the local key as before; neither: the PLAY_SERVICE_ACCOUNT error', async () => { + expect(() => playToken(root)).toThrow(/PLAY_SERVICE_ACCOUNT is not set/); + localKey(); + fakeGoogle(); + const t = playToken(root); + expect(await t()).toBe('local-file-token'); + expect(t.source()).toBe('file'); + expect(seen.length).toBe(0); + }); +}); + +describe('Play permissions from the broker', () => { + test('grants and APIs read; gaps name the permission, the API and their links', async () => { + writeBroker(); + const r = await brokerPlayInfo(brokerConfig(root)!, PKG); + expect(r.kind).toBe('ok'); + if (r.kind !== 'ok') return; + const gaps = playGaps(r.info, ['upload', 'listing', 'games']); + expect(gaps.map(g => g.name)).toEqual(['CAN_MANAGE_TRACK_APKS', 'gamesconfiguration.googleapis.com']); + expect(gaps[0]!.url).toBe('https://play.google.com/console/u/0/developers/5550000000000000001/users-and-permissions'); + expect(gaps[0]!.fix).toMatch(/google_play_grant/); + expect(gaps[1]!.url).toBe('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=sb-test-project'); + // read once per run and package + await brokerPlayInfo(brokerConfig(root)!, PKG); + expect(seen.filter(s => s.path.endsWith('/storebound')).length).toBe(1); + }); + + test('404: register the package, not "no access"', async () => { + writeBroker(); + const r = await brokerPlayInfo(brokerConfig(root)!, 'com.example.other'); + expect(r.kind).toBe('not-registered'); + if (r.kind === 'not-registered') expect(r.message).toMatch(/register the package .*not a missing Play access/); + }); + + test('preflight: a write stops before any Play call with the exact grant; a dry run only warns', async () => { + writeBroker(); + const google = fakeGoogle(); + const e = await playPreflight('play upload', root, PKG, { dry: false, warn: () => {} }).catch(x => x as Error); + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toMatch(/stopped before any call to Google Play/); + expect((e as Error).message).toMatch(/CAN_MANAGE_TRACK_APKS for com\.example\.game/); + const warned: string[] = []; + expect((await playPreflight('play upload', root, PKG, { dry: true, warn: m => warned.push(m) })).length).toBe(1); + expect(warned[0]).toMatch(/CAN_MANAGE_TRACK_APKS/); + // granted: nothing; a command without needs: nothing + expect(await playPreflight('play listing', root, PKG, { dry: false, warn: () => {} })).toEqual([]); + expect(await playPreflight('play status', root, PKG, { dry: false, warn: () => {} })).toEqual([]); + expect(google).toEqual([]); + }); +}); + +describe('Google 403 guardrail', () => { + const DISABLED = `GET /edits -> 403 {"error":{"code":403,"message":"Google Play Android Developer API has not been used in project 100000000001 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED","metadata":{"service":"androidpublisher.googleapis.com","consumer":"projects/sb-test-project"}}]}}`; + test('SERVICE_DISABLED: the API and its enable link, never retried', async () => { + const e = await accessError(DISABLED, { app: PKG }); + expect(e).toBeInstanceOf(GoogleAccessError); + expect(e!.noRetry).toBe(true); + expect(e!.message).toContain('https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com?project=sb-test-project'); + }); + + test('PERMISSION_DENIED through the Play client with broker tokens: one call, the grants the broker lists', async () => { + writeBroker(); + const google = fakeGoogle(() => json({ error: { code: 403, message: 'The caller does not have permission', status: 'PERMISSION_DENIED' } }, 403)); + const play = await playClient(root, PKG); + const e = await play.call('POST', `${play.API}/edits`, {}).then(() => null, x => x as Error); + expect(e).toBeInstanceOf(GoogleAccessError); + expect(google.length).toBe(1); + expect(e!.message).toMatch(/no access to com\.example\.game in Play Console/); + expect(e!.message).toMatch(/credential broker lists CAN_MANAGE_ORDERS, CAN_MANAGE_PUBLIC_LISTING/); + expect(play.credentialSource()).toBe('broker'); + }); +}); + +describe('no secret in output', () => { + test('redact removes tokens, bearer values and the key', () => { + expect(redact(`Authorization: Bearer ${API_KEY} token ya29.a0AfH6SM-x_y.z`, API_KEY)).toBe('Authorization: Bearer *** token ya29.***'); + }); + + test('doctor and the credential summary carry the source, never the key or a token', async () => { + writeBroker(); + await playToken(root)(); + const p = playCredentials(root); + expect(p.source).toBe('broker'); + const d = doctor(root); + const out = JSON.stringify(d) + playLine(d.play); + expect(d.play.source).toBe('broker'); + expect(playLine(d.play)).toMatch(/tokens from the credential broker/); + expect(out).not.toContain(API_KEY); + expect(out).not.toContain('ya29.'); + expect(out).not.toContain(ACCOUNT); // masked + }); + + test('a broker error body echoing the key or a token is redacted', async () => { + writeBroker(); + route = () => new Response(`upstream failed for Bearer ${API_KEY} ya29.secret-token`, { status: 502 }); + const e = await brokerToken(brokerConfig(root)! as BrokerConfig, ACCOUNT, 'androidpublisher').then(() => null, x => x as Error); + expect(e!.message).not.toContain(API_KEY); + expect(e!.message).not.toContain('ya29.secret-token'); + }); +}); diff --git a/tests/helpers/preload.ts b/tests/helpers/preload.ts index 9fe5d67..35c48f4 100644 --- a/tests/helpers/preload.ts +++ b/tests/helpers/preload.ts @@ -14,6 +14,10 @@ const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-')); process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials'); process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json'); process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks'); +// the credential broker of this machine (~/.storebound/broker.json, cli/lib/broker.ts) is never asked either +process.env.STOREBOUND_BROKER_CONFIG = join(machine, 'broker.json'); +delete process.env.STOREBOUND_BROKER_URL; +delete process.env.STOREBOUND_BROKER_KEY_FILE; // Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation: // DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.