@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.
- package/dist/bin/sync-runner.d.ts +26 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +77 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +165 -9
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +53 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +135 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +200 -13
- package/src/bin/sync-runner.ts +114 -5
- package/src/index.ts +1 -0
- package/src/vault-client.test.ts +173 -0
- package/src/vault-client.ts +78 -0
|
@@ -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 {
|
|
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
|
-
|
|
240
|
-
|
|
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: () =>
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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
|
|
131
|
-
*
|
|
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
package/src/vault-client.test.ts
CHANGED
|
@@ -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
|
});
|