@indigoai-us/hq-cloud 5.1.9 → 5.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/auth.js +2 -2
  2. package/dist/auth.js.map +1 -1
  3. package/dist/bin/sync-runner.d.ts +28 -0
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +101 -34
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +194 -1
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/accept.js +2 -2
  10. package/dist/cli/accept.js.map +1 -1
  11. package/dist/cli/share.d.ts +18 -0
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +71 -14
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +167 -13
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts.map +1 -1
  18. package/dist/cli/sync.js +6 -1
  19. package/dist/cli/sync.js.map +1 -1
  20. package/dist/cli/sync.test.js +31 -12
  21. package/dist/cli/sync.test.js.map +1 -1
  22. package/dist/cognito-auth.d.ts +13 -2
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +18 -9
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.d.ts +3 -3
  27. package/dist/cognito-auth.test.js +21 -10
  28. package/dist/cognito-auth.test.js.map +1 -1
  29. package/dist/vault-client.d.ts +1 -0
  30. package/dist/vault-client.d.ts.map +1 -1
  31. package/dist/vault-client.js +9 -2
  32. package/dist/vault-client.js.map +1 -1
  33. package/dist/vault-client.test.js +46 -0
  34. package/dist/vault-client.test.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/auth.ts +2 -2
  37. package/src/bin/sync-runner.test.ts +256 -1
  38. package/src/bin/sync-runner.ts +138 -34
  39. package/src/cli/accept.ts +2 -2
  40. package/src/cli/share.test.ts +201 -13
  41. package/src/cli/share.ts +91 -15
  42. package/src/cli/sync.test.ts +33 -12
  43. package/src/cli/sync.ts +6 -1
  44. package/src/cognito-auth.test.ts +22 -14
  45. package/src/cognito-auth.ts +31 -11
  46. package/src/vault-client.test.ts +60 -0
  47. package/src/vault-client.ts +8 -1
  48. package/test/invite-flow.integration.test.ts +1 -1
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);
@@ -54,6 +73,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
54
73
 
55
74
  // Resolve entity context (handles STS vending + caching)
56
75
  let ctx = await resolveEntityContext(companyRef, vaultConfig);
76
+ // Remote keys are company-relative; the on-disk scoping prefix is
77
+ // companies/{slug}/. Anything outside this folder gets skipped to avoid
78
+ // leaking cross-company state into the vault.
79
+ const syncRoot = path.join(hqRoot, "companies", ctx.slug);
57
80
  const shouldSync = createIgnoreFilter(hqRoot);
58
81
  const journal = readJournal(ctx.slug);
59
82
 
@@ -62,15 +85,32 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
62
85
  let filesSkipped = 0;
63
86
 
64
87
  // Collect all files to share
65
- const filesToShare = collectFiles(paths, hqRoot, shouldSync);
88
+ const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
66
89
 
67
90
  for (const { absolutePath, relativePath } of filesToShare) {
68
91
  if (!isWithinSizeLimit(absolutePath)) {
69
- console.error(` Skipped (too large): ${relativePath}`);
92
+ emit({
93
+ type: "error",
94
+ path: relativePath,
95
+ message: "file exceeds size limit",
96
+ });
70
97
  filesSkipped++;
71
98
  continue;
72
99
  }
73
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
+
74
114
  // Auto-refresh context if credentials expiring
75
115
  if (isExpiringSoon(ctx.expiresAt)) {
76
116
  ctx = await refreshEntityContext(companyRef, vaultConfig);
@@ -80,7 +120,6 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
80
120
  const remoteMeta = await headRemoteFile(ctx, relativePath);
81
121
  if (remoteMeta) {
82
122
  const journalEntry = journal.files[relativePath];
83
- const localHash = hashFile(absolutePath);
84
123
 
85
124
  // If remote has changed since our last sync, it's a conflict
86
125
  if (journalEntry && journalEntry.hash !== localHash) {
@@ -109,12 +148,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
109
148
  // Upload
110
149
  try {
111
150
  const stat = fs.statSync(absolutePath);
112
- const hash = hashFile(absolutePath);
113
151
 
114
152
  await uploadFile(ctx, absolutePath, relativePath);
115
153
 
116
154
  // Update journal with optional message
117
- updateEntry(journal, relativePath, hash, stat.size, "up");
155
+ updateEntry(journal, relativePath, localHash, stat.size, "up");
118
156
  if (message) {
119
157
  journal.files[relativePath] = {
120
158
  ...journal.files[relativePath],
@@ -124,11 +162,18 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
124
162
 
125
163
  filesUploaded++;
126
164
  bytesUploaded += stat.size;
127
- console.log(` ✓ ${relativePath}`);
165
+ emit({
166
+ type: "progress",
167
+ path: relativePath,
168
+ bytes: stat.size,
169
+ ...(message ? { message } : {}),
170
+ });
128
171
  } catch (err) {
129
- console.error(
130
- ` ✗ ${relativePath} — ${err instanceof Error ? err.message : err}`,
131
- );
172
+ emit({
173
+ type: "error",
174
+ path: relativePath,
175
+ message: err instanceof Error ? err.message : String(err),
176
+ });
132
177
  }
133
178
  }
134
179
 
@@ -137,6 +182,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
137
182
  return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
138
183
  }
139
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
+
140
201
  /**
141
202
  * Resolve active company from .hq/config.json or parent directory chain.
142
203
  */
@@ -155,10 +216,15 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
155
216
 
156
217
  /**
157
218
  * Collect files from paths (expanding directories recursively).
219
+ *
220
+ * Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not
221
+ * `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing
222
+ * anything outside a company's folder would leak state into the wrong vault.
158
223
  */
159
224
  function collectFiles(
160
225
  paths: string[],
161
226
  hqRoot: string,
227
+ syncRoot: string,
162
228
  filter: (p: string) => boolean,
163
229
  ): { absolutePath: string; relativePath: string }[] {
164
230
  const results: { absolutePath: string; relativePath: string }[] = [];
@@ -171,11 +237,16 @@ function collectFiles(
171
237
  continue;
172
238
  }
173
239
 
240
+ if (!isWithin(syncRoot, absolutePath)) {
241
+ console.error(` Warning: ${p} is outside company folder, skipping.`);
242
+ continue;
243
+ }
244
+
174
245
  const stat = fs.statSync(absolutePath);
175
246
  if (stat.isDirectory()) {
176
- results.push(...walkDir(absolutePath, hqRoot, filter));
247
+ results.push(...walkDir(absolutePath, syncRoot, filter));
177
248
  } else if (stat.isFile()) {
178
- const relativePath = path.relative(hqRoot, absolutePath);
249
+ const relativePath = path.relative(syncRoot, absolutePath);
179
250
  if (filter(absolutePath)) {
180
251
  results.push({ absolutePath, relativePath });
181
252
  }
@@ -187,7 +258,7 @@ function collectFiles(
187
258
 
188
259
  function walkDir(
189
260
  dir: string,
190
- root: string,
261
+ syncRoot: string,
191
262
  filter: (p: string) => boolean,
192
263
  ): { absolutePath: string; relativePath: string }[] {
193
264
  const results: { absolutePath: string; relativePath: string }[] = [];
@@ -199,14 +270,19 @@ function walkDir(
199
270
  if (!filter(absolutePath)) continue;
200
271
 
201
272
  if (entry.isDirectory()) {
202
- results.push(...walkDir(absolutePath, root, filter));
273
+ results.push(...walkDir(absolutePath, syncRoot, filter));
203
274
  } else if (entry.isFile()) {
204
275
  results.push({
205
276
  absolutePath,
206
- relativePath: path.relative(root, absolutePath),
277
+ relativePath: path.relative(syncRoot, absolutePath),
207
278
  });
208
279
  }
209
280
  }
210
281
 
211
282
  return results;
212
283
  }
284
+
285
+ function isWithin(parent: string, child: string): boolean {
286
+ const rel = path.relative(parent, child);
287
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
288
+ }
@@ -61,7 +61,7 @@ const mockVendResponse = {
61
61
  function setupFetchMock() {
62
62
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
63
63
  const urlStr = String(url);
64
- if (urlStr.includes("/entity/by-slug/")) {
64
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
65
65
  return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
66
66
  }
67
67
  if (urlStr.includes("/sts/vend")) {
@@ -98,7 +98,7 @@ describe("sync", () => {
98
98
  delete process.env.HQ_STATE_DIR;
99
99
  });
100
100
 
101
- it("downloads remote files that don't exist locally", async () => {
101
+ it("downloads remote files under companies/{slug}/ so two companies don't collide", async () => {
102
102
  const result = await sync({
103
103
  company: "acme",
104
104
  vaultConfig: mockConfig,
@@ -107,8 +107,26 @@ describe("sync", () => {
107
107
 
108
108
  expect(result.filesDownloaded).toBe(2);
109
109
  expect(result.aborted).toBe(false);
110
- expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(true);
111
- expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(true);
110
+ // Scoped under companies/{slug}/
111
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "handoff.md"))).toBe(true);
112
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "knowledge", "readme.md"))).toBe(true);
113
+ // NOT at hqRoot (pre-fix behavior would have written here and clobbered across companies)
114
+ expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(false);
115
+ expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(false);
116
+ });
117
+
118
+ it("scopes by resolved ctx.slug even when caller passes a UID", async () => {
119
+ // mockEntity.slug is "acme" regardless of the ref used; verify resolved
120
+ // slug drives the local path, not the caller's ref.
121
+ const result = await sync({
122
+ company: "cmp_01ABCDEF",
123
+ vaultConfig: mockConfig,
124
+ hqRoot: tmpDir,
125
+ });
126
+
127
+ expect(result.filesDownloaded).toBe(2);
128
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "handoff.md"))).toBe(true);
129
+ expect(fs.existsSync(path.join(tmpDir, "companies", "cmp_01ABCDEF", "docs", "handoff.md"))).toBe(false);
112
130
  });
113
131
 
114
132
  it("throws when no company specified and no active company", async () => {
@@ -129,8 +147,9 @@ describe("sync", () => {
129
147
  });
130
148
 
131
149
  it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
132
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
133
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
150
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
151
+ fs.mkdirSync(companyDocs, { recursive: true });
152
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
134
153
 
135
154
  fs.writeFileSync(
136
155
  journalPath,
@@ -157,12 +176,13 @@ describe("sync", () => {
157
176
 
158
177
  expect(result.conflicts).toBe(1);
159
178
  expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
160
- expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("local version");
179
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
161
180
  });
162
181
 
163
182
  it("aborts on --on-conflict abort", async () => {
164
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
165
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
183
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
184
+ fs.mkdirSync(companyDocs, { recursive: true });
185
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
166
186
 
167
187
  fs.writeFileSync(
168
188
  journalPath,
@@ -191,8 +211,9 @@ describe("sync", () => {
191
211
  });
192
212
 
193
213
  it("overwrites local on --on-conflict overwrite", async () => {
194
- fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
195
- fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
214
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
215
+ fs.mkdirSync(companyDocs, { recursive: true });
216
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
196
217
 
197
218
  fs.writeFileSync(
198
219
  journalPath,
@@ -220,6 +241,6 @@ describe("sync", () => {
220
241
  expect(result.conflicts).toBe(1);
221
242
  expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
222
243
  // File should be overwritten with mock content
223
- expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("mock file content");
244
+ expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("mock file content");
224
245
  });
225
246
  });
package/src/cli/sync.ts CHANGED
@@ -74,6 +74,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
74
74
 
75
75
  // Resolve entity context
76
76
  let ctx = await resolveEntityContext(companyRef, vaultConfig);
77
+ // Every company's files land under companies/{slug}/ so fanning out multiple
78
+ // companies into the same hqRoot doesn't cross-clobber files with overlapping
79
+ // S3 keys (e.g. every company has a .hq/manifest.json). Remote keys stay
80
+ // company-relative; the prefix lives only on disk.
81
+ const companyRoot = path.join(hqRoot, "companies", ctx.slug);
77
82
  const shouldSync = createIgnoreFilter(hqRoot);
78
83
  const journal = readJournal(ctx.slug);
79
84
 
@@ -86,7 +91,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
86
91
  const remoteFiles = await listRemoteFiles(ctx);
87
92
 
88
93
  for (const remoteFile of remoteFiles) {
89
- const localPath = path.join(hqRoot, remoteFile.key);
94
+ const localPath = path.join(companyRoot, remoteFile.key);
90
95
 
91
96
  // Apply ignore rules
92
97
  if (!shouldSync(localPath)) {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Unit tests for cognito-auth.ts — focus on the `expiresAt` shape contract.
3
3
  *
4
- * Canonical on-disk shape is ISO 8601 (what both writers emit). The reader
5
- * also tolerates a raw number (ms since epoch) for forward/backward compat
6
- * during rollouts, and fails safe on anything unparseable.
4
+ * Canonical on-disk shape is epoch milliseconds (number). The reader also
5
+ * tolerates ISO 8601 strings for backward compatibility with pre-migration
6
+ * token files, and fails safe on anything unparseable.
7
7
  */
8
8
 
9
9
  import * as fs from "fs";
@@ -100,11 +100,21 @@ describe("isExpiring — expiresAt shape tolerance", () => {
100
100
  });
101
101
 
102
102
  // ---------------------------------------------------------------------------
103
- // Round-trip: writers emit ISO, readers read ISO
103
+ // Round-trip: writers emit epoch-ms, readers read epoch-ms
104
104
  // ---------------------------------------------------------------------------
105
105
 
106
106
  describe("expiresAt shape round-trip", () => {
107
- it("saveCachedTokens + loadCachedTokens preserves ISO string shape", async () => {
107
+ it("saveCachedTokens + loadCachedTokens preserves epoch-ms number shape", async () => {
108
+ const { saveCachedTokens, loadCachedTokens } = await importModule();
109
+ const epochMs = Date.now() + 3600 * 1000;
110
+ saveCachedTokens({ ...baseTokens, expiresAt: epochMs });
111
+ const loaded = loadCachedTokens();
112
+ expect(loaded).not.toBeNull();
113
+ expect(typeof loaded?.expiresAt).toBe("number");
114
+ expect(loaded?.expiresAt).toBe(epochMs);
115
+ });
116
+
117
+ it("saveCachedTokens + loadCachedTokens tolerates legacy ISO string", async () => {
108
118
  const { saveCachedTokens, loadCachedTokens } = await importModule();
109
119
  const iso = new Date(Date.now() + 3600 * 1000).toISOString();
110
120
  saveCachedTokens({ ...baseTokens, expiresAt: iso });
@@ -112,12 +122,9 @@ describe("expiresAt shape round-trip", () => {
112
122
  expect(loaded).not.toBeNull();
113
123
  expect(typeof loaded?.expiresAt).toBe("string");
114
124
  expect(loaded?.expiresAt).toBe(iso);
115
- expect(loaded?.expiresAt).toMatch(
116
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
117
- );
118
125
  });
119
126
 
120
- it("refreshTokens writes an ISO string to cache", async () => {
127
+ it("refreshTokens writes epoch milliseconds to cache", async () => {
121
128
  vi.stubGlobal(
122
129
  "fetch",
123
130
  vi.fn(async () =>
@@ -135,6 +142,7 @@ describe("expiresAt shape round-trip", () => {
135
142
  );
136
143
 
137
144
  const { refreshTokens, loadCachedTokens } = await importModule();
145
+ const before = Date.now();
138
146
  const result = await refreshTokens(
139
147
  {
140
148
  region: "us-east-1",
@@ -143,14 +151,14 @@ describe("expiresAt shape round-trip", () => {
143
151
  },
144
152
  "prior-refresh-token",
145
153
  );
154
+ const after = Date.now();
146
155
 
147
- expect(typeof result.expiresAt).toBe("string");
148
- expect(result.expiresAt).toMatch(
149
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
150
- );
156
+ expect(typeof result.expiresAt).toBe("number");
157
+ expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
158
+ expect(result.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000);
151
159
 
152
160
  const onDisk = loadCachedTokens();
153
161
  expect(onDisk?.expiresAt).toBe(result.expiresAt);
154
- expect(typeof onDisk?.expiresAt).toBe("string");
162
+ expect(typeof onDisk?.expiresAt).toBe("number");
155
163
  });
156
164
  });
@@ -38,14 +38,25 @@ export interface CognitoAuthConfig {
38
38
  port?: number;
39
39
  /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
40
40
  scopes?: string[];
41
+ /**
42
+ * Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker
43
+ * is bypassed and Cognito redirects straight to the provider. When omitted,
44
+ * Cognito shows its default picker.
45
+ */
46
+ identityProvider?: string;
47
+ /**
48
+ * OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP
49
+ * honors it — Google uses it to force account re-selection.
50
+ */
51
+ prompt?: string;
41
52
  }
42
53
 
43
54
  export interface CognitoTokens {
44
55
  accessToken: string;
45
56
  idToken: string;
46
57
  refreshToken: string;
47
- /** ISO 8601 timestamp when the access token expires. */
48
- expiresAt: string;
58
+ /** Epoch milliseconds when the access token expires. Writers MUST emit a number. Readers accept ISO 8601 strings for backward compatibility with pre-migration token files. */
59
+ expiresAt: string | number;
49
60
  tokenType: "Bearer";
50
61
  }
51
62
 
@@ -78,7 +89,9 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
78
89
  if (!fs.existsSync(HQ_DIR)) {
79
90
  fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
80
91
  }
81
- fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
92
+ const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
93
+ fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
94
+ fs.renameSync(tmpPath, TOKEN_FILE);
82
95
  }
83
96
 
84
97
  export function clearCachedTokens(): void {
@@ -86,11 +99,10 @@ export function clearCachedTokens(): void {
86
99
  }
87
100
 
88
101
  /**
89
- * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is ISO 8601 (what
90
- * both writers in this file emit), but older/external writers may have left a
91
- * raw number. Accept both so a shape mismatch during rollout doesn't wedge
92
- * sign-in. Returns null for anything unparseable callers should treat that
93
- * as "expired" and force a refresh.
102
+ * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is epoch milliseconds
103
+ * (number). Older token files may contain ISO 8601 strings. Accept both for
104
+ * migration safety. Returns null for anything unparseable callers should
105
+ * treat that as "expired" and force a refresh.
94
106
  */
95
107
  function parseExpiresAtMs(raw: unknown): number | null {
96
108
  if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
@@ -158,7 +170,9 @@ export async function browserLogin(
158
170
  const { verifier, challenge } = generatePkce();
159
171
  const state = base64UrlEncode(crypto.randomBytes(16));
160
172
 
161
- const authUrl = new URL(`${authBaseUrl(config)}/login`);
173
+ // Use `/oauth2/authorize` (not `/login`) so `identity_provider` + `prompt`
174
+ // are honored. `/login` ignores those params and always shows the IdP picker.
175
+ const authUrl = new URL(`${authBaseUrl(config)}/oauth2/authorize`);
162
176
  authUrl.searchParams.set("client_id", config.clientId);
163
177
  authUrl.searchParams.set("response_type", "code");
164
178
  authUrl.searchParams.set("scope", scopes);
@@ -166,6 +180,12 @@ export async function browserLogin(
166
180
  authUrl.searchParams.set("code_challenge", challenge);
167
181
  authUrl.searchParams.set("code_challenge_method", "S256");
168
182
  authUrl.searchParams.set("state", state);
183
+ if (config.identityProvider) {
184
+ authUrl.searchParams.set("identity_provider", config.identityProvider);
185
+ }
186
+ if (config.prompt) {
187
+ authUrl.searchParams.set("prompt", config.prompt);
188
+ }
169
189
 
170
190
  const code = await waitForAuthCode(port, state);
171
191
  const tokens = await exchangeCodeForTokens(config, code, verifier, port);
@@ -300,7 +320,7 @@ async function exchangeCodeForTokens(
300
320
  accessToken: data.access_token,
301
321
  idToken: data.id_token,
302
322
  refreshToken: data.refresh_token,
303
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
323
+ expiresAt: Date.now() + data.expires_in * 1000,
304
324
  tokenType: "Bearer",
305
325
  };
306
326
  }
@@ -336,7 +356,7 @@ export async function refreshTokens(
336
356
  accessToken: data.access_token,
337
357
  idToken: data.id_token,
338
358
  refreshToken: data.refresh_token ?? currentRefreshToken,
339
- expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
359
+ expiresAt: Date.now() + data.expires_in * 1000,
340
360
  tokenType: "Bearer",
341
361
  };
342
362
  saveCachedTokens(tokens);
@@ -560,4 +560,64 @@ 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
+ });
563
623
  });
@@ -109,6 +109,7 @@ export interface EntityInfo {
109
109
  name?: string;
110
110
  bucketName?: string;
111
111
  status: string;
112
+ createdAt: string;
112
113
  }
113
114
 
114
115
  export interface PendingInviteByEmail {
@@ -343,7 +344,13 @@ export class VaultClient {
343
344
  displayName: string;
344
345
  }): Promise<EntityInfo> {
345
346
  const existing = await this.entity.listByType("person");
346
- if (existing.length > 0) return existing[0];
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];
347
354
 
348
355
  const slug =
349
356
  hints.displayName
@@ -57,7 +57,7 @@ describe("parseToken", () => {
57
57
  });
58
58
 
59
59
  it("extracts token from https:// URL", () => {
60
- expect(parseToken("https://hq.indigoai.com/accept/tok_xyz")).toBe("tok_xyz");
60
+ expect(parseToken("https://example.com/accept/tok_xyz")).toBe("tok_xyz");
61
61
  });
62
62
 
63
63
  it("returns raw token unchanged", () => {