@indigoai-us/hq-cli 5.10.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +42 -3
- package/dist/commands/members.d.ts +53 -0
- package/dist/commands/members.js +240 -0
- package/dist/commands/secrets.d.ts +2 -2
- package/dist/commands/secrets.js +33 -25
- package/dist/index.js +5 -2
- package/dist/utils/vault-api.d.ts +5 -0
- package/dist/utils/vault-api.js +35 -2
- package/package.json +1 -1
- package/src/commands/cloud.ts +40 -0
- package/src/commands/members.test.ts +304 -0
- package/src/commands/members.ts +367 -0
- package/src/commands/secrets.ts +86 -23
- package/src/index.ts +4 -0
- package/src/utils/vault-api.test.ts +111 -0
- package/src/utils/vault-api.ts +43 -0
package/src/commands/cloud.ts
CHANGED
|
@@ -23,9 +23,11 @@ import {
|
|
|
23
23
|
sync,
|
|
24
24
|
readJournal,
|
|
25
25
|
getJournalPath,
|
|
26
|
+
loadCachedTokens,
|
|
26
27
|
type ConflictStrategy,
|
|
27
28
|
type EntityContext,
|
|
28
29
|
type SyncProgressEvent,
|
|
30
|
+
type UploadAuthor,
|
|
29
31
|
} from "@indigoai-us/hq-cloud";
|
|
30
32
|
|
|
31
33
|
import {
|
|
@@ -146,6 +148,14 @@ export function registerCloudCommands(program: Command): void {
|
|
|
146
148
|
emitJson(event as unknown as Record<string, unknown>)
|
|
147
149
|
: undefined;
|
|
148
150
|
|
|
151
|
+
// Stamp every uploaded object's S3 user metadata with the syncing
|
|
152
|
+
// user's Cognito identity (`Metadata['created-by']`). The hq-console
|
|
153
|
+
// vault UI's CREATED BY column reads this back via HEAD; without it,
|
|
154
|
+
// every row renders `—`. Resolved best-effort from the cached
|
|
155
|
+
// idToken — pre-vended `--creds-from-stdin` paths still get author
|
|
156
|
+
// attribution as long as the caller is logged in locally.
|
|
157
|
+
const author = resolveUploadAuthorFromCache();
|
|
158
|
+
|
|
149
159
|
const result = await share({
|
|
150
160
|
paths: targetPaths,
|
|
151
161
|
company: options.company,
|
|
@@ -155,6 +165,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
155
165
|
entityContext,
|
|
156
166
|
hqRoot: options.hqRoot,
|
|
157
167
|
onEvent,
|
|
168
|
+
...(author ? { author } : {}),
|
|
158
169
|
});
|
|
159
170
|
|
|
160
171
|
if (jsonMode) {
|
|
@@ -343,3 +354,32 @@ async function readAllStdin(): Promise<string> {
|
|
|
343
354
|
}
|
|
344
355
|
return Buffer.concat(chunks).toString("utf8");
|
|
345
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
|
|
360
|
+
* Cognito idToken. Returns `undefined` when no tokens are cached or the
|
|
361
|
+
* token is missing the required claims — share() then skips the metadata
|
|
362
|
+
* stamp gracefully (not an error).
|
|
363
|
+
*
|
|
364
|
+
* We deliberately decode the JWT here instead of verifying it: Cognito
|
|
365
|
+
* already verified at issuance, and we only use the public claims to
|
|
366
|
+
* label the upload's S3 user metadata (no auth decision rides on it).
|
|
367
|
+
*/
|
|
368
|
+
function resolveUploadAuthorFromCache(): UploadAuthor | undefined {
|
|
369
|
+
const tokens = loadCachedTokens();
|
|
370
|
+
if (!tokens?.idToken) return undefined;
|
|
371
|
+
const parts = tokens.idToken.split(".");
|
|
372
|
+
if (parts.length !== 3) return undefined;
|
|
373
|
+
try {
|
|
374
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
375
|
+
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
376
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
377
|
+
const claims = JSON.parse(json) as { sub?: string; email?: string };
|
|
378
|
+
if (claims.sub && claims.email) {
|
|
379
|
+
return { userSub: claims.sub, email: claims.email };
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq members invite|list|revoke` (members.ts).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - detectTarget — pure validation for email vs personUid vs invalid
|
|
6
|
+
* - getCallerPersonUid — happy path + missing-person-entity branch
|
|
7
|
+
* - inviteMember — email + personUid targets, --paths gating, HTTP errors
|
|
8
|
+
* - listPendingInvites — happy path + 403
|
|
9
|
+
* - revokeInvite — happy path + 404
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
InviteHttpError,
|
|
16
|
+
detectTarget,
|
|
17
|
+
formatInviteHttpError,
|
|
18
|
+
getCallerPersonUid,
|
|
19
|
+
inviteMember,
|
|
20
|
+
listPendingInvites,
|
|
21
|
+
revokeInvite,
|
|
22
|
+
} from "./members.js";
|
|
23
|
+
|
|
24
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
25
|
+
return new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// detectTarget
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe("detectTarget", () => {
|
|
46
|
+
it("recognizes plain emails and lowercases them", () => {
|
|
47
|
+
expect(detectTarget("Alice@Example.com")).toEqual({
|
|
48
|
+
type: "email",
|
|
49
|
+
value: "alice@example.com",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("recognizes person UIDs", () => {
|
|
54
|
+
expect(detectTarget("prs_bob123")).toEqual({
|
|
55
|
+
type: "person",
|
|
56
|
+
value: "prs_bob123",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for invalid targets", () => {
|
|
61
|
+
expect(detectTarget("not-a-target")).toBeNull();
|
|
62
|
+
expect(detectTarget("cmp_company")).toBeNull();
|
|
63
|
+
expect(detectTarget("")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// getCallerPersonUid
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe("getCallerPersonUid", () => {
|
|
72
|
+
it("returns the personUid from the first membership", async () => {
|
|
73
|
+
fetchSpy.mockResolvedValueOnce(
|
|
74
|
+
jsonResponse(200, {
|
|
75
|
+
memberships: [
|
|
76
|
+
{ membershipKey: "k1", personUid: "prs_admin", companyUid: "cmp_a", role: "owner", status: "active" },
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const uid = await getCallerPersonUid("test-token");
|
|
82
|
+
expect(uid).toBe("prs_admin");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("throws if the caller has no person entity yet", async () => {
|
|
86
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { memberships: [] }));
|
|
87
|
+
await expect(getCallerPersonUid("test-token")).rejects.toThrow(/no person entity/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws on auth failure", async () => {
|
|
91
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized" }));
|
|
92
|
+
await expect(getCallerPersonUid("test-token")).rejects.toThrow(/run `hq login`/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// inviteMember
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("inviteMember", () => {
|
|
101
|
+
it("creates an invite for an email target and returns a magic link", async () => {
|
|
102
|
+
fetchSpy.mockResolvedValueOnce(
|
|
103
|
+
jsonResponse(200, {
|
|
104
|
+
membership: { role: "member", status: "pending" },
|
|
105
|
+
inviteToken: "tok_abc",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = await inviteMember({
|
|
110
|
+
target: "alice@example.com",
|
|
111
|
+
role: "member",
|
|
112
|
+
companyUid: "cmp_acme",
|
|
113
|
+
callerUid: "prs_admin",
|
|
114
|
+
token: "test-token",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.magicLink).toBe("hq://accept/tok_abc");
|
|
118
|
+
expect(result.membership.role).toBe("member");
|
|
119
|
+
|
|
120
|
+
const call = fetchSpy.mock.calls[0];
|
|
121
|
+
const body = JSON.parse((call[1]?.body as string) ?? "{}");
|
|
122
|
+
expect(body).toEqual({
|
|
123
|
+
companyUid: "cmp_acme",
|
|
124
|
+
role: "member",
|
|
125
|
+
invitedBy: "prs_admin",
|
|
126
|
+
inviteeEmail: "alice@example.com",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("creates an invite for a personUid target", async () => {
|
|
131
|
+
fetchSpy.mockResolvedValueOnce(
|
|
132
|
+
jsonResponse(200, {
|
|
133
|
+
membership: { role: "admin", status: "pending" },
|
|
134
|
+
inviteToken: "tok_456",
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await inviteMember({
|
|
139
|
+
target: "prs_bob",
|
|
140
|
+
role: "admin",
|
|
141
|
+
companyUid: "cmp_acme",
|
|
142
|
+
callerUid: "prs_admin",
|
|
143
|
+
token: "test-token",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
147
|
+
expect(body.personUid).toBe("prs_bob");
|
|
148
|
+
expect(body.inviteeEmail).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("forwards allowedPrefixes when --paths is set with --role guest", async () => {
|
|
152
|
+
fetchSpy.mockResolvedValueOnce(
|
|
153
|
+
jsonResponse(200, {
|
|
154
|
+
membership: { role: "guest", status: "pending" },
|
|
155
|
+
inviteToken: "tok_guest",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await inviteMember({
|
|
160
|
+
target: "alice@example.com",
|
|
161
|
+
role: "guest",
|
|
162
|
+
paths: "docs/, shared/",
|
|
163
|
+
companyUid: "cmp_acme",
|
|
164
|
+
callerUid: "prs_admin",
|
|
165
|
+
token: "test-token",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
169
|
+
expect(body.allowedPrefixes).toEqual(["docs/", "shared/"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rejects --paths with a non-guest role", async () => {
|
|
173
|
+
await expect(
|
|
174
|
+
inviteMember({
|
|
175
|
+
target: "alice@example.com",
|
|
176
|
+
role: "member",
|
|
177
|
+
paths: "docs/",
|
|
178
|
+
companyUid: "cmp_acme",
|
|
179
|
+
callerUid: "prs_admin",
|
|
180
|
+
token: "test-token",
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow(/--paths is only valid with --role guest/);
|
|
183
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects an invalid target", async () => {
|
|
187
|
+
await expect(
|
|
188
|
+
inviteMember({
|
|
189
|
+
target: "not-a-target",
|
|
190
|
+
role: "member",
|
|
191
|
+
companyUid: "cmp_acme",
|
|
192
|
+
callerUid: "prs_admin",
|
|
193
|
+
token: "test-token",
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow(/Invalid target/);
|
|
196
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects an unknown role", async () => {
|
|
200
|
+
await expect(
|
|
201
|
+
inviteMember({
|
|
202
|
+
target: "alice@example.com",
|
|
203
|
+
role: "superuser",
|
|
204
|
+
companyUid: "cmp_acme",
|
|
205
|
+
callerUid: "prs_admin",
|
|
206
|
+
token: "test-token",
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow(/Invalid role/);
|
|
209
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("wraps non-2xx responses in InviteHttpError", async () => {
|
|
213
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(409, { error: "duplicate" }));
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
inviteMember({
|
|
217
|
+
target: "alice@example.com",
|
|
218
|
+
role: "member",
|
|
219
|
+
companyUid: "cmp_acme",
|
|
220
|
+
callerUid: "prs_admin",
|
|
221
|
+
token: "test-token",
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// listPendingInvites
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
describe("listPendingInvites", () => {
|
|
232
|
+
it("returns the parsed invites array", async () => {
|
|
233
|
+
fetchSpy.mockResolvedValueOnce(
|
|
234
|
+
jsonResponse(200, {
|
|
235
|
+
invites: [
|
|
236
|
+
{
|
|
237
|
+
membershipKey: "k1",
|
|
238
|
+
inviteeEmail: "alice@example.com",
|
|
239
|
+
companyUid: "cmp_acme",
|
|
240
|
+
role: "member",
|
|
241
|
+
status: "pending",
|
|
242
|
+
invitedBy: "prs_admin",
|
|
243
|
+
invitedAt: "2026-05-04T12:00:00Z",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
250
|
+
expect(invites).toHaveLength(1);
|
|
251
|
+
expect(invites[0].inviteeEmail).toBe("alice@example.com");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("throws InviteHttpError on 403", async () => {
|
|
255
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
|
|
256
|
+
await expect(listPendingInvites("test-token", "cmp_acme")).rejects.toBeInstanceOf(
|
|
257
|
+
InviteHttpError,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// revokeInvite
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
describe("revokeInvite", () => {
|
|
267
|
+
it("posts membershipKey + companyUid", async () => {
|
|
268
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
269
|
+
|
|
270
|
+
await revokeInvite("test-token", "k1", "cmp_acme");
|
|
271
|
+
|
|
272
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
273
|
+
expect(body).toEqual({ membershipKey: "k1", companyUid: "cmp_acme" });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("throws InviteHttpError on 404", async () => {
|
|
277
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(404, { error: "not found" }));
|
|
278
|
+
await expect(revokeInvite("test-token", "k1", "cmp_acme")).rejects.toBeInstanceOf(
|
|
279
|
+
InviteHttpError,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// formatInviteHttpError
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
describe("formatInviteHttpError", () => {
|
|
289
|
+
it("maps 401 to a login hint", () => {
|
|
290
|
+
expect(formatInviteHttpError(401, "ignored")).toMatch(/run `hq login`/);
|
|
291
|
+
});
|
|
292
|
+
it("maps 403 to admin/owner hint", () => {
|
|
293
|
+
expect(formatInviteHttpError(403, "ignored")).toMatch(/admins and owners/);
|
|
294
|
+
});
|
|
295
|
+
it("maps 409 to duplicate-invite hint", () => {
|
|
296
|
+
expect(formatInviteHttpError(409, "ignored")).toMatch(/already has a membership/);
|
|
297
|
+
});
|
|
298
|
+
it("prefixes 5xx with 'Server error:'", () => {
|
|
299
|
+
expect(formatInviteHttpError(500, "boom")).toBe("Server error: boom");
|
|
300
|
+
});
|
|
301
|
+
it("falls through to the message for unmapped statuses", () => {
|
|
302
|
+
expect(formatInviteHttpError(400, "bad input")).toBe("bad input");
|
|
303
|
+
});
|
|
304
|
+
});
|