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:
parent
304407e523
commit
2c5e30090f
27 changed files with 1145 additions and 43 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }) },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue