98 lines
7 KiB
TypeScript
98 lines
7 KiB
TypeScript
// Google API errors -> typed findings. Google answers a disabled API, a missing app permission, a revoked key or a
|
|
// used-up quota with a JSON body ({"error":{"code":403,"status":"PERMISSION_DENIED","details":[{"reason":
|
|
// "SERVICE_DISABLED",...}]}}); storebound's clients put that body (on one line, maybe cut at 800 characters) into the
|
|
// Error message. This reads the message text (raw or cut), so every layer (CLI tables, health / analytics snapshots,
|
|
// the desktop window, even old cached snapshots) can say one plain line instead of showing the JSON. Pure.
|
|
|
|
export type GoogleFinding =
|
|
/** An API is not enabled in the service account's Google Cloud project (403 SERVICE_DISABLED). */
|
|
| { kind: 'api-disabled'; service: string; serviceTitle: string; projectId: string | null; activationUrl: string }
|
|
/** The service account has no access to this app in Play Console (403 PERMISSION_DENIED, not SERVICE_DISABLED). */
|
|
| { kind: 'no-app-access'; app: string | null; usersUrl: string }
|
|
/** Google refused the key itself (401 UNAUTHENTICATED, invalid_grant, Invalid JWT): revoked, deleted or expired. */
|
|
| { kind: 'auth-revoked' }
|
|
/** 429 / RESOURCE_EXHAUSTED / rateLimitExceeded: a quota is used up for now. */
|
|
| { kind: 'quota'; serviceTitle: string | null };
|
|
|
|
export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-and-permissions';
|
|
|
|
const TITLES: Record<string, string> = {
|
|
'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API',
|
|
'androidpublisher.googleapis.com': 'Google Play Android Developer API',
|
|
'storage.googleapis.com': 'Cloud Storage API',
|
|
'storage-api.googleapis.com': 'Cloud Storage API',
|
|
};
|
|
/** What the owner loses while that API is off (the start of the one line). */
|
|
const NEEDS: Record<string, string> = {
|
|
'playdeveloperreporting.googleapis.com': 'Crash and ANR data',
|
|
'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play',
|
|
'storage.googleapis.com': 'Play statistics (store listing visitors, installs)',
|
|
'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)',
|
|
};
|
|
|
|
/** A JSON string's content without its escapes (\\n as a space: one line). */
|
|
const unescape = (s: string): string => { try { return (JSON.parse(`"${s}"`) as string).replace(/\s+/g, ' ').trim(); } catch { return s.replace(/\\(.)/g, '$1'); } };
|
|
|
|
/** The API's own page in the Cloud console (the host the desktop may open), with the project chosen. */
|
|
export function enableUrl(service: string, projectId: string | null): string {
|
|
return `https://console.cloud.google.com/apis/library/${service}${projectId ? `?project=${projectId}` : ''}`;
|
|
}
|
|
|
|
/** The typed finding in a Google error text, or null when it is another error. `app`: the package, when the caller knows it. */
|
|
export function parseGoogleError(text: string, o: { app?: string | null } = {}): GoogleFinding | null {
|
|
const s = String(text ?? '');
|
|
const status = Number(/-> (\d{3})\b/.exec(s)?.[1] ?? /"code":\s*(\d{3})/.exec(s)?.[1] ?? NaN);
|
|
const app = o.app ?? /\/(?:applications|apps)\/([A-Za-z][\w.]*\.[\w.]+)/.exec(s)?.[1] ?? null;
|
|
if (/SERVICE_DISABLED|has not been used in project|accessNotConfigured|API has not been used|it is disabled\. Enable it| turned on in Google Cloud project/i.test(s)) {
|
|
const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1] ?? /apis\/api\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1]
|
|
?? /apis\/library\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1] ?? /([a-z0-9-]+\.googleapis\.com)/.exec(s)?.[1] ?? 'playdeveloperreporting.googleapis.com';
|
|
const projectId = /"consumer":\s*"projects\/([a-z0-9-]+)"/i.exec(s)?.[1] ?? /in (?:Google Cloud )?project ([a-z0-9-]+)/i.exec(s)?.[1] ?? /[?&]project=([a-z0-9-]+)/i.exec(s)?.[1] ?? null;
|
|
const serviceTitle = (/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1] && unescape(/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)![1]!))
|
|
?? /"message":\s*"([^"]*?) has not been used in project/.exec(s)?.[1] ?? / needs the (.+?) turned on in Google Cloud/.exec(s)?.[1] ?? TITLES[service] ?? service;
|
|
return { kind: 'api-disabled', service, serviceTitle, projectId, activationUrl: enableUrl(service, projectId) };
|
|
}
|
|
if (status === 429 || /RESOURCE_EXHAUSTED|rateLimitExceeded|quota(?: exceeded| metric)|Quota exceeded/i.test(s)) {
|
|
const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1];
|
|
return { kind: 'quota', serviceTitle: service ? TITLES[service] ?? service : null };
|
|
}
|
|
if (status === 401 || /UNAUTHENTICATED|invalid_grant|Invalid JWT|account not found|invalid_client/i.test(s)) return { kind: 'auth-revoked' };
|
|
if (status === 403 && /PERMISSION_DENIED|caller does not have permission|does not have permission|not have access/i.test(s)) return { kind: 'no-app-access', app, usersUrl: PLAY_USERS_URL };
|
|
return null;
|
|
}
|
|
|
|
/** The one plain line for a finding (no JSON). `withUrl`: the CLI adds where to fix it; the window has a button. */
|
|
export function findingText(f: GoogleFinding, o: { withUrl?: boolean } = {}): string {
|
|
switch (f.kind) {
|
|
case 'api-disabled': {
|
|
const what = NEEDS[f.service] ?? 'This read';
|
|
const line = `${what} needs the ${f.serviceTitle} turned on in Google Cloud project ${f.projectId ?? '(the service account\'s project)'}.`;
|
|
return o.withUrl ? `${line} Turn it on: ${f.activationUrl}` : line;
|
|
}
|
|
case 'no-app-access': {
|
|
const line = `The service account has no access to ${f.app ?? 'this app'} in Play Console: add it under Users and permissions (invite the service account, give it access to ${f.app ?? 'the app'}).`;
|
|
return o.withUrl ? `${line} ${f.usersUrl}` : line;
|
|
}
|
|
case 'auth-revoked':
|
|
return 'Google refused the service account key: it was revoked, deleted or has expired. Add a new JSON key (Google Cloud > IAM & Admin > Service accounts > Keys) and set it again.';
|
|
case 'quota':
|
|
return `The ${f.serviceTitle ?? 'Google API'} quota is used up for now: try again later (quotas reset per minute and per day).`;
|
|
}
|
|
}
|
|
|
|
/** An error text for people: the finding's line when there is one, else the text without a JSON body. */
|
|
export function plainError(text: string, o: { app?: string | null; withUrl?: boolean } = {}): string {
|
|
const f = parseGoogleError(text, o);
|
|
if (f) return findingText(f, o);
|
|
const s = String(text ?? '');
|
|
const msg = /"(?:message|detail|error_description)":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1];
|
|
const head = s.split(/\s[{[]"/)[0]!.trim();
|
|
return msg ? `${head} ${unescape(msg)}`.trim().slice(0, 300) : s.split('\n')[0]!.slice(0, 300);
|
|
}
|
|
|
|
/** A store read that failed, for snapshots (health, analytics) and JSON results: the plain line (with the fix URL),
|
|
* the typed finding when Google said what is wrong, and the raw text as `detail` (logs, a collapsed "Details"). */
|
|
export function storeReadError(raw: string, o: { app?: string | null } = {}): { error: string; finding?: GoogleFinding; detail?: string } {
|
|
const f = parseGoogleError(raw, o);
|
|
const error = f ? findingText(f, { withUrl: true }) : plainError(raw);
|
|
return { error, ...(f ? { finding: f } : {}), ...(error !== raw ? { detail: raw } : {}) };
|
|
}
|