@indigoai-us/hq-cloud 6.14.12 → 6.14.14

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.
Files changed (78) hide show
  1. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +6 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +3 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +42 -3
  11. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
  13. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-routes.js +3 -0
  15. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +17 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js +83 -33
  19. package/dist/bin/sync-runner.js.map +1 -1
  20. package/dist/bin/sync-runner.test.js +193 -5
  21. package/dist/bin/sync-runner.test.js.map +1 -1
  22. package/dist/cli/reindex.d.ts.map +1 -1
  23. package/dist/cli/reindex.js +23 -60
  24. package/dist/cli/reindex.js.map +1 -1
  25. package/dist/cli/reindex.test.d.ts +2 -2
  26. package/dist/cli/reindex.test.js +60 -109
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/cli/rescue.reindex.test.js +3 -2
  29. package/dist/cli/rescue.reindex.test.js.map +1 -1
  30. package/dist/cli/share.d.ts.map +1 -1
  31. package/dist/cli/share.js +12 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/share.test.js +14 -0
  34. package/dist/cli/share.test.js.map +1 -1
  35. package/dist/cli/sync.d.ts.map +1 -1
  36. package/dist/cli/sync.js +9 -1
  37. package/dist/cli/sync.js.map +1 -1
  38. package/dist/cli/sync.test.js +8 -0
  39. package/dist/cli/sync.test.js.map +1 -1
  40. package/dist/cognito-auth.d.ts +44 -12
  41. package/dist/cognito-auth.d.ts.map +1 -1
  42. package/dist/cognito-auth.js +440 -76
  43. package/dist/cognito-auth.js.map +1 -1
  44. package/dist/cognito-auth.test.js +174 -0
  45. package/dist/cognito-auth.test.js.map +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/machine-auth.test.js +683 -5
  51. package/dist/machine-auth.test.js.map +1 -1
  52. package/dist/vault-client.d.ts +2 -1
  53. package/dist/vault-client.d.ts.map +1 -1
  54. package/dist/vault-client.js +45 -9
  55. package/dist/vault-client.js.map +1 -1
  56. package/dist/vault-client.test.js +77 -7
  57. package/dist/vault-client.test.js.map +1 -1
  58. package/package.json +2 -1
  59. package/src/bin/sync-runner-company.ts +5 -0
  60. package/src/bin/sync-runner-planning.ts +2 -1
  61. package/src/bin/sync-runner-watch-loop.ts +51 -4
  62. package/src/bin/sync-runner-watch-routes.ts +2 -0
  63. package/src/bin/sync-runner.test.ts +244 -4
  64. package/src/bin/sync-runner.ts +99 -32
  65. package/src/cli/reindex.test.ts +63 -120
  66. package/src/cli/reindex.ts +23 -62
  67. package/src/cli/rescue.reindex.test.ts +3 -2
  68. package/src/cli/share.test.ts +20 -0
  69. package/src/cli/share.ts +7 -1
  70. package/src/cli/sync.test.ts +14 -0
  71. package/src/cli/sync.ts +5 -1
  72. package/src/cognito-auth.test.ts +227 -0
  73. package/src/cognito-auth.ts +533 -71
  74. package/src/index.ts +5 -1
  75. package/src/machine-auth.test.ts +808 -5
  76. package/src/vault-client.test.ts +100 -11
  77. package/src/vault-client.ts +55 -10
  78. package/test/share-sync.integration.test.ts +12 -9
@@ -338,3 +338,230 @@ describe("expiresAt shape round-trip", () => {
338
338
  expect(typeof onDisk?.expiresAt).toBe("number");
339
339
  });
340
340
  });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Concurrency-safe token refresh (cross-process lock)
344
+ //
345
+ // Boxes run several hq processes (`hq auth refresh` on a timer, the sync runner,
346
+ // replica-sync) that refresh the SAME cached session at once. Without
347
+ // coordination they race on the shared cache; and if the app client issues
348
+ // single-use (rotated) tokens, concurrent refreshers invalidate each other —
349
+ // the first consumes the token, the second gets `invalid_grant`. These pin the
350
+ // fix: a cross-process lock + a re-read of the freshest token inside the lock,
351
+ // so refreshes serialize and a consumed token is never reused. (Rotation does
352
+ // NOT reset the 30-day lifetime — see ADR-0009; durable never-log-out is per-box
353
+ // machine identity, which reuses this same lock for its mint sequence.)
354
+ // ---------------------------------------------------------------------------
355
+ describe("refreshTokens — concurrency-safe refresh (cross-process lock)", () => {
356
+ const config = {
357
+ region: "us-east-1",
358
+ userPoolDomain: "vault-indigo-hq-dev",
359
+ clientId: "test-client",
360
+ };
361
+
362
+ // A minimal unsigned JWT carrying a client_id claim, so the cache's
363
+ // client-ownership check (decodeAccessTokenClientId) resolves in tests.
364
+ const jwtFor = (clientId: string, extra: Record<string, unknown> = {}) =>
365
+ "eyJhbGciOiJub25lIn0." +
366
+ Buffer.from(JSON.stringify({ client_id: clientId, ...extra })).toString(
367
+ "base64url",
368
+ ) +
369
+ ".sig";
370
+
371
+ // Models Cognito refresh-token ROTATION with single-use enforcement: a valid
372
+ // refresh mints a new token and consumes the old one; reusing a consumed
373
+ // token returns 400 invalid_grant (exactly what took the outposts offline).
374
+ function rotatingCognitoFetch(initialToken: string) {
375
+ let current = initialToken;
376
+ const consumed = new Set<string>();
377
+ let counter = 0;
378
+ return vi.fn(async (_url: string, init?: { body?: string }) => {
379
+ const sent = new URLSearchParams(init?.body ?? "").get("refresh_token") ?? "";
380
+ if (sent !== current || consumed.has(sent)) {
381
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
382
+ status: 400,
383
+ headers: { "Content-Type": "application/json" },
384
+ });
385
+ }
386
+ consumed.add(sent);
387
+ counter += 1;
388
+ current = `refresh-${counter}`;
389
+ return new Response(
390
+ JSON.stringify({
391
+ access_token: jwtFor("test-client", { n: counter }),
392
+ id_token: jwtFor("test-client", { n: counter }),
393
+ refresh_token: current,
394
+ expires_in: 3600,
395
+ token_type: "Bearer",
396
+ }),
397
+ { status: 200, headers: { "Content-Type": "application/json" } },
398
+ );
399
+ });
400
+ }
401
+
402
+ it("persists the rotated refresh token when Cognito returns one", async () => {
403
+ vi.stubGlobal("fetch", rotatingCognitoFetch("R0"));
404
+ const { refreshTokens, loadCachedTokens } = await importModule();
405
+ const result = await refreshTokens(config, "R0");
406
+ expect(result.refreshToken).toBe("refresh-1");
407
+ expect(loadCachedTokens()?.refreshToken).toBe("refresh-1");
408
+ });
409
+
410
+ it("serializes concurrent refreshers: ONE network refresh, neither gets invalid_grant", async () => {
411
+ const fetchMock = rotatingCognitoFetch("R0");
412
+ vi.stubGlobal("fetch", fetchMock);
413
+ const { refreshTokens, saveCachedTokens, loadCachedTokens } =
414
+ await importModule();
415
+ // Seed an EXPIRED session (for THIS client) so both callers attempt refresh.
416
+ saveCachedTokens({
417
+ ...baseTokens,
418
+ accessToken: jwtFor("test-client"),
419
+ refreshToken: "R0",
420
+ expiresAt: Date.now() - 1000,
421
+ });
422
+
423
+ // Two processes refresh the same expiring session at once. Without the lock
424
+ // both send "R0"; the second would hit the mock's invalid_grant and throw.
425
+ const [a, b] = await Promise.all([
426
+ refreshTokens(config, "R0"),
427
+ refreshTokens(config, "R0"),
428
+ ]);
429
+
430
+ // Neither threw; the network was hit exactly once (the waiter reused the
431
+ // winner's fresh token under the lock); both hold that same token.
432
+ expect(fetchMock).toHaveBeenCalledTimes(1);
433
+ expect(a.refreshToken).toBe("refresh-1");
434
+ expect(b.refreshToken).toBe("refresh-1");
435
+ expect(a.accessToken).toBe(b.accessToken);
436
+ expect(loadCachedTokens()?.refreshToken).toBe("refresh-1");
437
+ });
438
+
439
+ it("reclaims a lock whose holder process has died (no deadlock)", async () => {
440
+ vi.stubGlobal("fetch", rotatingCognitoFetch("R0"));
441
+ const { refreshTokens } = await importModule();
442
+ // A crashed refresher left its lock behind, tagged with a pid that is not
443
+ // running — reclaimed by pid-liveness, not by an mtime heuristic.
444
+ const hqDir = path.join(tmpHome, ".hq");
445
+ fs.mkdirSync(hqDir, { recursive: true });
446
+ const lockPath = path.join(hqDir, "cognito-tokens.json.lock");
447
+ fs.writeFileSync(lockPath, "2147483646");
448
+
449
+ const result = await refreshTokens(config, "R0");
450
+ expect(result.refreshToken).toBe("refresh-1");
451
+ // Our own lock is released after the refresh completes (no leak).
452
+ expect(fs.existsSync(lockPath)).toBe(false);
453
+ });
454
+
455
+ it("does NOT reuse a cached session minted by a different app client", async () => {
456
+ const fetchMock = rotatingCognitoFetch("R-B");
457
+ vi.stubGlobal("fetch", fetchMock);
458
+ const { refreshTokens, saveCachedTokens } = await importModule();
459
+ // Cache holds a FRESH token, but for a DIFFERENT client.
460
+ saveCachedTokens({
461
+ ...baseTokens,
462
+ accessToken: jwtFor("other-client"),
463
+ refreshToken: "R-other",
464
+ expiresAt: Date.now() + 3600 * 1000,
465
+ });
466
+
467
+ // A caller for "test-client" must NOT be handed the other client's cached
468
+ // token; it must exchange the token it supplied.
469
+ const result = await refreshTokens(config, "R-B");
470
+ expect(fetchMock).toHaveBeenCalledTimes(1); // did not short-circuit on the wrong-client cache
471
+ expect(result.refreshToken).toBe("refresh-1");
472
+ });
473
+ });
474
+
475
+ describe("refresh expiry hardening", () => {
476
+ it("retries one transient refresh failure and preserves the refresh token", async () => {
477
+ const fetchMock = vi
478
+ .fn()
479
+ .mockResolvedValueOnce(
480
+ new Response(JSON.stringify({ message: "temporarily unavailable" }), {
481
+ status: 503,
482
+ }),
483
+ )
484
+ .mockResolvedValueOnce(
485
+ new Response(
486
+ JSON.stringify({
487
+ access_token: "fresh-access",
488
+ id_token: "fresh-id",
489
+ expires_in: 3600,
490
+ token_type: "Bearer",
491
+ }),
492
+ { status: 200, headers: { "Content-Type": "application/json" } },
493
+ ),
494
+ );
495
+ vi.stubGlobal("fetch", fetchMock);
496
+
497
+ const { refreshTokens } = await importModule();
498
+ const result = await refreshTokens(baseConfig, "preserved-refresh");
499
+
500
+ expect(fetchMock).toHaveBeenCalledTimes(2);
501
+ expect(result.accessToken).toBe("fresh-access");
502
+ expect(result.refreshToken).toBe("preserved-refresh");
503
+ });
504
+
505
+ it("clears an expired refresh session after one 401 retry", async () => {
506
+ const fetchMock = vi.fn(async () =>
507
+ new Response(JSON.stringify({ message: "refresh token expired" }), {
508
+ status: 401,
509
+ }),
510
+ );
511
+ vi.stubGlobal("fetch", fetchMock);
512
+
513
+ const { saveCachedTokens, loadCachedTokens, getValidAccessToken } =
514
+ await importModule();
515
+ saveCachedTokens({ ...baseTokens, expiresAt: 0 });
516
+
517
+ await expect(
518
+ getValidAccessToken(baseConfig, { interactive: false }),
519
+ ).rejects.toThrow(/quick refresh/i);
520
+
521
+ expect(fetchMock).toHaveBeenCalledTimes(2);
522
+ expect(loadCachedTokens()).toBeNull();
523
+ });
524
+
525
+ it("keeps the stored session after two transient network failures", async () => {
526
+ const fetchMock = vi.fn(async () => {
527
+ throw new Error("ECONNRESET");
528
+ });
529
+ vi.stubGlobal("fetch", fetchMock);
530
+
531
+ const { saveCachedTokens, loadCachedTokens, getValidAccessToken } =
532
+ await importModule();
533
+ saveCachedTokens({ ...baseTokens, expiresAt: 0 });
534
+
535
+ await expect(
536
+ getValidAccessToken(baseConfig, { interactive: false }),
537
+ ).rejects.toThrow(/ECONNRESET/);
538
+
539
+ expect(fetchMock).toHaveBeenCalledTimes(2);
540
+ expect(loadCachedTokens()?.refreshToken).toBe("refresh");
541
+ });
542
+
543
+ it("does not invalidate a newer login when an older token fails late", async () => {
544
+ const {
545
+ accessTokenFingerprint,
546
+ invalidateCachedTokensByFingerprint,
547
+ loadCachedTokens,
548
+ saveCachedTokens,
549
+ } = await importModule();
550
+
551
+ saveCachedTokens({
552
+ ...baseTokens,
553
+ accessToken: "old-access",
554
+ expiresAt: Date.now() + 60_000,
555
+ });
556
+ const oldFingerprint = accessTokenFingerprint("old-access");
557
+
558
+ saveCachedTokens({
559
+ ...baseTokens,
560
+ accessToken: "new-access",
561
+ expiresAt: Date.now() + 60_000,
562
+ });
563
+ invalidateCachedTokensByFingerprint(oldFingerprint);
564
+
565
+ expect(loadCachedTokens()?.accessToken).toBe("new-access");
566
+ });
567
+ });