@indigoai-us/hq-cloud 5.1.10 → 5.1.12

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.
@@ -54,8 +54,24 @@ import type {
54
54
  SyncResult,
55
55
  SyncProgressEvent,
56
56
  } from "../cli/sync.js";
57
+ import { share as defaultShare } from "../cli/share.js";
58
+ import type { ShareOptions, ShareResult } from "../cli/share.js";
57
59
  import type { ConflictStrategy } from "../cli/conflict.js";
58
60
 
61
+ /**
62
+ * Sync direction for a run.
63
+ *
64
+ * - `pull`: download-only (legacy `hq sync` behaviour, and the default for
65
+ * back-compat with pre-5.1.11 callers of the runner).
66
+ * - `push`: upload-only. Walks the company folder and sends every file whose
67
+ * local hash differs from the journal (skipUnchanged).
68
+ * - `both`: push first, then pull. "Sync Now" in the menubar app targets this.
69
+ * Push runs first so the subsequent pull doesn't redownload files we were
70
+ * about to replace; if a company aborts on push conflict, pull is skipped
71
+ * for that company but the fanout continues.
72
+ */
73
+ export type Direction = "pull" | "push" | "both";
74
+
59
75
  // ---------------------------------------------------------------------------
60
76
  // Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
61
77
  // imported) to avoid a circular dep between hq-cli and hq-cloud. If these
@@ -65,8 +81,8 @@ import type { ConflictStrategy } from "../cli/conflict.js";
65
81
 
66
82
  const DEFAULT_COGNITO: CognitoAuthConfig = {
67
83
  region: process.env.AWS_REGION ?? "us-east-1",
68
- userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "hq-vault-dev",
69
- clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "4mmujmjq3srakdueg656b9m0mp",
84
+ userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-dev",
85
+ clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "7r7an9keh0u6hlsvepl74tvqb0",
70
86
  port: process.env.HQ_COGNITO_CALLBACK_PORT
71
87
  ? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
72
88
  : 8765,
@@ -74,7 +90,7 @@ const DEFAULT_COGNITO: CognitoAuthConfig = {
74
90
 
75
91
  const DEFAULT_VAULT_API_URL =
76
92
  process.env.HQ_VAULT_API_URL ??
77
- "https://tqdwdqxv75.execute-api.us-east-1.amazonaws.com";
93
+ "https://ky8cgbl4yh.execute-api.us-east-1.amazonaws.com";
78
94
 
79
95
  const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
80
96
 
@@ -97,12 +113,27 @@ export type RunnerEvent =
97
113
  }
98
114
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
99
115
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
100
- | ({ type: "complete"; company: string } & SyncResult)
116
+ | ({
117
+ type: "complete";
118
+ company: string;
119
+ /**
120
+ * Upload counters. Always emitted (0 when the run was pull-only) so
121
+ * downstream consumers don't need to conditionally read the field.
122
+ * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
123
+ * to the Rust struct is a follow-up when the UI needs to surface push
124
+ * totals.
125
+ */
126
+ filesUploaded: number;
127
+ bytesUploaded: number;
128
+ } & SyncResult)
101
129
  | {
102
130
  type: "all-complete";
103
131
  companiesAttempted: number;
104
132
  filesDownloaded: number;
105
133
  bytesDownloaded: number;
134
+ /** Always emitted; 0 when no push phase ran. */
135
+ filesUploaded: number;
136
+ bytesUploaded: number;
106
137
  errors: Array<{ company: string; message: string }>;
107
138
  };
108
139
 
@@ -158,6 +189,8 @@ export interface RunnerDeps {
158
189
  createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
159
190
  /** Sync function. Defaults to `cli/sync.sync`. */
160
191
  sync?: (options: SyncOptions) => Promise<SyncResult>;
192
+ /** Share function (push phase). Defaults to `cli/share.share`. */
193
+ share?: (options: ShareOptions) => Promise<ShareResult>;
161
194
  }
162
195
 
163
196
  // ---------------------------------------------------------------------------
@@ -239,6 +272,7 @@ interface ParsedArgs {
239
272
  company?: string;
240
273
  onConflict: ConflictStrategy;
241
274
  hqRoot: string;
275
+ direction: Direction;
242
276
  }
243
277
 
244
278
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
@@ -246,6 +280,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
246
280
  let company: string | undefined;
247
281
  let onConflict: ConflictStrategy = "abort";
248
282
  let hqRoot = DEFAULT_HQ_ROOT;
283
+ let direction: Direction = "pull";
249
284
 
250
285
  for (let i = 0; i < argv.length; i++) {
251
286
  const arg = argv[i];
@@ -267,6 +302,16 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
267
302
  onConflict = val;
268
303
  break;
269
304
  }
305
+ case "--direction": {
306
+ const val = argv[++i];
307
+ if (val !== "pull" && val !== "push" && val !== "both") {
308
+ return {
309
+ error: `--direction must be one of pull|push|both, got: ${val ?? "(missing)"}`,
310
+ };
311
+ }
312
+ direction = val;
313
+ break;
314
+ }
270
315
  case "--hq-root":
271
316
  hqRoot = argv[++i];
272
317
  if (!hqRoot) return { error: "--hq-root requires a value" };
@@ -286,7 +331,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
286
331
  return { error: "Pass --companies or --company <slug>" };
287
332
  }
288
333
 
289
- return { companies, company, onConflict, hqRoot };
334
+ return { companies, company, onConflict, hqRoot, direction };
290
335
  }
291
336
 
292
337
  // ---------------------------------------------------------------------------
@@ -402,42 +447,99 @@ export async function runRunner(
402
447
 
403
448
  // ---- fanout -----------------------------------------------------------
404
449
  const syncFn = deps.sync ?? defaultSync;
405
- let totalFiles = 0;
406
- let totalBytes = 0;
450
+ const shareFn = deps.share ?? defaultShare;
451
+ const doPush = parsed.direction === "push" || parsed.direction === "both";
452
+ const doPull = parsed.direction === "pull" || parsed.direction === "both";
453
+ let totalDownloaded = 0;
454
+ let totalDownloadedBytes = 0;
455
+ let totalUploaded = 0;
456
+ let totalUploadedBytes = 0;
407
457
  const errors: Array<{ company: string; message: string }> = [];
408
458
 
409
459
  for (const target of plan) {
410
460
  const companyLabel = target.slug;
461
+ // Per-company event tagger — shared by push and pull phases so progress
462
+ // rows land on the right company regardless of which phase emitted them.
463
+ const tagAndEmit = (event: SyncProgressEvent): void => {
464
+ if (event.type === "progress") {
465
+ emit({
466
+ type: "progress",
467
+ company: companyLabel,
468
+ path: event.path,
469
+ bytes: event.bytes,
470
+ ...(event.message ? { message: event.message } : {}),
471
+ });
472
+ } else {
473
+ emit({
474
+ type: "error",
475
+ company: companyLabel,
476
+ path: event.path,
477
+ message: event.message,
478
+ });
479
+ }
480
+ };
481
+
411
482
  try {
412
- const result = await syncFn({
413
- company: target.uid,
414
- vaultConfig,
415
- hqRoot: parsed.hqRoot,
416
- onConflict: parsed.onConflict,
417
- onEvent: (event) => {
418
- // Tag per-file events with the company they belong to so the
419
- // menubar can route them to the right company's progress bar.
420
- if (event.type === "progress") {
421
- emit({
422
- type: "progress",
423
- company: companyLabel,
424
- path: event.path,
425
- bytes: event.bytes,
426
- ...(event.message ? { message: event.message } : {}),
427
- });
428
- } else {
429
- emit({
430
- type: "error",
431
- company: companyLabel,
432
- path: event.path,
433
- message: event.message,
434
- });
435
- }
436
- },
483
+ let pushResult: ShareResult = {
484
+ filesUploaded: 0,
485
+ bytesUploaded: 0,
486
+ filesSkipped: 0,
487
+ aborted: false,
488
+ };
489
+ let pullResult: SyncResult = {
490
+ filesDownloaded: 0,
491
+ bytesDownloaded: 0,
492
+ filesSkipped: 0,
493
+ conflicts: 0,
494
+ aborted: false,
495
+ };
496
+
497
+ // Push first so a subsequent pull doesn't overwrite files we were about
498
+ // to broadcast. Uses the walk-everything-under-companies/{slug}/ entry
499
+ // point with `skipUnchanged` so we don't re-upload files that haven't
500
+ // changed since the last sync.
501
+ if (doPush) {
502
+ pushResult = await shareFn({
503
+ paths: [path.join(parsed.hqRoot, "companies", target.slug)],
504
+ company: target.uid,
505
+ vaultConfig,
506
+ hqRoot: parsed.hqRoot,
507
+ onConflict: parsed.onConflict,
508
+ skipUnchanged: true,
509
+ onEvent: tagAndEmit,
510
+ });
511
+ }
512
+
513
+ // Pull runs unless the push phase aborted on conflict — aborted means
514
+ // the user has local edits + remote drift; blindly pulling would erase
515
+ // whichever side `--on-conflict abort` just protected.
516
+ if (doPull && !pushResult.aborted) {
517
+ pullResult = await syncFn({
518
+ company: target.uid,
519
+ vaultConfig,
520
+ hqRoot: parsed.hqRoot,
521
+ onConflict: parsed.onConflict,
522
+ onEvent: tagAndEmit,
523
+ });
524
+ }
525
+
526
+ emit({
527
+ type: "complete",
528
+ company: companyLabel,
529
+ filesDownloaded: pullResult.filesDownloaded,
530
+ bytesDownloaded: pullResult.bytesDownloaded,
531
+ filesUploaded: pushResult.filesUploaded,
532
+ bytesUploaded: pushResult.bytesUploaded,
533
+ filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
534
+ conflicts: pullResult.conflicts,
535
+ // Either phase aborting marks the company aborted — the UI treats
536
+ // `aborted: true` as "sync didn't complete cleanly for this company".
537
+ aborted: pullResult.aborted || pushResult.aborted,
437
538
  });
438
- emit({ type: "complete", company: companyLabel, ...result });
439
- totalFiles += result.filesDownloaded;
440
- totalBytes += result.bytesDownloaded;
539
+ totalDownloaded += pullResult.filesDownloaded;
540
+ totalDownloadedBytes += pullResult.bytesDownloaded;
541
+ totalUploaded += pushResult.filesUploaded;
542
+ totalUploadedBytes += pushResult.bytesUploaded;
441
543
  } catch (err) {
442
544
  const message = err instanceof Error ? err.message : String(err);
443
545
  errors.push({ company: companyLabel, message });
@@ -454,8 +556,10 @@ export async function runRunner(
454
556
  emit({
455
557
  type: "all-complete",
456
558
  companiesAttempted: plan.length,
457
- filesDownloaded: totalFiles,
458
- bytesDownloaded: totalBytes,
559
+ filesDownloaded: totalDownloaded,
560
+ bytesDownloaded: totalDownloadedBytes,
561
+ filesUploaded: totalUploaded,
562
+ bytesUploaded: totalUploadedBytes,
459
563
  errors,
460
564
  });
461
565
  return 0;
@@ -198,4 +198,146 @@ describe("share", () => {
198
198
 
199
199
  expect(result.filesUploaded).toBe(1);
200
200
  });
201
+
202
+ it("skipUnchanged=true skips files whose local hash matches the journal", async () => {
203
+ const companyRoot = path.join(tmpDir, "companies", "acme");
204
+ fs.mkdirSync(companyRoot, { recursive: true });
205
+ const testFile = path.join(companyRoot, "unchanged.md");
206
+ fs.writeFileSync(testFile, "stable content");
207
+
208
+ // Precompute the hash of the file so the journal matches exactly.
209
+ const { hashFile } = await import("../journal.js");
210
+ const hash = hashFile(testFile);
211
+
212
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
213
+ fs.writeFileSync(
214
+ journalPath,
215
+ JSON.stringify({
216
+ version: "1",
217
+ lastSync: new Date().toISOString(),
218
+ files: {
219
+ "unchanged.md": {
220
+ hash,
221
+ size: 15,
222
+ syncedAt: new Date().toISOString(),
223
+ direction: "up",
224
+ },
225
+ },
226
+ }),
227
+ );
228
+
229
+ const result = await share({
230
+ paths: [testFile],
231
+ company: "acme",
232
+ vaultConfig: mockConfig,
233
+ hqRoot: tmpDir,
234
+ skipUnchanged: true,
235
+ });
236
+
237
+ expect(result.filesUploaded).toBe(0);
238
+ expect(result.filesSkipped).toBe(1);
239
+ expect(uploadFile).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("skipUnchanged=true still uploads files whose hash differs from the journal", async () => {
243
+ const companyRoot = path.join(tmpDir, "companies", "acme");
244
+ fs.mkdirSync(companyRoot, { recursive: true });
245
+ const testFile = path.join(companyRoot, "changed.md");
246
+ fs.writeFileSync(testFile, "new content");
247
+
248
+ // Journal has a stale hash for this path — simulating "local has been
249
+ // edited since the last push".
250
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
251
+ fs.writeFileSync(
252
+ journalPath,
253
+ JSON.stringify({
254
+ version: "1",
255
+ lastSync: new Date().toISOString(),
256
+ files: {
257
+ "changed.md": {
258
+ hash: "stale-hash-from-previous-sync",
259
+ size: 10,
260
+ syncedAt: new Date().toISOString(),
261
+ direction: "up",
262
+ },
263
+ },
264
+ }),
265
+ );
266
+
267
+ const result = await share({
268
+ paths: [testFile],
269
+ company: "acme",
270
+ vaultConfig: mockConfig,
271
+ hqRoot: tmpDir,
272
+ skipUnchanged: true,
273
+ });
274
+
275
+ expect(result.filesUploaded).toBe(1);
276
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
277
+ });
278
+
279
+ it("skipUnchanged=false (default) uploads even when hash matches", async () => {
280
+ const companyRoot = path.join(tmpDir, "companies", "acme");
281
+ fs.mkdirSync(companyRoot, { recursive: true });
282
+ const testFile = path.join(companyRoot, "unchanged.md");
283
+ fs.writeFileSync(testFile, "stable content");
284
+
285
+ const { hashFile } = await import("../journal.js");
286
+ const hash = hashFile(testFile);
287
+
288
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
289
+ fs.writeFileSync(
290
+ journalPath,
291
+ JSON.stringify({
292
+ version: "1",
293
+ lastSync: new Date().toISOString(),
294
+ files: {
295
+ "unchanged.md": {
296
+ hash,
297
+ size: 15,
298
+ syncedAt: new Date().toISOString(),
299
+ direction: "up",
300
+ },
301
+ },
302
+ }),
303
+ );
304
+
305
+ const result = await share({
306
+ paths: [testFile],
307
+ company: "acme",
308
+ vaultConfig: mockConfig,
309
+ hqRoot: tmpDir,
310
+ // skipUnchanged omitted — preserves `hq share <file>` semantics
311
+ });
312
+
313
+ expect(result.filesUploaded).toBe(1);
314
+ expect(uploadFile).toHaveBeenCalled();
315
+ });
316
+
317
+ it("onEvent receives progress events instead of console output", async () => {
318
+ const companyRoot = path.join(tmpDir, "companies", "acme");
319
+ fs.mkdirSync(companyRoot, { recursive: true });
320
+ fs.writeFileSync(path.join(companyRoot, "a.md"), "aaa");
321
+ fs.writeFileSync(path.join(companyRoot, "b.md"), "bbb");
322
+
323
+ const events: Array<{ type: string; path: string; bytes?: number }> = [];
324
+ const result = await share({
325
+ paths: [companyRoot],
326
+ company: "acme",
327
+ vaultConfig: mockConfig,
328
+ hqRoot: tmpDir,
329
+ onEvent: (e) => {
330
+ events.push({
331
+ type: e.type,
332
+ path: e.path,
333
+ ...(e.type === "progress" ? { bytes: e.bytes } : {}),
334
+ });
335
+ },
336
+ });
337
+
338
+ expect(result.filesUploaded).toBe(2);
339
+ expect(events).toHaveLength(2);
340
+ expect(events.every((e) => e.type === "progress")).toBe(true);
341
+ expect(events.map((e) => e.path).sort()).toEqual(["a.md", "b.md"]);
342
+ });
201
343
  });
package/src/cli/share.ts CHANGED
@@ -14,6 +14,7 @@ import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js"
14
14
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
16
  import type { ConflictStrategy } from "./conflict.js";
17
+ import type { SyncProgressEvent } from "./sync.js";
17
18
 
18
19
  export interface ShareOptions {
19
20
  /** Path(s) to share (files or directories) */
@@ -28,6 +29,23 @@ export interface ShareOptions {
28
29
  vaultConfig: VaultServiceConfig;
29
30
  /** HQ root directory */
30
31
  hqRoot: string;
32
+ /**
33
+ * Per-file event callback. When present, suppresses the default
34
+ * `console.log`/`console.error` human output — same contract as `sync()`.
35
+ * This is the seam `hq-sync-runner` uses to stream ndjson for push events.
36
+ */
37
+ onEvent?: (event: SyncProgressEvent) => void;
38
+ /**
39
+ * When true, files whose local hash matches the journal entry from the
40
+ * last sync are skipped (no remote HEAD, no upload). This is the gate
41
+ * that makes "push everything that changed" efficient — without it, a
42
+ * bidirectional Sync Now would re-upload every file each tick.
43
+ *
44
+ * Default false to preserve `hq share <file>` semantics: when a user
45
+ * explicitly names a file, they expect it to be sent even if the local
46
+ * hash matches the last-sync state (e.g. to re-heal a bucket).
47
+ */
48
+ skipUnchanged?: boolean;
31
49
  }
32
50
 
33
51
  export interface ShareResult {
@@ -41,7 +59,8 @@ export interface ShareResult {
41
59
  * Share local file(s) to the entity vault.
42
60
  */
43
61
  export async function share(options: ShareOptions): Promise<ShareResult> {
44
- const { paths, company, message, onConflict, vaultConfig, hqRoot } = options;
62
+ const { paths, company, message, onConflict, vaultConfig, hqRoot, skipUnchanged } = options;
63
+ const emit = options.onEvent ?? defaultConsoleLogger;
45
64
 
46
65
  // Resolve company — slug, UID, or from active config
47
66
  const companyRef = company ?? resolveActiveCompany(hqRoot);
@@ -70,11 +89,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
70
89
 
71
90
  for (const { absolutePath, relativePath } of filesToShare) {
72
91
  if (!isWithinSizeLimit(absolutePath)) {
73
- console.error(` Skipped (too large): ${relativePath}`);
92
+ emit({
93
+ type: "error",
94
+ path: relativePath,
95
+ message: "file exceeds size limit",
96
+ });
74
97
  filesSkipped++;
75
98
  continue;
76
99
  }
77
100
 
101
+ // Skip-if-unchanged gate: the hot path for bidirectional Sync Now. When
102
+ // walking an entire company folder, this is what keeps us from re-uploading
103
+ // every file every tick. Off by default so `hq share <file>` keeps its
104
+ // explicit-intent semantics (user named it, user wants it sent).
105
+ const localHash = hashFile(absolutePath);
106
+ if (skipUnchanged) {
107
+ const existing = journal.files[relativePath];
108
+ if (existing && existing.hash === localHash) {
109
+ filesSkipped++;
110
+ continue;
111
+ }
112
+ }
113
+
78
114
  // Auto-refresh context if credentials expiring
79
115
  if (isExpiringSoon(ctx.expiresAt)) {
80
116
  ctx = await refreshEntityContext(companyRef, vaultConfig);
@@ -84,7 +120,6 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
84
120
  const remoteMeta = await headRemoteFile(ctx, relativePath);
85
121
  if (remoteMeta) {
86
122
  const journalEntry = journal.files[relativePath];
87
- const localHash = hashFile(absolutePath);
88
123
 
89
124
  // If remote has changed since our last sync, it's a conflict
90
125
  if (journalEntry && journalEntry.hash !== localHash) {
@@ -113,12 +148,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
113
148
  // Upload
114
149
  try {
115
150
  const stat = fs.statSync(absolutePath);
116
- const hash = hashFile(absolutePath);
117
151
 
118
152
  await uploadFile(ctx, absolutePath, relativePath);
119
153
 
120
154
  // Update journal with optional message
121
- updateEntry(journal, relativePath, hash, stat.size, "up");
155
+ updateEntry(journal, relativePath, localHash, stat.size, "up");
122
156
  if (message) {
123
157
  journal.files[relativePath] = {
124
158
  ...journal.files[relativePath],
@@ -128,11 +162,18 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
128
162
 
129
163
  filesUploaded++;
130
164
  bytesUploaded += stat.size;
131
- console.log(` ✓ ${relativePath}`);
165
+ emit({
166
+ type: "progress",
167
+ path: relativePath,
168
+ bytes: stat.size,
169
+ ...(message ? { message } : {}),
170
+ });
132
171
  } catch (err) {
133
- console.error(
134
- ` ✗ ${relativePath} — ${err instanceof Error ? err.message : err}`,
135
- );
172
+ emit({
173
+ type: "error",
174
+ path: relativePath,
175
+ message: err instanceof Error ? err.message : String(err),
176
+ });
136
177
  }
137
178
  }
138
179
 
@@ -141,6 +182,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
141
182
  return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
142
183
  }
143
184
 
185
+ /**
186
+ * Default human-readable share output. Preserves the exact format the CLI
187
+ * emitted before `onEvent` was added — tty users see no change.
188
+ */
189
+ function defaultConsoleLogger(event: SyncProgressEvent): void {
190
+ if (event.type === "progress") {
191
+ if (event.message) {
192
+ console.log(` ✓ ${event.path} — "${event.message}"`);
193
+ } else {
194
+ console.log(` ✓ ${event.path}`);
195
+ }
196
+ } else {
197
+ console.error(` ✗ ${event.path} — ${event.message}`);
198
+ }
199
+ }
200
+
144
201
  /**
145
202
  * Resolve active company from .hq/config.json or parent directory chain.
146
203
  */
@@ -146,7 +146,7 @@ describe("expiresAt shape round-trip", () => {
146
146
  const result = await refreshTokens(
147
147
  {
148
148
  region: "us-east-1",
149
- userPoolDomain: "hq-vault-dev",
149
+ userPoolDomain: "vault-indigo-hq-dev",
150
150
  clientId: "test-client",
151
151
  },
152
152
  "prior-refresh-token",
@@ -30,9 +30,9 @@ import open from "open";
30
30
  export interface CognitoAuthConfig {
31
31
  /** AWS region the User Pool lives in (e.g. "us-east-1"). */
32
32
  region: string;
33
- /** Cognito User Pool Domain prefix (e.g. "vault-indigo-stefanjohnson"). */
33
+ /** Cognito User Pool Domain prefix (e.g. "vault-indigo-hq-dev"). */
34
34
  userPoolDomain: string;
35
- /** App Client ID (e.g. "4mmujmjq3srakdueg656b9m0mp"). */
35
+ /** App Client ID (e.g. "7r7an9keh0u6hlsvepl74tvqb0"). */
36
36
  clientId: string;
37
37
  /** Loopback callback port. Defaults to 3000. */
38
38
  port?: number;
@@ -560,4 +560,89 @@ describe("VaultClient identity bootstrap", () => {
560
560
  const body = JSON.parse(init.body as string);
561
561
  expect(body.slug).toBe("user-12345678");
562
562
  });
563
+
564
+ it("listByType_roundtrips_createdAt", async () => {
565
+ fetchSpy.mockResolvedValueOnce(
566
+ jsonResponse(200, {
567
+ entities: [
568
+ {
569
+ uid: "prs_x",
570
+ slug: "alice",
571
+ type: "person",
572
+ status: "active",
573
+ createdAt: "2026-01-01T00:00:00Z",
574
+ },
575
+ ],
576
+ }),
577
+ );
578
+
579
+ const entities = await client.entity.listByType("person");
580
+ expect(entities).toHaveLength(1);
581
+ expect(entities[0].createdAt).toBe("2026-01-01T00:00:00Z");
582
+ });
583
+
584
+ it("ensureMyPersonEntity_picks_oldest_when_multiple", async () => {
585
+ fetchSpy.mockResolvedValueOnce(
586
+ jsonResponse(200, {
587
+ entities: [
588
+ { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-03-01T00:00:00Z" },
589
+ { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" },
590
+ { uid: "prs_c", slug: "c", type: "person", status: "active", createdAt: "2026-06-01T00:00:00Z" },
591
+ ],
592
+ }),
593
+ );
594
+
595
+ const person = await client.ensureMyPersonEntity({
596
+ ownerSub: "sub-multi",
597
+ displayName: "Multi User",
598
+ });
599
+
600
+ expect(person.uid).toBe("prs_a");
601
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
602
+ });
603
+
604
+ it("ensureMyPersonEntity_handles_missing_createdAt_deterministically", async () => {
605
+ fetchSpy.mockResolvedValueOnce(
606
+ jsonResponse(200, {
607
+ entities: [
608
+ { uid: "prs_z", slug: "z", type: "person", status: "active" },
609
+ { uid: "prs_a", slug: "a", type: "person", status: "active" },
610
+ ],
611
+ }),
612
+ );
613
+
614
+ const person = await client.ensureMyPersonEntity({
615
+ ownerSub: "sub-nodates",
616
+ displayName: "No Dates User",
617
+ });
618
+
619
+ // Both missing createdAt → "" tie, uid tiebreak selects prs_a
620
+ expect(person.uid).toBe("prs_a");
621
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
622
+ });
623
+
624
+ it("vendSelf_roundtrip", async () => {
625
+ fetchSpy.mockResolvedValueOnce(
626
+ jsonResponse(200, {
627
+ credentials: {
628
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
629
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
630
+ sessionToken: "FwoGZXIvYXdzEBY...",
631
+ },
632
+ expiresAt: "2026-01-01T01:00:00.000Z",
633
+ }),
634
+ );
635
+
636
+ const result = await client.sts.vendSelf({ personUid: "prs_x" });
637
+
638
+ expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
639
+ expect(result.credentials.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
640
+ expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
641
+ expect(typeof result.expiresAt).toBe("string");
642
+
643
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
644
+ expect(url).toBe("https://vault.test.example.com/sts/vend-self");
645
+ expect((init.method as string).toUpperCase()).toBe("POST");
646
+ expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
647
+ });
563
648
  });