@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.
@@ -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.authToken}` },
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.authToken}` },
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.authToken}`,
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
- /** Cognito JWT token for authentication */
101
- authToken: string;
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
  }
@@ -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
+ });
@@ -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 authToken: string;
215
+ private readonly getAuthToken: () => Promise<string>;
216
216
 
217
217
  constructor(config: VaultServiceConfig) {
218
218
  this.apiUrl = config.apiUrl.replace(/\/+$/, "");
219
- this.authToken = config.authToken;
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.authToken}`,
449
+ Authorization: `Bearer ${await this.getAuthToken()}`,
440
450
  Accept: "application/json",
441
451
  };
442
452