@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.
- 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 +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +7 -1
- 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 +10 -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 +93 -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-classify-ordering.test.js +42 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +5 -1
- package/dist/cli/rescue-core.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 +7 -1
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +122 -4
- package/src/bin/sync-runner.ts +92 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue-classify-ordering.test.ts +49 -0
- package/src/cli/rescue-core.ts +5 -1
- 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/cli/rescue-core.ts
CHANGED
|
@@ -65,6 +65,9 @@ const ALWAYS_PRESERVED_NAMES = [
|
|
|
65
65
|
".hq-sync-journal.json",
|
|
66
66
|
".hq",
|
|
67
67
|
".hq-conflicts",
|
|
68
|
+
// Machine-local state. SSM/root invocations can leave credentials here
|
|
69
|
+
// unreadable to the HQ user; rescue must neither snapshot nor classify it.
|
|
70
|
+
".local",
|
|
68
71
|
// Package-manager caches: never release-shipped, never drift-tracked, and
|
|
69
72
|
// huge (the pnpm content-addressed store alone is tens of thousands of
|
|
70
73
|
// files). Walking them just to classify everything user-only dominates the
|
|
@@ -531,7 +534,7 @@ function doRescue(
|
|
|
531
534
|
out("==> Mode: preserve-list (default)\n");
|
|
532
535
|
const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
|
|
533
536
|
out(
|
|
534
|
-
`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`,
|
|
537
|
+
`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts, .local${extra}\n`,
|
|
535
538
|
);
|
|
536
539
|
}
|
|
537
540
|
if (cfg.preserveSubpaths.length !== 0) {
|
|
@@ -720,6 +723,7 @@ function doRescue(
|
|
|
720
723
|
"--exclude=.hq-sync-journal.json",
|
|
721
724
|
"--exclude=.hq",
|
|
722
725
|
"--exclude=.hq-conflicts",
|
|
726
|
+
"--exclude=/.local",
|
|
723
727
|
"--exclude=/worktrees",
|
|
724
728
|
"--exclude=/.worktrees",
|
|
725
729
|
"--exclude=/.sst-sandbox-home",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wiring tests: `rescue()` runs `reindex()` after a successful, non-dry-run
|
|
3
|
-
* rescue so the generated skill wrappers /
|
|
4
|
-
*
|
|
3
|
+
* rescue so the generated skill wrappers / workers registry are refreshed once
|
|
4
|
+
* core/ has been re-laid-down (the personal overlay is read directly, not
|
|
5
|
+
* mirrored, so there are no personal mirror symlinks to refresh).
|
|
5
6
|
*
|
|
6
7
|
* The rescue algorithm (./rescue-core.js) is mocked so no real clone/overlay
|
|
7
8
|
* runs, and ./reindex.js is mocked to a spy so we assert the call without
|
package/src/cli/share.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ import * as readline from "readline";
|
|
|
41
41
|
import { share, _testing as shareTesting } from "./share.js";
|
|
42
42
|
import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
43
43
|
import type { EntityContext } from "../types.js";
|
|
44
|
+
import { VaultAuthError } from "../vault-client.js";
|
|
44
45
|
|
|
45
46
|
const mockConfig: VaultServiceConfig = {
|
|
46
47
|
apiUrl: "https://vault-api.test",
|
|
@@ -207,6 +208,25 @@ describe("share", () => {
|
|
|
207
208
|
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md", undefined, expect.anything());
|
|
208
209
|
});
|
|
209
210
|
|
|
211
|
+
it("propagates a per-file vault 401 to the runner auth boundary", async () => {
|
|
212
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
213
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
214
|
+
const testFile = path.join(companyRoot, "private.md");
|
|
215
|
+
fs.writeFileSync(testFile, "private");
|
|
216
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(
|
|
217
|
+
new VaultAuthError("upload unauthorized"),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
share({
|
|
222
|
+
paths: [testFile],
|
|
223
|
+
company: "acme",
|
|
224
|
+
vaultConfig: mockConfig,
|
|
225
|
+
hqRoot: tmpDir,
|
|
226
|
+
}),
|
|
227
|
+
).rejects.toBeInstanceOf(VaultAuthError);
|
|
228
|
+
});
|
|
229
|
+
|
|
210
230
|
it("respects ignore rules", async () => {
|
|
211
231
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
212
232
|
fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });
|
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,
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -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",
|
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
|
+
});
|