@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.
@@ -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
+ });