@indigoai-us/hq-cloud 6.14.11 → 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 (85) 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-classify-ordering.test.js +42 -0
  29. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  30. package/dist/cli/rescue-core.d.ts.map +1 -1
  31. package/dist/cli/rescue-core.js +5 -1
  32. package/dist/cli/rescue-core.js.map +1 -1
  33. package/dist/cli/rescue.reindex.test.js +3 -2
  34. package/dist/cli/rescue.reindex.test.js.map +1 -1
  35. package/dist/cli/share.d.ts.map +1 -1
  36. package/dist/cli/share.js +12 -1
  37. package/dist/cli/share.js.map +1 -1
  38. package/dist/cli/share.test.js +14 -0
  39. package/dist/cli/share.test.js.map +1 -1
  40. package/dist/cli/sync.d.ts.map +1 -1
  41. package/dist/cli/sync.js +9 -1
  42. package/dist/cli/sync.js.map +1 -1
  43. package/dist/cli/sync.test.js +8 -0
  44. package/dist/cli/sync.test.js.map +1 -1
  45. package/dist/cognito-auth.d.ts +44 -12
  46. package/dist/cognito-auth.d.ts.map +1 -1
  47. package/dist/cognito-auth.js +440 -76
  48. package/dist/cognito-auth.js.map +1 -1
  49. package/dist/cognito-auth.test.js +174 -0
  50. package/dist/cognito-auth.test.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +3 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/machine-auth.test.js +683 -5
  56. package/dist/machine-auth.test.js.map +1 -1
  57. package/dist/vault-client.d.ts +2 -1
  58. package/dist/vault-client.d.ts.map +1 -1
  59. package/dist/vault-client.js +45 -9
  60. package/dist/vault-client.js.map +1 -1
  61. package/dist/vault-client.test.js +77 -7
  62. package/dist/vault-client.test.js.map +1 -1
  63. package/package.json +2 -1
  64. package/src/bin/sync-runner-company.ts +5 -0
  65. package/src/bin/sync-runner-planning.ts +2 -1
  66. package/src/bin/sync-runner-watch-loop.ts +7 -1
  67. package/src/bin/sync-runner-watch-routes.ts +2 -0
  68. package/src/bin/sync-runner.test.ts +122 -4
  69. package/src/bin/sync-runner.ts +92 -32
  70. package/src/cli/reindex.test.ts +63 -120
  71. package/src/cli/reindex.ts +23 -62
  72. package/src/cli/rescue-classify-ordering.test.ts +49 -0
  73. package/src/cli/rescue-core.ts +5 -1
  74. package/src/cli/rescue.reindex.test.ts +3 -2
  75. package/src/cli/share.test.ts +20 -0
  76. package/src/cli/share.ts +7 -1
  77. package/src/cli/sync.test.ts +14 -0
  78. package/src/cli/sync.ts +5 -1
  79. package/src/cognito-auth.test.ts +227 -0
  80. package/src/cognito-auth.ts +533 -71
  81. package/src/index.ts +5 -1
  82. package/src/machine-auth.test.ts +808 -5
  83. package/src/vault-client.test.ts +100 -11
  84. package/src/vault-client.ts +55 -10
  85. package/test/share-sync.integration.test.ts +12 -9
@@ -92,8 +92,8 @@ describe("auth header injection", () => {
92
92
 
93
93
  describe("error mapping", () => {
94
94
  it("maps 401 to VaultAuthError", async () => {
95
- fetchSpy.mockResolvedValueOnce(
96
- jsonResponse(401, { message: "Token expired" }),
95
+ fetchSpy.mockImplementation(() =>
96
+ Promise.resolve(jsonResponse(401, { message: "Token expired" })),
97
97
  );
98
98
 
99
99
  await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
@@ -197,13 +197,100 @@ describe("retry behavior", () => {
197
197
  expect(fetchSpy).toHaveBeenCalledTimes(4);
198
198
  });
199
199
 
200
- it("does not retry on 401 (non-transient)", async () => {
201
- fetchSpy.mockResolvedValueOnce(jsonResponse(401, { message: "Expired" }));
200
+ // Regression: the deploy access-gate 401 of 2026-07-19. A token that expired
201
+ // mid-request is recoverable — callers passing a getter can mint a fresh one —
202
+ // but the client threw instead of retrying, so a transient auth lapse surfaced
203
+ // as a hard failure and the caller degraded a members-only gate to a password.
204
+ it("retries a 401 once when the token getter yields a fresh token", async () => {
205
+ const tokens = ["stale-token", "fresh-token"];
206
+ const getToken = vi.fn(async () => tokens.shift() ?? "fresh-token");
207
+ const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
202
208
 
203
- await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
209
+ fetchSpy
210
+ .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
211
+ .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
212
+
213
+ const result = await c.listMembersOfCompany("cmp_abc");
214
+ expect(result).toHaveLength(1);
215
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
216
+
217
+ const authHeader = (call: number) =>
218
+ (fetchSpy.mock.calls[call]![1] as RequestInit).headers as Record<string, string>;
219
+ expect(authHeader(0).Authorization).toBe("Bearer stale-token");
220
+ expect(authHeader(1).Authorization).toBe("Bearer fresh-token");
221
+ });
222
+
223
+ it("does not retry a 401 when the getter returns the same token", async () => {
224
+ const getToken = vi.fn(async () => "unchanging-token");
225
+ const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
226
+
227
+ fetchSpy.mockResolvedValue(jsonResponse(401, { message: "Nope" }));
228
+
229
+ await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
204
230
  expect(fetchSpy).toHaveBeenCalledTimes(1);
205
231
  });
206
232
 
233
+ // Review finding: the auth retry used to share the transient budget, so a
234
+ // 401 on the LAST attempt set the flag, hit `continue`, and fell out of the
235
+ // loop having never actually retried — the recovery silently did nothing in
236
+ // exactly the case it was written for.
237
+ it("still retries when the 401 lands on the final attempt", async () => {
238
+ const tokens = ["stale", "stale", "stale", "stale", "fresh"];
239
+ const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
240
+ const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
241
+
242
+ // Burn the whole transient budget first (attempts 0,1,2), so the 401 lands
243
+ // on attempt 3 — the last one the shared-budget loop would ever run.
244
+ fetchSpy
245
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
246
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
247
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
248
+ .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
249
+ .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
250
+
251
+ const result = await c.listMembersOfCompany("cmp_abc");
252
+ expect(result).toHaveLength(1);
253
+ // 5th call only happens if the auth retry got its own budget.
254
+ expect(fetchSpy).toHaveBeenCalledTimes(5);
255
+ });
256
+
257
+ // Review finding: a 401 consumed a transient-retry slot, so a request that
258
+ // hit an auth lapse had fewer retries left for genuine 5xx afterwards.
259
+ it("does not spend transient-retry budget on the auth retry", async () => {
260
+ const tokens = ["stale", "fresh"];
261
+ const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
262
+ const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
263
+
264
+ // 401 (free retry) then three 500s — the full transient budget must still
265
+ // be available, so the 4th call succeeds.
266
+ fetchSpy
267
+ .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
268
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
269
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
270
+ .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
271
+ .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
272
+
273
+ const result = await c.listMembersOfCompany("cmp_abc");
274
+ expect(result).toHaveLength(1);
275
+ expect(fetchSpy).toHaveBeenCalledTimes(5);
276
+ });
277
+
278
+ it("retries a 401 at most once even if the token keeps changing", async () => {
279
+ let n = 0;
280
+ const getToken = vi.fn(async () => `token-${n++}`);
281
+ const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
282
+
283
+ // mockImplementation (not mockResolvedValue): each attempt needs its own
284
+ // Response, since the body can only be read once.
285
+ fetchSpy.mockImplementation(() =>
286
+ Promise.resolve(jsonResponse(401, { message: "Still no" })),
287
+ );
288
+
289
+ await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
290
+ // 1 initial + exactly 1 auth retry — never a retry storm against the authorizer
291
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
292
+ });
293
+
207
294
  it("does not retry on 403 (non-transient)", async () => {
208
295
  fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
209
296
 
@@ -908,8 +995,10 @@ describe("listMyExplicitGrants (US-004)", () => {
908
995
  });
909
996
 
910
997
  it("maps 401 to VaultAuthError", async () => {
911
- fetchSpy.mockResolvedValueOnce(
912
- jsonResponse(401, { error: "Missing or invalid authorization token" }),
998
+ fetchSpy.mockImplementation(() =>
999
+ Promise.resolve(
1000
+ jsonResponse(401, { error: "Missing or invalid authorization token" }),
1001
+ ),
913
1002
  );
914
1003
 
915
1004
  await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
@@ -980,8 +1069,8 @@ describe("getMembershipSyncConfig (US-004)", () => {
980
1069
  });
981
1070
 
982
1071
  it("maps 401 to VaultAuthError", async () => {
983
- fetchSpy.mockResolvedValueOnce(
984
- jsonResponse(401, { error: "Token expired" }),
1072
+ fetchSpy.mockImplementation(() =>
1073
+ Promise.resolve(jsonResponse(401, { error: "Token expired" })),
985
1074
  );
986
1075
 
987
1076
  await expect(
@@ -1067,8 +1156,8 @@ describe("setMembershipSyncConfig (US-004)", () => {
1067
1156
  });
1068
1157
 
1069
1158
  it("maps 401 to VaultAuthError", async () => {
1070
- fetchSpy.mockResolvedValueOnce(
1071
- jsonResponse(401, { error: "Token expired" }),
1159
+ fetchSpy.mockImplementation(() =>
1160
+ Promise.resolve(jsonResponse(401, { error: "Token expired" })),
1072
1161
  );
1073
1162
 
1074
1163
  await expect(
@@ -10,6 +10,7 @@ import type { ClientInfo, VaultServiceConfig } from "./types.js";
10
10
  import { buildClientHeaders } from "./client-info.js";
11
11
  import { emitCloudTelemetry } from "./telemetry-events.js";
12
12
  import { z } from "zod";
13
+ import { accessTokenFingerprint } from "./cognito-auth.js";
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Error classes
@@ -27,7 +28,10 @@ export class VaultClientError extends Error {
27
28
  }
28
29
 
29
30
  export class VaultAuthError extends VaultClientError {
30
- constructor(message = "Authentication failed — session expired or invalid") {
31
+ constructor(
32
+ message = "Authentication failed — session expired or invalid",
33
+ public readonly tokenFingerprint?: string,
34
+ ) {
31
35
  super(message, 401);
32
36
  this.name = "VaultAuthError";
33
37
  }
@@ -1375,15 +1379,33 @@ export class VaultClient {
1375
1379
  ): Promise<T> {
1376
1380
  let lastError: Error | undefined;
1377
1381
  const maxRetries = options.maxRetries ?? MAX_RETRIES;
1378
-
1379
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1380
- if (attempt > 0) {
1382
+ // A 401 is not in isTransient() because most 401s are genuinely terminal.
1383
+ // But one shape of 401 is fully recoverable: the access token expired
1384
+ // between the caller resolving it and this request landing. Callers that
1385
+ // pass a getter (getValidAccessToken) can mint a fresh token on demand, so
1386
+ // we re-resolve once and retry IF the token actually changed. Static-string
1387
+ // callers get the same value back and fall through to the throw, so this
1388
+ // costs them nothing. Bounded to one attempt: a genuinely bad credential
1389
+ // must not turn into a retry storm against the authorizer.
1390
+ let authRetried = false;
1391
+ // The auth retry gets its own budget rather than borrowing from the
1392
+ // transient one. Sharing had two defects: a 401 early on left fewer
1393
+ // retries for later 5xx, and a 401 on the FINAL attempt set the flag,
1394
+ // hit `continue`, and fell out of the loop having never actually retried.
1395
+ let maxAttempts = maxRetries;
1396
+ // Backoff exists to let a rate limit or a struggling server recover.
1397
+ // Neither applies to a token swap, so the auth retry goes out immediately.
1398
+ let noBackoffAt = -1;
1399
+
1400
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
1401
+ if (attempt > 0 && attempt !== noBackoffAt) {
1381
1402
  const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
1382
1403
  await sleep(delay);
1383
1404
  }
1384
1405
 
1406
+ const authToken = await this.getAuthToken();
1385
1407
  const headers: Record<string, string> = {
1386
- Authorization: `Bearer ${await this.getAuthToken()}`,
1408
+ Authorization: `Bearer ${authToken}`,
1387
1409
  Accept: "application/json",
1388
1410
  ...buildClientHeaders(this.clientInfo),
1389
1411
  };
@@ -1410,7 +1432,7 @@ export class VaultClient {
1410
1432
  res = await fetch(`${this.apiUrl}${path}`, init);
1411
1433
  } catch (err) {
1412
1434
  lastError = err instanceof Error ? err : new Error(String(err));
1413
- if (attempt < maxRetries) continue;
1435
+ if (attempt < maxAttempts) continue;
1414
1436
  throw lastError;
1415
1437
  } finally {
1416
1438
  if (timeout !== undefined) clearTimeout(timeout);
@@ -1444,24 +1466,47 @@ export class VaultClient {
1444
1466
 
1445
1467
  const responseBody = await res.text();
1446
1468
 
1469
+ // Expired-token 401 → re-resolve once and retry with the fresh token.
1470
+ // Residual gap (deliberate): if the local cache still considers the token
1471
+ // valid — clock skew, or a server-side revocation the client can't see —
1472
+ // the getter returns the same string and we fall through to the throw.
1473
+ // Fixing that needs a force-refresh hook on the token getter contract.
1474
+ if (res.status === 401 && !authRetried) {
1475
+ authRetried = true;
1476
+ const refreshed = await this.getAuthToken().catch(() => undefined);
1477
+ if (refreshed && refreshed !== authToken) {
1478
+ lastError = this.mapError(res.status, responseBody, authToken);
1479
+ maxAttempts++; // free retry — must not eat the transient budget
1480
+ noBackoffAt = attempt + 1;
1481
+ continue;
1482
+ }
1483
+ }
1484
+
1447
1485
  // Non-retryable errors → throw immediately
1448
1486
  if (!isTransient(res.status)) {
1449
- throw this.mapError(res.status, responseBody);
1487
+ throw this.mapError(res.status, responseBody, authToken);
1450
1488
  }
1451
1489
 
1452
1490
  // Retryable — store and loop
1453
- lastError = this.mapError(res.status, responseBody);
1491
+ lastError = this.mapError(res.status, responseBody, authToken);
1454
1492
  }
1455
1493
 
1456
1494
  throw lastError ?? new VaultClientError("Request failed after retries", 500);
1457
1495
  }
1458
1496
 
1459
- private mapError(status: number, body: string): VaultClientError {
1497
+ private mapError(
1498
+ status: number,
1499
+ body: string,
1500
+ authToken?: string,
1501
+ ): VaultClientError {
1460
1502
  const message = this.extractMessage(body);
1461
1503
 
1462
1504
  switch (status) {
1463
1505
  case 401:
1464
- return new VaultAuthError(message);
1506
+ return new VaultAuthError(
1507
+ message,
1508
+ authToken ? accessTokenFingerprint(authToken) : undefined,
1509
+ );
1465
1510
  case 403:
1466
1511
  return new VaultPermissionDeniedError(message);
1467
1512
  case 404:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Integration test: share/sync lifecycle (VLT-5 US-003).
2
+ * Integration test: laptop → vault → mini share/sync lifecycle (VLT-5 US-003).
3
3
  *
4
4
  * Exercises the full share/sync cycle against a real dev-stage company entity
5
5
  * using real STS vending (VLT-3). Requires:
@@ -7,14 +7,17 @@
7
7
  * - VAULT_AUTH_TOKEN env var (Cognito JWT for an active member)
8
8
  * - VAULT_TEST_COMPANY env var (company slug with an active entity + bucket)
9
9
  *
10
- * Run: pnpm test:e2e
10
+ * Run: pnpm test:e2e:laptop-mini
11
11
  *
12
12
  * This test:
13
- * 1. M1 shares file A
14
- * 2. M2 (same creds, different local dir) syncs receives A
15
- * 3. M2 shares file B
16
- * 4. M1 syncs receives B
17
- * 5. M1 edits A locally + M2 pushes newer A → M1 syncs with --on-conflict=keep
13
+ * 1. Laptop shares a handoff into the vault
14
+ * 2. Mini (same membership, separate local root) syncs and receives it
15
+ * 3. Mini shares a finding back through the vault
16
+ * 4. Laptop syncs and receives it
17
+ * 5. Laptop keeps a local edit when Mini pushes a divergent update
18
+ *
19
+ * This deliberately exercises the vault transport only. The optional git
20
+ * handoff mirror is a separate subsystem and is not required for this path.
18
21
  *
19
22
  * Cleanup: all shared files are deleted from S3 on teardown.
20
23
  */
@@ -42,7 +45,7 @@ const canRun = VAULT_API_URL && VAULT_AUTH_TOKEN && VAULT_TEST_COMPANY;
42
45
 
43
46
  const TEST_PREFIX = `__integration-test-${Date.now()}`;
44
47
 
45
- describe.skipIf(!canRun)("share-sync integration", () => {
48
+ describe.skipIf(!canRun)("laptop-vault-mini share-sync integration", () => {
46
49
  let vaultConfig: VaultServiceConfig;
47
50
  let m1Root: string;
48
51
  let m2Root: string;
@@ -100,7 +103,7 @@ describe.skipIf(!canRun)("share-sync integration", () => {
100
103
  }
101
104
  });
102
105
 
103
- it("completes the full share/sync lifecycle", async () => {
106
+ it("moves a handoff laptop → vault → mini and a finding back", async () => {
104
107
  await setup();
105
108
 
106
109
  // --- Step 1: M1 shares file A ---