@mi9-identity/token-client 1.0.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.
package/dist/client.js ADDED
@@ -0,0 +1,485 @@
1
+ import { drainBody } from './drain-body.js';
2
+ import { claimGcpCredential } from './claim.js';
3
+ import { resolveRequestId } from './request-id.js';
4
+ import { isEmpty, isNull, isString, isUndefined } from './type-guards.js';
5
+ import { computeBackoffDelayMs, DEFAULT_BACKOFF, parseRetryAfterMs } from './backoff.js';
6
+ import { deadlineSignal, remainingMs, sleepOrThrowIfDeadlineExceeded, startDeadline } from './deadline.js';
7
+ import { DEFAULT_REFRESH_LEAD_TIME_MS, DEFAULT_REQUEST_ID_HEADER, MAX_RETRIES, NOOP_LOGGER } from './token-client.constants.js';
8
+ import { ConfigurationError, LazyClaimError, ProvisioningError, ResponseShapeError, RevocationError, TransientError } from './errors.js';
9
+ import { credentialMeResponseSchema, oauthErrorSchema, tokenResponseSchema } from './schemas.js';
10
+ /**
11
+ * Detect the rotation signal in a `/oauth/token` success response. ADR-7 fuses
12
+ * the body (`credentialRotated`) and header (`X-Mi9-Credential-Rotated`) signals
13
+ * — either one trips pickup.
14
+ *
15
+ * @param { TokenResponse } body The parsed token-response body.
16
+ * @param { Headers } headers The response headers.
17
+ * @returns { boolean } `true` when the issuer signalled rotation via either channel.
18
+ */
19
+ const isRotationSignal = (body, headers) => {
20
+ if (body.credentialRotated === true) {
21
+ return true;
22
+ }
23
+ const headerValue = headers.get('x-mi9-credential-rotated');
24
+ return isString(headerValue) && headerValue.toLowerCase() === 'true';
25
+ };
26
+ /**
27
+ * Best-effort extract of the `error` field from an OAuth error envelope; falls
28
+ * back to `'unknown'` when the body is missing or malformed.
29
+ *
30
+ * @param { Response } response The 4xx fetch response.
31
+ * @returns { Promise<string> } The `error` field value, or `'unknown'`.
32
+ */
33
+ const readOAuthErrorMessage = async (response) => {
34
+ const oauthErr = oauthErrorSchema.safeParse(await response.json().catch(() => ({})));
35
+ return oauthErr.success ? (oauthErr.data.error ?? 'unknown') : 'unknown';
36
+ };
37
+ /**
38
+ * Parse a successful `/oauth/token` 2xx body and bundle the rotation signal so
39
+ * the retry loop can return a single value.
40
+ *
41
+ * @param { Response } response The successful fetch response.
42
+ * @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
43
+ * @throws { ResponseShapeError } If the body fails JSON parsing or schema validation.
44
+ */
45
+ const parseTokenSuccess = async (response) => {
46
+ let json;
47
+ try {
48
+ json = await response.json();
49
+ }
50
+ catch (err) {
51
+ throw new ResponseShapeError(`Failed to parse /oauth/token JSON body: ${String(err)}`);
52
+ }
53
+ const parsed = tokenResponseSchema.safeParse(json);
54
+ if (!parsed.success) {
55
+ throw new ResponseShapeError(`Unexpected /oauth/token response shape: ${parsed.error.message}`);
56
+ }
57
+ return { body: parsed.data, rotated: isRotationSignal(parsed.data, response.headers) };
58
+ };
59
+ /**
60
+ * Build a token client bound to one consumer credential. The returned object
61
+ * is safe to share across the lifetime of the calling process — it manages
62
+ * its own in-memory cache, single-flight refresh, and rotation pickup.
63
+ *
64
+ * @param { TokenClientOptions } opts Client configuration (endpoints, credentials, lead time, retry policy, hooks).
65
+ * @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
66
+ */
67
+ export const createTokenClient = (opts) => {
68
+ if (isEmpty(opts.audience)) {
69
+ throw new ConfigurationError('createTokenClient: `audience` must be a non-empty string or array of strings');
70
+ }
71
+ const gcpEnabled = !isUndefined(opts.idTokenProvider);
72
+ // Classic mode carries a static credential; GCP lazy-claim mode bootstraps
73
+ // it on first use, so the credential fields are required only without a provider.
74
+ // The discriminated union already rejects this at compile time — this guard
75
+ // catches a caller that reached the runtime through an `any` or a JS import.
76
+ if (!gcpEnabled && (isEmpty(opts.clientId) || isEmpty(opts.clientSecret))) {
77
+ throw new ConfigurationError('createTokenClient: `clientId` and `clientSecret` are required unless `idTokenProvider` is set');
78
+ }
79
+ const log = opts.logger ?? NOOP_LOGGER;
80
+ const fetchImpl = opts.fetch ?? fetch;
81
+ const refreshLeadTimeMs = opts.refreshLeadTimeMs ?? DEFAULT_REFRESH_LEAD_TIME_MS;
82
+ const requestIdHeader = opts.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER;
83
+ const backoff = opts.backoff ?? DEFAULT_BACKOFF;
84
+ const state = {
85
+ clientId: opts.clientId ?? '',
86
+ secret: opts.clientSecret ?? '',
87
+ cached: undefined,
88
+ inflightRefresh: undefined,
89
+ inflightPickup: undefined,
90
+ closed: false,
91
+ };
92
+ const ensureOpen = () => {
93
+ if (state.closed) {
94
+ throw new Error('TokenClient is closed');
95
+ }
96
+ };
97
+ const buildHeaders = (extra) => ({
98
+ accept: 'application/json',
99
+ 'content-type': 'application/json',
100
+ [requestIdHeader]: resolveRequestId(opts.requestId),
101
+ ...extra,
102
+ });
103
+ const postJson = async (url, body, headers, signal) => {
104
+ const init = {
105
+ method: 'POST',
106
+ headers: buildHeaders(headers),
107
+ body: JSON.stringify(body),
108
+ };
109
+ if (!isUndefined(signal)) {
110
+ init.signal = signal;
111
+ }
112
+ return fetchImpl(url, init);
113
+ };
114
+ /**
115
+ * Build the JSON payload for `/oauth/token`. `audience` is forwarded
116
+ * verbatim — strings, one-element arrays, and multi-element arrays all pass
117
+ * through unchanged (no collapse). Omits `scope` entirely when the caller
118
+ * did not configure one.
119
+ *
120
+ * @returns { TokenRequestBody } The request body to POST.
121
+ */
122
+ const buildTokenRequestBody = () => {
123
+ const body = {
124
+ grantType: 'client_credentials',
125
+ clientId: state.clientId,
126
+ clientSecret: state.secret,
127
+ audience: opts.audience,
128
+ };
129
+ if (!isUndefined(opts.scope)) {
130
+ body.scope = opts.scope;
131
+ }
132
+ return body;
133
+ };
134
+ /**
135
+ * Map a non-2xx `/oauth/token` response to either a terminal throw (401
136
+ * revocation, 403 unauthorized audience, 404 not-provisioned, other) or a
137
+ * transient outcome the retry loop should sleep on.
138
+ *
139
+ * @param { Response } response The non-2xx fetch response.
140
+ * @param { number } attempt Zero-indexed retry attempt number.
141
+ * @returns { Promise<{ delayMs: number; transient: TransientError }> } Sleep delay and the transient error to remember.
142
+ * @throws { RevocationError } On 401.
143
+ * @throws { ConfigurationError } On 403 (`invalid_target` — audience not authorized for this credential).
144
+ * @throws { ProvisioningError } On 404.
145
+ * @throws { Error } On any other non-transient status.
146
+ */
147
+ const handleTokenErrorResponse = async (response, attempt) => {
148
+ if (response.status === 401) {
149
+ throw new RevocationError(`credential rejected by /oauth/token: ${await readOAuthErrorMessage(response)}`);
150
+ }
151
+ if (response.status === 403) {
152
+ throw new ConfigurationError(`audience rejected by /oauth/token: ${await readOAuthErrorMessage(response)}`);
153
+ }
154
+ if (response.status === 404) {
155
+ await drainBody(response);
156
+ throw new ProvisioningError('credential not provisioned (404 from /oauth/token)');
157
+ }
158
+ if (response.status === 429 || response.status >= 500) {
159
+ const retryAfter = parseRetryAfterMs(response.headers.get('retry-after'), backoff) ?? computeBackoffDelayMs(backoff, attempt);
160
+ await drainBody(response);
161
+ const transient = new TransientError(`/oauth/token returned ${String(response.status)}`);
162
+ log.warn({ attempt, status: response.status, retryAfterMs: retryAfter }, 'token-client.transient');
163
+ return { delayMs: retryAfter, transient };
164
+ }
165
+ throw new Error(`/oauth/token returned ${String(response.status)}: ${await readOAuthErrorMessage(response)}`);
166
+ };
167
+ /**
168
+ * One round-trip to `/oauth/token` for the configured consumer audience(s).
169
+ * Retries on transient failures per the backoff policy; 401/404 surface
170
+ * as terminal errors.
171
+ *
172
+ * @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset.
173
+ * @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
174
+ * @throws { TransientError } When retries are exhausted, or when `deadline` elapses before a retry.
175
+ */
176
+ const requestToken = async (deadline) => {
177
+ let lastTransient;
178
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
179
+ ensureOpen();
180
+ let response;
181
+ try {
182
+ response = await postJson(opts.tokenEndpoint, buildTokenRequestBody(), undefined, deadlineSignal(deadline));
183
+ }
184
+ catch (err) {
185
+ lastTransient = new TransientError('Network failure calling /oauth/token', { cause: err });
186
+ log.warn({ attempt, err: String(err) }, 'token-client.network_error');
187
+ await sleepOrThrowIfDeadlineExceeded(deadline, computeBackoffDelayMs(backoff, attempt), '/oauth/token');
188
+ continue;
189
+ }
190
+ if (response.ok) {
191
+ return parseTokenSuccess(response);
192
+ }
193
+ const { delayMs, transient } = await handleTokenErrorResponse(response, attempt);
194
+ lastTransient = transient;
195
+ await sleepOrThrowIfDeadlineExceeded(deadline, delayMs, '/oauth/token');
196
+ }
197
+ /* v8 ignore next -- defensive; loop runs at least once with MAX_RETRIES > 0 so lastTransient is always assigned */
198
+ throw lastTransient ?? new TransientError('exhausted retries calling /oauth/token');
199
+ };
200
+ /**
201
+ * Verify every configured audience is covered by the just-claimed
202
+ * credential's `authorizedAudiences`. Without this, a caller configured for
203
+ * an audience the credential does not authorize only learns about the gap
204
+ * from a 403 `invalid_target` on the very next mint — this fails fast,
205
+ * right after the claim, and names both sets so the operator can see the
206
+ * mismatch immediately.
207
+ *
208
+ * @param { readonly string[] } authorizedAudiences The claimed credential's `authorizedAudiences`.
209
+ * @returns { void } Nothing — throws on mismatch.
210
+ * @throws { LazyClaimError } When a configured audience is absent from `authorizedAudiences`.
211
+ */
212
+ const assertAudienceAuthorized = (authorizedAudiences) => {
213
+ const configuredAudiences = isString(opts.audience) ? [opts.audience] : opts.audience;
214
+ const unauthorized = configuredAudiences.filter((audience) => !authorizedAudiences.includes(audience));
215
+ if (isEmpty(unauthorized)) {
216
+ return;
217
+ }
218
+ throw new LazyClaimError(`configured audience(s) [${configuredAudiences.join(', ')}] not authorized for claimed credential (authorizedAudiences: [${authorizedAudiences.join(', ')}])`);
219
+ };
220
+ /**
221
+ * (Re)obtain this client's credential via the Tier-1 GCP lazy-claim
222
+ * (`POST /credentials/me`, `grantType: gcp_identity`) and swap the new
223
+ * `clientId` + secret into in-memory state. Runs inside the single-flight
224
+ * refresh promise (so it is coalesced with minting) and never fires
225
+ * `onSecretRotated` — Tier-1 credentials are held in memory and re-claimed on
226
+ * demand, not persisted.
227
+ *
228
+ * @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset. The claim receives whatever remains of it as its own `deadlineMs`.
229
+ * @returns { Promise<void> } Resolves once the freshly-claimed credential is in memory.
230
+ * @throws { LazyClaimError } The ID token was rejected (401/403), the provider threw, or the claimed credential's `authorizedAudiences` does not cover the configured `audience`.
231
+ * @throws { ProvisioningError } On 404 — the credential is not provisioned yet.
232
+ * @throws { TransientError } When `deadline` elapses before the claim completes.
233
+ */
234
+ const lazyClaim = async (deadline) => {
235
+ /* v8 ignore next 3 -- defensive; lazyClaim is only reached when gcpEnabled, i.e. idTokenProvider is set */
236
+ if (isUndefined(opts.idTokenProvider)) {
237
+ throw new LazyClaimError('no idTokenProvider configured for GCP lazy-claim');
238
+ }
239
+ const claim = await claimGcpCredential({
240
+ credentialEndpoint: opts.credentialEndpoint,
241
+ idTokenProvider: opts.idTokenProvider,
242
+ fetch: fetchImpl,
243
+ backoff,
244
+ requestIdHeader,
245
+ logger: log,
246
+ ...(isUndefined(opts.retailerCode) ? {} : { retailerCode: opts.retailerCode }),
247
+ ...(isUndefined(opts.requestId) ? {} : { requestId: opts.requestId }),
248
+ ...(isUndefined(deadline) ? {} : { deadlineMs: remainingMs(deadline) }),
249
+ });
250
+ assertAudienceAuthorized(claim.audience);
251
+ state.clientId = claim.clientId;
252
+ state.secret = claim.clientSecret;
253
+ log.info({ event: 'credential_claimed' }, 'token-client.credential_claimed');
254
+ };
255
+ /**
256
+ * Mint via `/oauth/token`. In GCP mode a 401 (revoked or force-revoked
257
+ * secret) triggers exactly one lazy re-claim followed by one retry; a second
258
+ * 401 is terminal (`RevocationError`) so a permanently rejected credential
259
+ * cannot loop. In classic mode a 401 surfaces unchanged.
260
+ *
261
+ * @param { Deadline | undefined } deadline Active wall-clock budget for the whole claim+mint chain, or undefined when `opts.deadlineMs` is unset. The SAME budget is reused across the initial mint, the re-claim, and the retry mint — it is never reset mid-chain.
262
+ * @returns { Promise<{ body: TokenResponse; rotated: boolean }> } Parsed body plus the rotation flag.
263
+ */
264
+ const mintReclaiming = async (deadline) => {
265
+ try {
266
+ return await requestToken(deadline);
267
+ }
268
+ catch (err) {
269
+ if (!gcpEnabled || !(err instanceof RevocationError)) {
270
+ throw err;
271
+ }
272
+ log.info({ event: 'reclaiming_after_401' }, 'token-client.reclaiming_after_401');
273
+ await lazyClaim(deadline);
274
+ return requestToken(deadline);
275
+ }
276
+ };
277
+ /**
278
+ * Call `/credentials/me grantType=jwt` to fetch the new client secret.
279
+ * Reuses the just-acquired consumer-aud access token as the bearer — the
280
+ * issuer's verifier accepts any audience in the credential's
281
+ * `authorizedAudiences`, so a second `/oauth/token` mint is no longer
282
+ * required.
283
+ *
284
+ * Status branching: 204 → `null` (rotation already acknowledged — nothing
285
+ * to deliver), 2xx → the new secret, 401 → `RevocationError`, 429 / 5xx →
286
+ * `TransientError`. No 404 branch — issuer surfaces `not_found` / `revoked`
287
+ * as 401 `invalid_client` from `grantType=jwt`, not 404.
288
+ *
289
+ * @param { string } accessToken The just-acquired consumer-aud JWT (with `authClass: pending_secret`) carried by the rotated `/oauth/token` response.
290
+ * @returns { Promise<string | null> } The new client secret, or `null` when the issuer answered 204 (already acknowledged — no-op).
291
+ * @throws { ResponseShapeError } If a 2xx body fails JSON parsing or schema validation.
292
+ * @throws { RevocationError } On 401.
293
+ * @throws { TransientError } On 429 / 5xx.
294
+ * @throws { Error } On any other non-2xx status.
295
+ */
296
+ const pickUpRotatedSecret = async (accessToken) => {
297
+ const response = await postJson(opts.credentialEndpoint, { grantType: 'jwt' }, { authorization: `Bearer ${accessToken}` });
298
+ // 204 No Content: another instance (or an earlier pickup) already
299
+ // acknowledged this rotation, so there is no new secret to deliver. A
300
+ // no-op, not a shape error — the 2xx branch below would try to parse an
301
+ // empty body and wrongly throw ResponseShapeError.
302
+ if (response.status === 204) {
303
+ await drainBody(response);
304
+ return null;
305
+ }
306
+ if (response.ok) {
307
+ let json;
308
+ try {
309
+ json = await response.json();
310
+ }
311
+ catch (err) {
312
+ throw new ResponseShapeError(`Failed to parse /credentials/me JSON body: ${String(err)}`);
313
+ }
314
+ const parsed = credentialMeResponseSchema.safeParse(json);
315
+ if (!parsed.success) {
316
+ throw new ResponseShapeError(`Unexpected /credentials/me response shape: ${parsed.error.message}`);
317
+ }
318
+ const next = parsed.data;
319
+ return next.clientSecret;
320
+ }
321
+ if (response.status === 401) {
322
+ const oauthErr = oauthErrorSchema.safeParse(await response.json().catch(() => ({})));
323
+ throw new RevocationError(`credential rejected by /credentials/me: ${oauthErr.success ? (oauthErr.data.error ?? 'unknown') : 'unknown'}`);
324
+ }
325
+ if (response.status === 429 || response.status >= 500) {
326
+ await drainBody(response);
327
+ throw new TransientError(`/credentials/me returned ${String(response.status)}`);
328
+ }
329
+ await drainBody(response);
330
+ throw new Error(`/credentials/me returned ${String(response.status)}`);
331
+ };
332
+ /**
333
+ * Pick up a rotated secret in the BACKGROUND. Detached from `acquireToken`
334
+ * on purpose: the access token just minted by `/oauth/token` is a valid
335
+ * product token regardless of whether this secondary `/credentials/me`
336
+ * call succeeds, so a pickup failure must never reject the caller or
337
+ * discard that token. Single-flight via `state.inflightPickup`; the
338
+ * rotation signal keeps firing on subsequent mints until the issuer records
339
+ * the pickup, so a failed attempt is simply retried on the next refresh.
340
+ *
341
+ * Persist contract: the new secret is written to in-memory state BEFORE
342
+ * `onSecretRotated` runs, so a persist-callback throw cannot strand the
343
+ * client on the old secret (the issuer has recorded the pickup and will
344
+ * revoke the old secret after the grace window). The throw is logged and
345
+ * swallowed; the application reconciles its durable store on restart.
346
+ *
347
+ * @param { string } accessToken The just-minted consumer-aud JWT used as the pickup bearer.
348
+ * @returns { void } Nothing — the work runs detached.
349
+ */
350
+ const startBackgroundPickup = (accessToken) => {
351
+ if (!isUndefined(state.inflightPickup)) {
352
+ return;
353
+ }
354
+ const task = (async () => {
355
+ const newSecret = await pickUpRotatedSecret(accessToken);
356
+ if (isNull(newSecret)) {
357
+ log.info({ event: 'rotation_already_acknowledged' }, 'token-client.rotation_already_acknowledged');
358
+ return;
359
+ }
360
+ state.secret = newSecret;
361
+ if (!isUndefined(opts.onSecretRotated)) {
362
+ try {
363
+ await opts.onSecretRotated(newSecret);
364
+ }
365
+ catch (err) {
366
+ log.error({ err: String(err) }, 'token-client.persist_failed');
367
+ }
368
+ }
369
+ })()
370
+ .catch((err) => {
371
+ log.warn({ err: String(err) }, 'token-client.rotation_pickup_failed');
372
+ })
373
+ .finally(() => {
374
+ state.inflightPickup = undefined;
375
+ });
376
+ state.inflightPickup = task;
377
+ };
378
+ /**
379
+ * Internal refresh path. Single-flight via `state.inflightRefresh` so a
380
+ * burst of concurrent `acquireToken` calls funnels through one network
381
+ * round-trip. The minted token is cached and returned immediately; a
382
+ * rotation signal kicks off a detached background pickup
383
+ * (`startBackgroundPickup`) rather than blocking the caller on the
384
+ * secondary `/credentials/me` call.
385
+ *
386
+ * `opts.deadlineMs` is read fresh here, at the top of the chain this
387
+ * single-flight refresh drives — so the wall-clock budget spans claim +
388
+ * mint + re-claim + retry mint as one chain, and every refresh gets its
389
+ * own budget rather than sharing (or being starved by) a prior one.
390
+ *
391
+ * @returns { Promise<AccessToken> } The newly-minted (and cached) access token.
392
+ */
393
+ const doRefresh = async () => {
394
+ const deadline = startDeadline(opts.deadlineMs);
395
+ if (gcpEnabled && state.secret === '') {
396
+ await lazyClaim(deadline);
397
+ }
398
+ const result = await mintReclaiming(deadline);
399
+ const expiresInMs = result.body.expiresIn * 1_000;
400
+ if (expiresInMs <= refreshLeadTimeMs) {
401
+ log.warn({ expiresInMs, refreshLeadTimeMs }, 'token-client.lead_time_exceeds_ttl');
402
+ }
403
+ const token = { token: result.body.accessToken, expiresAt: Date.now() + expiresInMs };
404
+ state.cached = token;
405
+ if (result.rotated) {
406
+ log.info({ event: 'credential_rotation_signal' }, 'token-client.rotation_signaled');
407
+ startBackgroundPickup(result.body.accessToken);
408
+ }
409
+ return token;
410
+ };
411
+ /**
412
+ * Refresh with single-flight. Concurrent callers wait on the same promise so
413
+ * only one round-trip to the issuer happens when the cache is stale. The
414
+ * first caller that notices the stale cache kicks off the refresh; others
415
+ * that arrive while it's in-flight wait for the result and get the fresh token
416
+ * when it resolves.
417
+ *
418
+ * @returns { Promise<AccessToken> } The newly-minted (and cached) access token.
419
+ */
420
+ const refreshSingleFlight = () => {
421
+ if (!isUndefined(state.inflightRefresh)) {
422
+ return state.inflightRefresh;
423
+ }
424
+ const promise = doRefresh().finally(() => {
425
+ state.inflightRefresh = undefined;
426
+ });
427
+ state.inflightRefresh = promise;
428
+ return promise;
429
+ };
430
+ /**
431
+ * Acquire the cached token if it's fresh, or refresh it if it's stale.
432
+ *
433
+ * @returns { Promise<AccessToken> } The cached or newly-minted access token.
434
+ */
435
+ const acquireToken = async () => {
436
+ ensureOpen();
437
+ const cached = state.cached;
438
+ if (!isUndefined(cached) && Date.now() < cached.expiresAt - refreshLeadTimeMs) {
439
+ return cached;
440
+ }
441
+ return refreshSingleFlight();
442
+ };
443
+ /**
444
+ * Discard the cached token and mint a fresh one. Intentionally coalesces
445
+ * with an in-flight refresh: when one is already running it returns that
446
+ * same promise rather than starting a second concurrent mint, so a
447
+ * `forceRefresh` racing an automatic refresh still funnels through one
448
+ * `/oauth/token` round-trip (single-flight via `state.inflightRefresh`).
449
+ *
450
+ * @returns { Promise<AccessToken> } The freshly-minted (and cached) access token.
451
+ */
452
+ const forceRefresh = async () => {
453
+ ensureOpen();
454
+ state.cached = undefined;
455
+ return refreshSingleFlight();
456
+ };
457
+ /**
458
+ * Close the client and clean up resources. After calling this, the client is
459
+ * permanently closed and all methods throw. This clears the cache and any
460
+ * in-flight refresh so that no future calls to `acquireToken` or `forceRefresh`
461
+ * can succeed. Call this on graceful shutdown to prevent a mid-refresh promise
462
+ * from outliving the surrounding service.
463
+ */
464
+ const close = () => {
465
+ state.closed = true;
466
+ state.cached = undefined;
467
+ state.inflightRefresh = undefined;
468
+ state.inflightPickup = undefined;
469
+ };
470
+ return { acquireToken, forceRefresh, close };
471
+ };
472
+ /**
473
+ * Build a Tier-1 GCP token client that lazy-claims its credential on first use
474
+ * (via `POST /credentials/me` `grantType=gcp_identity`) and silently re-claims
475
+ * when a mint is rejected with 401. A thin wrapper over `createTokenClient` with
476
+ * `idTokenProvider` required and `clientId` / `clientSecret` omitted (both are
477
+ * bootstrapped by the first claim). `idTokenProvider` is called with the
478
+ * credential endpoint URL — the issuer-pinned `aud` — and must return a fresh
479
+ * Google ID token each call.
480
+ *
481
+ * @param { GcpTokenClientOptions } opts GCP client configuration (endpoints, audience, ID-token provider, retry policy).
482
+ * @returns { TokenClient } The configured token client (`acquireToken`, `forceRefresh`, `close`).
483
+ */
484
+ export const createGcpTokenClient = (opts) => createTokenClient(opts);
485
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,78 @@
1
+ import { TransientError } from './errors.js';
2
+ /**
3
+ * Wall-clock budget for one claim+mint(+re-claim+mint) chain. `startDeadline`
4
+ * computes `expiresAt` once at the top of the public entry point so the
5
+ * budget spans every step of the chain instead of resetting at each retry,
6
+ * sleep, or re-claim.
7
+ */
8
+ export interface Deadline {
9
+ readonly budgetMs: number;
10
+ readonly expiresAt: number;
11
+ }
12
+ /**
13
+ * Start a wall-clock budget for one call, or return undefined when the
14
+ * caller configured none — undefined preserves today's unbounded-retry
15
+ * behaviour exactly, since every helper below is a no-op for it.
16
+ *
17
+ * @param { number | undefined } deadlineMs Configured wall-clock budget in ms.
18
+ * @returns { Deadline | undefined } The started budget, or undefined when unset.
19
+ */
20
+ export declare const startDeadline: (deadlineMs: number | undefined) => Deadline | undefined;
21
+ /**
22
+ * Milliseconds left before `deadline` expires, floored at 0.
23
+ *
24
+ * @param { Deadline } deadline The active budget.
25
+ * @returns { number } Remaining ms, floored at 0.
26
+ */
27
+ export declare const remainingMs: (deadline: Deadline) => number;
28
+ /**
29
+ * Milliseconds elapsed since `deadline` started, for the deadline-exceeded
30
+ * error message.
31
+ *
32
+ * @param { Deadline } deadline The active budget.
33
+ * @returns { number } Elapsed ms since the budget started.
34
+ */
35
+ export declare const elapsedMs: (deadline: Deadline) => number;
36
+ /**
37
+ * True when sleeping `delayMs` from now would meet or exceed `deadline` — the
38
+ * caller must throw instead of sleeping in that case rather than blocking
39
+ * past the configured budget.
40
+ *
41
+ * @param { Deadline } deadline The active budget.
42
+ * @param { number } delayMs The backoff delay about to be slept.
43
+ * @returns { boolean } True when the upcoming sleep would exceed the budget.
44
+ */
45
+ export declare const sleepWouldExceedDeadline: (deadline: Deadline, delayMs: number) => boolean;
46
+ /**
47
+ * An `AbortSignal` that fires when `deadline` expires, or undefined when no
48
+ * deadline is configured — pass this straight to `fetch` so a single stalled
49
+ * socket cannot outlive the budget either.
50
+ *
51
+ * @param { Deadline | undefined } deadline The active budget, or undefined when none is configured.
52
+ * @returns { AbortSignal | undefined } The timeout signal, or undefined when unconfigured.
53
+ */
54
+ export declare const deadlineSignal: (deadline: Deadline | undefined) => AbortSignal | undefined;
55
+ /**
56
+ * Build the deadline-exceeded `TransientError`, naming the configured budget
57
+ * and the elapsed time so a caller's logs show why the chain stopped short of
58
+ * the normal retry ceiling. The caller's documented "surface upstream as 503
59
+ * and retry later" action (see `TransientError`) still applies unchanged.
60
+ *
61
+ * @param { Deadline } deadline The exceeded budget.
62
+ * @param { string } context Short label for which call the deadline stopped (e.g. `'/oauth/token'`, `'/credentials/me (gcp_identity)'`).
63
+ * @returns { TransientError } The error to throw in place of sleeping.
64
+ */
65
+ export declare const deadlineExceededError: (deadline: Deadline, context: string) => TransientError;
66
+ /**
67
+ * Sleep `delayMs` before the next retry, or throw the deadline-exceeded error
68
+ * when that sleep would cross `deadline` — stopping the retry loop rather
69
+ * than blocking past the caller's configured budget.
70
+ *
71
+ * @param { Deadline | undefined } deadline Active wall-clock budget, or undefined when none is configured (preserves unbounded retry).
72
+ * @param { number } delayMs The backoff delay computed for this retry.
73
+ * @param { string } context Short label naming which call the deadline stopped, forwarded to `deadlineExceededError`.
74
+ * @returns { Promise<void> } Resolves after sleeping `delayMs`.
75
+ * @throws { TransientError } When `deadline` is configured and sleeping `delayMs` would meet or exceed it.
76
+ */
77
+ export declare const sleepOrThrowIfDeadlineExceeded: (deadline: Deadline | undefined, delayMs: number, context: string) => Promise<void>;
78
+ //# sourceMappingURL=deadline.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { sleep } from './backoff.js';
2
+ import { TransientError } from './errors.js';
3
+ import { isUndefined } from './type-guards.js';
4
+ /**
5
+ * Start a wall-clock budget for one call, or return undefined when the
6
+ * caller configured none — undefined preserves today's unbounded-retry
7
+ * behaviour exactly, since every helper below is a no-op for it.
8
+ *
9
+ * @param { number | undefined } deadlineMs Configured wall-clock budget in ms.
10
+ * @returns { Deadline | undefined } The started budget, or undefined when unset.
11
+ */
12
+ export const startDeadline = (deadlineMs) => isUndefined(deadlineMs) ? undefined : { budgetMs: deadlineMs, expiresAt: Date.now() + deadlineMs };
13
+ /**
14
+ * Milliseconds left before `deadline` expires, floored at 0.
15
+ *
16
+ * @param { Deadline } deadline The active budget.
17
+ * @returns { number } Remaining ms, floored at 0.
18
+ */
19
+ export const remainingMs = (deadline) => Math.max(0, deadline.expiresAt - Date.now());
20
+ /**
21
+ * Milliseconds elapsed since `deadline` started, for the deadline-exceeded
22
+ * error message.
23
+ *
24
+ * @param { Deadline } deadline The active budget.
25
+ * @returns { number } Elapsed ms since the budget started.
26
+ */
27
+ export const elapsedMs = (deadline) => deadline.budgetMs - remainingMs(deadline);
28
+ /**
29
+ * True when sleeping `delayMs` from now would meet or exceed `deadline` — the
30
+ * caller must throw instead of sleeping in that case rather than blocking
31
+ * past the configured budget.
32
+ *
33
+ * @param { Deadline } deadline The active budget.
34
+ * @param { number } delayMs The backoff delay about to be slept.
35
+ * @returns { boolean } True when the upcoming sleep would exceed the budget.
36
+ */
37
+ export const sleepWouldExceedDeadline = (deadline, delayMs) => delayMs >= remainingMs(deadline);
38
+ /**
39
+ * An `AbortSignal` that fires when `deadline` expires, or undefined when no
40
+ * deadline is configured — pass this straight to `fetch` so a single stalled
41
+ * socket cannot outlive the budget either.
42
+ *
43
+ * @param { Deadline | undefined } deadline The active budget, or undefined when none is configured.
44
+ * @returns { AbortSignal | undefined } The timeout signal, or undefined when unconfigured.
45
+ */
46
+ export const deadlineSignal = (deadline) => (isUndefined(deadline) ? undefined : AbortSignal.timeout(remainingMs(deadline)));
47
+ /**
48
+ * Build the deadline-exceeded `TransientError`, naming the configured budget
49
+ * and the elapsed time so a caller's logs show why the chain stopped short of
50
+ * the normal retry ceiling. The caller's documented "surface upstream as 503
51
+ * and retry later" action (see `TransientError`) still applies unchanged.
52
+ *
53
+ * @param { Deadline } deadline The exceeded budget.
54
+ * @param { string } context Short label for which call the deadline stopped (e.g. `'/oauth/token'`, `'/credentials/me (gcp_identity)'`).
55
+ * @returns { TransientError } The error to throw in place of sleeping.
56
+ */
57
+ export const deadlineExceededError = (deadline, context) => new TransientError(`${context}: deadlineMs budget of ${String(deadline.budgetMs)}ms exceeded after ${String(elapsedMs(deadline))}ms elapsed — stopping instead of retrying past the deadline`);
58
+ /**
59
+ * Sleep `delayMs` before the next retry, or throw the deadline-exceeded error
60
+ * when that sleep would cross `deadline` — stopping the retry loop rather
61
+ * than blocking past the caller's configured budget.
62
+ *
63
+ * @param { Deadline | undefined } deadline Active wall-clock budget, or undefined when none is configured (preserves unbounded retry).
64
+ * @param { number } delayMs The backoff delay computed for this retry.
65
+ * @param { string } context Short label naming which call the deadline stopped, forwarded to `deadlineExceededError`.
66
+ * @returns { Promise<void> } Resolves after sleeping `delayMs`.
67
+ * @throws { TransientError } When `deadline` is configured and sleeping `delayMs` would meet or exceed it.
68
+ */
69
+ export const sleepOrThrowIfDeadlineExceeded = async (deadline, delayMs, context) => {
70
+ if (!isUndefined(deadline) && sleepWouldExceedDeadline(deadline, delayMs)) {
71
+ throw deadlineExceededError(deadline, context);
72
+ }
73
+ await sleep(delayMs);
74
+ };
75
+ //# sourceMappingURL=deadline.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Drain a fetch Response body the caller is about to discard. Skipping this
3
+ * leaks the underlying connection in the undici pool — long-running pods see
4
+ * degraded throughput on the next request cycle.
5
+ *
6
+ * @param { Response } response The fetch response whose body needs draining.
7
+ * @returns { Promise<void> } Resolves when the body has been cancelled.
8
+ */
9
+ export declare const drainBody: (response: Response) => Promise<void>;
10
+ //# sourceMappingURL=drain-body.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Drain a fetch Response body the caller is about to discard. Skipping this
3
+ * leaks the underlying connection in the undici pool — long-running pods see
4
+ * degraded throughput on the next request cycle.
5
+ *
6
+ * @param { Response } response The fetch response whose body needs draining.
7
+ * @returns { Promise<void> } Resolves when the body has been cancelled.
8
+ */
9
+ export const drainBody = async (response) => {
10
+ try {
11
+ await response.body?.cancel();
12
+ }
13
+ catch {
14
+ // Body already consumed or stream errored — either way nothing for
15
+ // the caller to recover from.
16
+ }
17
+ };
18
+ //# sourceMappingURL=drain-body.js.map