@indigoai-us/hq-cloud 5.1.8 → 5.1.10

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 (51) hide show
  1. package/dist/auth.js +2 -2
  2. package/dist/auth.js.map +1 -1
  3. package/dist/bin/sync-runner.d.ts +26 -3
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +77 -2
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +165 -9
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/accept.js +2 -2
  10. package/dist/cli/accept.js.map +1 -1
  11. package/dist/cli/share.d.ts.map +1 -1
  12. package/dist/cli/share.js +23 -7
  13. package/dist/cli/share.js.map +1 -1
  14. package/dist/cli/share.test.js +51 -13
  15. package/dist/cli/share.test.js.map +1 -1
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +6 -1
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +31 -12
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts +13 -2
  22. package/dist/cognito-auth.d.ts.map +1 -1
  23. package/dist/cognito-auth.js +18 -9
  24. package/dist/cognito-auth.js.map +1 -1
  25. package/dist/cognito-auth.test.d.ts +3 -3
  26. package/dist/cognito-auth.test.js +21 -10
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/vault-client.d.ts +43 -0
  32. package/dist/vault-client.d.ts.map +1 -1
  33. package/dist/vault-client.js +53 -0
  34. package/dist/vault-client.js.map +1 -1
  35. package/dist/vault-client.test.js +135 -0
  36. package/dist/vault-client.test.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/auth.ts +2 -2
  39. package/src/bin/sync-runner.test.ts +200 -13
  40. package/src/bin/sync-runner.ts +114 -5
  41. package/src/cli/accept.ts +2 -2
  42. package/src/cli/share.test.ts +59 -13
  43. package/src/cli/share.ts +25 -6
  44. package/src/cli/sync.test.ts +33 -12
  45. package/src/cli/sync.ts +6 -1
  46. package/src/cognito-auth.test.ts +22 -14
  47. package/src/cognito-auth.ts +31 -11
  48. package/src/index.ts +1 -0
  49. package/src/vault-client.test.ts +173 -0
  50. package/src/vault-client.ts +78 -0
  51. package/test/invite-flow.integration.test.ts +1 -1
@@ -61,7 +61,7 @@ const mockVendResponse = {
61
61
  function setupFetchMock() {
62
62
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
63
63
  const urlStr = String(url);
64
- if (urlStr.includes("/entity/by-slug/")) {
64
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
65
65
  return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
66
66
  }
67
67
  if (urlStr.includes("/sts/vend")) {
@@ -98,7 +98,7 @@ describe("sync", () => {
98
98
  delete process.env.HQ_STATE_DIR;
99
99
  });
100
100
 
101
- it("downloads remote files that don't exist locally", async () => {
101
+ it("downloads remote files under companies/{slug}/ so two companies don't collide", async () => {
102
102
  const result = await sync({
103
103
  company: "acme",
104
104
  vaultConfig: mockConfig,
@@ -107,8 +107,26 @@ describe("sync", () => {
107
107
 
108
108
  expect(result.filesDownloaded).toBe(2);
109
109
  expect(result.aborted).toBe(false);
110
- expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(true);
111
- expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(true);
110
+ // Scoped under companies/{slug}/
111
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "handoff.md"))).toBe(true);
112
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "readme.md"))).toBe(true);
113
+ // NOT at hqRoot (pre-fix behavior would have written here and clobbered across companies)
114
+ expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(false);
115
+ expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(false);
116
+ });
117
+
118
+ it("scopes by resolved ctx.slug even when caller passes a UID", async () => {
119
+ // mockEntity.slug is "acme" regardless of the ref used; verify resolved
120
+ // slug drives the local path, not the caller's ref.
121
+ const result = await sync({
122
+ company: "cmp_01ABCDEF",
123
+ vaultConfig: mockConfig,
124
+ hqRoot: tmpDir,
125
+ });
126
+
127
+ expect(result.filesDownloaded).toBe(2);
128
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "handoff.md"))).toBe(true);
129
+ expect(fs.existsSync(path.join(tmpDir, "companies", "cmp_01ABCDEF", "docs", "handoff.md"))).toBe(false);
112
130
  });
113
131
 
114
132
  it("throws when no company specified and no active company", async () => {
@@ -129,8 +147,9 @@ describe("sync", () => {
129
147
  });
130
148
 
131
149
  it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
132
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
133
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
150
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
151
+ fs.mkdirSync(companyDocs, { recursive: true });
152
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
134
153
 
135
154
  fs.writeFileSync(
136
155
  journalPath,
@@ -157,12 +176,13 @@ describe("sync", () => {
157
176
 
158
177
  expect(result.conflicts).toBe(1);
159
178
  expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
160
- expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("local version");
179
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
161
180
  });
162
181
 
163
182
  it("aborts on --on-conflict abort", async () => {
164
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
165
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
183
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
184
+ fs.mkdirSync(companyDocs, { recursive: true });
185
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
166
186
 
167
187
  fs.writeFileSync(
168
188
  journalPath,
@@ -191,8 +211,9 @@ describe("sync", () => {
191
211
  });
192
212
 
193
213
  it("overwrites local on --on-conflict overwrite", async () => {
194
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
195
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
214
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
215
+ fs.mkdirSync(companyDocs, { recursive: true });
216
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
196
217
 
197
218
  fs.writeFileSync(
198
219
  journalPath,
@@ -220,6 +241,6 @@ describe("sync", () => {
220
241
  expect(result.conflicts).toBe(1);
221
242
  expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
222
243
  // File should be overwritten with mock content
223
- expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("mock file content");
244
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
224
245
  });
225
246
  });
package/src/cli/sync.ts CHANGED
@@ -74,6 +74,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
74
74
 
75
75
  // Resolve entity context
76
76
  let ctx = await resolveEntityContext(companyRef, vaultConfig);
77
+ // Every company's files land under companies/{slug}/ so fanning out multiple
78
+ // companies into the same hqRoot doesn't cross-clobber files with overlapping
79
+ // S3 keys (e.g. every company has a .hq/manifest.json). Remote keys stay
80
+ // company-relative; the prefix lives only on disk.
81
+ const companyRoot = path.join(hqRoot, "companies", ctx.slug);
77
82
  const shouldSync = createIgnoreFilter(hqRoot);
78
83
  const journal = readJournal(ctx.slug);
79
84
 
@@ -86,7 +91,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
86
91
  const remoteFiles = await listRemoteFiles(ctx);
87
92
 
88
93
  for (const remoteFile of remoteFiles) {
89
- const localPath = path.join(hqRoot, remoteFile.key);
94
+ const localPath = path.join(companyRoot, remoteFile.key);
90
95
 
91
96
  // Apply ignore rules
92
97
  if (!shouldSync(localPath)) {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Unit tests for cognito-auth.ts — focus on the `expiresAt` shape contract.
3
3
  *
4
- * Canonical on-disk shape is ISO 8601 (what both writers emit). The reader
5
- * also tolerates a raw number (ms since epoch) for forward/backward compat
6
- * during rollouts, and fails safe on anything unparseable.
4
+ * Canonical on-disk shape is epoch milliseconds (number). The reader also
5
+ * tolerates ISO 8601 strings for backward compatibility with pre-migration
6
+ * token files, and fails safe on anything unparseable.
7
7
  */
8
8
 
9
9
  import * as fs from "fs";
@@ -100,11 +100,21 @@ describe("isExpiring — expiresAt shape tolerance", () => {
100
100
  });
101
101
 
102
102
  // ---------------------------------------------------------------------------
103
- // Round-trip: writers emit ISO, readers read ISO
103
+ // Round-trip: writers emit epoch-ms, readers read epoch-ms
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
106
  describe("expiresAt shape round-trip", () => {
107
- it("saveCachedTokens + loadCachedTokens preserves ISO string shape", async () => {
107
+ it("saveCachedTokens + loadCachedTokens preserves epoch-ms number shape", async () => {
108
+ const { saveCachedTokens, loadCachedTokens } = await importModule();
109
+ const epochMs = Date.now() + 3600 * 1000;
110
+ saveCachedTokens({ ...baseTokens, expiresAt: epochMs });
111
+ const loaded = loadCachedTokens();
112
+ expect(loaded).not.toBeNull();
113
+ expect(typeof loaded?.expiresAt).toBe("number");
114
+ expect(loaded?.expiresAt).toBe(epochMs);
115
+ });
116
+
117
+ it("saveCachedTokens + loadCachedTokens tolerates legacy ISO string", async () => {
108
118
  const { saveCachedTokens, loadCachedTokens } = await importModule();
109
119
  const iso = new Date(Date.now() + 3600 * 1000).toISOString();
110
120
  saveCachedTokens({ ...baseTokens, expiresAt: iso });
@@ -112,12 +122,9 @@ describe("expiresAt shape round-trip", () => {
112
122
  expect(loaded).not.toBeNull();
113
123
  expect(typeof loaded?.expiresAt).toBe("string");
114
124
  expect(loaded?.expiresAt).toBe(iso);
115
- expect(loaded?.expiresAt).toMatch(
116
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
117
- );
118
125
  });
119
126
 
120
- it("refreshTokens writes an ISO string to cache", async () => {
127
+ it("refreshTokens writes epoch milliseconds to cache", async () => {
121
128
  vi.stubGlobal(
122
129
  "fetch",
123
130
  vi.fn(async () =>
@@ -135,6 +142,7 @@ describe("expiresAt shape round-trip", () => {
135
142
  );
136
143
 
137
144
  const { refreshTokens, loadCachedTokens } = await importModule();
145
+ const before = Date.now();
138
146
  const result = await refreshTokens(
139
147
  {
140
148
  region: "us-east-1",
@@ -143,14 +151,14 @@ describe("expiresAt shape round-trip", () => {
143
151
  },
144
152
  "prior-refresh-token",
145
153
  );
154
+ const after = Date.now();
146
155
 
147
- expect(typeof result.expiresAt).toBe("string");
148
- expect(result.expiresAt).toMatch(
149
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
150
- );
156
+ expect(typeof result.expiresAt).toBe("number");
157
+ expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
158
+ expect(result.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000);
151
159
 
152
160
  const onDisk = loadCachedTokens();
153
161
  expect(onDisk?.expiresAt).toBe(result.expiresAt);
154
- expect(typeof onDisk?.expiresAt).toBe("string");
162
+ expect(typeof onDisk?.expiresAt).toBe("number");
155
163
  });
156
164
  });
@@ -38,14 +38,25 @@ export interface CognitoAuthConfig {
38
38
  port?: number;
39
39
  /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
40
40
  scopes?: string[];
41
+ /**
42
+ * Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker
43
+ * is bypassed and Cognito redirects straight to the provider. When omitted,
44
+ * Cognito shows its default picker.
45
+ */
46
+ identityProvider?: string;
47
+ /**
48
+ * OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP
49
+ * honors it — Google uses it to force account re-selection.
50
+ */
51
+ prompt?: string;
41
52
  }
42
53
 
43
54
  export interface CognitoTokens {
44
55
  accessToken: string;
45
56
  idToken: string;
46
57
  refreshToken: string;
47
- /** ISO 8601 timestamp when the access token expires. */
48
- expiresAt: string;
58
+ /** Epoch milliseconds when the access token expires. Writers MUST emit a number. Readers accept ISO 8601 strings for backward compatibility with pre-migration token files. */
59
+ expiresAt: string | number;
49
60
  tokenType: "Bearer";
50
61
  }
51
62
 
@@ -78,7 +89,9 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
78
89
  if (!fs.existsSync(HQ_DIR)) {
79
90
  fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
80
91
  }
81
- fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
92
+ const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
93
+ fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
94
+ fs.renameSync(tmpPath, TOKEN_FILE);
82
95
  }
83
96
 
84
97
  export function clearCachedTokens(): void {
@@ -86,11 +99,10 @@ export function clearCachedTokens(): void {
86
99
  }
87
100
 
88
101
  /**
89
- * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is ISO 8601 (what
90
- * both writers in this file emit), but older/external writers may have left a
91
- * raw number. Accept both so a shape mismatch during rollout doesn't wedge
92
- * sign-in. Returns null for anything unparseable callers should treat that
93
- * as "expired" and force a refresh.
102
+ * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is epoch milliseconds
103
+ * (number). Older token files may contain ISO 8601 strings. Accept both for
104
+ * migration safety. Returns null for anything unparseable callers should
105
+ * treat that as "expired" and force a refresh.
94
106
  */
95
107
  function parseExpiresAtMs(raw: unknown): number | null {
96
108
  if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
@@ -158,7 +170,9 @@ export async function browserLogin(
158
170
  const { verifier, challenge } = generatePkce();
159
171
  const state = base64UrlEncode(crypto.randomBytes(16));
160
172
 
161
- const authUrl = new URL(`${authBaseUrl(config)}/login`);
173
+ // Use `/oauth2/authorize` (not `/login`) so `identity_provider` + `prompt`
174
+ // are honored. `/login` ignores those params and always shows the IdP picker.
175
+ const authUrl = new URL(`${authBaseUrl(config)}/oauth2/authorize`);
162
176
  authUrl.searchParams.set("client_id", config.clientId);
163
177
  authUrl.searchParams.set("response_type", "code");
164
178
  authUrl.searchParams.set("scope", scopes);
@@ -166,6 +180,12 @@ export async function browserLogin(
166
180
  authUrl.searchParams.set("code_challenge", challenge);
167
181
  authUrl.searchParams.set("code_challenge_method", "S256");
168
182
  authUrl.searchParams.set("state", state);
183
+ if (config.identityProvider) {
184
+ authUrl.searchParams.set("identity_provider", config.identityProvider);
185
+ }
186
+ if (config.prompt) {
187
+ authUrl.searchParams.set("prompt", config.prompt);
188
+ }
169
189
 
170
190
  const code = await waitForAuthCode(port, state);
171
191
  const tokens = await exchangeCodeForTokens(config, code, verifier, port);
@@ -300,7 +320,7 @@ async function exchangeCodeForTokens(
300
320
  accessToken: data.access_token,
301
321
  idToken: data.id_token,
302
322
  refreshToken: data.refresh_token,
303
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
323
+ expiresAt: Date.now() + data.expires_in * 1000,
304
324
  tokenType: "Bearer",
305
325
  };
306
326
  }
@@ -336,7 +356,7 @@ export async function refreshTokens(
336
356
  accessToken: data.access_token,
337
357
  idToken: data.id_token,
338
358
  refreshToken: data.refresh_token ?? currentRefreshToken,
339
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
359
+ expiresAt: Date.now() + data.expires_in * 1000,
340
360
  tokenType: "Bearer",
341
361
  };
342
362
  saveCachedTokens(tokens);
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ export type {
70
70
  EntityInfo,
71
71
  CreateEntityInput,
72
72
  CreateEntityResult,
73
+ PendingInviteByEmail,
73
74
  } from "./vault-client.js";
74
75
 
75
76
  // STS child vending (VLT-8)
@@ -387,4 +387,177 @@ describe("API surface", () => {
387
387
  const memberships = await client.listMyMemberships();
388
388
  expect(memberships).toEqual([]);
389
389
  });
390
+
391
+ it("listMyPendingInvitesByEmail hits GET /membership/pending-by-email", async () => {
392
+ fetchSpy.mockResolvedValueOnce(
393
+ jsonResponse(200, {
394
+ invites: [
395
+ {
396
+ membershipKey: "email:stefan@getindigo.ai#cmp_abc",
397
+ companyUid: "cmp_abc",
398
+ role: "owner",
399
+ invitedBy: "sub-admin",
400
+ invitedAt: "2026-04-20T00:00:00Z",
401
+ },
402
+ ],
403
+ }),
404
+ );
405
+
406
+ const invites = await client.listMyPendingInvitesByEmail();
407
+ expect(invites).toHaveLength(1);
408
+ expect(invites[0].companyUid).toBe("cmp_abc");
409
+
410
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
411
+ expect(url).toBe(
412
+ "https://vault.test.example.com/membership/pending-by-email",
413
+ );
414
+ expect(init.method).toBe("GET");
415
+ });
416
+
417
+ it("listMyPendingInvitesByEmail returns [] when server omits the key", async () => {
418
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
419
+ const invites = await client.listMyPendingInvitesByEmail();
420
+ expect(invites).toEqual([]);
421
+ });
422
+
423
+ it("claimPendingInvitesByEmail POSTs personUid to /membership/claim-by-email", async () => {
424
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
425
+
426
+ await client.claimPendingInvitesByEmail("ent_person_stefan");
427
+
428
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
429
+ expect(url).toBe(
430
+ "https://vault.test.example.com/membership/claim-by-email",
431
+ );
432
+ expect(init.method).toBe("POST");
433
+ expect(JSON.parse(init.body as string)).toEqual({
434
+ personUid: "ent_person_stefan",
435
+ });
436
+ });
437
+ });
438
+
439
+ describe("VaultClient identity bootstrap", () => {
440
+ let client: VaultClient;
441
+ let fetchSpy: MockInstance<typeof fetch>;
442
+
443
+ beforeEach(() => {
444
+ fetchSpy = vi.spyOn(globalThis, "fetch");
445
+ fetchSpy.mockResolvedValue(jsonResponse(200, {}));
446
+ client = new VaultClient({
447
+ apiUrl: "https://vault.test.example.com",
448
+ authToken: "test-token",
449
+ region: "us-east-1",
450
+ });
451
+ });
452
+
453
+ afterEach(() => {
454
+ fetchSpy.mockRestore();
455
+ });
456
+
457
+ it("entity.listByType GETs /entity/by-type/{type}", async () => {
458
+ fetchSpy.mockResolvedValueOnce(
459
+ jsonResponse(200, {
460
+ entities: [
461
+ {
462
+ uid: "ent_person_stefan",
463
+ slug: "stefan-johnson",
464
+ type: "person",
465
+ status: "active",
466
+ },
467
+ ],
468
+ }),
469
+ );
470
+
471
+ const entities = await client.entity.listByType("person");
472
+ expect(entities).toHaveLength(1);
473
+ const [url] = fetchSpy.mock.calls[0] as [string];
474
+ expect(url).toBe(
475
+ "https://vault.test.example.com/entity/by-type/person",
476
+ );
477
+ });
478
+
479
+ it("entity.listByType returns [] when server omits the key", async () => {
480
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
481
+ const entities = await client.entity.listByType("person");
482
+ expect(entities).toEqual([]);
483
+ });
484
+
485
+ it("ensureMyPersonEntity short-circuits when a person entity already exists", async () => {
486
+ fetchSpy.mockResolvedValueOnce(
487
+ jsonResponse(200, {
488
+ entities: [
489
+ {
490
+ uid: "ent_person_existing",
491
+ slug: "already-there",
492
+ type: "person",
493
+ status: "active",
494
+ },
495
+ ],
496
+ }),
497
+ );
498
+
499
+ const person = await client.ensureMyPersonEntity({
500
+ ownerSub: "sub-abc",
501
+ displayName: "Stefan Johnson",
502
+ });
503
+
504
+ expect(person.uid).toBe("ent_person_existing");
505
+ // Only one HTTP call — list. No POST /entity.
506
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
507
+ });
508
+
509
+ it("ensureMyPersonEntity POSTs /entity with a slug derived from displayName when none exist", async () => {
510
+ fetchSpy
511
+ .mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
512
+ .mockResolvedValueOnce(
513
+ jsonResponse(200, {
514
+ entity: {
515
+ uid: "ent_person_new",
516
+ slug: "stefan-johnson",
517
+ type: "person",
518
+ status: "active",
519
+ },
520
+ }),
521
+ );
522
+
523
+ const person = await client.ensureMyPersonEntity({
524
+ ownerSub: "sub-abc",
525
+ displayName: "Stefan Johnson",
526
+ });
527
+
528
+ expect(person.uid).toBe("ent_person_new");
529
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
530
+ const [url, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
531
+ expect(url).toBe("https://vault.test.example.com/entity");
532
+ expect(init.method).toBe("POST");
533
+ expect(JSON.parse(init.body as string)).toEqual({
534
+ type: "person",
535
+ name: "Stefan Johnson",
536
+ slug: "stefan-johnson",
537
+ });
538
+ });
539
+
540
+ it("ensureMyPersonEntity falls back to user-<sub-suffix> when displayName slugifies to empty", async () => {
541
+ fetchSpy
542
+ .mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
543
+ .mockResolvedValueOnce(
544
+ jsonResponse(200, {
545
+ entity: {
546
+ uid: "ent_person_new",
547
+ slug: "user-12345678",
548
+ type: "person",
549
+ status: "active",
550
+ },
551
+ }),
552
+ );
553
+
554
+ await client.ensureMyPersonEntity({
555
+ ownerSub: "sub-abcdef12345678",
556
+ displayName: "!!!",
557
+ });
558
+
559
+ const [, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
560
+ const body = JSON.parse(init.body as string);
561
+ expect(body.slug).toBe("user-12345678");
562
+ });
390
563
  });
@@ -105,10 +105,21 @@ export interface EntityInfo {
105
105
  uid: string;
106
106
  slug: string;
107
107
  type: string;
108
+ /** Human-readable display name — surfaced in UIs that list companies. */
109
+ name?: string;
108
110
  bucketName?: string;
109
111
  status: string;
110
112
  }
111
113
 
114
+ export interface PendingInviteByEmail {
115
+ membershipKey: string;
116
+ companyUid: string;
117
+ role: MembershipRole;
118
+ inviteToken?: string;
119
+ invitedBy: string;
120
+ invitedAt: string;
121
+ }
122
+
112
123
  export interface CreateEntityInput {
113
124
  type: "person" | "company";
114
125
  slug: string;
@@ -238,6 +249,31 @@ export class VaultClient {
238
249
  return data.memberships;
239
250
  }
240
251
 
252
+ /**
253
+ * List the caller's email-keyed pending invites. Server reads the email
254
+ * from the Cognito JWT, so no parameters are needed client-side.
255
+ *
256
+ * Used on first sign-in (installer + sync-runner) to detect invites that
257
+ * were sent to the caller's email before they had a person entity. Pair
258
+ * with {@link claimPendingInvitesByEmail} to rewrite those rows once the
259
+ * person exists.
260
+ */
261
+ async listMyPendingInvitesByEmail(): Promise<PendingInviteByEmail[]> {
262
+ const data = await this.get<{ invites: PendingInviteByEmail[] }>(
263
+ "/membership/pending-by-email",
264
+ );
265
+ return data.invites ?? [];
266
+ }
267
+
268
+ /**
269
+ * Rewrite every email-keyed pending invite for the caller's email so it
270
+ * becomes personUid-keyed. Idempotent — zero-cost for returning users who
271
+ * have no pending invites. The caller's email is inferred from the JWT.
272
+ */
273
+ async claimPendingInvitesByEmail(personUid: string): Promise<void> {
274
+ await this.post("/membership/claim-by-email", { personUid });
275
+ }
276
+
241
277
  async listMembersOfCompany(companyUid: string): Promise<Membership[]> {
242
278
  const data = await this.get<{ members: Membership[] }>(
243
279
  `/membership/company/${encodeURIComponent(companyUid)}`,
@@ -279,8 +315,50 @@ export class VaultClient {
279
315
  const data = await this.post<CreateEntityResult>("/entity", input);
280
316
  return data.entity;
281
317
  },
318
+
319
+ /** Return every entity of `type` owned by the caller (scoped by JWT). */
320
+ listByType: async (type: string): Promise<EntityInfo[]> => {
321
+ const data = await this.get<{ entities: EntityInfo[] }>(
322
+ `/entity/by-type/${encodeURIComponent(type)}`,
323
+ );
324
+ return data.entities ?? [];
325
+ },
282
326
  };
283
327
 
328
+ // -- Identity bootstrap ---------------------------------------------------
329
+
330
+ /**
331
+ * Return the caller's person entity, creating it if one does not exist.
332
+ *
333
+ * Mirrors the installer's `ensurePersonEntity` bootstrap (`vault-handoff.ts`):
334
+ * pre-condition for {@link claimPendingInvitesByEmail}, which needs a
335
+ * concrete `personUid` to rewrite the email-keyed rows against.
336
+ *
337
+ * The slug is derived from `displayName`; if slugification yields an empty
338
+ * string, falls back to `user-<last-8-of-ownerSub>` so the POST always has
339
+ * a non-empty slug.
340
+ */
341
+ async ensureMyPersonEntity(hints: {
342
+ ownerSub: string;
343
+ displayName: string;
344
+ }): Promise<EntityInfo> {
345
+ const existing = await this.entity.listByType("person");
346
+ if (existing.length > 0) return existing[0];
347
+
348
+ const slug =
349
+ hints.displayName
350
+ .toLowerCase()
351
+ .replace(/[^a-z0-9]+/g, "-")
352
+ .replace(/^-+|-+$/g, "")
353
+ .slice(0, 63) || `user-${hints.ownerSub.slice(-8).toLowerCase()}`;
354
+
355
+ return this.entity.create({
356
+ type: "person",
357
+ name: hints.displayName,
358
+ slug,
359
+ });
360
+ }
361
+
284
362
  // -- Provisioning operations (VLT-2) -----------------------------------------
285
363
 
286
364
  async provisionBucket(companyUid: string): Promise<{ bucketName: string; kmsKeyId: string }> {
@@ -57,7 +57,7 @@ describe("parseToken", () => {
57
57
  });
58
58
 
59
59
  it("extracts token from https:// URL", () => {
60
- expect(parseToken("https://hq.indigoai.com/accept/tok_xyz")).toBe("tok_xyz");
60
+ expect(parseToken("https://example.com/accept/tok_xyz")).toBe("tok_xyz");
61
61
  });
62
62
 
63
63
  it("returns raw token unchanged", () => {