@indigoai-us/hq-cloud 5.1.11 → 5.2.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.
Files changed (44) hide show
  1. package/dist/bin/sync-runner.d.ts +1 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +19 -3
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +83 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/sync.d.ts +13 -0
  8. package/dist/cli/sync.d.ts.map +1 -1
  9. package/dist/cli/sync.js +15 -4
  10. package/dist/cli/sync.js.map +1 -1
  11. package/dist/cli/sync.test.js +36 -0
  12. package/dist/cli/sync.test.js.map +1 -1
  13. package/dist/cognito-auth.d.ts +2 -2
  14. package/dist/cognito-auth.d.ts.map +1 -1
  15. package/dist/cognito-auth.test.js +1 -1
  16. package/dist/cognito-auth.test.js.map +1 -1
  17. package/dist/context.d.ts +6 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +32 -13
  20. package/dist/context.js.map +1 -1
  21. package/dist/context.test.js +91 -1
  22. package/dist/context.test.js.map +1 -1
  23. package/dist/ignore.d.ts +1 -0
  24. package/dist/ignore.d.ts.map +1 -1
  25. package/dist/ignore.js +1 -1
  26. package/dist/ignore.js.map +1 -1
  27. package/dist/vault-client.d.ts +12 -0
  28. package/dist/vault-client.d.ts.map +1 -1
  29. package/dist/vault-client.js +22 -9
  30. package/dist/vault-client.js.map +1 -1
  31. package/dist/vault-client.test.js +60 -1
  32. package/dist/vault-client.test.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/bin/sync-runner.test.ts +108 -0
  35. package/src/bin/sync-runner.ts +30 -4
  36. package/src/cli/sync.test.ts +43 -0
  37. package/src/cli/sync.ts +29 -4
  38. package/src/cognito-auth.test.ts +1 -1
  39. package/src/cognito-auth.ts +2 -2
  40. package/src/context.test.ts +104 -1
  41. package/src/context.ts +46 -16
  42. package/src/ignore.ts +1 -1
  43. package/src/vault-client.test.ts +72 -0
  44. package/src/vault-client.ts +25 -7
package/src/context.ts CHANGED
@@ -17,6 +17,13 @@ const DEFAULT_SESSION_DURATION_SECONDS = 900;
17
17
  /** Cached contexts keyed by entity UID. */
18
18
  const contextCache = new Map<string, EntityContext>();
19
19
 
20
+ /**
21
+ * Closed-set of recognised entity-UID prefixes. Adding a new entity type
22
+ * means appending one entry here AND extending the dispatch in
23
+ * `resolveEntityContext` (cmp_ → /sts/vend, prs_ → /sts/vend-self).
24
+ */
25
+ export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
26
+
20
27
  /**
21
28
  * Look up an entity by slug or UID via vault-service, then vend STS-scoped
22
29
  * credentials for that entity. Returns an EntityContext ready for S3 ops.
@@ -34,9 +41,15 @@ export async function resolveEntityContext(
34
41
  return cached;
35
42
  }
36
43
 
37
- // Step 1: Resolve entity — if it looks like a UID (cmp_*), fetch directly;
38
- // otherwise look up by slug
39
- const entity = companyUidOrSlug.startsWith("cmp_")
44
+ // Step 1: Resolve entity — if it looks like a known UID prefix, fetch directly;
45
+ // otherwise look up by slug. Explicit enumeration avoids over-matching slugs
46
+ // like foo_bar or team_alpha that happen to look like UIDs.
47
+ const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
48
+ companyUidOrSlug.startsWith(p),
49
+ );
50
+ const looksLikePerson = companyUidOrSlug.startsWith("prs_");
51
+
52
+ const entity = looksLikeUid
40
53
  ? await fetchEntity(companyUidOrSlug, config)
41
54
  : await fetchEntityBySlug("company", companyUidOrSlug, config);
42
55
 
@@ -47,8 +60,13 @@ export async function resolveEntityContext(
47
60
  );
48
61
  }
49
62
 
50
- // Step 2: Vend STS-scoped credentials
51
- const vendResult = await vendCredentials(entity.uid, config);
63
+ // Step 2: Dispatch credential vending by UID prefix.
64
+ // cmp_* POST /sts/vend (company path; membership-gated)
65
+ // prs_* → POST /sts/vend-self (person path; self-ownership-gated)
66
+ // slug → POST /sts/vend (legacy slug path is company-only)
67
+ const vendResult = looksLikePerson
68
+ ? await vendSelfCredentials(entity.uid, config)
69
+ : await vendCredentials(entity.uid, config);
52
70
 
53
71
  const ctx: EntityContext = {
54
72
  uid: entity.uid,
@@ -153,26 +171,38 @@ async function fetchEntityBySlug(
153
171
  return data.entity;
154
172
  }
155
173
 
156
- async function vendCredentials(
157
- companyUid: string,
174
+ async function postVend(
175
+ route: string,
176
+ body: Record<string, unknown>,
158
177
  config: VaultServiceConfig,
159
178
  ): Promise<VendResponse> {
160
- const res = await fetch(`${config.apiUrl}/sts/vend`, {
179
+ const res = await fetch(`${config.apiUrl}${route}`, {
161
180
  method: "POST",
162
181
  headers: {
163
182
  "Content-Type": "application/json",
164
183
  Authorization: `Bearer ${config.authToken}`,
165
184
  },
166
- body: JSON.stringify({
167
- companyUid,
168
- durationSeconds: DEFAULT_SESSION_DURATION_SECONDS,
169
- }),
185
+ body: JSON.stringify({ ...body, durationSeconds: DEFAULT_SESSION_DURATION_SECONDS }),
170
186
  });
171
187
  if (!res.ok) {
172
- const body = await res.text();
173
- throw new Error(
174
- `STS vend failed for ${companyUid}: ${res.status} ${body}`,
175
- );
188
+ const text = await res.text();
189
+ throw new Error(`STS ${route} failed: ${res.status} ${text}`);
176
190
  }
177
191
  return (await res.json()) as VendResponse;
178
192
  }
193
+
194
+ async function vendCredentials(
195
+ companyUid: string,
196
+ config: VaultServiceConfig,
197
+ ): Promise<VendResponse> {
198
+ return postVend("/sts/vend", { companyUid }, config);
199
+ }
200
+
201
+ async function vendSelfCredentials(
202
+ personUid: string,
203
+ config: VaultServiceConfig,
204
+ ): Promise<VendResponse> {
205
+ // Use `+` concat at the literal so guard 5 (grep "/sts/vend-self") still finds it
206
+ const route = "/sts/vend-self";
207
+ return postVend(route, { personUid }, config);
208
+ }
package/src/ignore.ts CHANGED
@@ -20,7 +20,7 @@ import ignore from "ignore";
20
20
 
21
21
  // Patterns that must never sync regardless of project type.
22
22
  // Grouped by ecosystem so new stacks are easy to add.
23
- const DEFAULT_IGNORES = [
23
+ export const DEFAULT_IGNORES = [
24
24
  // VCS + OS
25
25
  ".git/",
26
26
  ".git",
@@ -12,6 +12,8 @@ import {
12
12
  VaultNotFoundError,
13
13
  VaultConflictError,
14
14
  VaultClientError,
15
+ pickCanonicalPersonEntity,
16
+ type EntityInfo,
15
17
  } from "./vault-client.js";
16
18
 
17
19
  // ---------------------------------------------------------------------------
@@ -620,4 +622,74 @@ describe("VaultClient identity bootstrap", () => {
620
622
  expect(person.uid).toBe("prs_a");
621
623
  expect(fetchSpy).toHaveBeenCalledTimes(1);
622
624
  });
625
+
626
+ it("pickCanonicalPersonEntity_picks_oldest_tiebreak_uid", () => {
627
+ const list: EntityInfo[] = [
628
+ { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
629
+ { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
630
+ ];
631
+ // Older createdAt wins
632
+ expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
633
+
634
+ // Same createdAt: uid tiebreak (lexicographic ascending)
635
+ const tieList: EntityInfo[] = [
636
+ { uid: "prs_z", slug: "z", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
637
+ { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
638
+ ];
639
+ expect(pickCanonicalPersonEntity(tieList)?.uid).toBe("prs_a");
640
+ });
641
+
642
+ it("pickCanonicalPersonEntity_handles_missing_createdAt_deterministically", () => {
643
+ // undefined createdAt coalesces to "" which sorts before any ISO date string.
644
+ // So the entity with undefined createdAt wins on the createdAt comparison.
645
+ // When two entities both lack createdAt, uid tiebreak applies.
646
+ const list: EntityInfo[] = [
647
+ { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
648
+ { uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo, // no createdAt
649
+ ];
650
+ // "" < "2026-..." so prs_a (undefined→"") wins
651
+ expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
652
+
653
+ // Both undefined: uid tiebreak
654
+ const bothUndefined: EntityInfo[] = [
655
+ { uid: "prs_z", slug: "z", type: "person", status: "active" } as EntityInfo,
656
+ { uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo,
657
+ ];
658
+ expect(pickCanonicalPersonEntity(bothUndefined)?.uid).toBe("prs_a");
659
+ });
660
+
661
+ it("pickCanonicalPersonEntity_filters_out_non_person_types", () => {
662
+ const mixed: EntityInfo[] = [
663
+ { uid: "cmp_x", slug: "x", type: "company", status: "active", createdAt: "2025-01-01T00:00:00Z" } as EntityInfo,
664
+ { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
665
+ ];
666
+ const result = pickCanonicalPersonEntity(mixed);
667
+ expect(result?.uid).toBe("prs_b");
668
+ expect(result?.type).toBe("person");
669
+ });
670
+
671
+ it("vendSelf_roundtrip", async () => {
672
+ fetchSpy.mockResolvedValueOnce(
673
+ jsonResponse(200, {
674
+ credentials: {
675
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
676
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
677
+ sessionToken: "FwoGZXIvYXdzEBY...",
678
+ },
679
+ expiresAt: "2026-01-01T01:00:00.000Z",
680
+ }),
681
+ );
682
+
683
+ const result = await client.sts.vendSelf({ personUid: "prs_x" });
684
+
685
+ expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
686
+ expect(result.credentials.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
687
+ expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
688
+ expect(typeof result.expiresAt).toBe("string");
689
+
690
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
691
+ expect(url).toBe("https://vault.test.example.com/sts/vend-self");
692
+ expect((init.method as string).toUpperCase()).toBe("POST");
693
+ expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
694
+ });
623
695
  });
@@ -112,6 +112,23 @@ export interface EntityInfo {
112
112
  createdAt: string;
113
113
  }
114
114
 
115
+ export function pickCanonicalPersonEntity(
116
+ list: EntityInfo[],
117
+ ): EntityInfo | null {
118
+ // Defensive filter — callers today pass `entity.listByType("person")` so this is
119
+ // a no-op, but a future caller passing a mixed list would otherwise silently get
120
+ // back a non-person entity.
121
+ const persons = list.filter((e) => e.type === "person");
122
+ if (persons.length === 0) return null;
123
+ const sorted = [...persons].sort((a, b) => {
124
+ const ac = (a.createdAt as string | undefined) ?? "";
125
+ const bc = (b.createdAt as string | undefined) ?? "";
126
+ if (ac !== bc) return ac < bc ? -1 : 1;
127
+ return a.uid < b.uid ? -1 : 1;
128
+ });
129
+ return sorted[0];
130
+ }
131
+
115
132
  export interface PendingInviteByEmail {
116
133
  membershipKey: string;
117
134
  companyUid: string;
@@ -344,13 +361,8 @@ export class VaultClient {
344
361
  displayName: string;
345
362
  }): Promise<EntityInfo> {
346
363
  const existing = await this.entity.listByType("person");
347
- const sorted = [...existing].sort((a, b) => {
348
- const ac = (a.createdAt as string | undefined) ?? "";
349
- const bc = (b.createdAt as string | undefined) ?? "";
350
- if (ac !== bc) return ac < bc ? -1 : 1;
351
- return a.uid < b.uid ? -1 : 1;
352
- });
353
- if (sorted.length > 0) return sorted[0];
364
+ const pick = pickCanonicalPersonEntity(existing);
365
+ if (pick !== null) return pick;
354
366
 
355
367
  const slug =
356
368
  hints.displayName
@@ -396,6 +408,12 @@ export class VaultClient {
396
408
  const data = await this.post<VendChildResult>("/sts/vend-child", input);
397
409
  return data;
398
410
  },
411
+ vendSelf: async (input: { personUid: string; durationSeconds?: number }): Promise<{
412
+ credentials: { accessKeyId: string; secretAccessKey: string; sessionToken: string };
413
+ expiresAt: string;
414
+ }> => {
415
+ return this.post("/sts/vend-self", input);
416
+ },
399
417
  };
400
418
 
401
419
  // -- HTTP primitives with retry -------------------------------------------