@indigoai-us/hq-cloud 6.12.2 → 6.12.4

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.
@@ -23,3 +23,33 @@ export function createRunnerEmitter(
23
23
  ): (event: RunnerEvent) => void {
24
24
  return (event) => routeEvents(event, streams);
25
25
  }
26
+
27
+ /**
28
+ * Emit a CLASSIFIED `error` event for a top-level uncaught runner rejection,
29
+ * BEFORE the process exits non-zero.
30
+ *
31
+ * The menubar (hq-sync) treats a non-zero runner exit with NO error event seen
32
+ * at all as an "unexplained crash" and ALWAYS alerts on it. That is correct for
33
+ * a real panic/OOM, but the historical top-level catch wrote only a plain
34
+ * stderr line (not a protocol event) for EVERY uncaught rejection — so even a
35
+ * benign cause that bubbled to the top (transient network blip, a
36
+ * not-yet-provisioned 404, an expected ACL-scope skip) drove a false alert
37
+ * (HQ-SYNC-WEB-6). Emitting a structured `{type:"error"}` event here routes the
38
+ * failure through the menubar's `is_alertable_error` classifier, so the benign
39
+ * cases are suppressed while genuine defects still surface.
40
+ *
41
+ * Best-effort: if routing itself throws, fall back to a plain stderr line so the
42
+ * failure is never fully silent in the logs.
43
+ */
44
+ export function emitUncaughtRunnerError(
45
+ err: unknown,
46
+ streams: RunnerEventStreams,
47
+ ): void {
48
+ const message =
49
+ err instanceof Error ? (err.stack ?? err.message) : String(err);
50
+ try {
51
+ routeEvents({ type: "error", message, path: "(runner)" }, streams);
52
+ } catch {
53
+ streams.stderr.write(`hq-sync-runner: uncaught error — ${message}\n`);
54
+ }
55
+ }
@@ -115,7 +115,10 @@ import {
115
115
  type StartEventSyncOptions,
116
116
  } from "../sync/event-sync.js";
117
117
  import { migratePersonalVaultJournal } from "../journal.js";
118
- import { createRunnerEmitter } from "./sync-runner-events.js";
118
+ import {
119
+ createRunnerEmitter,
120
+ emitUncaughtRunnerError,
121
+ } from "./sync-runner-events.js";
119
122
  import {
120
123
  buildFanoutPlan,
121
124
  emitFanoutPlan,
@@ -1473,9 +1476,15 @@ if (isDirectInvocation) {
1473
1476
  runRunnerWithLoop(process.argv.slice(2))
1474
1477
  .then((code) => process.exit(code))
1475
1478
  .catch((err) => {
1476
- process.stderr.write(
1477
- `hq-sync-runner: uncaught error ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`,
1478
- );
1479
+ // A top-level rejection means the runner died before (or instead of)
1480
+ // emitting its protocol stream. Emit a CLASSIFIED `error` event first so
1481
+ // the menubar runs it through `is_alertable_error` and suppresses the
1482
+ // benign cases, instead of treating every uncaught exit as an unexplained
1483
+ // "silent death" crash (HQ-SYNC-WEB-6).
1484
+ emitUncaughtRunnerError(err, {
1485
+ stdout: process.stdout,
1486
+ stderr: process.stderr,
1487
+ });
1479
1488
  process.exit(1);
1480
1489
  });
1481
1490
  }
@@ -24,6 +24,11 @@ const hoisted = vi.hoisted(() => ({
24
24
  // lets a test simulate a mkdir failure at a NON-wrapper site (e.g. the
25
25
  // `.claude/skills` parent or a `core/<type>` mirror dir).
26
26
  failMkdirContaining: "" as string,
27
+ // HQ-CLI-4: when set, any symlink whose LINK path contains this substring
28
+ // throws EPERM — simulates Windows WITHOUT Developer Mode, where symlink
29
+ // creation is a privileged operation. Off by default so other tests see the
30
+ // real `symlinkSync`.
31
+ failSymlinkContaining: "" as string,
27
32
  }));
28
33
  vi.mock("fs", async (importOriginal) => {
29
34
  const actual = await importOriginal<typeof import("fs")>();
@@ -40,7 +45,24 @@ vi.mock("fs", async (importOriginal) => {
40
45
  }
41
46
  return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
42
47
  }) as typeof actual.mkdirSync;
43
- const patched = { ...actual, mkdirSync };
48
+ const symlinkSync = ((target: unknown, linkPath: unknown, type?: unknown) => {
49
+ if (
50
+ hoisted.failSymlinkContaining !== "" &&
51
+ typeof linkPath === "string" &&
52
+ linkPath.includes(hoisted.failSymlinkContaining)
53
+ ) {
54
+ throw Object.assign(
55
+ new Error(`EPERM: operation not permitted, symlink '${target}' -> '${linkPath}'`),
56
+ { code: "EPERM" },
57
+ );
58
+ }
59
+ return (actual.symlinkSync as (t: unknown, l: unknown, ty?: unknown) => unknown)(
60
+ target,
61
+ linkPath,
62
+ type,
63
+ );
64
+ }) as typeof actual.symlinkSync;
65
+ const patched = { ...actual, mkdirSync, symlinkSync };
44
66
  return { ...patched, default: patched };
45
67
  });
46
68
 
@@ -307,4 +329,49 @@ describe("reindex", () => {
307
329
  // The mirror for the failed type is skipped; skill surfacing still happened.
308
330
  expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
309
331
  });
332
+
333
+ // ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
334
+ // Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
335
+ // site but left the symlink sites unguarded, so `hq reindex` still aborted
336
+ // on Windows. safeSymlink must degrade gracefully like safeMkdir.
337
+ it("does not abort reindex when a skill-wrapper symlink fails with EPERM (Windows no Developer Mode)", () => {
338
+ writeSkill("core/skills/demo");
339
+
340
+ hoisted.failSymlinkContaining = path.join(".claude", "skills");
341
+ try {
342
+ // Every per-file wrapper symlink throws EPERM, but reindex must still
343
+ // complete cleanly (exit 0) rather than crash + flood Sentry.
344
+ expect(reindex({ repoRoot: root }).status).toBe(0);
345
+ } finally {
346
+ hoisted.failSymlinkContaining = "";
347
+ }
348
+
349
+ // The wrapper DIR was still created; the symlink inside was just skipped.
350
+ expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
351
+ expect(
352
+ fs.existsSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
353
+ ).toBe(false);
354
+ });
355
+
356
+ it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
357
+ writeSkill("core/skills/demo");
358
+ fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
359
+ fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
360
+
361
+ // Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
362
+ // succeed, proving the skip is scoped and the reindex completes.
363
+ hoisted.failSymlinkContaining = path.join("core", "knowledge");
364
+ try {
365
+ expect(reindex({ repoRoot: root }).status).toBe(0);
366
+ } finally {
367
+ hoisted.failSymlinkContaining = "";
368
+ }
369
+
370
+ // The personal mirror was skipped (EPERM)...
371
+ expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
372
+ // ...but unaffected work still happened: the skill wrapper + its symlink.
373
+ expect(
374
+ fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
375
+ ).toBe(true);
376
+ });
310
377
  });
@@ -154,6 +154,31 @@ function safeMkdir(dir: string, label: string): boolean {
154
154
  }
155
155
  }
156
156
 
157
+ // Create a symlink, tolerating platform-specific failures instead of aborting
158
+ // the whole reindex — the symlink sibling of safeMkdir. The classic case
159
+ // (HQ-CLI-4) is Windows WITHOUT Developer Mode (or an elevated shell), where
160
+ // symlink creation is a privileged operation and `fs.symlinkSync` throws EPERM
161
+ // (Win32 ERROR_PRIVILEGE_NOT_HELD). A single symlink we can't create should
162
+ // degrade gracefully — warn with an actionable hint and skip just that link so
163
+ // the rest of the reindex (other wrappers, mirrors, registry regen) still runs.
164
+ // reindex is idempotent and re-run on hooks, so a later run re-creates the link
165
+ // once the privilege is granted. Returns true on success, false on skip.
166
+ function safeSymlink(target: string, linkPath: string, label: string): boolean {
167
+ try {
168
+ fs.symlinkSync(target, linkPath);
169
+ return true;
170
+ } catch (err) {
171
+ const code = (err as NodeJS.ErrnoException).code;
172
+ warn(
173
+ `reindex: could not create ${label} '${linkPath}' -> '${target}' ` +
174
+ `(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows, ` +
175
+ `creating symlinks needs Developer Mode (Settings -> Privacy & security ` +
176
+ `-> For developers) or an elevated shell.`,
177
+ );
178
+ return false;
179
+ }
180
+ }
181
+
157
182
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
158
183
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
159
184
  function isLegacyCommandTarget(t: string): boolean {
@@ -325,7 +350,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
325
350
  continue;
326
351
  }
327
352
 
328
- fs.symlinkSync(relativeTarget, linkPath);
353
+ safeSymlink(relativeTarget, linkPath, `.claude/skills/${wrapperName}/${entry}`);
329
354
  }
330
355
 
331
356
  // Prune symlinks in the wrapper whose source entry no longer exists.
@@ -424,7 +449,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
424
449
  continue;
425
450
  }
426
451
 
427
- fs.symlinkSync(relativeTarget, linkPath);
452
+ safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
428
453
  }
429
454
  }
430
455
 
@@ -299,15 +299,63 @@ interface InitiateAuthResponse {
299
299
  message?: string;
300
300
  }
301
301
 
302
+ /** Tunables for the machine-mint retry-on-throttle loop. Defaults are the
303
+ * production values; tests pass `baseDelayMs: 0` to retry without waiting. */
304
+ export interface MintRetryOptions {
305
+ /** Retries AFTER the first attempt (so total attempts = maxRetries + 1). */
306
+ maxRetries?: number;
307
+ /** Backoff for the first retry in ms; doubles each subsequent attempt. */
308
+ baseDelayMs?: number;
309
+ /** Ceiling for any single backoff sleep. */
310
+ maxDelayMs?: number;
311
+ }
312
+
313
+ const DEFAULT_MINT_RETRY: Required<MintRetryOptions> = {
314
+ maxRetries: 5,
315
+ baseDelayMs: 250,
316
+ maxDelayMs: 5000,
317
+ };
318
+
319
+ /**
320
+ * Cognito signals a per-user mint throttle as `TooManyRequestsException` — and
321
+ * (observed in prod on the Infraredi agent box, 2026-06-26) returns it under
322
+ * HTTP 400, not 429. Match the typed error first and the status second so both
323
+ * shapes are treated as transient throttles to retry, not hard auth failures.
324
+ */
325
+ function isCognitoThrottle(status: number, type: string | undefined): boolean {
326
+ if (typeof type === "string" && type.includes("TooManyRequests")) return true;
327
+ return status === 429;
328
+ }
329
+
330
+ function sleep(ms: number): Promise<void> {
331
+ return new Promise((resolve) => setTimeout(resolve, ms));
332
+ }
333
+
334
+ /** Full-jitter exponential backoff: a random delay in
335
+ * [0, min(maxDelayMs, baseDelayMs * 2^attempt)]. Jitter de-syncs a concurrent
336
+ * burst of retries so they don't re-collide on the same per-user limit. */
337
+ function backoffDelayMs(
338
+ attempt: number,
339
+ opts: Required<MintRetryOptions>,
340
+ ): number {
341
+ const ceiling = Math.min(opts.maxDelayMs, opts.baseDelayMs * 2 ** attempt);
342
+ return Math.floor(Math.random() * (ceiling + 1));
343
+ }
344
+
302
345
  /**
303
346
  * Mint a fresh session for the machine identity via USER_PASSWORD_AUTH
304
347
  * against the Cognito IDP endpoint (plain unsigned HTTP — no AWS SDK
305
348
  * dependency). Caches BOTH tokens with correct field semantics and returns
306
349
  * them.
350
+ *
351
+ * Retries on a `TooManyRequestsException` throttle with exponential backoff +
352
+ * full jitter (transient, per-user rate limit); every other non-OK response
353
+ * (bad creds, disabled flow) is a hard error surfaced immediately.
307
354
  */
308
355
  export async function mintMachineTokens(
309
356
  config: CognitoAuthConfig,
310
357
  creds?: MachineCreds,
358
+ retry?: MintRetryOptions,
311
359
  ): Promise<CognitoTokens> {
312
360
  const machineCreds = creds ?? loadMachineCreds();
313
361
  if (!machineCreds) {
@@ -320,68 +368,131 @@ export async function mintMachineTokens(
320
368
  // which the CLI's default (browser) client may not.
321
369
  const region = machineCreds.region ?? config.region;
322
370
  const clientId = machineCreds.clientId ?? config.clientId;
323
- const res = await fetch(
324
- `https://cognito-idp.${region}.amazonaws.com/`,
325
- {
326
- method: "POST",
327
- headers: {
328
- "Content-Type": "application/x-amz-json-1.1",
329
- "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
330
- },
331
- body: JSON.stringify({
332
- AuthFlow: "USER_PASSWORD_AUTH",
333
- ClientId: clientId,
334
- AuthParameters: {
335
- USERNAME: machineCreds.username,
336
- PASSWORD: machineCreds.secret,
371
+ const retryOpts: Required<MintRetryOptions> = { ...DEFAULT_MINT_RETRY, ...retry };
372
+
373
+ let lastError: CognitoAuthError | null = null;
374
+ for (let attempt = 0; attempt <= retryOpts.maxRetries; attempt++) {
375
+ const res = await fetch(
376
+ `https://cognito-idp.${region}.amazonaws.com/`,
377
+ {
378
+ method: "POST",
379
+ headers: {
380
+ "Content-Type": "application/x-amz-json-1.1",
381
+ "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
337
382
  },
338
- }),
339
- },
340
- );
341
- const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
342
- if (!res.ok) {
343
- throw new CognitoAuthError(
344
- `Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
345
- );
346
- }
347
- const result = data.AuthenticationResult;
348
- if (!result?.AccessToken || !result?.IdToken) {
349
- throw new CognitoAuthError(
350
- `Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
383
+ body: JSON.stringify({
384
+ AuthFlow: "USER_PASSWORD_AUTH",
385
+ ClientId: clientId,
386
+ AuthParameters: {
387
+ USERNAME: machineCreds.username,
388
+ PASSWORD: machineCreds.secret,
389
+ },
390
+ }),
391
+ },
351
392
  );
393
+ const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
394
+ if (!res.ok) {
395
+ const err = new CognitoAuthError(
396
+ `Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
397
+ );
398
+ // Back off and retry a transient per-user throttle rather than failing
399
+ // the whole sync run; surface every other auth error immediately.
400
+ if (
401
+ isCognitoThrottle(res.status, data.__type) &&
402
+ attempt < retryOpts.maxRetries
403
+ ) {
404
+ lastError = err;
405
+ await sleep(backoffDelayMs(attempt, retryOpts));
406
+ continue;
407
+ }
408
+ throw err;
409
+ }
410
+ const result = data.AuthenticationResult;
411
+ if (!result?.AccessToken || !result?.IdToken) {
412
+ throw new CognitoAuthError(
413
+ `Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
414
+ );
415
+ }
416
+ const tokens: CognitoTokens = {
417
+ accessToken: result.AccessToken,
418
+ idToken: result.IdToken,
419
+ // Machine creds never expire — expiry is handled by re-minting, so the
420
+ // refresh token (when Cognito returns one at all) is never exercised.
421
+ refreshToken: result.RefreshToken ?? "",
422
+ expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
423
+ tokenType: "Bearer",
424
+ };
425
+ saveCachedTokens(tokens);
426
+ return tokens;
352
427
  }
353
- const tokens: CognitoTokens = {
354
- accessToken: result.AccessToken,
355
- idToken: result.IdToken,
356
- // Machine creds never expire — expiry is handled by re-minting, so the
357
- // refresh token (when Cognito returns one at all) is never exercised.
358
- refreshToken: result.RefreshToken ?? "",
359
- expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
360
- tokenType: "Bearer",
361
- };
362
- saveCachedTokens(tokens);
363
- return tokens;
428
+ // Unreachable in practice — the final throttled attempt throws inside the
429
+ // loop — but keeps the function total for the type checker.
430
+ throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
364
431
  }
365
432
 
433
+ // In-process machine-token memo (sync-runner mint-storm fix).
434
+ //
435
+ // `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
436
+ // many requests concurrently (presign primes, STS vend, the per-file tombstone
437
+ // HEAD verifies). The on-disk cache alone does NOT bound mints: at run start
438
+ // the disk token is often within its expiry buffer, so N concurrent callers
439
+ // each independently observe "expiring" and each fire USER_PASSWORD_AUTH —
440
+ // bursting Cognito's per-user limit (TooManyRequestsException) on large vaults
441
+ // (observed: 56 throttled mints over 314 files on the Infraredi box, 2026-06-26).
442
+ //
443
+ // This module-level memo makes mints O(1) per process:
444
+ // - hot path: a live in-memory token serves every later request with no disk
445
+ // read and no network — a 300-file sync does ONE mint, not one-per-request;
446
+ // - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
447
+ // collapses onto a single in-flight mint instead of one mint per caller.
448
+ // One machine identity per process is assumed (true for agent boxes), so a
449
+ // single slot suffices.
450
+ let inProcessMachineTokens: CognitoTokens | null = null;
451
+ let inFlightMachineMint: Promise<CognitoTokens> | null = null;
452
+
366
453
  /**
367
454
  * Return a valid (non-expiring) machine session, re-minting on demand.
368
- * Cache-hit path never touches the network.
455
+ * Cache-hit path never touches the network — and after the first mint, the
456
+ * in-process memo serves every subsequent request for the life of the token.
369
457
  */
370
458
  export async function getValidMachineTokens(
371
459
  config: CognitoAuthConfig,
372
460
  ): Promise<CognitoTokens> {
373
- const machineCreds = loadMachineCreds();
374
- const cached = loadCachedTokens();
375
- if (machineCreds && cached && !isExpiring(cached, 120)) {
376
- // Compare against the client we'd actually mint with (creds-file clientId
377
- // wins over config), and require the cached ID token to prove this exact
378
- // agent identity. Opaque/missing/human-shaped claims are treated as stale.
379
- const expectedClientId = machineCreds.clientId ?? config.clientId;
380
- if (cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)) {
381
- return cached;
461
+ // Hot path: a live in-memory token serves every request after the first.
462
+ if (inProcessMachineTokens && !isExpiring(inProcessMachineTokens, 120)) {
463
+ return inProcessMachineTokens;
464
+ }
465
+ // Single-flight: collapse a concurrent burst of misses onto one resolution.
466
+ // The assignment below completes before the first `await` yields, so sibling
467
+ // callers in the same tick see the in-flight promise and reuse it.
468
+ if (inFlightMachineMint) return inFlightMachineMint;
469
+
470
+ inFlightMachineMint = (async () => {
471
+ const machineCreds = loadMachineCreds();
472
+ const cached = loadCachedTokens();
473
+ if (machineCreds && cached && !isExpiring(cached, 120)) {
474
+ // Compare against the client we'd actually mint with (creds-file clientId
475
+ // wins over config), and require the cached ID token to prove this exact
476
+ // agent identity. Opaque/missing/human-shaped claims are treated as stale.
477
+ const expectedClientId = machineCreds.clientId ?? config.clientId;
478
+ if (
479
+ cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)
480
+ ) {
481
+ return cached;
482
+ }
382
483
  }
484
+ return mintMachineTokens(config, machineCreds ?? undefined);
485
+ })();
486
+
487
+ try {
488
+ const tokens = await inFlightMachineMint;
489
+ inProcessMachineTokens = tokens;
490
+ return tokens;
491
+ } finally {
492
+ // Clear on both success and failure: a rejected mint must not wedge later
493
+ // calls onto a permanently-failed promise.
494
+ inFlightMachineMint = null;
383
495
  }
384
- return mintMachineTokens(config, machineCreds ?? undefined);
385
496
  }
386
497
 
387
498
  // ---------------------------------------------------------------------------
@@ -355,6 +355,143 @@ describe("getValidMachineTokens", () => {
355
355
  });
356
356
  });
357
357
 
358
+ // ---------------------------------------------------------------------------
359
+ // Regression: constant token mints over N files (sync-runner mint storm)
360
+ // ---------------------------------------------------------------------------
361
+ //
362
+ // `getValidAccessToken` (→ getValidMachineTokens) is invoked on EVERY vault
363
+ // request a sync makes, including the per-file tombstone HEAD verifies. The
364
+ // 2026-06-26 prod incident was 56 throttled USER_PASSWORD_AUTH mints over 314
365
+ // files on one box: with no in-process memo + single-flight, mints scaled with
366
+ // request count and tripped Cognito's per-user limit. These lock the invariant
367
+ // that mints are O(1) per process, independent of N.
368
+
369
+ describe("regression: constant mints regardless of file count", () => {
370
+ it.each([10, 300])(
371
+ "performs exactly ONE token mint across %i sequential file-verify requests",
372
+ async (n) => {
373
+ writeCreds();
374
+ const { fetchMock } = stubMintFetch();
375
+ const { getValidMachineTokens } = await importModule();
376
+
377
+ for (let i = 0; i < n; i++) {
378
+ await getValidMachineTokens(CONFIG);
379
+ }
380
+
381
+ // One mint for N requests — NOT proportional to N. The first call mints;
382
+ // every later call is served by the in-process memo (no network).
383
+ expect(fetchMock).toHaveBeenCalledTimes(1);
384
+ },
385
+ );
386
+
387
+ it.each([10, 300])(
388
+ "collapses a concurrent burst of %i requests onto a SINGLE mint (single-flight)",
389
+ async (n) => {
390
+ writeCreds();
391
+ const { fetchMock } = stubMintFetch();
392
+ const { getValidMachineTokens } = await importModule();
393
+
394
+ // Fire all N at once, as the run-start fan-out does. Without single-flight
395
+ // every concurrent miss would mint and burst the per-user limit.
396
+ const results = await Promise.all(
397
+ Array.from({ length: n }, () => getValidMachineTokens(CONFIG)),
398
+ );
399
+
400
+ expect(fetchMock).toHaveBeenCalledTimes(1);
401
+ // All callers observe the same token.
402
+ for (const r of results) expect(r).toEqual(results[0]);
403
+
404
+ // Follow-up sequential requests stay on the memo — still one mint total.
405
+ await getValidMachineTokens(CONFIG);
406
+ expect(fetchMock).toHaveBeenCalledTimes(1);
407
+ },
408
+ );
409
+ });
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Throttle handling: retry TooManyRequestsException with backoff
413
+ // ---------------------------------------------------------------------------
414
+
415
+ describe("mintMachineTokens throttle retry", () => {
416
+ /** Fetch mock that throttles (HTTP 400 + TooManyRequestsException, the shape
417
+ * Cognito actually returns) for the first `throttleCount` calls, then 200s. */
418
+ function stubThrottleThenSuccess(throttleCount: number) {
419
+ let call = 0;
420
+ const fetchMock = vi.fn(async () => {
421
+ call++;
422
+ if (call <= throttleCount) {
423
+ return new Response(
424
+ JSON.stringify({
425
+ __type: "TooManyRequestsException",
426
+ message: "Too many requests",
427
+ }),
428
+ { status: 400 },
429
+ );
430
+ }
431
+ return new Response(
432
+ JSON.stringify({
433
+ AuthenticationResult: {
434
+ AccessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
435
+ IdToken: fakeJwt({ token_use: "id" }),
436
+ ExpiresIn: 3600,
437
+ },
438
+ }),
439
+ { status: 200 },
440
+ );
441
+ });
442
+ vi.stubGlobal("fetch", fetchMock);
443
+ return fetchMock;
444
+ }
445
+
446
+ it("retries a throttled mint with backoff and eventually succeeds", async () => {
447
+ writeCreds();
448
+ const fetchMock = stubThrottleThenSuccess(2);
449
+ const { mintMachineTokens } = await importModule();
450
+
451
+ // baseDelayMs: 0 keeps the test instant — jitter * 0 == 0.
452
+ const tokens = await mintMachineTokens(CONFIG, undefined, {
453
+ baseDelayMs: 0,
454
+ maxRetries: 5,
455
+ });
456
+
457
+ expect(fetchMock).toHaveBeenCalledTimes(3); // 2 throttles + 1 success
458
+ const idClaims = JSON.parse(
459
+ Buffer.from(tokens.idToken.split(".")[1], "base64url").toString(),
460
+ );
461
+ expect(idClaims.token_use).toBe("id");
462
+ });
463
+
464
+ it("gives up after maxRetries and throws when throttling never clears", async () => {
465
+ writeCreds();
466
+ const fetchMock = stubThrottleThenSuccess(Number.POSITIVE_INFINITY);
467
+ const { mintMachineTokens, CognitoAuthError } = await importModule();
468
+
469
+ await expect(
470
+ mintMachineTokens(CONFIG, undefined, { baseDelayMs: 0, maxRetries: 2 }),
471
+ ).rejects.toBeInstanceOf(CognitoAuthError);
472
+
473
+ // maxRetries: 2 → 3 total attempts (initial + 2 retries).
474
+ expect(fetchMock).toHaveBeenCalledTimes(3);
475
+ });
476
+
477
+ it("does NOT retry a non-throttle auth failure (bad creds fail fast)", async () => {
478
+ writeCreds();
479
+ const fetchMock = vi.fn(async () =>
480
+ new Response(
481
+ JSON.stringify({ __type: "NotAuthorizedException", message: "nope" }),
482
+ { status: 400 },
483
+ ),
484
+ );
485
+ vi.stubGlobal("fetch", fetchMock);
486
+ const { mintMachineTokens, CognitoAuthError } = await importModule();
487
+
488
+ await expect(
489
+ mintMachineTokens(CONFIG, undefined, { baseDelayMs: 0, maxRetries: 5 }),
490
+ ).rejects.toBeInstanceOf(CognitoAuthError);
491
+ expect(fetchMock).toHaveBeenCalledTimes(1); // no retries
492
+ });
493
+ });
494
+
358
495
  // ---------------------------------------------------------------------------
359
496
  // getValidAccessToken — machine-mode short circuit
360
497
  // ---------------------------------------------------------------------------