@indigoai-us/hq-cloud 5.1.8 → 5.1.10
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 +26 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +77 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +165 -9
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.js +2 -2
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +23 -7
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +51 -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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +53 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +135 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- package/src/bin/sync-runner.test.ts +200 -13
- package/src/bin/sync-runner.ts +114 -5
- package/src/cli/accept.ts +2 -2
- package/src/cli/share.test.ts +59 -13
- package/src/cli/share.ts +25 -6
- 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/index.ts +1 -0
- package/src/vault-client.test.ts +173 -0
- package/src/vault-client.ts +78 -0
- package/test/invite-flow.integration.test.ts +1 -1
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/index.ts
CHANGED
package/src/vault-client.test.ts
CHANGED
|
@@ -387,4 +387,177 @@ describe("API surface", () => {
|
|
|
387
387
|
const memberships = await client.listMyMemberships();
|
|
388
388
|
expect(memberships).toEqual([]);
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
it("listMyPendingInvitesByEmail hits GET /membership/pending-by-email", async () => {
|
|
392
|
+
fetchSpy.mockResolvedValueOnce(
|
|
393
|
+
jsonResponse(200, {
|
|
394
|
+
invites: [
|
|
395
|
+
{
|
|
396
|
+
membershipKey: "email:stefan@getindigo.ai#cmp_abc",
|
|
397
|
+
companyUid: "cmp_abc",
|
|
398
|
+
role: "owner",
|
|
399
|
+
invitedBy: "sub-admin",
|
|
400
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const invites = await client.listMyPendingInvitesByEmail();
|
|
407
|
+
expect(invites).toHaveLength(1);
|
|
408
|
+
expect(invites[0].companyUid).toBe("cmp_abc");
|
|
409
|
+
|
|
410
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
411
|
+
expect(url).toBe(
|
|
412
|
+
"https://vault.test.example.com/membership/pending-by-email",
|
|
413
|
+
);
|
|
414
|
+
expect(init.method).toBe("GET");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("listMyPendingInvitesByEmail returns [] when server omits the key", async () => {
|
|
418
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
419
|
+
const invites = await client.listMyPendingInvitesByEmail();
|
|
420
|
+
expect(invites).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("claimPendingInvitesByEmail POSTs personUid to /membership/claim-by-email", async () => {
|
|
424
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
425
|
+
|
|
426
|
+
await client.claimPendingInvitesByEmail("ent_person_stefan");
|
|
427
|
+
|
|
428
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
429
|
+
expect(url).toBe(
|
|
430
|
+
"https://vault.test.example.com/membership/claim-by-email",
|
|
431
|
+
);
|
|
432
|
+
expect(init.method).toBe("POST");
|
|
433
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
434
|
+
personUid: "ent_person_stefan",
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe("VaultClient identity bootstrap", () => {
|
|
440
|
+
let client: VaultClient;
|
|
441
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
442
|
+
|
|
443
|
+
beforeEach(() => {
|
|
444
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
445
|
+
fetchSpy.mockResolvedValue(jsonResponse(200, {}));
|
|
446
|
+
client = new VaultClient({
|
|
447
|
+
apiUrl: "https://vault.test.example.com",
|
|
448
|
+
authToken: "test-token",
|
|
449
|
+
region: "us-east-1",
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
afterEach(() => {
|
|
454
|
+
fetchSpy.mockRestore();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("entity.listByType GETs /entity/by-type/{type}", async () => {
|
|
458
|
+
fetchSpy.mockResolvedValueOnce(
|
|
459
|
+
jsonResponse(200, {
|
|
460
|
+
entities: [
|
|
461
|
+
{
|
|
462
|
+
uid: "ent_person_stefan",
|
|
463
|
+
slug: "stefan-johnson",
|
|
464
|
+
type: "person",
|
|
465
|
+
status: "active",
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const entities = await client.entity.listByType("person");
|
|
472
|
+
expect(entities).toHaveLength(1);
|
|
473
|
+
const [url] = fetchSpy.mock.calls[0] as [string];
|
|
474
|
+
expect(url).toBe(
|
|
475
|
+
"https://vault.test.example.com/entity/by-type/person",
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("entity.listByType returns [] when server omits the key", async () => {
|
|
480
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
481
|
+
const entities = await client.entity.listByType("person");
|
|
482
|
+
expect(entities).toEqual([]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("ensureMyPersonEntity short-circuits when a person entity already exists", async () => {
|
|
486
|
+
fetchSpy.mockResolvedValueOnce(
|
|
487
|
+
jsonResponse(200, {
|
|
488
|
+
entities: [
|
|
489
|
+
{
|
|
490
|
+
uid: "ent_person_existing",
|
|
491
|
+
slug: "already-there",
|
|
492
|
+
type: "person",
|
|
493
|
+
status: "active",
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const person = await client.ensureMyPersonEntity({
|
|
500
|
+
ownerSub: "sub-abc",
|
|
501
|
+
displayName: "Stefan Johnson",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(person.uid).toBe("ent_person_existing");
|
|
505
|
+
// Only one HTTP call — list. No POST /entity.
|
|
506
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("ensureMyPersonEntity POSTs /entity with a slug derived from displayName when none exist", async () => {
|
|
510
|
+
fetchSpy
|
|
511
|
+
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
512
|
+
.mockResolvedValueOnce(
|
|
513
|
+
jsonResponse(200, {
|
|
514
|
+
entity: {
|
|
515
|
+
uid: "ent_person_new",
|
|
516
|
+
slug: "stefan-johnson",
|
|
517
|
+
type: "person",
|
|
518
|
+
status: "active",
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const person = await client.ensureMyPersonEntity({
|
|
524
|
+
ownerSub: "sub-abc",
|
|
525
|
+
displayName: "Stefan Johnson",
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(person.uid).toBe("ent_person_new");
|
|
529
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
530
|
+
const [url, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
531
|
+
expect(url).toBe("https://vault.test.example.com/entity");
|
|
532
|
+
expect(init.method).toBe("POST");
|
|
533
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
534
|
+
type: "person",
|
|
535
|
+
name: "Stefan Johnson",
|
|
536
|
+
slug: "stefan-johnson",
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("ensureMyPersonEntity falls back to user-<sub-suffix> when displayName slugifies to empty", async () => {
|
|
541
|
+
fetchSpy
|
|
542
|
+
.mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
|
|
543
|
+
.mockResolvedValueOnce(
|
|
544
|
+
jsonResponse(200, {
|
|
545
|
+
entity: {
|
|
546
|
+
uid: "ent_person_new",
|
|
547
|
+
slug: "user-12345678",
|
|
548
|
+
type: "person",
|
|
549
|
+
status: "active",
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
await client.ensureMyPersonEntity({
|
|
555
|
+
ownerSub: "sub-abcdef12345678",
|
|
556
|
+
displayName: "!!!",
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const [, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
|
|
560
|
+
const body = JSON.parse(init.body as string);
|
|
561
|
+
expect(body.slug).toBe("user-12345678");
|
|
562
|
+
});
|
|
390
563
|
});
|
package/src/vault-client.ts
CHANGED
|
@@ -105,10 +105,21 @@ export interface EntityInfo {
|
|
|
105
105
|
uid: string;
|
|
106
106
|
slug: string;
|
|
107
107
|
type: string;
|
|
108
|
+
/** Human-readable display name — surfaced in UIs that list companies. */
|
|
109
|
+
name?: string;
|
|
108
110
|
bucketName?: string;
|
|
109
111
|
status: string;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
export interface PendingInviteByEmail {
|
|
115
|
+
membershipKey: string;
|
|
116
|
+
companyUid: string;
|
|
117
|
+
role: MembershipRole;
|
|
118
|
+
inviteToken?: string;
|
|
119
|
+
invitedBy: string;
|
|
120
|
+
invitedAt: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
export interface CreateEntityInput {
|
|
113
124
|
type: "person" | "company";
|
|
114
125
|
slug: string;
|
|
@@ -238,6 +249,31 @@ export class VaultClient {
|
|
|
238
249
|
return data.memberships;
|
|
239
250
|
}
|
|
240
251
|
|
|
252
|
+
/**
|
|
253
|
+
* List the caller's email-keyed pending invites. Server reads the email
|
|
254
|
+
* from the Cognito JWT, so no parameters are needed client-side.
|
|
255
|
+
*
|
|
256
|
+
* Used on first sign-in (installer + sync-runner) to detect invites that
|
|
257
|
+
* were sent to the caller's email before they had a person entity. Pair
|
|
258
|
+
* with {@link claimPendingInvitesByEmail} to rewrite those rows once the
|
|
259
|
+
* person exists.
|
|
260
|
+
*/
|
|
261
|
+
async listMyPendingInvitesByEmail(): Promise<PendingInviteByEmail[]> {
|
|
262
|
+
const data = await this.get<{ invites: PendingInviteByEmail[] }>(
|
|
263
|
+
"/membership/pending-by-email",
|
|
264
|
+
);
|
|
265
|
+
return data.invites ?? [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Rewrite every email-keyed pending invite for the caller's email so it
|
|
270
|
+
* becomes personUid-keyed. Idempotent — zero-cost for returning users who
|
|
271
|
+
* have no pending invites. The caller's email is inferred from the JWT.
|
|
272
|
+
*/
|
|
273
|
+
async claimPendingInvitesByEmail(personUid: string): Promise<void> {
|
|
274
|
+
await this.post("/membership/claim-by-email", { personUid });
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
async listMembersOfCompany(companyUid: string): Promise<Membership[]> {
|
|
242
278
|
const data = await this.get<{ members: Membership[] }>(
|
|
243
279
|
`/membership/company/${encodeURIComponent(companyUid)}`,
|
|
@@ -279,8 +315,50 @@ export class VaultClient {
|
|
|
279
315
|
const data = await this.post<CreateEntityResult>("/entity", input);
|
|
280
316
|
return data.entity;
|
|
281
317
|
},
|
|
318
|
+
|
|
319
|
+
/** Return every entity of `type` owned by the caller (scoped by JWT). */
|
|
320
|
+
listByType: async (type: string): Promise<EntityInfo[]> => {
|
|
321
|
+
const data = await this.get<{ entities: EntityInfo[] }>(
|
|
322
|
+
`/entity/by-type/${encodeURIComponent(type)}`,
|
|
323
|
+
);
|
|
324
|
+
return data.entities ?? [];
|
|
325
|
+
},
|
|
282
326
|
};
|
|
283
327
|
|
|
328
|
+
// -- Identity bootstrap ---------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Return the caller's person entity, creating it if one does not exist.
|
|
332
|
+
*
|
|
333
|
+
* Mirrors the installer's `ensurePersonEntity` bootstrap (`vault-handoff.ts`):
|
|
334
|
+
* pre-condition for {@link claimPendingInvitesByEmail}, which needs a
|
|
335
|
+
* concrete `personUid` to rewrite the email-keyed rows against.
|
|
336
|
+
*
|
|
337
|
+
* The slug is derived from `displayName`; if slugification yields an empty
|
|
338
|
+
* string, falls back to `user-<last-8-of-ownerSub>` so the POST always has
|
|
339
|
+
* a non-empty slug.
|
|
340
|
+
*/
|
|
341
|
+
async ensureMyPersonEntity(hints: {
|
|
342
|
+
ownerSub: string;
|
|
343
|
+
displayName: string;
|
|
344
|
+
}): Promise<EntityInfo> {
|
|
345
|
+
const existing = await this.entity.listByType("person");
|
|
346
|
+
if (existing.length > 0) return existing[0];
|
|
347
|
+
|
|
348
|
+
const slug =
|
|
349
|
+
hints.displayName
|
|
350
|
+
.toLowerCase()
|
|
351
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
352
|
+
.replace(/^-+|-+$/g, "")
|
|
353
|
+
.slice(0, 63) || `user-${hints.ownerSub.slice(-8).toLowerCase()}`;
|
|
354
|
+
|
|
355
|
+
return this.entity.create({
|
|
356
|
+
type: "person",
|
|
357
|
+
name: hints.displayName,
|
|
358
|
+
slug,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
284
362
|
// -- Provisioning operations (VLT-2) -----------------------------------------
|
|
285
363
|
|
|
286
364
|
async provisionBucket(companyUid: string): Promise<{ bucketName: string; kmsKeyId: string }> {
|
|
@@ -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", () => {
|