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

@ -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' }) },