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