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

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