@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.
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +28 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +101 -34
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +194 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.js +2 -2
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/share.d.ts +18 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +71 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +167 -13
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +6 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +31 -12
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +13 -2
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +18 -9
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +3 -3
- package/dist/cognito-auth.test.js +21 -10
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +1 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +9 -2
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +46 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- package/src/bin/sync-runner.test.ts +256 -1
- package/src/bin/sync-runner.ts +138 -34
- package/src/cli/accept.ts +2 -2
- package/src/cli/share.test.ts +201 -13
- package/src/cli/share.ts +91 -15
- package/src/cli/sync.test.ts +33 -12
- package/src/cli/sync.ts +6 -1
- package/src/cognito-auth.test.ts +22 -14
- package/src/cognito-auth.ts +31 -11
- package/src/vault-client.test.ts +60 -0
- package/src/vault-client.ts +8 -1
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
165
|
+
emit({
|
|
166
|
+
type: "progress",
|
|
167
|
+
path: relativePath,
|
|
168
|
+
bytes: stat.size,
|
|
169
|
+
...(message ? { message } : {}),
|
|
170
|
+
});
|
|
128
171
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
|
|
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,
|
|
247
|
+
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
177
248
|
} else if (stat.isFile()) {
|
|
178
|
-
const relativePath = path.relative(
|
|
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
|
-
|
|
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,
|
|
273
|
+
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
203
274
|
} else if (entry.isFile()) {
|
|
204
275
|
results.push({
|
|
205
276
|
absolutePath,
|
|
206
|
-
relativePath: path.relative(
|
|
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
|
+
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
111
|
-
expect(fs.existsSync(path.join(tmpDir, "
|
|
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
|
-
|
|
133
|
-
fs.
|
|
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(
|
|
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
|
-
|
|
165
|
-
fs.
|
|
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
|
-
|
|
195
|
-
fs.
|
|
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(
|
|
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(
|
|
94
|
+
const localPath = path.join(companyRoot, remoteFile.key);
|
|
90
95
|
|
|
91
96
|
// Apply ignore rules
|
|
92
97
|
if (!shouldSync(localPath)) {
|
package/src/cognito-auth.test.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
148
|
-
expect(result.expiresAt).
|
|
149
|
-
|
|
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("
|
|
162
|
+
expect(typeof onDisk?.expiresAt).toBe("number");
|
|
155
163
|
});
|
|
156
164
|
});
|
package/src/cognito-auth.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
359
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
340
360
|
tokenType: "Bearer",
|
|
341
361
|
};
|
|
342
362
|
saveCachedTokens(tokens);
|
package/src/vault-client.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/vault-client.ts
CHANGED
|
@@ -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
|
-
|
|
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://
|
|
60
|
+
expect(parseToken("https://example.com/accept/tok_xyz")).toBe("tok_xyz");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
it("returns raw token unchanged", () => {
|