@indigoai-us/hq-cloud 5.9.0 → 5.10.0
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/dist/bin/sync-runner.d.ts +43 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +126 -17
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +136 -2
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/context.js +15 -3
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +61 -0
- package/dist/context.test.js.map +1 -1
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +1 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +13 -3
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +67 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +149 -2
- package/src/bin/sync-runner.ts +188 -18
- package/src/context.test.ts +81 -0
- package/src/context.ts +16 -3
- package/src/types.ts +11 -2
- package/src/vault-client.test.ts +94 -0
- package/src/vault-client.ts +13 -3
package/src/context.test.ts
CHANGED
|
@@ -303,3 +303,84 @@ describe("isExpiringSoon", () => {
|
|
|
303
303
|
expect(isExpiringSoon(past)).toBe(true);
|
|
304
304
|
});
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Refreshable authToken getter
|
|
309
|
+
//
|
|
310
|
+
// Regression for the personal-sync 401: a long-running `hq-sync-runner` run
|
|
311
|
+
// captures vaultConfig.authToken as a string at startup, then `refreshEntityContext`
|
|
312
|
+
// fires ~13 min into the personal-company sync (STS expiry) and uses that
|
|
313
|
+
// stale string against API Gateway's JWT authorizer → 401. The getter form
|
|
314
|
+
// lets every fetchEntity / postVend call resolve the latest token from disk.
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
describe("authToken getter is invoked per-request (regression: personal-sync 401)", () => {
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
clearContextCache();
|
|
320
|
+
vi.restoreAllMocks();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("calls the getter on entity fetch AND on vend (every request)", async () => {
|
|
324
|
+
setupFetchMock();
|
|
325
|
+
const getter = vi.fn(async () => "fresh-token");
|
|
326
|
+
|
|
327
|
+
await resolveEntityContext("cmp_01ABCDEF", {
|
|
328
|
+
apiUrl: "https://vault-api.test",
|
|
329
|
+
authToken: getter,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Two upstream requests: fetchEntity (UID looks like a UID) + postVend.
|
|
333
|
+
expect(getter).toHaveBeenCalledTimes(2);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("picks up a rotated token between resolveEntityContext and refreshEntityContext", async () => {
|
|
337
|
+
const fetchMock = setupFetchMock();
|
|
338
|
+
let current = "stale-token";
|
|
339
|
+
const cfg = {
|
|
340
|
+
apiUrl: "https://vault-api.test",
|
|
341
|
+
authToken: async () => current,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
await resolveEntityContext("cmp_01ABCDEF", cfg);
|
|
345
|
+
|
|
346
|
+
// fetchEntity + vend during initial resolution — both used "stale-token"
|
|
347
|
+
const initialAuth = (fetchMock.mock.calls as [string, RequestInit][]).map(
|
|
348
|
+
([, init]) =>
|
|
349
|
+
(init.headers as Record<string, string>).Authorization,
|
|
350
|
+
);
|
|
351
|
+
expect(initialAuth.every((a) => a === "Bearer stale-token")).toBe(true);
|
|
352
|
+
|
|
353
|
+
// Simulate the on-disk token rotating mid-flight.
|
|
354
|
+
current = "fresh-token";
|
|
355
|
+
|
|
356
|
+
await refreshEntityContext("cmp_01ABCDEF", cfg);
|
|
357
|
+
|
|
358
|
+
// The post-refresh calls must use the new token. Without the per-request
|
|
359
|
+
// getter, refreshEntityContext would still send "Bearer stale-token" and
|
|
360
|
+
// 401 against the gateway.
|
|
361
|
+
const allCalls = fetchMock.mock.calls as [string, RequestInit][];
|
|
362
|
+
const postRefreshCalls = allCalls.slice(initialAuth.length);
|
|
363
|
+
expect(postRefreshCalls.length).toBeGreaterThan(0);
|
|
364
|
+
for (const [, init] of postRefreshCalls) {
|
|
365
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
366
|
+
"Bearer fresh-token",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("static-string authToken still works (back-compat)", async () => {
|
|
372
|
+
const fetchMock = setupFetchMock();
|
|
373
|
+
await resolveEntityContext("cmp_01ABCDEF", {
|
|
374
|
+
apiUrl: "https://vault-api.test",
|
|
375
|
+
authToken: "static-token",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const calls = fetchMock.mock.calls as [string, RequestInit][];
|
|
379
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
380
|
+
for (const [, init] of calls) {
|
|
381
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
382
|
+
"Bearer static-token",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
package/src/context.ts
CHANGED
|
@@ -138,12 +138,25 @@ interface VendResponse {
|
|
|
138
138
|
expiresAt: string;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the current bearer token from a VaultServiceConfig. Accepts either
|
|
143
|
+
* a static string (back-compat with short-lived tools) or an async getter
|
|
144
|
+
* (long-running callers like `hq-sync-runner` pass `() => getValidAccessToken(...)`
|
|
145
|
+
* so each request picks up token refreshes that landed in
|
|
146
|
+
* `~/.hq/cognito-tokens.json` since the run started).
|
|
147
|
+
*/
|
|
148
|
+
async function resolveAuthToken(config: VaultServiceConfig): Promise<string> {
|
|
149
|
+
return typeof config.authToken === "function"
|
|
150
|
+
? config.authToken()
|
|
151
|
+
: config.authToken;
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
async function fetchEntity(
|
|
142
155
|
uid: string,
|
|
143
156
|
config: VaultServiceConfig,
|
|
144
157
|
): Promise<EntityResponse> {
|
|
145
158
|
const res = await fetch(`${config.apiUrl}/entity/${uid}`, {
|
|
146
|
-
headers: { Authorization: `Bearer ${config
|
|
159
|
+
headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
|
|
147
160
|
});
|
|
148
161
|
if (!res.ok) {
|
|
149
162
|
const body = await res.text();
|
|
@@ -159,7 +172,7 @@ async function fetchEntityBySlug(
|
|
|
159
172
|
config: VaultServiceConfig,
|
|
160
173
|
): Promise<EntityResponse> {
|
|
161
174
|
const res = await fetch(`${config.apiUrl}/entity/by-slug/${type}/${slug}`, {
|
|
162
|
-
headers: { Authorization: `Bearer ${config
|
|
175
|
+
headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
|
|
163
176
|
});
|
|
164
177
|
if (!res.ok) {
|
|
165
178
|
const body = await res.text();
|
|
@@ -180,7 +193,7 @@ async function postVend(
|
|
|
180
193
|
method: "POST",
|
|
181
194
|
headers: {
|
|
182
195
|
"Content-Type": "application/json",
|
|
183
|
-
Authorization: `Bearer ${config
|
|
196
|
+
Authorization: `Bearer ${await resolveAuthToken(config)}`,
|
|
184
197
|
},
|
|
185
198
|
body: JSON.stringify({ ...body, durationSeconds: DEFAULT_SESSION_DURATION_SECONDS }),
|
|
186
199
|
});
|
package/src/types.ts
CHANGED
|
@@ -97,8 +97,17 @@ export interface VaultCredentials {
|
|
|
97
97
|
export interface VaultServiceConfig {
|
|
98
98
|
/** Vault API base URL (e.g. https://vault-api.example.com) */
|
|
99
99
|
apiUrl: string;
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Cognito JWT token for authentication. Either a static string OR an async
|
|
102
|
+
* getter that returns the current token on every call. Long-running consumers
|
|
103
|
+
* (e.g. `hq-sync-runner`'s multi-company fanout) MUST pass a getter — a
|
|
104
|
+
* captured string can outlive the 60-min Cognito access token TTL, which
|
|
105
|
+
* causes mid-flight `refreshEntityContext` calls to 401 against
|
|
106
|
+
* API Gateway's JWT authorizer even though `~/.hq/cognito-tokens.json` was
|
|
107
|
+
* refreshed under them by another process (e.g. the menubar). The getter
|
|
108
|
+
* lets every request resolve the latest token via `getValidAccessToken`.
|
|
109
|
+
*/
|
|
110
|
+
authToken: string | (() => string | Promise<string>);
|
|
102
111
|
/** AWS region for S3 client (defaults to entity region or us-east-1) */
|
|
103
112
|
region?: string;
|
|
104
113
|
}
|
package/src/vault-client.test.ts
CHANGED
|
@@ -693,3 +693,97 @@ describe("VaultClient identity bootstrap", () => {
|
|
|
693
693
|
expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
|
|
694
694
|
});
|
|
695
695
|
});
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Refreshable authToken getter
|
|
699
|
+
//
|
|
700
|
+
// Regression for the personal-sync 401: a captured `authToken` string can
|
|
701
|
+
// outlive Cognito's 60-min access token TTL during long multi-company
|
|
702
|
+
// fanouts, causing mid-flight `refreshEntityContext` calls to 401. The
|
|
703
|
+
// getter form lets every request resolve the latest token from disk.
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
describe("authToken getter (refreshable token)", () => {
|
|
707
|
+
// Response bodies can only be read once, so each mock call needs a fresh
|
|
708
|
+
// Response. Using mockImplementation (not mockResolvedValue) ensures a new
|
|
709
|
+
// Response instance per request — same pattern as the retry tests above.
|
|
710
|
+
function alwaysOk(body: unknown): void {
|
|
711
|
+
fetchSpy.mockImplementation(() =>
|
|
712
|
+
Promise.resolve(jsonResponse(200, body)),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
it("calls the getter on every request", async () => {
|
|
717
|
+
const getter = vi.fn(async () => "first-token");
|
|
718
|
+
const c = new VaultClient({
|
|
719
|
+
apiUrl: "https://vault.test.example.com",
|
|
720
|
+
authToken: getter,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
alwaysOk({ members: [] });
|
|
724
|
+
|
|
725
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
726
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
727
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
728
|
+
|
|
729
|
+
expect(getter).toHaveBeenCalledTimes(3);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("picks up a rotated token without recreating the client", async () => {
|
|
733
|
+
let current = "stale-token";
|
|
734
|
+
const c = new VaultClient({
|
|
735
|
+
apiUrl: "https://vault.test.example.com",
|
|
736
|
+
authToken: async () => current,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
alwaysOk({ members: [] });
|
|
740
|
+
|
|
741
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
742
|
+
const [, firstInit] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
743
|
+
expect((firstInit.headers as Record<string, string>).Authorization).toBe(
|
|
744
|
+
"Bearer stale-token",
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Simulate the menubar refreshing ~/.hq/cognito-tokens.json mid-flight.
|
|
748
|
+
current = "fresh-token";
|
|
749
|
+
|
|
750
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
751
|
+
const [, secondInit] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
752
|
+
expect((secondInit.headers as Record<string, string>).Authorization).toBe(
|
|
753
|
+
"Bearer fresh-token",
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("supports a sync getter (returns plain string, not a promise)", async () => {
|
|
758
|
+
const c = new VaultClient({
|
|
759
|
+
apiUrl: "https://vault.test.example.com",
|
|
760
|
+
authToken: () => "sync-token",
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
alwaysOk({ members: [] });
|
|
764
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
765
|
+
|
|
766
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
767
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
768
|
+
"Bearer sync-token",
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("static-string authToken still works (back-compat)", async () => {
|
|
773
|
+
const c = new VaultClient({
|
|
774
|
+
apiUrl: "https://vault.test.example.com",
|
|
775
|
+
authToken: "static-token",
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
alwaysOk({ members: [] });
|
|
779
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
780
|
+
await c.listMembersOfCompany("cmp_abc");
|
|
781
|
+
|
|
782
|
+
const calls = fetchSpy.mock.calls as [string, RequestInit][];
|
|
783
|
+
for (const [, init] of calls) {
|
|
784
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
785
|
+
"Bearer static-token",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
});
|
package/src/vault-client.ts
CHANGED
|
@@ -212,11 +212,21 @@ async function sleep(ms: number): Promise<void> {
|
|
|
212
212
|
|
|
213
213
|
export class VaultClient {
|
|
214
214
|
private readonly apiUrl: string;
|
|
215
|
-
private readonly
|
|
215
|
+
private readonly getAuthToken: () => Promise<string>;
|
|
216
216
|
|
|
217
217
|
constructor(config: VaultServiceConfig) {
|
|
218
218
|
this.apiUrl = config.apiUrl.replace(/\/+$/, "");
|
|
219
|
-
|
|
219
|
+
// Normalize string|getter into a single async getter so the request path
|
|
220
|
+
// doesn't have to branch. Static strings still work — they just produce a
|
|
221
|
+
// getter that returns the same value forever (suitable for short-lived
|
|
222
|
+
// tools and tests). Long-running callers pass a getter that re-reads
|
|
223
|
+
// `~/.hq/cognito-tokens.json` via `getValidAccessToken`, which is what
|
|
224
|
+
// makes the request layer self-heal across token refreshes.
|
|
225
|
+
const tok = config.authToken;
|
|
226
|
+
this.getAuthToken =
|
|
227
|
+
typeof tok === "function"
|
|
228
|
+
? async () => tok()
|
|
229
|
+
: async () => tok;
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
// -- Membership operations ------------------------------------------------
|
|
@@ -436,7 +446,7 @@ export class VaultClient {
|
|
|
436
446
|
}
|
|
437
447
|
|
|
438
448
|
const headers: Record<string, string> = {
|
|
439
|
-
Authorization: `Bearer ${this.
|
|
449
|
+
Authorization: `Bearer ${await this.getAuthToken()}`,
|
|
440
450
|
Accept: "application/json",
|
|
441
451
|
};
|
|
442
452
|
|