storebound/cli/lib/google-auth.ts
CRT 56e107dbbe Crash and vitals health: storebound health [--weekly], validate findings, store-health skill
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.
2026-10-10 23:28:10 +08:00

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