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