Play Developer Reporting API v1beta1 (crashRateMetricSet / anrRateMetricSet DAILY queries in America/Los_Angeles, user-perceived 28-day rates, worst device models, errorIssues:search) and App Store Connect perfPowerMetrics, diagnostic signatures and TestFlight crash submissions. Thresholds 1.09 % crash / 0.47 % ANR / 8 % per device (developer.android.com/topic/performance/vitals): over = blocker, from 80 % = warn, in validate too (--no-health). Weekly Markdown report with week-over-week change in .storebound/health/. MCP: health (read-only), health_report.
44 lines
2.6 KiB
TypeScript
44 lines
2.6 KiB
TypeScript
// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency.
|
|
import type { ServiceAccount } from './credentials';
|
|
|
|
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
/** Play Developer Reporting API (vitals): https://developers.google.com/play/developer/reporting/reference/rest */
|
|
export const REPORTING_SCOPE = 'https://www.googleapis.com/auth/playdeveloperreporting';
|
|
/** Cloud Storage read-only (Play statistics exports in the pubsite_prod_rev_ bucket). */
|
|
export const STORAGE_READ_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_only';
|
|
const b64u = (b: ArrayBuffer | string): string => Buffer.from(typeof b === 'string' ? b : new Uint8Array(b)).toString('base64url');
|
|
|
|
function pemToDer(pem: string): ArrayBuffer {
|
|
const bytes = Buffer.from(pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '').replace(/\s+/g, ''), 'base64');
|
|
const out = new ArrayBuffer(bytes.length);
|
|
new Uint8Array(out).set(bytes);
|
|
return out;
|
|
}
|
|
|
|
export async function signAssertion(sa: ServiceAccount, nowS: number, scope = PLAY_SCOPE): Promise<string> {
|
|
const key = await crypto.subtle.importKey('pkcs8', pemToDer(sa.private_key), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
|
const head = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
const claims = b64u(JSON.stringify({ iss: sa.client_email, scope, aud: sa.token_uri || 'https://oauth2.googleapis.com/token', iat: nowS, exp: nowS + 3600 }));
|
|
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(`${head}.${claims}`));
|
|
return `${head}.${claims}.${b64u(sig)}`;
|
|
}
|
|
|
|
/** Cached access token getter (refreshes 5 minutes before expiry). */
|
|
export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promise<string> {
|
|
let token = '';
|
|
let until = 0;
|
|
return async () => {
|
|
if (token && Date.now() < until) return token;
|
|
const assertion = await signAssertion(sa, Math.floor(Date.now() / 1000), scope);
|
|
const r = await fetch(sa.token_uri || 'https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }),
|
|
});
|
|
const j = (await r.json()) as { access_token?: string; expires_in?: number; error_description?: string };
|
|
if (!j.access_token) throw new Error('Google token: ' + (j.error_description ?? r.status));
|
|
token = j.access_token;
|
|
until = Date.now() + ((j.expires_in ?? 3600) - 300) * 1000;
|
|
return token;
|
|
};
|
|
}
|