@indigoai-us/hq-cloud 5.1.8 → 5.1.9

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.
@@ -15,7 +15,11 @@ import type {
15
15
  VaultClientSurface,
16
16
  } from "./sync-runner.js";
17
17
  import type { SyncResult, SyncOptions } from "../cli/sync.js";
18
- import type { Membership, EntityInfo } from "../vault-client.js";
18
+ import type {
19
+ Membership,
20
+ EntityInfo,
21
+ PendingInviteByEmail,
22
+ } from "../vault-client.js";
19
23
  import { VaultAuthError } from "../vault-client.js";
20
24
 
21
25
  // ---------------------------------------------------------------------------
@@ -70,11 +74,31 @@ function makeVaultStub(
70
74
  opts: {
71
75
  memberships?: Array<Pick<Membership, "companyUid">>;
72
76
  entityGet?: (uid: string) => Promise<EntityInfo>;
77
+ pendingInvites?: Array<Record<string, unknown>>;
78
+ ensurePerson?: (hints: {
79
+ ownerSub: string;
80
+ displayName: string;
81
+ }) => Promise<EntityInfo>;
82
+ claim?: (personUid: string) => Promise<void>;
73
83
  } = {},
74
84
  ): VaultClientSurface {
75
85
  const memberships = opts.memberships ?? [];
86
+ const pending = opts.pendingInvites ?? [];
76
87
  return {
77
88
  listMyMemberships: () => Promise.resolve(memberships as Membership[]),
89
+ listMyPendingInvitesByEmail: () =>
90
+ Promise.resolve(pending as unknown as PendingInviteByEmail[]),
91
+ claimPendingInvitesByEmail:
92
+ opts.claim ?? (() => Promise.resolve(undefined)),
93
+ ensureMyPersonEntity:
94
+ opts.ensurePerson ??
95
+ (() =>
96
+ Promise.resolve({
97
+ uid: "ent_person_default",
98
+ type: "person",
99
+ slug: "default-person",
100
+ status: "active",
101
+ } as unknown as EntityInfo)),
78
102
  entity: {
79
103
  get:
80
104
  opts.entityGet ??
@@ -181,12 +205,9 @@ describe("auth", () => {
181
205
  it("emits auth-error when VaultAuthError thrown during discovery", async () => {
182
206
  const deps = makeDeps({
183
207
  createVaultClient: () => ({
208
+ ...makeVaultStub(),
184
209
  listMyMemberships: () =>
185
210
  Promise.reject(new VaultAuthError("token expired")),
186
- entity: {
187
- get: (uid: string) =>
188
- Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
189
- },
190
211
  }),
191
212
  });
192
213
  const code = await runRunner(["--companies"], deps);
@@ -199,11 +220,8 @@ describe("auth", () => {
199
220
  it("emits error event and returns 1 on non-auth discovery failure", async () => {
200
221
  const deps = makeDeps({
201
222
  createVaultClient: () => ({
223
+ ...makeVaultStub(),
202
224
  listMyMemberships: () => Promise.reject(new Error("network down")),
203
- entity: {
204
- get: (uid: string) =>
205
- Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
206
- },
207
225
  }),
208
226
  });
209
227
  const code = await runRunner(["--companies"], deps);
@@ -218,6 +236,150 @@ describe("auth", () => {
218
236
  });
219
237
  });
220
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // claim-dance (first sign-in)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("claim-dance", () => {
244
+ const claims = {
245
+ sub: "sub-abc",
246
+ email: "stefan@getindigo.ai",
247
+ name: "Stefan Johnson",
248
+ };
249
+
250
+ it("claims pending invites + ensures person before listing memberships", async () => {
251
+ const ensureSpy = vi.fn().mockResolvedValue({
252
+ uid: "ent_person_stefan",
253
+ type: "person",
254
+ slug: "stefan-johnson",
255
+ status: "active",
256
+ });
257
+ const claimSpy = vi.fn().mockResolvedValue(undefined);
258
+ // First listMyMemberships returns the just-claimed row.
259
+ let listCalls = 0;
260
+ const stub = makeVaultStub({
261
+ pendingInvites: [
262
+ {
263
+ membershipKey: "inv_1",
264
+ companyUid: "cmp_indigo",
265
+ role: "owner",
266
+ invitedBy: "sub-admin",
267
+ invitedAt: "2026-04-20T00:00:00Z",
268
+ },
269
+ ],
270
+ ensurePerson: ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
271
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
272
+ });
273
+ stub.listMyMemberships = () => {
274
+ listCalls++;
275
+ return Promise.resolve([{ companyUid: "cmp_indigo" }] as Membership[]);
276
+ };
277
+
278
+ const deps = makeDeps({
279
+ createVaultClient: () => stub,
280
+ getIdTokenClaims: () => claims,
281
+ });
282
+ const code = await runRunner(["--companies"], deps);
283
+ expect(code).toBe(0);
284
+ expect(ensureSpy).toHaveBeenCalledWith({
285
+ ownerSub: "sub-abc",
286
+ displayName: "Stefan Johnson",
287
+ });
288
+ expect(claimSpy).toHaveBeenCalledWith("ent_person_stefan");
289
+ expect(listCalls).toBe(1);
290
+ // setup-needed must NOT fire — the user has memberships after the claim.
291
+ expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(
292
+ false,
293
+ );
294
+ });
295
+
296
+ it("skips ensurePerson + claim when no pending invites exist", async () => {
297
+ const ensureSpy = vi.fn();
298
+ const claimSpy = vi.fn();
299
+ const deps = makeDeps({
300
+ createVaultClient: () =>
301
+ makeVaultStub({
302
+ pendingInvites: [],
303
+ ensurePerson:
304
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
305
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
306
+ }),
307
+ getIdTokenClaims: () => claims,
308
+ });
309
+ const code = await runRunner(["--companies"], deps);
310
+ expect(code).toBe(0);
311
+ expect(ensureSpy).not.toHaveBeenCalled();
312
+ expect(claimSpy).not.toHaveBeenCalled();
313
+ // No memberships, no invites — truly empty → setup-needed is correct here.
314
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
315
+ });
316
+
317
+ it("skips claim-dance entirely when no idToken claims are available", async () => {
318
+ const pendingSpy = vi.fn().mockResolvedValue([]);
319
+ const stub = makeVaultStub();
320
+ stub.listMyPendingInvitesByEmail =
321
+ pendingSpy as unknown as VaultClientSurface["listMyPendingInvitesByEmail"];
322
+ const deps = makeDeps({
323
+ createVaultClient: () => stub,
324
+ getIdTokenClaims: () => null,
325
+ });
326
+ await runRunner(["--companies"], deps);
327
+ expect(pendingSpy).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("does not fail the run when claim-dance throws (best-effort)", async () => {
331
+ const stub = makeVaultStub({
332
+ memberships: [{ companyUid: "cmp_a" }],
333
+ });
334
+ stub.listMyPendingInvitesByEmail = () =>
335
+ Promise.reject(new Error("vault 500"));
336
+ const deps = makeDeps({
337
+ createVaultClient: () => stub,
338
+ getIdTokenClaims: () => claims,
339
+ });
340
+ const code = await runRunner(["--companies"], deps);
341
+ expect(code).toBe(0);
342
+ // Sync proceeds as usual for the existing membership.
343
+ expect(deps.sync).toHaveBeenCalledTimes(1);
344
+ expect(deps.stderr.raw()).toContain("claim-dance skipped");
345
+ });
346
+
347
+ it("falls back to given_name + family_name when name claim is absent", async () => {
348
+ const ensureSpy = vi.fn().mockResolvedValue({
349
+ uid: "ent_person_x",
350
+ type: "person",
351
+ slug: "x",
352
+ status: "active",
353
+ });
354
+ const deps = makeDeps({
355
+ createVaultClient: () =>
356
+ makeVaultStub({
357
+ pendingInvites: [
358
+ {
359
+ membershipKey: "inv_1",
360
+ companyUid: "cmp_x",
361
+ role: "owner",
362
+ invitedBy: "sub-admin",
363
+ invitedAt: "2026-04-20T00:00:00Z",
364
+ },
365
+ ],
366
+ ensurePerson:
367
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
368
+ }),
369
+ getIdTokenClaims: () => ({
370
+ sub: "sub-xyz",
371
+ given_name: "Ada",
372
+ family_name: "Lovelace",
373
+ }),
374
+ });
375
+ await runRunner(["--companies"], deps);
376
+ expect(ensureSpy).toHaveBeenCalledWith({
377
+ ownerSub: "sub-xyz",
378
+ displayName: "Ada Lovelace",
379
+ });
380
+ });
381
+ });
382
+
221
383
  // ---------------------------------------------------------------------------
222
384
  // target resolution
223
385
  // ---------------------------------------------------------------------------
@@ -236,11 +398,11 @@ describe("target resolution", () => {
236
398
  const listSpy = vi.fn();
237
399
  const deps = makeDeps({
238
400
  createVaultClient: () => ({
239
- listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
240
- entity: {
241
- get: (uid: string) =>
401
+ ...makeVaultStub({
402
+ entityGet: (uid: string) =>
242
403
  Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
243
- },
404
+ }),
405
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
244
406
  }),
245
407
  });
246
408
  const code = await runRunner(["--company", "cmp_abc"], deps);
@@ -303,6 +465,31 @@ describe("fanout-plan", () => {
303
465
  expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
304
466
  });
305
467
 
468
+ it("includes entity.name on plan entries when available", async () => {
469
+ const deps = makeDeps({
470
+ createVaultClient: () =>
471
+ makeVaultStub({
472
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
473
+ entityGet: (uid: string) =>
474
+ Promise.resolve({
475
+ uid,
476
+ slug: uid === "cmp_a" ? "acme" : "beta",
477
+ name: uid === "cmp_a" ? "Acme Corp" : undefined,
478
+ } as unknown as EntityInfo),
479
+ }),
480
+ });
481
+
482
+ const code = await runRunner(["--companies"], deps);
483
+ expect(code).toBe(0);
484
+ const plan = deps.stdout
485
+ .events()
486
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
487
+ expect(plan.companies).toEqual([
488
+ { uid: "cmp_a", slug: "acme", name: "Acme Corp" },
489
+ { uid: "cmp_b", slug: "beta" },
490
+ ]);
491
+ });
492
+
306
493
  it("degrades to UID when entity.get returns falsy slug", async () => {
307
494
  const deps = makeDeps({
308
495
  createVaultClient: () =>
@@ -38,12 +38,15 @@ import * as fs from "fs";
38
38
  import { fileURLToPath } from "url";
39
39
  import {
40
40
  getValidAccessToken,
41
+ loadCachedTokens,
41
42
  VaultClient,
42
43
  VaultAuthError,
43
44
  type CognitoAuthConfig,
45
+ type CognitoTokens,
44
46
  type VaultServiceConfig,
45
47
  type Membership,
46
48
  type EntityInfo,
49
+ type PendingInviteByEmail,
47
50
  } from "../index.js";
48
51
  import { sync as defaultSync } from "../cli/sync.js";
49
52
  import type {
@@ -90,7 +93,7 @@ export type RunnerEvent =
90
93
  | { type: "auth-error"; message: string }
91
94
  | {
92
95
  type: "fanout-plan";
93
- companies: Array<{ uid: string; slug: string }>;
96
+ companies: Array<{ uid: string; slug: string; name?: string }>;
94
97
  }
95
98
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
96
99
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
@@ -113,11 +116,26 @@ export type RunnerEvent =
113
116
  */
114
117
  export interface VaultClientSurface {
115
118
  listMyMemberships: () => Promise<Membership[]>;
119
+ listMyPendingInvitesByEmail: () => Promise<PendingInviteByEmail[]>;
120
+ claimPendingInvitesByEmail: (personUid: string) => Promise<void>;
121
+ ensureMyPersonEntity: (hints: {
122
+ ownerSub: string;
123
+ displayName: string;
124
+ }) => Promise<EntityInfo>;
116
125
  entity: {
117
126
  get: (uid: string) => Promise<EntityInfo>;
118
127
  };
119
128
  }
120
129
 
130
+ /** Minimal shape of the claims we read off the Cognito idToken. */
131
+ interface IdTokenClaims {
132
+ sub?: string;
133
+ email?: string;
134
+ name?: string;
135
+ given_name?: string;
136
+ family_name?: string;
137
+ }
138
+
121
139
  export interface RunnerDeps {
122
140
  /** Where to write ndjson events. Defaults to `process.stdout`. */
123
141
  stdout?: { write: (chunk: string) => boolean | void };
@@ -125,16 +143,93 @@ export interface RunnerDeps {
125
143
  stderr?: { write: (chunk: string) => boolean | void };
126
144
  /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
127
145
  getAccessToken?: () => Promise<string>;
146
+ /**
147
+ * Read the caller's identity claims (sub/email/name) off the cached Cognito
148
+ * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
149
+ * when no cached tokens exist — the runner will then skip the claim-dance
150
+ * and fall through to the usual listMyMemberships path.
151
+ */
152
+ getIdTokenClaims?: () => IdTokenClaims | null;
128
153
  /**
129
154
  * Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
130
- * Tests inject a stub here — only `listMyMemberships` and `entity.get` are
131
- * called by the runner, so stubs only need to implement those.
155
+ * Tests inject a stub here — the runner only calls the methods listed in
156
+ * `VaultClientSurface`.
132
157
  */
133
158
  createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
134
159
  /** Sync function. Defaults to `cli/sync.sync`. */
135
160
  sync?: (options: SyncOptions) => Promise<SyncResult>;
136
161
  }
137
162
 
163
+ // ---------------------------------------------------------------------------
164
+ // JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
165
+ // We do NOT verify the signature here — Cognito already did that when it
166
+ // issued the token, and we only read the public claims (sub/email/name) to
167
+ // drive the claim-dance + create the person entity. If the token is tampered
168
+ // with, the downstream vault-service call will reject it (signature-verified
169
+ // there) long before any claimed value causes harm.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function decodeJwtClaims(jwt: string): IdTokenClaims | null {
173
+ const parts = jwt.split(".");
174
+ if (parts.length !== 3) return null;
175
+ try {
176
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
177
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
178
+ const json = Buffer.from(padded, "base64").toString("utf-8");
179
+ return JSON.parse(json) as IdTokenClaims;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function defaultGetIdTokenClaims(): IdTokenClaims | null {
186
+ const tokens: CognitoTokens | null = loadCachedTokens();
187
+ if (!tokens?.idToken) return null;
188
+ return decodeJwtClaims(tokens.idToken);
189
+ }
190
+
191
+ /**
192
+ * Best-effort: claim any email-keyed pending invites that were sent before
193
+ * this user had a person entity. Mirrors the installer's vault-handoff flow.
194
+ *
195
+ * Silent on the happy path — only logs to stderr on soft failures (so a
196
+ * transient network blip doesn't block the sync). Never throws: a caller who
197
+ * can't list memberships despite an unclaimed invite is no worse off than the
198
+ * pre-claim-dance behavior (which was to emit setup-needed).
199
+ */
200
+ async function runClaimDance(
201
+ client: VaultClientSurface,
202
+ claims: IdTokenClaims,
203
+ stderr: { write: (chunk: string) => boolean | void },
204
+ ): Promise<void> {
205
+ try {
206
+ const pending = await client.listMyPendingInvitesByEmail();
207
+ if (pending.length === 0) return;
208
+
209
+ const displayName =
210
+ claims.name ??
211
+ [claims.given_name, claims.family_name].filter(Boolean).join(" ") ??
212
+ claims.email ??
213
+ "";
214
+ const ownerSub = claims.sub ?? "";
215
+ if (!ownerSub || !displayName) {
216
+ stderr.write(
217
+ "hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
218
+ );
219
+ return;
220
+ }
221
+
222
+ const person = await client.ensureMyPersonEntity({
223
+ ownerSub,
224
+ displayName,
225
+ });
226
+ await client.claimPendingInvitesByEmail(person.uid);
227
+ } catch (err) {
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ stderr.write(`hq-sync-runner: claim-dance skipped — ${msg}\n`);
230
+ }
231
+ }
232
+
138
233
  // ---------------------------------------------------------------------------
139
234
  // argv parser — intentionally minimal (no commander/yargs dep)
140
235
  // ---------------------------------------------------------------------------
@@ -244,8 +339,20 @@ export async function runRunner(
244
339
  let memberships: Pick<Membership, "companyUid">[];
245
340
  try {
246
341
  if (parsed.companies) {
342
+ // Before giving up on memberships, run the claim-dance: new users signed
343
+ // in via the tray may have email-keyed invites waiting for them. Without
344
+ // this, an invited user would see "setup-needed" on every tray click.
345
+ const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
346
+ const claims = getClaims();
347
+ if (claims) {
348
+ await runClaimDance(client, claims, stderr);
349
+ }
350
+
247
351
  memberships = await client.listMyMemberships();
248
352
  if (memberships.length === 0) {
353
+ // Truly empty — still a valid state (no memberships = nothing to
354
+ // sync). The tray will show a friendly "create your first company"
355
+ // CTA rather than an alarm banner.
249
356
  emit({ type: "setup-needed" });
250
357
  return 0;
251
358
  }
@@ -278,16 +385,18 @@ export async function runRunner(
278
385
  // The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
279
386
  // If the entity fetch fails for some row (entity deleted, scoping issue),
280
387
  // degrade to using the UID as the slug rather than aborting the run.
281
- const plan: Array<{ uid: string; slug: string }> = [];
388
+ const plan: Array<{ uid: string; slug: string; name?: string }> = [];
282
389
  for (const m of memberships) {
283
390
  let slug = m.companyUid;
391
+ let name: string | undefined;
284
392
  try {
285
393
  const info = await client.entity.get(m.companyUid);
286
394
  slug = info.slug || m.companyUid;
395
+ name = info.name;
287
396
  } catch {
288
397
  // Best-effort — keep UID as the display identifier.
289
398
  }
290
- plan.push({ uid: m.companyUid, slug });
399
+ plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
291
400
  }
292
401
  emit({ type: "fanout-plan", companies: plan });
293
402
 
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
  });