cursedbelt-server 4.13.1 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,750 @@
1
+ /**
2
+ * `cursedbelt-server/google-oauth` — the ONE Google OAuth implementation in this generation:
3
+ * consent URL, authorization-code exchange, refresh-token exchange, revocation, credential
4
+ * reading from env + a 0600 env file, a cached access-token minter, a live credential probe
5
+ * and the 403-to-remedy sentence.
6
+ *
7
+ * ── Why it is here ──────────────────────────────────────────────────────────
8
+ * Two apps held byte-for-byte copies of it (task 099, census entry 407), one of which had
9
+ * already forked ahead of the other with the credential-resolution half. Two copies of a
10
+ * credential parser are two answers to "which env keys are canonical", and the drift costs a
11
+ * sign-in. This file is the union of both, with the stricter body wherever they differed.
12
+ *
13
+ * ── Nothing here knows who is calling ───────────────────────────────────────
14
+ * No app name, no hostname, no secrets path, no env-file location and no scope list is
15
+ * baked in. The caller supplies the secrets file, the env-var names it accepts
16
+ * ({@link CredentialKeys}), the label a failure sentence names and the remedy it offers.
17
+ *
18
+ * ── Two ways a refresh token arrives, one exchange ──────────────────────────
19
+ * · {@link readGoogleCredentials} + {@link createGoogleTokenMinter} serve a consumer whose
20
+ * refresh token is a 0600 file on the machine, minted once by hand.
21
+ * · {@link buildConsentUrl} + {@link exchangeAuthCode} serve a consumer that obtains the
22
+ * refresh token AT RUNTIME from a browser consent and stores it itself. Feed it straight
23
+ * back in — `createGoogleTokenMinter({ credentials })` — so a consenting app still has
24
+ * exactly one `grant_type=refresh_token` POST.
25
+ *
26
+ * ── Failure is always LOUD, and never a secret ──────────────────────────────
27
+ * Every failure is a typed result carrying the exact remedy; nothing here throws. A
28
+ * credential that cannot be read, a token Google refuses, or a scope that was never granted
29
+ * must NEVER look like "nothing to report". No credential value is ever logged, returned in
30
+ * an error string, or thrown — Google's `error_description` is echoed because it describes
31
+ * the REQUEST, never the secret.
32
+ *
33
+ * ── A leaf ──────────────────────────────────────────────────────────────────
34
+ * One file, node builtins only, listed in `leafSubpathsImportNothing.spec.ts`. It is one file
35
+ * on purpose: the leaf fixture copies exactly the exported file, so a sibling import would be
36
+ * a red, and a single module cannot have the import cycle the old three-file split existed to
37
+ * avoid.
38
+ */
39
+ import { readFileSync } from 'node:fs';
40
+ import { homedir } from 'node:os';
41
+ import path from 'node:path';
42
+
43
+ // ─── Endpoints ───────────────────────────────────────────────────────────────
44
+
45
+ export const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
46
+ export const GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
47
+ export const GOOGLE_REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
48
+
49
+ // ─── Shared shapes ───────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Deliberately looser than `typeof fetch`.
53
+ *
54
+ * Every caller here passes one URL string and an init, and `typeof fetch` also carries Bun's
55
+ * `preconnect` property — so typing the seam as `typeof fetch` makes `async () =>
56
+ * Response.json(…)` a compile error in every test that fakes it, for a capability no code
57
+ * path uses.
58
+ */
59
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
60
+
61
+ /**
62
+ * An environment to read credentials from. `process.env` satisfies it, and so does a plain
63
+ * object in a test — deliberately not `NodeJS.ProcessEnv`, so a consumer's declarations do not
64
+ * need `@types/node` to type-check against this one.
65
+ */
66
+ export type EnvLike = Readonly<Record<string, string | undefined>>;
67
+
68
+ /** `process.env` where there is a `process`; an empty env where there is not. */
69
+ const ambientEnv = (): EnvLike => (typeof process !== 'undefined' ? process.env : {});
70
+
71
+ export interface GoogleCredentials {
72
+ clientId: string;
73
+ clientSecret: string;
74
+ refreshToken: string;
75
+ }
76
+
77
+ /**
78
+ * WHICH KIND of `unavailable`, structurally rather than by reading the sentence.
79
+ *
80
+ * 🔴 A caller that treats these two alike rings the wrong alarm. `refused` is Google saying no
81
+ * to a credential the owner has to act on; `transport` is a machine that could not reach
82
+ * Google — a laptop on a train — and a bell claiming a dead grant every time the wifi drops is
83
+ * how a real credential alarm gets ignored.
84
+ */
85
+ export type UnavailableCause = 'transport' | 'refused';
86
+
87
+ /** Everything below `ok` carries the exact remedy — never a quiet empty result. */
88
+ export type TokenResult =
89
+ | { status: 'ok'; accessToken: string; grantedScopes: string[] }
90
+ | { status: 'unconfigured'; reason: string; fix: string }
91
+ | { status: 'unavailable'; reason: string; fix: string; cause?: UnavailableCause };
92
+
93
+ // ─── Env-file reading ────────────────────────────────────────────────────────
94
+
95
+ /** `~/x` → `<home>/x`. Anything else is returned unchanged. */
96
+ export const expandTilde = (p: string): string =>
97
+ p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p;
98
+
99
+ /**
100
+ * Parse a `KEY=value` env file, tolerating quotes, blanks, `export`, `#` comments and CRLF.
101
+ *
102
+ * 🔴 CRLF is split on, not trimmed afterwards. Both app copies split on `\n` alone, and `.`
103
+ * does not match `\r`, so `(.*)$` failed on EVERY line of a CRLF file: a secrets file saved by
104
+ * a Windows editor parsed to `{}` and read as "no credential on this machine" — `unconfigured`,
105
+ * with the setup remedy, for a file that held all three values. Pinned in the spec.
106
+ */
107
+ export function parseEnvFile(text: string): Record<string, string> {
108
+ const out: Record<string, string> = {};
109
+ for (const line of text.split(/\r?\n/)) {
110
+ if (/^\s*#/.test(line)) continue;
111
+ const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
112
+ if (m?.[1]) out[m[1]] = (m[2] ?? '').trim().replace(/^["']|["']$/g, '');
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** The parsed secrets file, or `{}` when there is none or it cannot be read. Never throws. */
118
+ const readEnvFile = (secretsFile: string | undefined): Record<string, string> => {
119
+ if (!secretsFile) return {};
120
+ try {
121
+ return parseEnvFile(readFileSync(expandTilde(secretsFile), 'utf8'));
122
+ } catch {
123
+ return {};
124
+ }
125
+ };
126
+
127
+ /** Read any other key — env first, then the same secrets file (an API key, an address). */
128
+ export function readSecret(name: string, options: { env?: EnvLike; secretsFile?: string } = {}): string {
129
+ const fromEnv = (options.env ?? ambientEnv())[name]?.trim();
130
+ if (fromEnv) return fromEnv;
131
+ return readEnvFile(options.secretsFile)[name]?.trim() ?? '';
132
+ }
133
+
134
+ // ─── Credential resolution ───────────────────────────────────────────────────
135
+
136
+ /**
137
+ * The env-var names a caller will accept for each half of the credential, most canonical
138
+ * FIRST. A consumer with historical aliases lists them after its canonical names; a new
139
+ * consumer passes one name each.
140
+ */
141
+ export interface CredentialKeys {
142
+ clientId: readonly string[];
143
+ clientSecret: readonly string[];
144
+ refreshToken: readonly string[];
145
+ }
146
+
147
+ export const CANONICAL_CREDENTIAL_KEYS: CredentialKeys = {
148
+ clientId: ['GOOGLE_OAUTH_CLIENT_ID'],
149
+ clientSecret: ['GOOGLE_OAUTH_CLIENT_SECRET'],
150
+ refreshToken: ['GOOGLE_OAUTH_REFRESH_TOKEN'],
151
+ };
152
+
153
+ export interface ReadCredentialsOptions {
154
+ env?: EnvLike;
155
+ /** A 0600 file outside every checkout. `~` is expanded. The caller decides where it is. */
156
+ secretsFile?: string;
157
+ keys?: CredentialKeys;
158
+ }
159
+
160
+ /** Which name supplied one half of a credential, and where that name was read from. */
161
+ export interface CredentialSource {
162
+ name: string;
163
+ from: 'env' | 'file';
164
+ }
165
+
166
+ /**
167
+ * WHICH NAMES a credential actually came out of.
168
+ *
169
+ * 🔴 Why this is not a debugging nicety. A credential list is a PREFERENCE order, so a value
170
+ * under the preferred name wins merely by existing — it is never checked against the value
171
+ * under the alias. Measured 2026-09-15: one 0600 file held two different refresh tokens for
172
+ * the identical client and account, the canonical name was dead (`invalid_grant`) and the
173
+ * alias beside it exchanged fine. Every call took the dead one and reported "the grant has
174
+ * been revoked, re-consent" — a sentence that was false, sent the owner to a browser, and
175
+ * cost forty minutes of not believing it. `shadowed` makes that a five-second diagnosis.
176
+ */
177
+ export interface GoogleCredentialResolution {
178
+ /** The same value `readGoogleCredentials` returns — null if any half is missing. */
179
+ credentials: GoogleCredentials | null;
180
+ /** The name that supplied each half, or null when no listed name had a value. */
181
+ used: {
182
+ clientId: CredentialSource | null;
183
+ clientSecret: CredentialSource | null;
184
+ refreshToken: CredentialSource | null;
185
+ };
186
+ /** Names holding a value that lost to an earlier one. Never a value, only a name. */
187
+ shadowed: {
188
+ clientId: string[];
189
+ clientSecret: string[];
190
+ refreshToken: string[];
191
+ };
192
+ /** Every listed name for a half that nothing supplied — the `unconfigured` detail. */
193
+ missing: string[];
194
+ }
195
+
196
+ /**
197
+ * Read a Google OAuth credential AND report which names carried it.
198
+ *
199
+ * Precedence: env overrides the secrets file, and earlier key names override later aliases.
200
+ */
201
+ export function resolveGoogleCredentials(options: ReadCredentialsOptions = {}): GoogleCredentialResolution {
202
+ const env = options.env ?? ambientEnv();
203
+ const keys = options.keys ?? CANONICAL_CREDENTIAL_KEYS;
204
+ const fromFile = readEnvFile(options.secretsFile);
205
+ const read = (names: readonly string[]): { value: string; used: CredentialSource | null; shadowed: string[] } => {
206
+ let used: CredentialSource | null = null;
207
+ let value = '';
208
+ const shadowed: string[] = [];
209
+ for (const n of names) {
210
+ const fromEnv = env[n]?.trim();
211
+ const v = fromEnv || fromFile[n]?.trim();
212
+ if (!v) continue;
213
+ // The FIRST name that has a value wins. The rest are recorded rather than discarded —
214
+ // that list is the whole diagnosis when Google refuses the winner.
215
+ if (used === null) {
216
+ used = { name: n, from: fromEnv ? 'env' : 'file' };
217
+ value = v;
218
+ } else {
219
+ shadowed.push(n);
220
+ }
221
+ }
222
+ return { value, used, shadowed };
223
+ };
224
+ const clientId = read(keys.clientId);
225
+ const clientSecret = read(keys.clientSecret);
226
+ const refreshToken = read(keys.refreshToken);
227
+ const complete = Boolean(clientId.value && clientSecret.value && refreshToken.value);
228
+ return {
229
+ credentials: complete
230
+ ? { clientId: clientId.value, clientSecret: clientSecret.value, refreshToken: refreshToken.value }
231
+ : null,
232
+ used: { clientId: clientId.used, clientSecret: clientSecret.used, refreshToken: refreshToken.used },
233
+ shadowed: {
234
+ clientId: clientId.shadowed,
235
+ clientSecret: clientSecret.shadowed,
236
+ refreshToken: refreshToken.shadowed,
237
+ },
238
+ missing: [
239
+ ...(clientId.used ? [] : keys.clientId),
240
+ ...(clientSecret.used ? [] : keys.clientSecret),
241
+ ...(refreshToken.used ? [] : keys.refreshToken),
242
+ ],
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Read a Google OAuth credential — env overriding the secrets file, and earlier key names
248
+ * overriding later aliases.
249
+ *
250
+ * Returns null when any of the three is missing. A PARTIAL credential is the same as none:
251
+ * two of three cannot mint a token, and treating it as present would turn a setup mistake
252
+ * into an `unavailable` ("Google refused it") instead of the `unconfigured` ("you have not
253
+ * finished setting it up") that names the real fix.
254
+ */
255
+ export function readGoogleCredentials(options: ReadCredentialsOptions = {}): GoogleCredentials | null {
256
+ return resolveGoogleCredentials(options).credentials;
257
+ }
258
+
259
+ // ─── Consent + token endpoint ────────────────────────────────────────────────
260
+
261
+ export interface ConsentUrlOptions {
262
+ clientId: string;
263
+ /**
264
+ * Must match an Authorized redirect URI on the OAuth client EXACTLY — Google compares the
265
+ * string, not the URL. A trailing slash is a different URI.
266
+ */
267
+ redirectUri: string;
268
+ scopes: readonly string[];
269
+ /** The caller's CSRF/binding token. Google hands it back on the callback verbatim. */
270
+ state: string;
271
+ /**
272
+ * `consent` (the default) forces Google to re-issue a refresh token.
273
+ *
274
+ * 🔴 Not a preference. Google issues `refresh_token` only on the FIRST consent for a
275
+ * client+account pair; a second connect without this returns an access token and nothing
276
+ * else, so an app that stored only what arrived would be connected for an hour and then
277
+ * permanently unable to refresh.
278
+ */
279
+ prompt?: 'consent' | 'select_account' | 'none';
280
+ }
281
+
282
+ /** The URL to send the browser to. Pure — no network, so a caller can test its own wiring. */
283
+ export function buildConsentUrl(options: ConsentUrlOptions): string {
284
+ const params = new URLSearchParams({
285
+ client_id: options.clientId,
286
+ redirect_uri: options.redirectUri,
287
+ response_type: 'code',
288
+ scope: options.scopes.join(' '),
289
+ // Without `offline` Google issues no refresh token at all and the grant dies with the
290
+ // first access token.
291
+ access_type: 'offline',
292
+ include_granted_scopes: 'true',
293
+ prompt: options.prompt ?? 'consent',
294
+ state: options.state,
295
+ });
296
+ return `${GOOGLE_AUTH_ENDPOINT}?${params.toString()}`;
297
+ }
298
+
299
+ export interface ExchangeOptions {
300
+ clientId: string;
301
+ clientSecret: string;
302
+ redirectUri: string;
303
+ code: string;
304
+ fetchImpl?: FetchLike;
305
+ timeoutMs?: number;
306
+ }
307
+
308
+ export type AuthCodeResult =
309
+ | {
310
+ status: 'ok';
311
+ accessToken: string;
312
+ /**
313
+ * Absent when Google chose not to re-issue one. A caller that already holds a refresh
314
+ * token for this account must KEEP it rather than storing an empty string — see
315
+ * {@link ConsentUrlOptions.prompt}.
316
+ */
317
+ refreshToken?: string;
318
+ expiresInSeconds: number;
319
+ grantedScopes: string[];
320
+ }
321
+ | {
322
+ status: 'unavailable';
323
+ reason: string;
324
+ fix: string;
325
+ /** See {@link UnavailableCause}. Always set by this module; optional in the type. */
326
+ cause?: UnavailableCause;
327
+ };
328
+
329
+ /**
330
+ * The one POST to Google's token endpoint, whichever grant is being exercised.
331
+ *
332
+ * Both grants send form-encoded parameters to the same URL and get back the same body shape,
333
+ * so they share a reader. What differs is the SENTENCE a failure produces, which is why the
334
+ * remedies are parameters.
335
+ */
336
+ async function postTokenRequest(
337
+ params: Record<string, string>,
338
+ options: {
339
+ fetchImpl?: FetchLike;
340
+ timeoutMs?: number;
341
+ /** e.g. "exchange the consent code" — completes "could not reach Google to …". */
342
+ what: string;
343
+ refusalFix: string;
344
+ },
345
+ ): Promise<AuthCodeResult> {
346
+ const doFetch = options.fetchImpl ?? fetch;
347
+ let res: Response;
348
+ try {
349
+ res = await doFetch(GOOGLE_TOKEN_ENDPOINT, {
350
+ method: 'POST',
351
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
352
+ body: new URLSearchParams(params).toString(),
353
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
354
+ });
355
+ } catch (err) {
356
+ return {
357
+ status: 'unavailable',
358
+ cause: 'transport',
359
+ reason: `could not reach Google to ${options.what}: ${err instanceof Error ? err.message : String(err)}`,
360
+ fix: "check this machine's internet connection and try again",
361
+ };
362
+ }
363
+
364
+ type TokenBody = {
365
+ access_token?: unknown;
366
+ refresh_token?: unknown;
367
+ expires_in?: unknown;
368
+ scope?: unknown;
369
+ error?: unknown;
370
+ error_description?: unknown;
371
+ };
372
+ /**
373
+ * 🔴 A parse failure must NEVER mask a refusal. Google answers a revoked refresh token with
374
+ * a 400 whose body is sometimes not JSON at all, and reporting that as "the response was
375
+ * not JSON" sends the owner looking at a transport problem for what is a re-consent. So the
376
+ * STATUS is read first and the body is only ever a source of detail.
377
+ */
378
+ const body = (await res.json().catch(() => null)) as TokenBody | null;
379
+
380
+ if (!res.ok || typeof body?.access_token !== 'string' || body.access_token.length === 0) {
381
+ const detail =
382
+ (typeof body?.error_description === 'string' && body.error_description) ||
383
+ (typeof body?.error === 'string' && body.error) ||
384
+ `HTTP ${res.status}`;
385
+ return res.ok && body === null
386
+ ? {
387
+ status: 'unavailable',
388
+ cause: 'transport',
389
+ reason: "Google's token response was not JSON",
390
+ fix: 'try again; this is almost always transient',
391
+ }
392
+ : {
393
+ status: 'unavailable',
394
+ cause: 'refused',
395
+ reason: `Google refused the request: ${detail}`,
396
+ fix: options.refusalFix,
397
+ };
398
+ }
399
+
400
+ return {
401
+ status: 'ok',
402
+ accessToken: body.access_token,
403
+ ...(typeof body.refresh_token === 'string' && body.refresh_token ? { refreshToken: body.refresh_token } : {}),
404
+ expiresInSeconds: typeof body.expires_in === 'number' ? body.expires_in : 3600,
405
+ grantedScopes: typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : [],
406
+ };
407
+ }
408
+
409
+ /** Trade the `?code=` Google sent to the callback for tokens. */
410
+ export function exchangeAuthCode(options: ExchangeOptions): Promise<AuthCodeResult> {
411
+ return postTokenRequest(
412
+ {
413
+ code: options.code,
414
+ client_id: options.clientId,
415
+ client_secret: options.clientSecret,
416
+ redirect_uri: options.redirectUri,
417
+ grant_type: 'authorization_code',
418
+ },
419
+ {
420
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
421
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
422
+ what: 'exchange the consent code',
423
+ // `invalid_grant` here almost always means the code was already used or the
424
+ // redirect_uri does not match the one registered.
425
+ refusalFix: "confirm this app's redirect URI is listed on the OAuth client, then press Connect again",
426
+ },
427
+ );
428
+ }
429
+
430
+ export interface RefreshOptions {
431
+ clientId: string;
432
+ clientSecret: string;
433
+ refreshToken: string;
434
+ fetchImpl?: FetchLike;
435
+ timeoutMs?: number;
436
+ /** What to tell the owner when Google refuses the token — usually "re-consent". */
437
+ refusalFix?: string;
438
+ }
439
+
440
+ /**
441
+ * Trade a stored refresh token for a fresh access token, REPORTING ITS LIFETIME.
442
+ *
443
+ * The uncached half of {@link createGoogleTokenMinter}, exported because a consumer that
444
+ * PERSISTS the access token has to know when it dies — a caller that guesses an hour is a
445
+ * caller whose token is occasionally already dead when it is used.
446
+ */
447
+ export function refreshAccessToken(options: RefreshOptions): Promise<AuthCodeResult> {
448
+ return postTokenRequest(
449
+ {
450
+ client_id: options.clientId,
451
+ client_secret: options.clientSecret,
452
+ refresh_token: options.refreshToken,
453
+ grant_type: 'refresh_token',
454
+ },
455
+ {
456
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
457
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
458
+ what: 'refresh the access token',
459
+ refusalFix: options.refusalFix ?? 'the refresh token may have been revoked — re-run consent',
460
+ },
461
+ );
462
+ }
463
+
464
+ /**
465
+ * Best-effort revocation on disconnect. NEVER throws and never reports failure as a blocker:
466
+ * the local record is deleted either way, and a disconnect that refuses because Google was
467
+ * unreachable leaves the owner holding a connection they asked to end. Google having already
468
+ * forgotten the token answers 400, which is the same outcome as success from here — `false`
469
+ * says only "Google did not confirm it".
470
+ */
471
+ export async function revokeGoogleToken(
472
+ token: string,
473
+ options: { fetchImpl?: FetchLike; timeoutMs?: number } = {},
474
+ ): Promise<boolean> {
475
+ if (!token) return false;
476
+ const doFetch = options.fetchImpl ?? fetch;
477
+ try {
478
+ const res = await doFetch(GOOGLE_REVOKE_ENDPOINT, {
479
+ method: 'POST',
480
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
481
+ body: new URLSearchParams({ token }).toString(),
482
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15_000),
483
+ });
484
+ return res.ok;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ // ─── The cached minter ───────────────────────────────────────────────────────
491
+
492
+ export interface TokenMinterOptions {
493
+ /**
494
+ * Supplied directly (a consenting app's stored refresh token). `null` means "there are
495
+ * none"; `undefined` means "read them from `env` + `secretsFile` under `keys`".
496
+ */
497
+ credentials?: GoogleCredentials | null;
498
+ env?: EnvLike;
499
+ secretsFile?: string;
500
+ keys?: CredentialKeys;
501
+ fetchImpl?: FetchLike;
502
+ timeoutMs?: number;
503
+ now?: () => number;
504
+ /**
505
+ * What to tell the owner when the credential is missing or Google refuses it. Each consumer
506
+ * mints its token a different way, so the remedy is theirs.
507
+ */
508
+ setupFix?: string;
509
+ /** Named in the failure sentences, e.g. "the digest" or "the YouTube connection". */
510
+ label?: string;
511
+ }
512
+
513
+ export interface GoogleTokenMinter {
514
+ /** A valid access token, or a typed failure carrying the remedy. */
515
+ token(): Promise<TokenResult>;
516
+ /**
517
+ * The scopes Google says this grant actually has. Empty when the token could not be minted
518
+ * at all — callers must distinguish "no scopes" from "could not ask".
519
+ */
520
+ grantedScopes(): Promise<string[]>;
521
+ /** Drop the cached token — for a test, or after a 401 that suggests a stale one. */
522
+ invalidate(): void;
523
+ }
524
+
525
+ const DEFAULT_LABEL = 'this app';
526
+ const DEFAULT_SETUP_FIX = 're-run the one-time Google consent for this app';
527
+ /** A token is never handed out so close to expiry that the call it is used for could land after it dies. */
528
+ const EXPIRY_MARGIN_MS = 60_000;
529
+
530
+ /**
531
+ * A short-lived access token, cached in memory until {@link EXPIRY_MARGIN_MS} before it
532
+ * expires.
533
+ *
534
+ * The refresh token is exchanged on demand rather than held as a long-lived bearer, and the
535
+ * cache exists because one caller makes several calls across several APIs — minting per call
536
+ * would make the caller's latency proportional to its call count.
537
+ */
538
+ export function createGoogleTokenMinter(options: TokenMinterOptions = {}): GoogleTokenMinter {
539
+ const timeout = options.timeoutMs ?? 30_000;
540
+ const now = options.now ?? Date.now;
541
+ const label = options.label ?? DEFAULT_LABEL;
542
+ const fix = options.setupFix ?? DEFAULT_SETUP_FIX;
543
+
544
+ let cached: { accessToken: string; scopes: string[]; expiresAt: number } | null = null;
545
+
546
+ const mint = async (): Promise<TokenResult> => {
547
+ const creds =
548
+ options.credentials !== undefined
549
+ ? options.credentials
550
+ : readGoogleCredentials({ env: options.env, secretsFile: options.secretsFile, keys: options.keys });
551
+ if (!creds) {
552
+ return {
553
+ status: 'unconfigured',
554
+ reason: `no Google OAuth credential for ${label} is on this machine yet`,
555
+ fix,
556
+ };
557
+ }
558
+ const refreshed = await refreshAccessToken({
559
+ clientId: creds.clientId,
560
+ clientSecret: creds.clientSecret,
561
+ refreshToken: creds.refreshToken,
562
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
563
+ timeoutMs: timeout,
564
+ refusalFix: fix,
565
+ });
566
+ if (refreshed.status !== 'ok') return refreshed;
567
+
568
+ cached = {
569
+ accessToken: refreshed.accessToken,
570
+ scopes: refreshed.grantedScopes,
571
+ expiresAt: now() + Math.max(0, refreshed.expiresInSeconds * 1000 - EXPIRY_MARGIN_MS),
572
+ };
573
+ return { status: 'ok', accessToken: cached.accessToken, grantedScopes: cached.scopes };
574
+ };
575
+
576
+ // Closures, not `this`: `const { token } = minter` must keep working.
577
+ const token = async (): Promise<TokenResult> => {
578
+ if (cached && now() < cached.expiresAt) {
579
+ return { status: 'ok', accessToken: cached.accessToken, grantedScopes: cached.scopes };
580
+ }
581
+ return await mint();
582
+ };
583
+
584
+ return {
585
+ token,
586
+ async grantedScopes(): Promise<string[]> {
587
+ const t = await token();
588
+ return t.status === 'ok' ? t.grantedScopes : [];
589
+ },
590
+ invalidate() {
591
+ cached = null;
592
+ },
593
+ };
594
+ }
595
+
596
+ // ─── The live probe ──────────────────────────────────────────────────────────
597
+
598
+ /**
599
+ * What an EXCHANGE of the credential a caller is about to use actually did.
600
+ *
601
+ * Deliberately not a `TokenResult`: no access token comes out of here, because a probe that
602
+ * handed one back would become a second minting path with its own cache.
603
+ */
604
+ export interface CredentialProbe {
605
+ /**
606
+ * FOUR states, and the split between the last two is the load-bearing one: `refused` is
607
+ * Google saying no to this credential and is the owner's to fix; `unreachable` is this
608
+ * machine's network and fixes itself.
609
+ */
610
+ status: 'ok' | 'unconfigured' | 'refused' | 'unreachable';
611
+ /** The env name whose refresh token was exchanged. "" when none was found or credentials were supplied. */
612
+ refreshTokenName: string;
613
+ /** Other names for the refresh token that hold a value and were NOT used. */
614
+ shadowedRefreshTokenNames: string[];
615
+ /** What Google says this grant carries. Empty unless `status` is "ok". */
616
+ grantedScopes: string[];
617
+ /** One sentence for a bell, a boot log or a health body. Never a credential value. */
618
+ summary: string;
619
+ /** The exact next action. "" when `status` is "ok". */
620
+ fix: string;
621
+ }
622
+
623
+ /**
624
+ * 🔴 EXCHANGE the credential rather than assuming a present value is a working one.
625
+ *
626
+ * `readGoogleCredentials` cannot tell a dead value from a live one, because "present" is all
627
+ * it ever asked. The probe does the one thing that answers the question: a real
628
+ * `grant_type=refresh_token` POST, reported with the NAME it used and the names it shadowed.
629
+ * It is safe to run at boot — it mints a short-lived access token, drops it, and never throws.
630
+ */
631
+ export async function probeCredentialExchange(options: TokenMinterOptions = {}): Promise<CredentialProbe> {
632
+ const label = options.label ?? DEFAULT_LABEL;
633
+ const setupFix = options.setupFix ?? DEFAULT_SETUP_FIX;
634
+ // A caller that supplied the credential directly has no names to report, and must not be
635
+ // told about env vars it never read.
636
+ const supplied = options.credentials !== undefined;
637
+ const resolution = supplied
638
+ ? null
639
+ : resolveGoogleCredentials({ env: options.env, secretsFile: options.secretsFile, keys: options.keys });
640
+ const credentials = supplied ? (options.credentials ?? null) : (resolution?.credentials ?? null);
641
+ const refreshTokenName = resolution?.used.refreshToken?.name ?? '';
642
+ const shadowed = resolution?.shadowed.refreshToken ?? [];
643
+
644
+ if (!credentials) {
645
+ const missing = resolution?.missing ?? [];
646
+ return {
647
+ status: 'unconfigured',
648
+ refreshTokenName,
649
+ shadowedRefreshTokenNames: shadowed,
650
+ grantedScopes: [],
651
+ summary:
652
+ `no Google OAuth credential for ${label} is on this machine yet` +
653
+ (missing.length > 0 ? ` — nothing supplies ${missing.join(' or ')}` : ''),
654
+ fix: setupFix,
655
+ };
656
+ }
657
+
658
+ const refreshed = await refreshAccessToken({
659
+ clientId: credentials.clientId,
660
+ clientSecret: credentials.clientSecret,
661
+ refreshToken: credentials.refreshToken,
662
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
663
+ timeoutMs: options.timeoutMs ?? 30_000,
664
+ refusalFix: setupFix,
665
+ });
666
+
667
+ // The name is on BOTH outcomes on purpose. A green probe that does not say which of two
668
+ // spellings it proved is a green probe that proves nothing about the other one.
669
+ const used = refreshTokenName ? ` using ${refreshTokenName}` : '';
670
+ const one = shadowed.length === 1;
671
+ if (refreshed.status === 'ok') {
672
+ const alsoSet =
673
+ shadowed.length > 0
674
+ ? ` ${shadowed.join(' and ')} also hold${one ? 's' : ''} a value for the same credential and ${one ? 'is' : 'are'} never reached.`
675
+ : '';
676
+ return {
677
+ status: 'ok',
678
+ refreshTokenName,
679
+ shadowedRefreshTokenNames: shadowed,
680
+ grantedScopes: refreshed.grantedScopes,
681
+ summary: `Google exchanged ${label}'s refresh token${used} — ${refreshed.grantedScopes.length} scope(s) granted.${alsoSet}`,
682
+ fix: '',
683
+ };
684
+ }
685
+
686
+ // A machine that could not reach Google has proved nothing about the credential, and must
687
+ // not be reported as though it had.
688
+ if (refreshed.cause === 'transport') {
689
+ return {
690
+ status: 'unreachable',
691
+ refreshTokenName,
692
+ shadowedRefreshTokenNames: shadowed,
693
+ grantedScopes: [],
694
+ summary: `${refreshed.reason} — ${label}'s credential was NOT tested, so this says nothing about whether it works.`,
695
+ fix: refreshed.fix,
696
+ };
697
+ }
698
+
699
+ // 🔴 "The grant has been revoked" is the WRONG diagnosis when a second, untried value for
700
+ // the same credential is sitting in the same file.
701
+ const alsoUntried =
702
+ shadowed.length > 0
703
+ ? ` ${shadowed.join(' and ')} also hold${one ? 's' : ''} a value for the same credential and ${one ? 'was' : 'were'} never tried.`
704
+ : '';
705
+ return {
706
+ status: 'refused',
707
+ refreshTokenName,
708
+ shadowedRefreshTokenNames: shadowed,
709
+ grantedScopes: [],
710
+ summary: `${refreshed.reason}${used} — ${label}'s Google calls will all fail until this is fixed.${alsoUntried}`,
711
+ fix:
712
+ shadowed.length > 0
713
+ ? `Before re-consenting, check whether ${shadowed.join(' or ')} holds the live grant: the preferred name ${refreshTokenName} wins by being PRESENT, not by working. Leaving exactly one of those names set resolves it either way. Otherwise: ${setupFix}`
714
+ : setupFix,
715
+ };
716
+ }
717
+
718
+ // ─── 403 → remedy ────────────────────────────────────────────────────────────
719
+
720
+ /**
721
+ * Turn a Google 403 into something the owner can act on.
722
+ *
723
+ * A missing scope is the single most likely failure after the owner consents once and the app
724
+ * later grows a capability. Naming the MISSING scope and the re-consent command is the
725
+ * difference between a two-minute fix and an evening.
726
+ *
727
+ * 🔴 The two branches say genuinely different things: `ACCESS_TOKEN_SCOPE_INSUFFICIENT` is a
728
+ * consent problem fixed in a browser, while a 403 on a granted scope is an API that is not
729
+ * ENABLED on the project, fixed in a different console page entirely.
730
+ */
731
+ export function describeScopeFailure(options: {
732
+ api: string;
733
+ needed: string;
734
+ granted: readonly string[];
735
+ httpStatus: number;
736
+ /** The command that re-runs consent, e.g. `bun run google:consent`. */
737
+ consentCommand: string;
738
+ }): { reason: string; fix: string } {
739
+ const has = options.granted.includes(options.needed);
740
+ if (!has && options.granted.length > 0) {
741
+ return {
742
+ reason: `${options.api} refused the call (HTTP ${options.httpStatus}): the grant on this machine does NOT include ${options.needed}. It has: ${options.granted.join(', ')}`,
743
+ fix: `run ${options.consentCommand} and approve ${options.needed}. The existing token keeps working for everything it already covers, so nothing else breaks while you do it.`,
744
+ };
745
+ }
746
+ return {
747
+ reason: `${options.api} refused the call (HTTP ${options.httpStatus}) even though ${options.needed} appears to be granted — the API may be disabled for this Google Cloud project`,
748
+ fix: `open Google Cloud Console → APIs and Services → Library and confirm the ${options.api} API is ENABLED for the project that owns this OAuth client`,
749
+ };
750
+ }