credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry

This commit is contained in:
CRTkafa 2026-10-25 22:50:52 +07:00
parent 304407e523
commit 2c5e30090f
27 changed files with 1145 additions and 43 deletions

View file

@ -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/<resource id>`, 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 <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

View file

@ -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.",

View file

@ -2087,6 +2087,13 @@ export const tr: Record<keyof typeof en, string> = {
'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.',

View file

@ -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 ? <> · <span className="mono-id selectable" title={t('acct.devIdHint')}>{t('acct.devId', { id: a.developerId })}</span></> : <> · <span title={t('acct.devIdHint')}>{t('acct.noDevId')}</span></>)}
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
{a.kind === 'play' && check?.source ? ` · ${check.source === 'broker' ? t('acct.viaBroker') : t('acct.viaFile')}` : ''}
</div>
</div>
<div className="actions">
@ -122,6 +123,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
</Notice>
</div>
)}
{check?.grants && check.grants.length > 0 && (
<div className="acct-fix acct-grants">
<div className="small strong">{t('acct.grantsTitle')}</div>
<ul className="small">
{check.grants.map(g => (
<li key={g.packageName}>
<span className="mono-id selectable">{g.packageName}</span>{': '}
{!g.registered ? <span className="muted">{t('acct.notRegistered')}</span>
: g.missing.length ? <Chip tone="warn">{t('acct.grantsMissing', { list: g.missing.join(', ') })}</Chip>
: g.grants ? <Chip tone="ok">{t('acct.grantsOk')}</Chip> : <span className="muted">{t('acct.grantsNone')}</span>}
{g.grants && g.grants.length > 0 && <span className="muted mono-id" title={(g.enabledApis ?? []).join(', ')}>{` ${g.grants.join(', ')}`}</span>}
</li>
))}
</ul>
</div>
)}
{h?.error && <div className="acct-fix"><Notice tone="bad">{h.error}</Notice></div>}
</div>
);

View file

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

View file

@ -22,6 +22,7 @@ import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { applyImport, describeProfiles, planImport, setPlayDeveloperId } from './lib/accounts';
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
import { brokerConfig } from './lib/broker';
import { confirm, type Ctx } from './lib/ctx';
import { textTable } from './lib/table';
import { printJson } from './lib/log';
@ -42,9 +43,10 @@ export async function run(args: Args): Promise<void> {
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
return;
}
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play via', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playSource === 'broker' ? 'credential broker' : r.playSource === 'file' ? 'key file' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default <profile>)`);
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
try { const b = brokerConfig(); if (b) console.log(`credential broker: ${b.url} (${b.from === 'env' ? 'STOREBOUND_BROKER_URL' : tilde(b.from)}); a profile with "broker": false keeps its key file`); } catch (e) { console.log(`credential broker: not usable: ${(e as Error).message}`); }
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default <profile>, or accounts use <profile> in the project)');
return;
}

View file

@ -354,13 +354,12 @@ export async function authFor(store: Store, root: string): Promise<Auth> {
const { jwt } = ascJwt(root);
return () => 'Bearer ' + jwt();
}
const { playServiceAccount } = await import('../lib/credentials');
const { tokenSource } = await import('../lib/google-auth');
const sa = playServiceAccount(root);
const sources = new Map<string, () => Promise<string>>();
const { PLAY_SCOPE, playToken } = await import('../lib/google-auth');
// the credential broker when it has the scope, else the local key; throws now when neither is there
const sources = new Map<string, () => Promise<string>>([[PLAY_SCOPE, playToken(root, PLAY_SCOPE)]]);
return async req => {
const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope;
if (!sources.has(scope)) sources.set(scope, tokenSource(sa, scope));
if (!sources.has(scope)) sources.set(scope, playToken(root, scope));
return 'Bearer ' + (await sources.get(scope)!());
};
}

View file

@ -14,9 +14,10 @@
// desktop shows it read-only ("no local project");
// apps remove apple:<id> | <entry id> takes it out
import type { Args } from './lib/args';
import { ascKey, playServiceAccount } from './lib/credentials';
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
import { ascKey } from './lib/credentials';
import type { AccountCheck, AppsListResult, BrokerGrant } from './lib/desktop-protocol';
import { PLAY_SCOPE, playCredentials, playToken, REPORTING_SCOPE } from './lib/google-auth';
import { brokerConfig, brokerPlayInfo, BrokerRefused, BrokerUnavailable, BrokerUnknownAccount, enabledApisOf, grantsOf, type PlayNeed, playGaps } from './lib/broker';
import { errorBody } from './lib/http';
import { done, log, printJson, warn } from './lib/log';
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
@ -39,8 +40,8 @@ function stores(args: Args, root: string): Store[] {
// every store whose credentials are set (env, or .storebound/credentials.json in this folder)
const out: Store[] = [];
try { ascKey(root); out.push('apple'); } catch { /* not set */ }
try { playServiceAccount(root); out.push('play'); } catch { /* not set */ }
if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json)');
if (playCredentials(root).source) out.push('play');
if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), or a credential broker (~/.storebound/broker.json)');
return out;
}
@ -71,7 +72,7 @@ function googleGet(token: () => Promise<string>, base: string): Get {
/** Play icon of one package: an edit is opened, the default language's icon listed, and the edit deleted (never committed). */
function playIcon(root: string): (pkg: string) => Promise<string | null> {
const token = tokenSource(playServiceAccount(root), PLAY_SCOPE);
const token = playToken(root, PLAY_SCOPE);
const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/';
const call = async (method: string, url: string) => {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()) } });
@ -96,7 +97,7 @@ function playIcon(root: string): (pkg: string) => Promise<string | null> {
};
}
const principalOf = (root: string): string | null => { try { return playServiceAccount(root).client_email; } catch { return null; } };
const principalOf = (root: string): string | null => playCredentials(root).principal;
export async function listApps(store: Store, root: string, icons: boolean): Promise<AppsListResult> {
const at = new Date().toISOString();
@ -104,15 +105,15 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom
const r = await listAppleApps(await appleGet(root), { icons, bytes });
return { store: 'ios', ...r, at };
}
const sa = playServiceAccount(root);
const token = playToken(root, REPORTING_SCOPE);
try {
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
const r = await listPlayApps(googleGet(token, REPORTING_API), icons ? { icon: playIcon(root) } : {});
return { store: 'android', ...r, at };
} catch (e) {
// the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the
// Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list
if (!isServiceDisabled(e)) throw e;
const c = playCheck(sa.client_email, { error: e });
const c = playCheck(token.principal(), { error: e });
const known = knownPlayPackages(root);
return { store: 'android', apps: localPlayApps(known), at,
warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] };
@ -152,12 +153,56 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
} catch (e) { return appleCheck({ error: e }); }
}
let principal: string | null = null;
let source: AccountCheck['source'] = null;
try {
const sa = playServiceAccount(root);
principal = sa.client_email;
const page = await googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API)('/apps:search?pageSize=1000');
return playCheck(principal, { apps: (page.apps ?? []).length });
} catch (e) { return playCheck(principal, { error: e }); }
const creds = playCredentials(root);
principal = creds.principal;
source = creds.source;
if (creds.broker?.error) throw new BrokerRefused(creds.broker.error, 0);
if (source === 'broker') {
// proves the broker: one androidpublisher token (it may fall back to the local key, then source says "file")
const t = playToken(root, PLAY_SCOPE);
await t();
source = t.source();
principal = t.principal() ?? principal;
}
let apps: number | null = null;
let reporting: ReturnType<typeof playToken> | null = null;
try { reporting = playToken(root, REPORTING_SCOPE); } catch (e) { if (source !== 'broker') throw e; }
if (reporting) apps = ((await googleGet(reporting, REPORTING_API)('/apps:search?pageSize=1000')).apps ?? []).length;
const c = apps === null
? { store: 'android' as const, status: 'ok' as const, principal, apps: null, message: `The credential broker hands out Play tokens for ${principal ?? 'the service account'} (the app count needs the Reporting API scope, which the broker does not hand out).`, fix: null, docs: null }
: playCheck(principal, { apps });
return { ...c, source, ...(await brokerGrants(root, principal)) };
} catch (e) {
if (e instanceof BrokerRefused || e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)
return { store: 'android', status: 'error', principal, apps: null, source, message: (e as Error).message, fix: 'Check the credential broker: ~/.storebound/broker.json (url, apiKeyFile) and the scopes of the API key (google:read, google:token).', docs: null };
return { ...playCheck(principal, { error: e }), source };
}
}
const ALL_NEEDS: PlayNeed[] = ['upload', 'listing', 'iap', 'declarations', 'reviews'];
/** With a credential broker: the Play permissions and enabled APIs it lists for the Play apps this machine knows (at
* most 25, 4 reads at a time), for the account check (CLI and the desktop's Accounts view). */
async function brokerGrants(root: string, principal: string | null): Promise<{ grants?: BrokerGrant[] }> {
let cfg;
try { cfg = brokerConfig(root); } catch { return {}; }
if (!cfg) return {};
const pkgs = knownPlayPackages(root).slice(0, 25).map(k => k.packageName);
const out: (BrokerGrant | null)[] = new Array(pkgs.length).fill(null);
let next = 0;
await Promise.all(Array.from({ length: Math.min(4, pkgs.length) }, async () => {
while (next < pkgs.length) {
const i = next++, pkg = pkgs[i]!;
const r = await brokerPlayInfo(cfg, pkg);
if (r.kind === 'ok') {
if (principal && r.info.account && r.info.account !== principal) continue;
out[i] = { packageName: pkg, registered: true, grants: grantsOf(r.info), enabledApis: enabledApisOf(r.info), missing: playGaps(r.info, ALL_NEEDS).map(g => g.name) };
} else if (r.kind === 'not-registered') out[i] = { packageName: pkg, registered: false, grants: null, enabledApis: null, missing: [] };
}
}));
return { grants: out.filter((g): g is BrokerGrant => !!g) };
}
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);

View file

@ -9,7 +9,8 @@ import { join, resolve } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { credentialStatus } from './lib/credentials';
import { credentialStatus, maskUser } from './lib/credentials';
import { type PlayCredentials, playCredentials } from './lib/google-auth';
import { log, printJson } from './lib/log';
import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
@ -38,6 +39,9 @@ export interface DoctorResult {
/** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or
* placeholder (a template value, treated as missing). Never the values. */
credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[];
/** Where Play tokens come from: the credential broker (~/.storebound/broker.json) or the local key file; the
* e-mail masked, never a key. */
play: PlayCredentials;
/** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */
foreignRsync: string | null;
}
@ -50,10 +54,24 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd),
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), play: maskedPlay(cwd),
foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null };
}
function maskedPlay(cwd: string): PlayCredentials {
const p = playCredentials(cwd);
return { ...p, principal: p.principal ? maskUser(p.principal) : null };
}
/** The one line about Play access: which source, and the broker's state. */
export function playLine(p: PlayCredentials): string {
const who = p.principal ? ` for ${p.principal}` : '';
if (p.source === 'broker') return `Play: tokens from the credential broker ${p.broker!.url}${who}${p.local ? ' (local key as fallback)' : ''}`;
const broken = p.broker?.error ? ` · credential broker not usable: ${p.broker.error}` : '';
if (p.source === 'file') return `Play: local service account key${who}${broken}`;
return `Play: no service account key and no credential broker${broken}`;
}
/** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */
export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } {
const tool = r.tools.find(t => t.id === id);
@ -95,6 +113,7 @@ export async function run(args: Args): Promise<void> {
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
log('doctor', playLine(r.play));
if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`);
const missing = r.tools.filter(t => !t.found || t.status === 'outdated');
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }

View file

@ -105,6 +105,7 @@ async function main(): Promise<void> {
const loaded = await loadConfig(args.get('config'));
const { acquireLock, lockKeys } = await import('./lib/lock');
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
await playPreflight(key, loaded, args);
await (await COMMANDS[key]![0]()).run({ loaded, args });
};
await (args.has('json') ? withJsonOutput(go) : go());
@ -119,8 +120,20 @@ async function main(): Promise<void> {
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
const { acquireLock, lockKeys } = await import('./lib/lock');
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
await playPreflight(key, loaded, args);
await (await COMMANDS[key]![0]()).run({ loaded, args });
});
}
/** Play commands with a credential broker: the package's grants and APIs are read first, and a missing permission or a
* disabled API stops the command before any Play call (a dry run or a read only warns). cli/lib/broker.ts. */
async function playPreflight(key: string, loaded: { root: string; config: { play?: { packageName: string } } }, args: ReturnType<typeof parseArgs>): Promise<void> {
if (!key.startsWith('play ')) return;
const b = await import('./lib/broker');
if (!b.COMMAND_NEEDS[key]) return;
const { warn } = await import('./lib/log');
const read = args.has('dry') || ['status', 'pull'].includes(String(args._[0] ?? ''));
await b.playPreflight(key, loaded.root, loaded.config.play?.packageName, { dry: read, warn: m => warn('preflight', m) });
}
main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); });

View file

@ -9,6 +9,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSyn
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials';
import { brokerConfig, profileBrokerOff } from './broker';
export type Kind = 'apple' | 'play';
/** The store keys that move to a profile (one developer account's), never the app's own ones. */
@ -36,12 +37,19 @@ export function profileIdentity(name: string): Identity {
}
/** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; error?: string }[] {
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null;
/** Where Play tokens come from: the credential broker (~/.storebound/broker.json), the profile's key file, or none. */
playSource: 'broker' | 'file' | null; error?: string }[] {
let broker = false;
try { broker = !!brokerConfig(); } catch { /* a broken broker.json: doctor reports it */ }
return profileNames().map(profile => {
try {
const id = profileIdentity(profile);
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId };
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; }
const email = id.playEmail ?? profileEnv(profile).PLAY_SERVICE_ACCOUNT_EMAIL ?? null;
const viaBroker = broker && !profileBrokerOff(profile) && !!email;
const playSource = viaBroker ? 'broker' as const : id.playEmail ? 'file' as const : null;
return { profile, apple: !!id.appleKeyId, play: !!playSource, playAccount: email && playSource ? maskUser(email) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId, playSource };
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, playSource: null, error: (e as Error).message }; }
});
}

327
cli/lib/broker.ts Normal file
View file

@ -0,0 +1,327 @@
// Credential broker: a server of the owner's that holds the Google service-account keys, the Cloud project facts and
// the Play permission grants, so this machine needs no Google key of its own. storebound asks it for short-lived
// Google access tokens (POST /api/google/token) and for the Play facts of one package (GET
// /api/google/play/<package>/storebound: the service account's Play permissions, the Cloud projects' enabled APIs, the
// SDK config files). docs/credential-broker.md.
//
// Config: ~/.storebound/broker.json (file: env STOREBOUND_BROKER_CONFIG)
// { "url": "https://broker.example.com", "apiKeyFile": "~/.storebound/broker.key", "account"?: "<service account e-mail>" }
// env STOREBOUND_BROKER_URL / STOREBOUND_BROKER_KEY_FILE override the file. Which Google account to mint for:
// PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile, .storebound/credentials.json), else "account" in broker.json,
// else the client_email of a local key, else the account the broker names for the package. Off: env
// STOREBOUND_BROKER=off, or "broker": false in a credential profile (that profile keeps its local key).
// The key file holds one scoped API key (google:read google:token); on macOS / Linux it must be mode 600 (a group or
// world readable file is refused). Tokens live in memory only, until 5 minutes before they expire, one request per
// account + scope at a time; they are never written or logged, and every error text has ya29. tokens and the API key
// redacted.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { activeProfile, credentialValue, PROFILE_RE, profileDir } from './credentials';
import { enableUrl, PLAY_USERS_URL } from './google-errors';
export type BrokerScope = 'androidpublisher' | 'cloud-platform.read-only' | 'firebase.readonly';
export const BROKER_SCOPES: readonly BrokerScope[] = ['androidpublisher', 'cloud-platform.read-only', 'firebase.readonly'];
export interface BrokerConfig {
url: string;
keyFile: string;
/** The Google account (service account e-mail) to mint tokens for, when configured. */
account: string | null;
/** Where the URL came from: "env" or the broker.json path. */
from: string;
}
/** The broker did not answer (network error, timeout, 5xx): callers fall back to a local key when there is one. */
export class BrokerUnavailable extends Error {}
/** The broker refused this machine's API key (401 / 403): no fallback, no retry; the key needs its scopes. */
export class BrokerRefused extends Error { constructor(msg: string, readonly status: number) { super(msg); } }
/** The broker has no such Google account (400 / 404 on a token request): a local key may still serve it. */
export class BrokerUnknownAccount extends Error {}
const OFF = /^(off|0|false|no)$/i;
export const brokerConfigFile = (): string => process.env.STOREBOUND_BROKER_CONFIG || join(homedir(), '.storebound', 'broker.json');
const expand = (p: string, base: string): string => { const x = p.replace(/^~(?=$|[/\\])/, homedir()); return isAbsolute(x) ? x : resolve(base, x); };
/** A profile that says "broker": false keeps its local key (also env STOREBOUND_PROFILE_<NAME>_BROKER=off). */
export function profileBrokerOff(name: string): boolean {
if (!PROFILE_RE.test(name)) return false;
if (OFF.test(process.env[`STOREBOUND_PROFILE_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BROKER`] ?? '')) return true;
try {
const f = join(profileDir(), `${name}.json`);
if (!existsSync(f)) return false;
const v = (JSON.parse(readFileSync(f, 'utf8')) as Record<string, unknown>).broker;
return v === false || (typeof v === 'string' && OFF.test(v));
} catch { return false; }
}
function profileOptsOut(root: string): boolean {
try { const p = activeProfile(root); return !!p && profileBrokerOff(p.name); } catch { return false; }
}
/** Only https, or http to this machine (a local test broker): the API key never travels in clear text. */
export function checkBrokerUrl(raw: string): string {
let u: URL;
try { u = new URL(raw); } catch { throw new Error(`credential broker url "${raw}" is not a URL (https://broker.example.com)`); }
const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`credential broker url ${u.origin}: https only (http only for localhost)`);
return u.origin + u.pathname.replace(/\/+$/, '');
}
/**
* The broker config for a project (null: not configured, or turned off for it). Throws on an unreadable broker.json or
* a bad URL: a broken config is reported, never silently skipped.
*/
export function brokerConfig(root?: string): BrokerConfig | null {
if (OFF.test(process.env.STOREBOUND_BROKER ?? '')) return null;
if (root && profileOptsOut(root)) return null;
const f = brokerConfigFile();
let file: { url?: unknown; apiKeyFile?: unknown; account?: unknown } = {};
if (existsSync(f)) {
try { file = JSON.parse(readFileSync(f, 'utf8')) as typeof file; } catch (e) { throw new Error(`${f}: ${(e as Error).message}`); }
if (!file || typeof file !== 'object' || Array.isArray(file)) throw new Error(`${f}: an object { "url", "apiKeyFile" }`);
}
const envUrl = process.env.STOREBOUND_BROKER_URL;
const raw = envUrl || (typeof file.url === 'string' ? file.url : '');
if (!raw) return null;
const keyRaw = process.env.STOREBOUND_BROKER_KEY_FILE || (typeof file.apiKeyFile === 'string' && file.apiKeyFile ? file.apiKeyFile : join(dirname(f), 'broker.key'));
let account: string | null = null;
try { account = (root ? credentialValue(root, 'PLAY_SERVICE_ACCOUNT_EMAIL') : process.env.PLAY_SERVICE_ACCOUNT_EMAIL) || null; } catch { /* unreadable project file */ }
if (!account && typeof file.account === 'string' && file.account) account = file.account;
return { url: checkBrokerUrl(raw), keyFile: expand(keyRaw, dirname(f)), account, from: envUrl ? 'env' : f };
}
/** The API key from the key file. Refuses a group / world readable file on POSIX. Never printed. */
export function brokerKey(cfg: BrokerConfig, platform: NodeJS.Platform = process.platform): string {
let mode: number;
try { mode = statSync(cfg.keyFile).mode; } catch { throw new Error(`credential broker key file ${cfg.keyFile} not found: put the API key there (one line, chmod 600)`); }
if (platform !== 'win32' && (mode & 0o077)) throw new Error(`refusing the credential broker key file ${cfg.keyFile}: it is readable by group or others (mode ${(mode & 0o777).toString(8)}); run chmod 600 ${cfg.keyFile}`);
const k = readFileSync(cfg.keyFile, 'utf8').trim();
if (!k || /\s/.test(k)) throw new Error(`credential broker key file ${cfg.keyFile}: one API key on one line`);
return k;
}
/** An error text without Google access tokens, bearer values or the given secrets. */
export function redact(text: string, ...secrets: (string | null | undefined)[]): string {
let s = String(text ?? '').replace(/ya29\.[A-Za-z0-9._~+/=-]+/g, 'ya29.***').replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1***');
for (const x of secrets) if (x && x.length >= 6) s = s.split(x).join('***');
return s;
}
const host = (cfg: BrokerConfig): string => { try { return new URL(cfg.url).host; } catch { return cfg.url; } };
export const brokerTimeoutMs = (): number => Number(process.env.STOREBOUND_BROKER_TIMEOUT_MS) || 15_000;
interface Answer { status: number; json: unknown; text: string }
async function request(cfg: BrokerConfig, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Answer> {
const key = brokerKey(cfg);
let r: Response;
try {
r = await fetch(cfg.url + path, {
method, signal: AbortSignal.timeout(brokerTimeoutMs()),
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (e) { throw new BrokerUnavailable(`credential broker ${host(cfg)} did not answer: ${redact((e as Error).message, key)}`); }
const raw = await r.text().catch(() => '');
let json: unknown = null;
try { json = raw ? JSON.parse(raw) : null; } catch { /* not JSON */ }
const text = redact(raw, key).slice(0, 300);
if (r.status >= 500) throw new BrokerUnavailable(`credential broker ${host(cfg)} -> ${r.status} ${text}`.trim());
return { status: r.status, json, text };
}
const errOf = (a: Answer): string => {
const j = a.json as { error?: unknown; message?: unknown } | null;
const m = j && typeof j === 'object' ? [j.error, j.message].filter(x => typeof x === 'string').join(': ') : '';
return m || a.text;
};
const tokens = new Map<string, { token: string; until: number }>();
const inflight = new Map<string, Promise<string>>();
const infos = new Map<string, Promise<PlayInfoResult>>();
/** Forgets every cached token and package answer (tests; a long-running process after a key change). */
export function clearBrokerCache(): void { tokens.clear(); inflight.clear(); infos.clear(); }
/**
* A Google access token for `account` with one broker scope. Cached in memory until 5 minutes before `expiresAt`;
* parallel callers share one request. 401 / 403 throw BrokerRefused (no retry), 400 / 404 BrokerUnknownAccount, no
* answer BrokerUnavailable.
*/
export async function brokerToken(cfg: BrokerConfig, account: string, scope: BrokerScope, now: () => number = Date.now): Promise<string> {
const k = `${cfg.url}\n${account}\n${scope}`;
const hit = tokens.get(k);
if (hit && now() < hit.until) return hit.token;
const running = inflight.get(k);
if (running) return running;
const p = (async () => {
const a = await request(cfg, 'POST', '/api/google/token', { account, scopes: [scope] });
if (a.status === 401 || a.status === 403)
throw new BrokerRefused(`the credential broker ${host(cfg)} refused the token request (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): its API key lacks the google:token scope or was revoked. Give the key google:read and google:token on the broker; storebound does not retry.`, a.status);
if (a.status === 400 || a.status === 404)
throw new BrokerUnknownAccount(`the credential broker ${host(cfg)} has no Google account ${account} for scope ${scope} (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): import its key on the broker, or set PLAY_SERVICE_ACCOUNT_EMAIL to an account it holds`);
const j = a.json as { accessToken?: unknown; expiresAt?: unknown } | null;
if (a.status !== 200 || typeof j?.accessToken !== 'string' || !j.accessToken || typeof j.expiresAt !== 'number')
throw new Error(`credential broker ${host(cfg)} token -> ${a.status}: no accessToken / expiresAt in the answer`);
const exp = j.expiresAt > 1e12 ? j.expiresAt : j.expiresAt * 1000;
tokens.set(k, { token: j.accessToken, until: exp - 300_000 });
return j.accessToken;
})().finally(() => inflight.delete(k));
inflight.set(k, p);
return p;
}
export interface BrokerPlayInfo {
packageName: string;
developerId?: string | null;
account?: string | null;
accountPermissions?: { developer?: string[]; app?: string[] } | null;
grantsCheckedAt?: number | null;
projects?: { projectId: string; projectNumber?: string; enabledApis?: string[]; billing?: string }[];
sdkConfigs?: { platform: string; sha256?: string; projectId?: string; firebaseAppId?: string; bundle?: string; drift?: boolean }[];
/** Optional fix links the broker may hand out. */
grantUrl?: string;
}
export type PlayInfoResult =
| { kind: 'ok'; info: BrokerPlayInfo }
/** 404: the package is not registered on the broker (not "no access"). */
| { kind: 'not-registered'; message: string }
| { kind: 'refused'; message: string }
| { kind: 'unavailable'; message: string };
/** What the broker knows about one Play package (once per run and package). Never throws. */
export function brokerPlayInfo(cfg: BrokerConfig, pkg: string): Promise<PlayInfoResult> {
const k = `${cfg.url}\n${pkg}`;
const cached = infos.get(k);
if (cached) return cached;
const p = (async (): Promise<PlayInfoResult> => {
try {
const a = await request(cfg, 'GET', `/api/google/play/${encodeURIComponent(pkg)}/storebound`);
if (a.status === 404) return { kind: 'not-registered', message: `${pkg} is not registered on the credential broker ${host(cfg)}: register the package there (google_play_set) to get its permission checks; this is not a missing Play access` };
if (a.status === 401 || a.status === 403) return { kind: 'refused', message: `the credential broker ${host(cfg)} refused the package read (${a.status}): its API key lacks the google:read scope` };
const j = a.json as BrokerPlayInfo | null;
if (a.status !== 200 || !j || typeof j !== 'object') return { kind: 'unavailable', message: `credential broker ${host(cfg)} -> ${a.status} ${a.text}`.trim() };
return { kind: 'ok', info: { ...j, packageName: typeof j.packageName === 'string' ? j.packageName : pkg } };
} catch (e) {
if (!(e instanceof BrokerUnavailable)) infos.delete(k);
return { kind: 'unavailable', message: (e as Error).message };
}
})();
infos.set(k, p);
return p;
}
/** The Play permission each kind of work needs (Play Console's app permission names, as the broker reports them). */
export const PLAY_GRANTS = {
upload: 'CAN_MANAGE_TRACK_APKS',
listing: 'CAN_MANAGE_PUBLIC_LISTING',
iap: 'CAN_MANAGE_ORDERS',
declarations: 'CAN_MANAGE_APP_CONTENT',
reviews: 'CAN_REPLY_TO_REVIEWS',
} as const;
export type PlayNeed = keyof typeof PLAY_GRANTS | 'games';
const NEED_WORDS: Record<PlayNeed, string> = { upload: 'uploads and releases', listing: 'the store listing and images', iap: 'in-app products and subscriptions', declarations: 'Data safety and app content', reviews: 'review replies', games: 'Play Games Services configuration' };
export const ANDROIDPUBLISHER_API = 'androidpublisher.googleapis.com';
export const GAMES_CONFIG_SERVICE = 'gamesconfiguration.googleapis.com';
/** What each storebound command needs from Play (the preflight before it runs). */
export const COMMAND_NEEDS: Record<string, PlayNeed[]> = {
'play upload': ['upload'], 'play release': ['upload'], 'play rollout': ['upload'], 'play share': ['upload'],
'play listing': ['listing'], 'play images': ['listing'], 'play products': ['iap'], 'play subscriptions': ['iap'],
'play data-safety': ['declarations'], 'play games': ['games'],
};
export interface AccessGap {
kind: 'grant' | 'api';
need: PlayNeed;
/** The missing permission (CAN_MANAGE_TRACK_APKS) or API (gamesconfiguration.googleapis.com). */
name: string;
message: string;
fix: string;
url: string;
}
/** The grants of a package as one list (developer-level permissions count for every app). */
export const grantsOf = (info: BrokerPlayInfo): string[] | null => {
const p = info.accountPermissions;
if (!p || (!Array.isArray(p.app) && !Array.isArray(p.developer))) return null;
return [...new Set([...(p.developer ?? []), ...(p.app ?? [])])].sort();
};
/** Every API the broker saw enabled in the package's Cloud projects; null when it reported none. */
export const enabledApisOf = (info: BrokerPlayInfo): string[] | null => {
const ps = (info.projects ?? []).filter(p => Array.isArray(p.enabledApis));
return ps.length ? [...new Set(ps.flatMap(p => p.enabledApis!))].sort() : null;
};
/** The missing Play permissions and disabled APIs for `needs`, per the broker's answer. Unknown facts are no gap. */
export function playGaps(info: BrokerPlayInfo, needs: PlayNeed[]): AccessGap[] {
const pkg = info.packageName, who = info.account ?? 'the service account';
const gaps: AccessGap[] = [];
const grants = grantsOf(info);
const usersUrl = info.grantUrl ?? (info.developerId && /^\d+$/.test(info.developerId) ? `https://play.google.com/console/u/0/developers/${info.developerId}/users-and-permissions` : PLAY_USERS_URL);
for (const need of new Set(needs)) {
if (need === 'games' || !grants) continue;
const perm = PLAY_GRANTS[need];
if (grants.includes(perm)) continue;
gaps.push({ kind: 'grant', need, name: perm, url: usersUrl,
message: `${who} lacks the Play permission ${perm} for ${pkg} (needed for ${NEED_WORDS[need]})`,
fix: `grant ${perm} for ${pkg} on the credential broker (google_play_grant), or in Play Console > Users and permissions > ${who} > App permissions: ${usersUrl}` });
}
const apis = enabledApisOf(info);
if (apis) {
const project = (info.projects ?? []).find(p => Array.isArray(p.enabledApis))?.projectId ?? null;
const want = new Set<string>([ANDROIDPUBLISHER_API, ...(needs.includes('games') ? [GAMES_CONFIG_SERVICE] : [])]);
for (const api of want) {
if (apis.includes(api)) continue;
const url = enableUrl(api, project);
gaps.push({ kind: 'api', need: api === GAMES_CONFIG_SERVICE ? 'games' : needs[0] ?? 'upload', name: api, url,
message: `${api} is not enabled in Google Cloud project ${project ?? '(the service account\'s project)'} (needed for ${api === GAMES_CONFIG_SERVICE ? NEED_WORDS.games : 'every Play API call'})`,
fix: `enable ${api} on the credential broker (google_api_enable), or open ${url} and press Enable` });
}
}
return gaps;
}
const SDK_FILES: Record<string, string[]> = {
android: ['android/app/google-services.json', 'app/google-services.json', 'google-services.json', 'android/google-services.json'],
ios: ['ios/App/App/GoogleService-Info.plist', 'ios/Runner/GoogleService-Info.plist', 'ios/GoogleService-Info.plist', 'GoogleService-Info.plist'],
};
/** SDK config files the broker says drifted, or whose sha256 differs from the repo's copy. */
export function sdkConfigGaps(info: BrokerPlayInfo, root: string): string[] {
const out: string[] = [];
for (const c of info.sdkConfigs ?? []) {
const label = `${c.platform} SDK config${c.bundle ? ` (${c.bundle})` : ''}`;
if (c.drift) out.push(`${label}: the credential broker reports drift from the Firebase project ${c.projectId ?? ''}; download it again on the broker`.replace(/ ;/, ';'));
const file = (SDK_FILES[c.platform] ?? []).map(f => join(root, f)).find(f => existsSync(f));
if (file && c.sha256 && /^[0-9a-f]{64}$/i.test(c.sha256)) {
const sha = createHash('sha256').update(readFileSync(file)).digest('hex');
if (sha.toLowerCase() !== c.sha256.toLowerCase()) out.push(`${label}: ${file} differs from the broker's copy (sha256 ${sha.slice(0, 12)}... vs ${c.sha256.slice(0, 12)}...): take the broker's file`);
}
}
return out;
}
/** One error text for the gaps that stop a command. */
export const gapsText = (gaps: AccessGap[]): string => gaps.map(g => `${g.message}. Fix: ${g.fix}`).join('\n');
/**
* Before a Play command runs (cli/index.ts): with a broker, read the package's grants and APIs and stop early with the
* exact missing permission / API and its link. A dry run only warns. No broker, an unreachable one, or a package the
* broker does not know: a note, then the command runs (Google answers for itself).
*/
export async function playPreflight(command: string, root: string, pkg: string | undefined, o: { dry: boolean; warn: (msg: string) => void }): Promise<AccessGap[]> {
const needs = COMMAND_NEEDS[command];
if (!needs || !pkg) return [];
let cfg: BrokerConfig | null;
try { cfg = brokerConfig(root); } catch (e) { o.warn((e as Error).message); return []; }
if (!cfg) return [];
const r = await brokerPlayInfo(cfg, pkg);
if (r.kind !== 'ok') { o.warn(r.message); return []; }
const gaps = playGaps(r.info, needs);
if (!gaps.length) return [];
if (o.dry) { for (const g of gaps) o.warn(`${g.message}. Fix: ${g.fix}`); return gaps; }
throw new Error(`${command} stopped before any call to Google Play:\n${gapsText(gaps)}`);
}

View file

@ -21,6 +21,8 @@
// 4. the default profile (none named): store keys only
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account
// the broker mints tokens for, and "broker": false keeps a profile on its local key.
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@ -44,7 +46,7 @@ function fileStore(root: string): Store {
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
* for reports too; an app's Android upload keystore stays in the project. */
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const;
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL'] as const;
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
@ -158,7 +160,7 @@ export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean
}
/** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const;
/** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never
@ -206,7 +208,7 @@ export function parseServiceAccount(text: string): ServiceAccount {
export function playServiceAccount(root: string): ServiceAccount {
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles');
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key, or a credential broker in ~/.storebound/broker.json; `storebound accounts` lists the profiles');
return parseServiceAccount(inline(v, /^\s*\{/, root));
}

View file

@ -315,6 +315,22 @@ export interface AccountCheck {
/** What to do, in words. */
fix: string | null;
docs: string | null;
/** Play: where the tokens come from, the credential broker or the local key file (no values). */
source?: 'broker' | 'file' | null;
/** Play with a credential broker: the grants and APIs it lists per Play app of this machine. */
grants?: BrokerGrant[];
}
/** One Play app's access as the credential broker lists it (GET /api/google/play/<package>/storebound). */
export interface BrokerGrant {
packageName: string;
/** false: the package is not registered on the broker (no data; not a missing access). */
registered: boolean;
/** Play permissions of the service account (developer-level ones included); null when the broker has none. */
grants: string[] | null;
enabledApis: string[] | null;
/** Permissions / APIs storebound's commands need that are not there. */
missing: string[];
}
/**

34
cli/lib/google-access.ts Normal file
View file

@ -0,0 +1,34 @@
// A Google 401 / 403 that says what is missing (SERVICE_DISABLED, PERMISSION_DENIED, a refused key) becomes one
// GoogleAccessError with the exact API or grant and the link to fix it. It is never retried: the same call fails the
// same way until the owner (or the credential broker) changes something. With the broker as the token source the text
// also names the Play permissions the broker lists for the package and the broker-side fix.
import { brokerConfig, brokerPlayInfo, grantsOf, redact } from './broker';
import { findingText, type GoogleFinding, parseGoogleError } from './google-errors';
export class GoogleAccessError extends Error {
/** Never sent again: retry loops and the upsert layer check this. */
readonly noRetry = true;
constructor(msg: string, readonly finding: GoogleFinding) { super(msg); }
}
/** The GoogleAccessError for a failed call's text, or null when the text is another error. */
export async function accessError(text: string, o: { app?: string | null; root?: string; source?: 'broker' | 'file' } = {}): Promise<GoogleAccessError | null> {
const f = parseGoogleError(text, { app: o.app ?? null });
if (!f || f.kind === 'quota') return null;
const lines = [redact(text), findingText(f, { withUrl: true })];
if (o.source === 'broker') {
if (f.kind === 'auth-revoked') lines.push('The token came from the credential broker: its key for this service account was revoked or deleted at Google; rotate the key on the broker.');
if (f.kind === 'api-disabled') lines.push(`Or enable it on the credential broker (google_api_enable ${f.projectId ?? '<project>'} ${f.service}).`);
if (f.kind === 'no-app-access' && o.root && o.app) {
try {
const cfg = brokerConfig(o.root);
const r = cfg ? await brokerPlayInfo(cfg, o.app) : null;
if (r?.kind === 'ok') {
const g = grantsOf(r.info);
lines.push(`The credential broker lists ${g ? (g.length ? g.join(', ') : 'no Play permissions') : 'no permission data'} for ${r.info.account ?? 'the service account'} on ${o.app}: grant the missing one there (google_play_grant).`);
} else if (r) lines.push(r.message);
} catch { /* the Google error stands on its own */ }
}
}
return new GoogleAccessError(lines.join('\n'), f);
}

View file

@ -1,5 +1,7 @@
// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency.
import type { ServiceAccount } from './credentials';
import { playServiceAccount, type ServiceAccount } from './credentials';
import { BROKER_SCOPES, type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, type BrokerScope, brokerToken, BrokerUnavailable, BrokerUnknownAccount, redact } from './broker';
import { warn } from './log';
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
/** Play Developer Reporting API (vitals): https://developers.google.com/play/developer/reporting/reference/rest */
@ -42,3 +44,97 @@ export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promi
return token;
};
}
// ---- Play access through a credential broker (cli/lib/broker.ts) or a local key ----
/** The broker scope a Google scope maps to; null: the broker hands out no such scope (a local key is needed). */
export const BROKER_SCOPE_OF: Record<string, BrokerScope> = { [PLAY_SCOPE]: 'androidpublisher', [STORAGE_READ_SCOPE]: 'cloud-platform.read-only' };
export type PlaySource = 'broker' | 'file';
/** A Play access token getter that also says where its tokens come from (no values). */
export interface PlayToken {
(): Promise<string>;
/** "broker" until the broker failed over to the local key (then "file"). */
source(): PlaySource;
/** The service account e-mail the tokens are for, when known. */
principal(): string | null;
}
const fellBack = new Set<string>();
/**
* The Play token getter of a project: the credential broker when it is configured and has the scope (POST
* /api/google/token), else the local service account key (env, profile, .storebound/credentials.json). The broker not
* answering, or not knowing the account, falls back to the local key once with a warning; a broker that refuses the
* API key (401 / 403) fails. `brokerScope: null` forces the local key (writes the broker has no scope for).
* Throws at once when neither is there.
*/
export function playToken(root: string, scope = PLAY_SCOPE, o: { packageName?: string; brokerScope?: BrokerScope | null; warn?: (msg: string) => void } = {}): PlayToken {
let sa: ServiceAccount | null = null, saErr: Error | null = null;
try { sa = playServiceAccount(root); } catch (e) { saErr = e as Error; }
const cfg = brokerConfig(root);
const bscope = o.brokerScope === undefined ? BROKER_SCOPE_OF[scope] ?? null : o.brokerScope;
const local = sa ? tokenSource(sa, scope) : null;
if (!cfg || !bscope) {
if (!local) {
if (cfg && !bscope) throw new Error(`${scope.replace('https://www.googleapis.com/auth/', '')}: the credential broker hands out no such scope (${BROKER_SCOPES.join(', ')}), and there is no local service account key (PLAY_SERVICE_ACCOUNT) for it`);
throw saErr!;
}
return Object.assign(() => local(), { source: (): PlaySource => 'file', principal: () => sa!.client_email });
}
let account = cfg.account ?? sa?.client_email ?? null;
let mode: PlaySource = 'broker';
const say = o.warn ?? ((m: string) => warn('credentials', m));
const get = async (): Promise<string> => {
if (mode === 'file') return local!();
try {
if (!account && o.packageName) {
const r = await brokerPlayInfo(cfg, o.packageName);
if (r.kind === 'unavailable') throw new BrokerUnavailable(r.message);
if (r.kind === 'ok' && r.info.account) account = r.info.account;
}
if (!account) throw new Error('credential broker: which Google account? Set PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile or .storebound/credentials.json) or "account" in broker.json');
return await brokerToken(cfg, account, bscope);
} catch (e) {
if (local && (e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)) {
const once = `${cfg.url}\n${sa!.client_email}`;
if (!fellBack.has(once)) { fellBack.add(once); say(`${redact((e as Error).message)}; using the local service account key instead`); }
mode = 'file';
account = sa!.client_email;
return local();
}
throw e;
}
};
return Object.assign(get, { source: () => mode, principal: () => account });
}
export interface PlayCredentials {
/** Where Play tokens come from for this project: the broker, the local key file, or nowhere. */
source: PlaySource | null;
/** The service account e-mail when known (masked by the callers that print it). */
principal: string | null;
/** A local key is there too (the broker's fallback). */
local: boolean;
/** The broker config (its URL and key file path, never the key), and what is wrong with it. */
broker: { url: string; keyFile: string; from: string; error?: string } | null;
error?: string;
}
/** Where this project's Play access comes from, without network calls and without values. */
export function playCredentials(root: string): PlayCredentials {
let sa: ServiceAccount | null = null, error: string | undefined;
try { sa = playServiceAccount(root); } catch (e) { error = (e as Error).message; }
let cfg: BrokerConfig | null = null, brokerError: string | undefined;
try { cfg = brokerConfig(root); } catch (e) { brokerError = (e as Error).message; }
if (cfg) { try { brokerKey(cfg); } catch (e) { brokerError = (e as Error).message; } }
const broker = cfg ? { url: cfg.url, keyFile: cfg.keyFile, from: cfg.from, ...(brokerError ? { error: brokerError } : {}) } : null;
const usable = !!cfg && !brokerError;
return {
source: usable ? 'broker' : sa ? 'file' : null,
principal: (usable ? cfg!.account : null) ?? sa?.client_email ?? null,
local: !!sa, broker,
...(brokerError || !(usable || sa) ? { error: brokerError ?? error } : {}),
};
}

View file

@ -1,6 +1,6 @@
// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle.
import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { PLAY_SCOPE, playToken } from '../lib/google-auth';
import { accessError } from '../lib/google-access';
import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
@ -15,7 +15,8 @@ export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already bee
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
export async function playClient(root: string, packageName: string) {
const token = tokenSource(playServiceAccount(root));
// the credential broker's token when one is configured, else the local service account key (cli/lib/google-auth.ts)
const token = playToken(root, PLAY_SCOPE, { packageName });
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`;
const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`;
@ -49,6 +50,8 @@ export async function playClient(root: string, packageName: string) {
const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text);
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status);
if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T;
// a disabled API or a missing Play permission: the exact API / grant and its link, never retried (pitfall 122)
if (r.status === 403 || r.status === 401) { const g = await accessError(`${method} ${short()} -> ${r.status} ${msg}`, { app: packageName, root, source: token.source() }); if (g) throw g; }
throw new Error(`${method} ${short()} -> ${r.status} ${msg}`);
}
}
@ -141,6 +144,8 @@ export async function playClient(root: string, packageName: string) {
return {
API, UPLOAD, packageName, call,
/** Where this client's tokens come from: "broker" or "file" (no values). */
credentialSource: () => token.source(),
/**
* Read-only edit: opens one, runs fn (GETs only), then deletes it, also on error. Never validated or committed.
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).

View file

@ -9,11 +9,12 @@
// configuration with the fields Play has and we do not manage (icon, sort rank) kept. Nothing is published: the
// owner's Publish in Play Console makes the draft live.
import { playServiceAccount } from '../lib/credentials';
import type { PlayToken } from '../lib/google-auth';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services';
import { PLAY_SCOPE, tokenSource } from '../lib/google-auth';
import { PLAY_SCOPE, playToken } from '../lib/google-auth';
import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors';
import { errorBody } from '../lib/http';
import { done, log, warn } from '../lib/log';
@ -36,13 +37,24 @@ API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run s
}
export function gamesConfigClient(root: string) {
const account = playServiceAccount(root);
const token = tokenSource(account, PLAY_SCOPE);
// reads: the credential broker's cloud-platform.read-only token (androidpublisher does not cover this API), else the
// local key; writes stay on the local key (the broker has no write scope for it): made only when a write is sent
let projectId: string | null = null;
try { projectId = playServiceAccount(root).project_id ?? null; } catch { /* broker only */ }
const readToken = playToken(root, PLAY_SCOPE, { brokerScope: 'cloud-platform.read-only' });
let writeToken: PlayToken | null = null;
const token = (method: string): Promise<string> => {
if (method === 'GET') return readToken();
try { writeToken ??= playToken(root, PLAY_SCOPE, { brokerScope: null }); } catch (e) {
throw new Error(`Play Games configuration writes need a local service account key (PLAY_SERVICE_ACCOUNT): the credential broker hands out read scopes only for this API. ${(e as Error).message}`);
}
return writeToken();
};
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
const url = GAMES_CONFIG_API + path;
const safe = method !== 'POST' && retrySafe(method, url);
for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token(method)), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
if (r.status === 429 && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
if (r.status >= 500) {
if (safe && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; }
@ -53,7 +65,7 @@ export function gamesConfigClient(root: string) {
const t = await r.text();
if (!r.ok) {
const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`;
const off = r.status === 403 ? gamesApiDisabledText(msg, account.project_id ?? null) : null;
const off = r.status === 403 ? gamesApiDisabledText(msg, projectId) : null;
throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg);
}
return (t ? JSON.parse(t) : {}) as T;

View file

@ -1,7 +1,6 @@
// Small client for the other Google APIs storebound reads with the same Play service account: the Play Developer
// Reporting API (vitals) and Cloud Storage (Play statistics exports). Read-only callers; retries 429 / 5xx.
import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth';
import { playToken } from '../lib/google-auth';
import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import * as rt from '../lib/rt';
@ -9,7 +8,8 @@ import * as rt from '../lib/rt';
export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1';
export function googleClient(root: string, scope: string) {
const token = tokenSource(playServiceAccount(root), scope);
// the credential broker when it hands out this scope, else the local service account key
const token = playToken(root, scope);
async function send(method: string, url: string, body?: unknown): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },

View file

@ -40,6 +40,39 @@ import { creativeNote } from './shots/creative';
import { creativeWaiting, localCreative } from './apple/creative-assets';
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
import { listingFileMentions } from './lib/listing-mentions';
import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker';
import { playCredentials } from './lib/google-auth';
import { usesPlayGames } from './lib/game-services';
/** What the config's Play work needs from the service account: uploads and the listing always, products,
* declarations and Play Games when the config has them. */
export function playNeedsOf(config: Loaded['config']): PlayNeed[] {
const needs: PlayNeed[] = ['upload', 'listing'];
if ((config.products ?? []).some(p => sellsOn(config, p, 'play')) || allSubscriptions(config.subscriptions).some(x => sellsOn(config, x.sub, 'play'))) needs.push('iap');
if (config.dataSafety) needs.push('declarations');
if (usesPlayGames(config)) needs.push('games');
return needs;
}
/** With a credential broker: the Play permissions and enabled APIs it lists for the package, the gaps for what the
* config needs (blockers: the commands would get a 403), and SDK config drift. Nothing without a broker. */
export async function brokerFindings(loaded: Loaded): Promise<Finding[]> {
const { config, root } = loaded;
const pkg = config.play?.packageName;
if (!pkg) return [];
let cfg;
try { cfg = brokerConfig(root); } catch (e) { return [{ store: 'play', level: 'warn', code: 'broker-config', msg: (e as Error).message, fix: '~/.storebound/broker.json: { "url", "apiKeyFile" }' }]; }
if (!cfg) return [];
const r = await brokerPlayInfo(cfg, pkg);
if (r.kind === 'not-registered') return [{ store: 'play', level: 'warn', code: 'broker-not-registered', msg: r.message, fix: 'register the package on the credential broker (google_play_set)' }];
if (r.kind !== 'ok') return [{ store: 'play', level: 'warn', code: 'broker-unavailable', msg: `${r.message}: permission checks skipped`, fix: 'check the credential broker url and API key (~/.storebound/broker.json)' }];
const out: Finding[] = [];
const grants = grantsOf(r.info), apis = enabledApisOf(r.info);
out.push({ store: 'play', level: 'ok', code: 'broker-access', msg: `credential broker: ${r.info.account ?? 'service account'} on ${pkg}: Play permissions ${grants ? (grants.join(', ') || 'none') : 'not listed'}; enabled APIs ${apis ? (apis.join(', ') || 'none') : 'not listed'}` });
for (const g of playGaps(r.info, playNeedsOf(config))) out.push({ store: 'play', level: 'blocker', code: g.kind === 'grant' ? 'broker-grant' : 'broker-api', msg: g.message, fix: g.fix, url: g.url });
for (const m of sdkConfigGaps(r.info, root)) out.push({ store: 'play', level: 'warn', code: 'sdk-config', msg: m });
return out;
}
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
@ -231,8 +264,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' });
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
if ((both || a.has('play')) && config.play) {
// the broker's grants first: a missing permission is named before the edit reads hit the same 403
const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]);
findings.push(...access);
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); }
} else if (a.has('play')) warn('validate', 'no play section in the config');
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
if (config.games || config.apple) {
@ -259,7 +295,9 @@ export async function run(ctx: Ctx): Promise<void> {
findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' })));
}
const blockers = findings.filter(f => f.level === 'blocker').length;
if (a.has('json')) printJson({ blockers, findings });
// where Play tokens come from (MCP validate reads it): "broker" or "file", never a value
const credentials = config.play ? { play: { source: playCredentials(ctx.loaded.root).source } } : {};
if (a.has('json')) printJson({ blockers, findings, credentials });
else {
console.log(formatFindings(findings));
log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)');

View file

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

85
docs/credential-broker.md Normal file
View file

@ -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/<profile>.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_<NAME>_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/<package>/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 <url> 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.

View file

@ -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_<id>.p8` (default `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`) |
| `ASC_VENDOR_NUMBER` | vendor number for `reports apple-sales` / `apple-finance` (Payments and Financial Reports) |

View file

@ -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"). |

View file

@ -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/<package>/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).

View file

@ -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<Response>;
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<typeof Bun.serve>;
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<string, unknown> = {}): 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');
});
});

View file

@ -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.