@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.
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +6 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +42 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +3 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +83 -33
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +193 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +23 -60
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +2 -2
- package/dist/cli/reindex.test.js +60 -109
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +3 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +14 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +8 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +44 -12
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +440 -76
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +174 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.js +683 -5
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +2 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +45 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +77 -7
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +2 -1
- package/src/bin/sync-runner-company.ts +5 -0
- package/src/bin/sync-runner-planning.ts +2 -1
- package/src/bin/sync-runner-watch-loop.ts +51 -4
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +244 -4
- package/src/bin/sync-runner.ts +99 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue.reindex.test.ts +3 -2
- package/src/cli/share.test.ts +20 -0
- package/src/cli/share.ts +7 -1
- package/src/cli/sync.test.ts +14 -0
- package/src/cli/sync.ts +5 -1
- package/src/cognito-auth.test.ts +227 -0
- package/src/cognito-auth.ts +533 -71
- package/src/index.ts +5 -1
- package/src/machine-auth.test.ts +808 -5
- package/src/vault-client.test.ts +100 -11
- package/src/vault-client.ts +55 -10
- package/test/share-sync.integration.test.ts +12 -9
package/src/cognito-auth.test.ts
CHANGED
|
@@ -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
|
+
});
|