@indigoai-us/hq-cloud 6.14.12 → 6.14.13

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 +1 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +7 -1
  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 +10 -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 +93 -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 +7 -1
  62. package/src/bin/sync-runner-watch-routes.ts +2 -0
  63. package/src/bin/sync-runner.test.ts +122 -4
  64. package/src/bin/sync-runner.ts +92 -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
package/src/cli/share.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  } from "../lib/conflict-file.js";
68
68
  import { appendConflictEntry } from "../lib/conflict-index.js";
69
69
  import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
70
+ import { VaultAuthError } from "../vault-client.js";
70
71
 
71
72
  /**
72
73
  * Push-side fresh-collision convergence probe.
@@ -110,7 +111,8 @@ async function remoteContentDiffers(
110
111
  ? hashSymlinkTarget(fs.readlinkSync(probePath))
111
112
  : hashFile(probePath);
112
113
  return remoteHash !== localHash;
113
- } catch {
114
+ } catch (err) {
115
+ if (err instanceof VaultAuthError) throw err;
114
116
  return true;
115
117
  } finally {
116
118
  try {
@@ -1388,6 +1390,7 @@ async function executeUploads(
1388
1390
  try {
1389
1391
  await performUpload(precondition);
1390
1392
  } catch (err) {
1393
+ if (err instanceof VaultAuthError) throw err;
1391
1394
  if (isPreconditionFailed(err)) {
1392
1395
  conflictPaths.push(relativePath);
1393
1396
  const resolution = await resolveConflictSerialized({
@@ -1410,6 +1413,7 @@ async function executeUploads(
1410
1413
  try {
1411
1414
  await performUpload(undefined);
1412
1415
  } catch (retryErr) {
1416
+ if (retryErr instanceof VaultAuthError) throw retryErr;
1413
1417
  run.emit({
1414
1418
  type: "error",
1415
1419
  path: relativePath,
@@ -1501,6 +1505,7 @@ async function writePushConflictMirror(
1501
1505
  });
1502
1506
  }
1503
1507
  } catch (mirrorErr) {
1508
+ if (mirrorErr instanceof VaultAuthError) throw mirrorErr;
1504
1509
  run.emit({
1505
1510
  type: "error",
1506
1511
  path: item.relativePath,
@@ -1562,6 +1567,7 @@ async function executeDeletes(
1562
1567
  deleted: true,
1563
1568
  });
1564
1569
  } catch (err) {
1570
+ if (err instanceof VaultAuthError) throw err;
1565
1571
  run.emit({
1566
1572
  type: "error",
1567
1573
  path: relativePath,
@@ -58,6 +58,7 @@ import { share } from "./share.js";
58
58
  import * as s3Module from "../s3.js";
59
59
  import { reindex } from "./reindex.js";
60
60
  import { readSyncProgress } from "../sync-progress.js";
61
+ import { VaultAuthError } from "../vault-client.js";
61
62
 
62
63
  const mockConfig: VaultServiceConfig = {
63
64
  apiUrl: "https://vault-api.test",
@@ -154,6 +155,19 @@ describe("sync", () => {
154
155
  expect(s3Module.classifyVaultKey("docs/readme.md", "company")).toBeNull();
155
156
  });
156
157
 
158
+ it("propagates a per-file vault 401 to the runner auth boundary", async () => {
159
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
160
+ { key: "docs/private.md", size: 10, lastModified: new Date(), etag: '"private"' },
161
+ ]);
162
+ vi.mocked(s3Module.downloadFile).mockRejectedValueOnce(
163
+ new VaultAuthError("download unauthorized"),
164
+ );
165
+
166
+ await expect(
167
+ sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir }),
168
+ ).rejects.toBeInstanceOf(VaultAuthError);
169
+ });
170
+
157
171
  it("runs reindex against hqRoot after a sync that downloaded files", async () => {
158
172
  await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
159
173
  // skipLock: the surrounding sync run already holds the per-root lock.
package/src/cli/sync.ts CHANGED
@@ -12,7 +12,7 @@ import type {
12
12
  VaultServiceConfig,
13
13
  SyncJournal,
14
14
  } from "../types.js";
15
- import { VaultClient, type SyncMode } from "../vault-client.js";
15
+ import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
16
16
  import {
17
17
  emitCloudTelemetry,
18
18
  type TelemetryClaims,
@@ -1311,6 +1311,7 @@ async function executeConflictItem(
1311
1311
  : (downloaded.contentHash ?? hashFile(conflictAbs));
1312
1312
  converged = remoteHash === item.localHash;
1313
1313
  } catch (probeErr) {
1314
+ if (probeErr instanceof VaultAuthError) throw probeErr;
1314
1315
  run.emit({
1315
1316
  type: "error",
1316
1317
  path: remoteFile.key,
@@ -1563,6 +1564,7 @@ async function downloadOne(
1563
1564
  counters.bytesDownloaded += size;
1564
1565
  counters.changedPaths.push(remoteFile.key);
1565
1566
  } catch (err) {
1567
+ if (err instanceof VaultAuthError) throw err;
1566
1568
  if (err instanceof LocalSnapshotChangedError) {
1567
1569
  reportLocalSnapshotConflict(run, counters, remoteFile.key);
1568
1570
  } else if (isAccessDenied(err)) {
@@ -1617,6 +1619,7 @@ async function emitAndReportNewFiles(
1617
1619
  addedBy = head.metadata["created-by"];
1618
1620
  }
1619
1621
  } catch (headErr) {
1622
+ if (headErr instanceof VaultAuthError) throw headErr;
1620
1623
  try {
1621
1624
  console.error(
1622
1625
  `[hq-sync] HeadObject failed for ${nf.path}: ${
@@ -1683,6 +1686,7 @@ async function verifyPlannedJournalTombstones(
1683
1686
  const head = await headRemoteFile(run.ctx, tombstone.key);
1684
1687
  return head === null ? tombstone : null;
1685
1688
  } catch (err) {
1689
+ if (err instanceof VaultAuthError) throw err;
1686
1690
  if (isAccessDenied(err)) return null;
1687
1691
  run.emit({
1688
1692
  type: "error",
@@ -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
+ });