@indigoai-us/hq-cloud 5.1.12 → 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.
@@ -85,6 +85,7 @@ function makeVaultStub(
85
85
  opts: {
86
86
  memberships?: Array<Pick<Membership, "companyUid">>;
87
87
  entityGet?: (uid: string) => Promise<EntityInfo>;
88
+ listPersons?: () => Promise<EntityInfo[]>;
88
89
  pendingInvites?: Array<Record<string, unknown>>;
89
90
  ensurePerson?: (hints: {
90
91
  ownerSub: string;
@@ -121,6 +122,9 @@ function makeVaultStub(
121
122
  bucketName: `bucket-${uid}`,
122
123
  status: "active",
123
124
  } as unknown as EntityInfo)),
125
+ listByType:
126
+ opts.listPersons ??
127
+ (() => Promise.resolve([])),
124
128
  },
125
129
  };
126
130
  }
@@ -1050,6 +1054,110 @@ describe("--direction", () => {
1050
1054
  });
1051
1055
  });
1052
1056
 
1057
+ // ---------------------------------------------------------------------------
1058
+ // Personal slot fanout (A/B/C)
1059
+ // ---------------------------------------------------------------------------
1060
+
1061
+ describe("personal slot fanout", () => {
1062
+ const olderPerson: EntityInfo = {
1063
+ uid: "prs_older",
1064
+ slug: "older-person",
1065
+ type: "person",
1066
+ status: "active",
1067
+ createdAt: "2026-01-01T00:00:00Z",
1068
+ bucketName: "hq-vault-prs-older",
1069
+ } as unknown as EntityInfo;
1070
+
1071
+ const newerPerson: EntityInfo = {
1072
+ uid: "prs_newer",
1073
+ slug: "newer-person",
1074
+ type: "person",
1075
+ status: "active",
1076
+ createdAt: "2026-06-01T00:00:00Z",
1077
+ bucketName: "hq-vault-prs-newer",
1078
+ } as unknown as EntityInfo;
1079
+
1080
+ it("A: fanout-plan ends with personal slot using canonical-sort-selected person (older createdAt wins)", async () => {
1081
+ const deps = makeDeps({
1082
+ createVaultClient: () =>
1083
+ makeVaultStub({
1084
+ memberships: [{ companyUid: "cmp_a" }],
1085
+ entityGet: (uid: string) =>
1086
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1087
+ // Return persons in reversed order (newer first) to test canonical sort
1088
+ listPersons: () => Promise.resolve([newerPerson, olderPerson]),
1089
+ }),
1090
+ });
1091
+
1092
+ const code = await runRunner(["--companies"], deps);
1093
+ expect(code).toBe(0);
1094
+
1095
+ const planEvent = deps.stdout
1096
+ .events()
1097
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
1098
+ expect(planEvent).toBeDefined();
1099
+
1100
+ const lastEntry = planEvent.companies[planEvent.companies.length - 1];
1101
+ expect(lastEntry.slug).toBe("personal");
1102
+ expect(lastEntry.uid).toBe("prs_older");
1103
+ expect((lastEntry as Record<string, unknown>).bucketName).toBe("hq-vault-prs-older");
1104
+ expect((lastEntry as Record<string, unknown>).personalMode).toBe(true);
1105
+ expect((lastEntry as Record<string, unknown>).journalSlug).toBe("personal");
1106
+ });
1107
+
1108
+ it("B: syncFn invoked with personalMode: true + journalSlug: 'personal' for personal slot", async () => {
1109
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1110
+ const deps = makeDeps({
1111
+ createVaultClient: () =>
1112
+ makeVaultStub({
1113
+ memberships: [{ companyUid: "cmp_a" }],
1114
+ entityGet: (uid: string) =>
1115
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1116
+ listPersons: () => Promise.resolve([newerPerson, olderPerson]),
1117
+ }),
1118
+ sync: syncSpy,
1119
+ });
1120
+
1121
+ const code = await runRunner(["--companies"], deps);
1122
+ expect(code).toBe(0);
1123
+
1124
+ const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
1125
+ (c) => c[0].company?.startsWith("prs_"),
1126
+ );
1127
+ expect(personalCall).toBeDefined();
1128
+ const personalArgs = personalCall![0];
1129
+ expect(personalArgs.personalMode).toBe(true);
1130
+ expect(personalArgs.journalSlug).toBe("personal");
1131
+ });
1132
+
1133
+ it("C: company slots' syncFn args do NOT contain personalMode or journalSlug", async () => {
1134
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1135
+ const deps = makeDeps({
1136
+ createVaultClient: () =>
1137
+ makeVaultStub({
1138
+ memberships: [{ companyUid: "cmp_a" }],
1139
+ entityGet: (uid: string) =>
1140
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1141
+ listPersons: () => Promise.resolve([olderPerson]),
1142
+ }),
1143
+ sync: syncSpy,
1144
+ });
1145
+
1146
+ const code = await runRunner(["--companies"], deps);
1147
+ expect(code).toBe(0);
1148
+
1149
+ const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
1150
+ (c) => c[0].company?.startsWith("cmp_"),
1151
+ );
1152
+ expect(companyCalls.length).toBeGreaterThan(0);
1153
+ for (const [args] of companyCalls) {
1154
+ const keys = Object.keys(args);
1155
+ expect(keys).not.toContain("personalMode");
1156
+ expect(keys).not.toContain("journalSlug");
1157
+ }
1158
+ });
1159
+ });
1160
+
1053
1161
  // ---------------------------------------------------------------------------
1054
1162
  // Re-initialize for each test (mock state hygiene)
1055
1163
  // ---------------------------------------------------------------------------
@@ -48,6 +48,7 @@ import {
48
48
  type EntityInfo,
49
49
  type PendingInviteByEmail,
50
50
  } from "../index.js";
51
+ import { pickCanonicalPersonEntity } from "../vault-client.js";
51
52
  import { sync as defaultSync } from "../cli/sync.js";
52
53
  import type {
53
54
  SyncOptions,
@@ -155,6 +156,7 @@ export interface VaultClientSurface {
155
156
  }) => Promise<EntityInfo>;
156
157
  entity: {
157
158
  get: (uid: string) => Promise<EntityInfo>;
159
+ listByType: (type: string) => Promise<EntityInfo[]>;
158
160
  };
159
161
  }
160
162
 
@@ -430,7 +432,14 @@ export async function runRunner(
430
432
  // The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
431
433
  // If the entity fetch fails for some row (entity deleted, scoping issue),
432
434
  // degrade to using the UID as the slug rather than aborting the run.
433
- const plan: Array<{ uid: string; slug: string; name?: string }> = [];
435
+ const plan: Array<{
436
+ uid: string;
437
+ slug: string;
438
+ name?: string;
439
+ bucketName?: string;
440
+ personalMode?: boolean;
441
+ journalSlug?: string;
442
+ }> = [];
434
443
  for (const m of memberships) {
435
444
  let slug = m.companyUid;
436
445
  let name: string | undefined;
@@ -443,6 +452,21 @@ export async function runRunner(
443
452
  }
444
453
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
445
454
  }
455
+
456
+ if (parsed.companies) {
457
+ const persons = await client.entity.listByType("person");
458
+ const pick = pickCanonicalPersonEntity(persons);
459
+ if (pick?.bucketName) {
460
+ plan.push({
461
+ slug: "personal",
462
+ uid: pick.uid,
463
+ bucketName: pick.bucketName,
464
+ personalMode: true,
465
+ journalSlug: "personal",
466
+ });
467
+ }
468
+ }
469
+
446
470
  emit({ type: "fanout-plan", companies: plan });
447
471
 
448
472
  // ---- fanout -----------------------------------------------------------
@@ -519,6 +543,8 @@ export async function runRunner(
519
543
  vaultConfig,
520
544
  hqRoot: parsed.hqRoot,
521
545
  onConflict: parsed.onConflict,
546
+ ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
547
+ ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
522
548
  onEvent: tagAndEmit,
523
549
  });
524
550
  }
@@ -34,6 +34,7 @@ vi.mock("../s3.js", async () => {
34
34
  });
35
35
 
36
36
  import { sync } from "./sync.js";
37
+ import * as s3Module from "../s3.js";
37
38
 
38
39
  const mockConfig: VaultServiceConfig = {
39
40
  apiUrl: "https://vault-api.test",
@@ -210,6 +211,48 @@ describe("sync", () => {
210
211
  expect(result.aborted).toBe(true);
211
212
  });
212
213
 
214
+ it("journalSlug: 'personal' routes journal I/O to sync-journal.personal.json", async () => {
215
+ const result = await sync({
216
+ company: "acme",
217
+ vaultConfig: mockConfig,
218
+ hqRoot: tmpDir,
219
+ journalSlug: "personal",
220
+ });
221
+
222
+ expect(result.filesDownloaded).toBe(2);
223
+ // Journal written to personal slug, not ctx.slug ("acme")
224
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
225
+ expect(fs.existsSync(personalJournalPath)).toBe(true);
226
+ // The acme journal must NOT have been written
227
+ expect(fs.existsSync(journalPath)).toBe(false);
228
+ });
229
+
230
+ it("personalMode: true skips companies/* keys and downloads root keys to hqRoot", async () => {
231
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
232
+ { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
233
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
234
+ ]);
235
+
236
+ const result = await sync({
237
+ company: "acme",
238
+ vaultConfig: mockConfig,
239
+ hqRoot: tmpDir,
240
+ personalMode: true,
241
+ });
242
+
243
+ // Exact counts (regression-tight)
244
+ expect(result.filesSkipped).toBe(1);
245
+ expect(result.filesDownloaded).toBe(1);
246
+
247
+ // companies/* must NOT land anywhere
248
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
249
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
250
+
251
+ // docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
252
+ expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
253
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
254
+ });
255
+
213
256
  it("overwrites local on --on-conflict overwrite", async () => {
214
257
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
215
258
  fs.mkdirSync(companyDocs, { recursive: true });
package/src/cli/sync.ts CHANGED
@@ -46,6 +46,19 @@ export interface SyncOptions {
46
46
  * default human logger is used. See `SyncProgressEvent`.
47
47
  */
48
48
  onEvent?: (event: SyncProgressEvent) => void;
49
+ /**
50
+ * When true, the caller is syncing against the caller's person-entity
51
+ * bucket. Pulled keys whose path starts with `companies/` are dropped
52
+ * (belt-and-braces — the person bucket should never contain those,
53
+ * but the runner must not write them into the user's company folders).
54
+ */
55
+ personalMode?: boolean;
56
+ /**
57
+ * Override for the per-slug journal file name. Defaults to `ctx.slug`.
58
+ * sync-runner passes `journalSlug: "personal"` for the personal slot so
59
+ * TS runner and Rust first-push share idempotency state.
60
+ */
61
+ journalSlug?: string;
49
62
  }
50
63
 
51
64
  export interface SyncResult {
@@ -78,9 +91,16 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
78
91
  // companies into the same hqRoot doesn't cross-clobber files with overlapping
79
92
  // S3 keys (e.g. every company has a .hq/manifest.json). Remote keys stay
80
93
  // company-relative; the prefix lives only on disk.
81
- const companyRoot = path.join(hqRoot, "companies", ctx.slug);
94
+ // In personalMode the journal slug + S3 keys are person-relative (e.g. "docs/foo.md");
95
+ // the local target is `hqRoot` directly, NOT `<hqRoot>/companies/<personSlug>/`. This
96
+ // keeps round-trip parity with the Rust personal first-push (Step 7) which sources
97
+ // `<hqRoot>/docs/foo.md`.
98
+ const companyRoot = options.personalMode === true
99
+ ? hqRoot
100
+ : path.join(hqRoot, "companies", ctx.slug);
82
101
  const shouldSync = createIgnoreFilter(hqRoot);
83
- const journal = readJournal(ctx.slug);
102
+ const journalSlug = options.journalSlug ?? ctx.slug;
103
+ const journal = readJournal(journalSlug);
84
104
 
85
105
  let filesDownloaded = 0;
86
106
  let bytesDownloaded = 0;
@@ -93,6 +113,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
93
113
  for (const remoteFile of remoteFiles) {
94
114
  const localPath = path.join(companyRoot, remoteFile.key);
95
115
 
116
+ if (options.personalMode === true && remoteFile.key.startsWith("companies/")) {
117
+ filesSkipped++;
118
+ continue;
119
+ }
120
+
96
121
  // Apply ignore rules
97
122
  if (!shouldSync(localPath)) {
98
123
  filesSkipped++;
@@ -126,7 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
126
151
  );
127
152
 
128
153
  if (resolution === "abort") {
129
- writeJournal(ctx.slug, journal);
154
+ writeJournal(journalSlug, journal);
130
155
  return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
131
156
  }
132
157
  if (resolution === "keep" || resolution === "skip") {
@@ -181,7 +206,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
181
206
  }
182
207
  }
183
208
 
184
- writeJournal(ctx.slug, journal);
209
+ writeJournal(journalSlug, journal);
185
210
 
186
211
  return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
187
212
  }
@@ -162,11 +162,114 @@ describe("resolveEntityContext", () => {
162
162
  setupFetchMock({ vendStatus: 403 });
163
163
 
164
164
  await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
165
- /STS vend failed/,
165
+ /STS.*vend.*failed/,
166
166
  );
167
167
  });
168
168
  });
169
169
 
170
+ describe("routing by UID prefix and vend-self dispatch", () => {
171
+ beforeEach(() => {
172
+ clearContextCache();
173
+ vi.restoreAllMocks();
174
+ });
175
+
176
+ it("prs_* UID: entity resolved via /entity/{uid} and credentials via /sts/vend-self", async () => {
177
+ const prsEntity = {
178
+ uid: "prs_01PERSON",
179
+ slug: "test-person",
180
+ bucketName: "hq-vault-prs-01person",
181
+ status: "active",
182
+ };
183
+ const calls: string[] = [];
184
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
185
+ const u = String(url);
186
+ calls.push(u);
187
+ if (u.includes("/entity/prs_")) {
188
+ return { ok: true, status: 200, json: async () => ({ entity: prsEntity }), text: async () => "" };
189
+ }
190
+ if (u.includes("/sts/vend-self")) {
191
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
192
+ }
193
+ return { ok: false, status: 404, text: async () => "Not found" };
194
+ }));
195
+
196
+ await resolveEntityContext("prs_01PERSON", mockConfig);
197
+
198
+ expect(calls.some((u) => u.includes("/entity/prs_01PERSON"))).toBe(true);
199
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
200
+ expect(vendCalls).toHaveLength(1);
201
+ expect(vendCalls[0]).toContain("/sts/vend-self");
202
+ });
203
+
204
+ it("foo_bar slug: entity resolved via /entity/by-slug/company/foo_bar and credentials via /sts/vend", async () => {
205
+ const calls: string[] = [];
206
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
207
+ const u = String(url);
208
+ calls.push(u);
209
+ if (u.includes("/entity/by-slug/")) {
210
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
211
+ }
212
+ if (u.includes("/sts/vend")) {
213
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
214
+ }
215
+ return { ok: false, status: 404, text: async () => "Not found" };
216
+ }));
217
+
218
+ await resolveEntityContext("foo_bar", mockConfig);
219
+
220
+ expect(calls.some((u) => u.includes("/entity/by-slug/company/foo_bar"))).toBe(true);
221
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
222
+ expect(vendCalls).toHaveLength(1);
223
+ expect(vendCalls[0]).not.toContain("/sts/vend-self");
224
+ expect(vendCalls[0]).toContain("/sts/vend");
225
+ });
226
+
227
+ it("team_alpha slug: entity resolved via /entity/by-slug/company/team_alpha and credentials via /sts/vend", async () => {
228
+ const calls: string[] = [];
229
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
230
+ const u = String(url);
231
+ calls.push(u);
232
+ if (u.includes("/entity/by-slug/")) {
233
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
234
+ }
235
+ if (u.includes("/sts/vend")) {
236
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
237
+ }
238
+ return { ok: false, status: 404, text: async () => "Not found" };
239
+ }));
240
+
241
+ await resolveEntityContext("team_alpha", mockConfig);
242
+
243
+ expect(calls.some((u) => u.includes("/entity/by-slug/company/team_alpha"))).toBe(true);
244
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
245
+ expect(vendCalls).toHaveLength(1);
246
+ expect(vendCalls[0]).not.toContain("/sts/vend-self");
247
+ });
248
+
249
+ it("cmp_* UID: entity resolved via /entity/{uid} and credentials via /sts/vend", async () => {
250
+ const calls: string[] = [];
251
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
252
+ const u = String(url);
253
+ calls.push(u);
254
+ if (u.includes("/entity/cmp_")) {
255
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
256
+ }
257
+ if (u.includes("/sts/vend")) {
258
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
259
+ }
260
+ return { ok: false, status: 404, text: async () => "Not found" };
261
+ }));
262
+
263
+ await resolveEntityContext("cmp_01ABCDEF", mockConfig);
264
+
265
+ expect(calls.some((u) => u.includes("/entity/cmp_01ABCDEF"))).toBe(true);
266
+ const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
267
+ expect(vendCalls).toHaveLength(1);
268
+ expect(vendCalls[0]).not.toContain("/sts/vend-self");
269
+ expect(vendCalls[0]).toContain("/sts/vend");
270
+ });
271
+ });
272
+
170
273
  describe("refreshEntityContext", () => {
171
274
  beforeEach(() => {
172
275
  clearContextCache();
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
  // ---------------------------------------------------------------------------
@@ -621,6 +623,51 @@ describe("VaultClient identity bootstrap", () => {
621
623
  expect(fetchSpy).toHaveBeenCalledTimes(1);
622
624
  });
623
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
+
624
671
  it("vendSelf_roundtrip", async () => {
625
672
  fetchSpy.mockResolvedValueOnce(
626
673
  jsonResponse(200, {
@@ -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