@indigoai-us/hq-cli 5.18.1 → 5.18.3
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/CHANGELOG.md +43 -0
- package/dist/commands/members.d.ts +35 -2
- package/dist/commands/members.js +92 -22
- package/package.json +1 -1
- package/src/commands/members.test.ts +134 -4
- package/src/commands/members.ts +155 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.18.3] — 2026-05-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`hq members invite`: loud no-email warning + clearer next-steps.**
|
|
10
|
+
schemaVersion-2 invites only create the email-keyed pending DDB row —
|
|
11
|
+
the CLI does NOT send email. New output leads with a `chalk.yellow
|
|
12
|
+
⚠ No email was sent` warning + a two-option "do ONE of: manually
|
|
13
|
+
notify / use hq-console UI" block. Previous wording read like passive
|
|
14
|
+
next-step copy; admins mistook it for "email sent." (#22)
|
|
15
|
+
- **`hq members revoke <bare-email>`: no more 404.** Accepts bare email
|
|
16
|
+
and bare `prs_*` personUids, wrapping them into the canonical
|
|
17
|
+
`email:<email>#<companyUid>` / `<personUid>#<companyUid>` membership
|
|
18
|
+
key shape before posting to the server. (#22)
|
|
19
|
+
|
|
20
|
+
## [5.18.2] — 2026-05-21
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **`hq members invite` + `hq members list` under schemaVersion 2 server.**
|
|
25
|
+
Aligns CLI with hq-pro membership schema-v2 (live-smoke surfaced regression). (#20, #21)
|
|
26
|
+
|
|
27
|
+
## [5.18.1] — 2026-05-21
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Wire narrow-hint banner into per-company pull (US-011).** Banner now renders
|
|
32
|
+
on the per-company `hq sync pull` path, not just full-sync. (#18, #19)
|
|
33
|
+
|
|
34
|
+
## [5.18.0] — 2026-05-21
|
|
35
|
+
|
|
36
|
+
### Added — sync-browse-vs-sync
|
|
37
|
+
|
|
38
|
+
- **`hq sync mode <shared|narrow>`** — set per-membership sync mode (US-006).
|
|
39
|
+
- **`hq sync narrow --dry-run | --apply`** — preview/execute ACL narrowing for
|
|
40
|
+
the active company (US-007).
|
|
41
|
+
- **`hq files browse <prefix>` + `hq files cat <path>`** — read-only vault
|
|
42
|
+
browse via purpose=browse STS scope, distinct from sync (US-008).
|
|
43
|
+
- **Narrow-hint banner** — surfaces on `hq sync` when local membership is
|
|
44
|
+
shared-mode but cloud signals narrowing is recommended (US-011).
|
|
45
|
+
- Bumps `@indigoai-us/hq-cloud` to 5.23.0 (VaultClient SDK + sync engine
|
|
46
|
+
narrowing + journal v2). (#16, #17)
|
|
47
|
+
|
|
5
48
|
## [5.14.1] — 2026-05-14
|
|
6
49
|
|
|
7
50
|
### Fixed
|
|
@@ -20,13 +20,29 @@ export interface InviteOptions {
|
|
|
20
20
|
callerUid: string;
|
|
21
21
|
token: string;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Outcome of `hq members invite`. Two shapes depending on server schema:
|
|
25
|
+
*
|
|
26
|
+
* - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
|
|
27
|
+
* invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
|
|
28
|
+
* is populated so the caller can print or paste it.
|
|
29
|
+
* - **schemaVersion 2+** (current production) — membership row is
|
|
30
|
+
* email-keyed and authoritative. There is no token; the invitee
|
|
31
|
+
* accepts by signing into HQ with the same email. `inviteToken` +
|
|
32
|
+
* `magicLink` are both `undefined`; the caller prints sign-in
|
|
33
|
+
* instructions instead.
|
|
34
|
+
*
|
|
35
|
+
* `membership` is always populated when the server returned 2xx.
|
|
36
|
+
*/
|
|
23
37
|
export interface InviteResult {
|
|
24
|
-
inviteToken
|
|
25
|
-
magicLink
|
|
38
|
+
inviteToken?: string;
|
|
39
|
+
magicLink?: string;
|
|
26
40
|
membership: {
|
|
41
|
+
membershipKey?: string;
|
|
27
42
|
role: string;
|
|
28
43
|
status: string;
|
|
29
44
|
inviteToken?: string;
|
|
45
|
+
inviteeEmail?: string;
|
|
30
46
|
};
|
|
31
47
|
}
|
|
32
48
|
export interface DetectedTarget {
|
|
@@ -50,6 +66,23 @@ export declare class InviteHttpError extends Error {
|
|
|
50
66
|
}
|
|
51
67
|
export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
|
|
52
68
|
export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
71
|
+
* the server requires. Accepts three input forms:
|
|
72
|
+
*
|
|
73
|
+
* 1. Full membership key — already has `#<companyUid>`; passed through.
|
|
74
|
+
* Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
|
|
75
|
+
* 2. Bare email — wrap as `email:<email>#<companyUid>`.
|
|
76
|
+
* 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
|
|
77
|
+
* 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
|
|
78
|
+
* so the server can decide. Server may 404 if the token doesn't resolve.
|
|
79
|
+
*
|
|
80
|
+
* Pure function — no I/O — so it's trivially unit-testable. The previous
|
|
81
|
+
* shape sent the user's raw arg straight through, which meant `hq members
|
|
82
|
+
* revoke alice@example.com` always 404'd ("Invite not found") even when
|
|
83
|
+
* that exact email was just shown by `hq members list`.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveRevokeTargetToMembershipKey(arg: string, companyUid: string): string;
|
|
53
86
|
export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
|
|
54
87
|
export declare function registerMembersCommand(program: Command): void;
|
|
55
88
|
//# sourceMappingURL=members.d.ts.map
|
package/dist/commands/members.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c06b812a-ee4a-5701-a64b-cee2fcfef523")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
@@ -72,19 +72,26 @@ export async function inviteMember(options) {
|
|
|
72
72
|
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
73
73
|
}
|
|
74
74
|
const data = (await res.json());
|
|
75
|
-
|
|
76
|
-
// depending on vault-service version. Resolve from either; never emit
|
|
77
|
-
// `hq://accept/undefined` (a broken link that looks like success).
|
|
78
|
-
const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
|
|
79
|
-
if (!inviteToken) {
|
|
75
|
+
if (!data.membership) {
|
|
80
76
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
81
|
-
throw new Error(`Invite
|
|
82
|
-
"
|
|
77
|
+
throw new Error(`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
|
|
78
|
+
"This is a server-side regression — file an issue.");
|
|
83
79
|
}
|
|
80
|
+
// Two server schemas in the wild:
|
|
81
|
+
// - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
|
|
82
|
+
// the invitee redeems via `hq://accept/{token}`.
|
|
83
|
+
// - Current (schemaVersion 2+): membership row is email-keyed and
|
|
84
|
+
// authoritative — there is no token. The invitee accepts by signing
|
|
85
|
+
// into HQ with the invited email. The CLI must NOT throw here
|
|
86
|
+
// (previously did: "response did not include an invite token") — the
|
|
87
|
+
// invite IS successfully created on the server; the caller just gets
|
|
88
|
+
// undefined for inviteToken/magicLink and prints sign-in instructions.
|
|
89
|
+
const inviteToken = data.inviteToken ?? data.membership.inviteToken;
|
|
84
90
|
return {
|
|
85
|
-
inviteToken
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
...(inviteToken
|
|
92
|
+
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
93
|
+
: {}),
|
|
94
|
+
membership: data.membership,
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
97
|
export class InviteHttpError extends Error {
|
|
@@ -122,8 +129,39 @@ export async function listPendingInvites(token, companyUid) {
|
|
|
122
129
|
const err = (await res.json().catch(() => ({})));
|
|
123
130
|
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
124
131
|
}
|
|
132
|
+
// Server schema: `{ pending: [...] }`. Earlier dev branches used
|
|
133
|
+
// `{ invites: [...] }` which the CLI still accepts as a fallback for
|
|
134
|
+
// operators running staging stages that haven't caught up yet.
|
|
125
135
|
const data = (await res.json());
|
|
126
|
-
return data?.invites ?? [];
|
|
136
|
+
return data?.pending ?? data?.invites ?? [];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
140
|
+
* the server requires. Accepts three input forms:
|
|
141
|
+
*
|
|
142
|
+
* 1. Full membership key — already has `#<companyUid>`; passed through.
|
|
143
|
+
* Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
|
|
144
|
+
* 2. Bare email — wrap as `email:<email>#<companyUid>`.
|
|
145
|
+
* 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
|
|
146
|
+
* 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
|
|
147
|
+
* so the server can decide. Server may 404 if the token doesn't resolve.
|
|
148
|
+
*
|
|
149
|
+
* Pure function — no I/O — so it's trivially unit-testable. The previous
|
|
150
|
+
* shape sent the user's raw arg straight through, which meant `hq members
|
|
151
|
+
* revoke alice@example.com` always 404'd ("Invite not found") even when
|
|
152
|
+
* that exact email was just shown by `hq members list`.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveRevokeTargetToMembershipKey(arg, companyUid) {
|
|
155
|
+
if (arg.includes("#"))
|
|
156
|
+
return arg;
|
|
157
|
+
const detected = detectTarget(arg);
|
|
158
|
+
if (detected?.type === "email") {
|
|
159
|
+
return `email:${detected.value}#${companyUid}`;
|
|
160
|
+
}
|
|
161
|
+
if (detected?.type === "person") {
|
|
162
|
+
return `${detected.value}#${companyUid}`;
|
|
163
|
+
}
|
|
164
|
+
return arg;
|
|
127
165
|
}
|
|
128
166
|
export async function revokeInvite(token, tokenOrKey, companyUid) {
|
|
129
167
|
const res = await vaultApiFetch({
|
|
@@ -163,10 +201,41 @@ export function registerMembersCommand(program) {
|
|
|
163
201
|
});
|
|
164
202
|
console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
|
|
165
203
|
console.log();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
204
|
+
if (result.magicLink) {
|
|
205
|
+
// Legacy server schema — magic-link redemption.
|
|
206
|
+
console.log(chalk.bold("Magic link:"));
|
|
207
|
+
console.log(` ${result.magicLink}`);
|
|
208
|
+
console.log();
|
|
209
|
+
console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
213
|
+
// No magic link to share; invitee accepts by signing into HQ.
|
|
214
|
+
//
|
|
215
|
+
// CRITICAL UX NOTE: this CLI command does NOT send any email.
|
|
216
|
+
// hq-pro only writes the DDB pending row; only the hq-console UI
|
|
217
|
+
// path triggers Resend. Operators who run `hq members invite`
|
|
218
|
+
// expecting an email to fly out get silently broken flows. The
|
|
219
|
+
// output below uses chalk.yellow + an explicit "no email sent"
|
|
220
|
+
// line so this never sneaks past again.
|
|
221
|
+
const inviteeEmail = result.membership.inviteeEmail ??
|
|
222
|
+
(typeof target === "string" && target.includes("@")
|
|
223
|
+
? target
|
|
224
|
+
: undefined);
|
|
225
|
+
console.log(chalk.yellow("⚠ No email was sent. `hq members invite` only creates the pending membership row."));
|
|
226
|
+
console.log();
|
|
227
|
+
console.log(chalk.bold("To complete the invite, do ONE of:"));
|
|
228
|
+
console.log(` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`);
|
|
229
|
+
console.log(` at https://hq.getindigo.ai with that email address.`);
|
|
230
|
+
console.log(` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`);
|
|
231
|
+
console.log(` invite instead — the UI path triggers an automated email via Resend.`);
|
|
232
|
+
console.log();
|
|
233
|
+
console.log(chalk.dim("The pending membership row claims itself on the invitee's first sign-in."));
|
|
234
|
+
if (result.membership.membershipKey) {
|
|
235
|
+
console.log();
|
|
236
|
+
console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
170
239
|
}
|
|
171
240
|
catch (err) {
|
|
172
241
|
if (err instanceof InviteHttpError) {
|
|
@@ -225,15 +294,16 @@ export function registerMembersCommand(program) {
|
|
|
225
294
|
}
|
|
226
295
|
});
|
|
227
296
|
members
|
|
228
|
-
.command("revoke <
|
|
229
|
-
.description("Revoke a pending invite
|
|
230
|
-
.action(async (
|
|
297
|
+
.command("revoke <target>")
|
|
298
|
+
.description("Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.")
|
|
299
|
+
.action(async (target) => {
|
|
231
300
|
try {
|
|
232
301
|
const token = await ensureCognitoToken();
|
|
233
302
|
const companySlug = members.opts().company;
|
|
234
303
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
235
|
-
|
|
236
|
-
|
|
304
|
+
const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
|
|
305
|
+
await revokeInvite(token, membershipKey, companyUid);
|
|
306
|
+
console.log(chalk.green(`Revoked invite '${membershipKey}'`));
|
|
237
307
|
}
|
|
238
308
|
catch (err) {
|
|
239
309
|
if (err instanceof InviteHttpError) {
|
|
@@ -253,4 +323,4 @@ export function registerMembersCommand(program) {
|
|
|
253
323
|
});
|
|
254
324
|
}
|
|
255
325
|
//# sourceMappingURL=members.js.map
|
|
256
|
-
//# debugId=
|
|
326
|
+
//# debugId=c06b812a-ee4a-5701-a64b-cee2fcfef523
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getCallerPersonUid,
|
|
19
19
|
inviteMember,
|
|
20
20
|
listPendingInvites,
|
|
21
|
+
resolveRevokeTargetToMembershipKey,
|
|
21
22
|
revokeInvite,
|
|
22
23
|
} from "./members.js";
|
|
23
24
|
|
|
@@ -246,13 +247,49 @@ describe("inviteMember", () => {
|
|
|
246
247
|
expect(result.magicLink).toBe("hq://accept/tok_nested");
|
|
247
248
|
});
|
|
248
249
|
|
|
249
|
-
it("
|
|
250
|
+
it("schemaVersion 2: no inviteToken in response → success with undefined magicLink", async () => {
|
|
251
|
+
// 2026-05-21 fix: the server moved to email-keyed authoritative
|
|
252
|
+
// membership rows (schemaVersion 2) — the invite IS created, but
|
|
253
|
+
// there is no token to redeem. The CLI must NOT throw here; instead
|
|
254
|
+
// the action handler prints "sign in with the invited email"
|
|
255
|
+
// instructions. Previously this case threw "did not include an
|
|
256
|
+
// invite token" which produced false-failure UX on a working invite.
|
|
250
257
|
fetchSpy.mockResolvedValueOnce(
|
|
251
|
-
jsonResponse(
|
|
252
|
-
membership: {
|
|
258
|
+
jsonResponse(201, {
|
|
259
|
+
membership: {
|
|
260
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
261
|
+
role: "member",
|
|
262
|
+
status: "pending",
|
|
263
|
+
inviteeEmail: "alice@example.com",
|
|
264
|
+
schemaVersion: 2,
|
|
265
|
+
},
|
|
253
266
|
}),
|
|
254
267
|
);
|
|
255
268
|
|
|
269
|
+
const result = await inviteMember({
|
|
270
|
+
target: "alice@example.com",
|
|
271
|
+
role: "member",
|
|
272
|
+
companyUid: "cmp_acme",
|
|
273
|
+
callerUid: "prs_admin",
|
|
274
|
+
token: "test-token",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.inviteToken).toBeUndefined();
|
|
278
|
+
expect(result.magicLink).toBeUndefined();
|
|
279
|
+
expect(result.membership.role).toBe("member");
|
|
280
|
+
expect(result.membership.status).toBe("pending");
|
|
281
|
+
expect(result.membership.membershipKey).toBe(
|
|
282
|
+
"email:alice@example.com#cmp_acme",
|
|
283
|
+
);
|
|
284
|
+
expect(result.membership.inviteeEmail).toBe("alice@example.com");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("throws when the response has no membership row at all (server bug)", async () => {
|
|
288
|
+
// Belt-and-suspenders: a 2xx response with NO membership row is a
|
|
289
|
+
// server-side regression — surface it loudly so it doesn't silently
|
|
290
|
+
// succeed-but-do-nothing.
|
|
291
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(201, {}));
|
|
292
|
+
|
|
256
293
|
await expect(
|
|
257
294
|
inviteMember({
|
|
258
295
|
target: "alice@example.com",
|
|
@@ -261,7 +298,7 @@ describe("inviteMember", () => {
|
|
|
261
298
|
callerUid: "prs_admin",
|
|
262
299
|
token: "test-token",
|
|
263
300
|
}),
|
|
264
|
-
).rejects.toThrow(/
|
|
301
|
+
).rejects.toThrow(/no membership row/);
|
|
265
302
|
});
|
|
266
303
|
});
|
|
267
304
|
|
|
@@ -313,6 +350,47 @@ describe("listPendingInvites", () => {
|
|
|
313
350
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
314
351
|
await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
|
|
315
352
|
});
|
|
353
|
+
|
|
354
|
+
it("schemaVersion 2: reads `pending` key (the canonical server response)", async () => {
|
|
355
|
+
// 2026-05-21 fix: live server returns `{ pending: [...] }`, not
|
|
356
|
+
// `{ invites: [...] }`. The CLI must read `pending` as the primary
|
|
357
|
+
// and fall back to `invites` for older stages.
|
|
358
|
+
fetchSpy.mockResolvedValueOnce(
|
|
359
|
+
jsonResponse(200, {
|
|
360
|
+
pending: [
|
|
361
|
+
{
|
|
362
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
363
|
+
inviteeEmail: "alice@example.com",
|
|
364
|
+
companyUid: "cmp_acme",
|
|
365
|
+
role: "member",
|
|
366
|
+
status: "pending",
|
|
367
|
+
invitedBy: "prs_admin",
|
|
368
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
369
|
+
schemaVersion: 2,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
376
|
+
expect(invites).toHaveLength(1);
|
|
377
|
+
expect(invites[0].membershipKey).toBe("email:alice@example.com#cmp_acme");
|
|
378
|
+
expect(invites[0].inviteeEmail).toBe("alice@example.com");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("prefers `pending` over legacy `invites` when both present (server transition)", async () => {
|
|
382
|
+
// Defensive: an in-flight server deploy could briefly return BOTH
|
|
383
|
+
// fields. CLI takes the canonical `pending` key.
|
|
384
|
+
fetchSpy.mockResolvedValueOnce(
|
|
385
|
+
jsonResponse(200, {
|
|
386
|
+
pending: [{ membershipKey: "k_pending" } as never],
|
|
387
|
+
invites: [{ membershipKey: "k_legacy" } as never],
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
391
|
+
expect(invites).toHaveLength(1);
|
|
392
|
+
expect(invites[0].membershipKey).toBe("k_pending");
|
|
393
|
+
});
|
|
316
394
|
});
|
|
317
395
|
|
|
318
396
|
// ---------------------------------------------------------------------------
|
|
@@ -337,6 +415,58 @@ describe("revokeInvite", () => {
|
|
|
337
415
|
});
|
|
338
416
|
});
|
|
339
417
|
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// resolveRevokeTargetToMembershipKey
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
describe("resolveRevokeTargetToMembershipKey", () => {
|
|
423
|
+
// Regression: `hq members revoke alice@example.com` used to send the raw
|
|
424
|
+
// email straight to /membership/revoke, which the server rejects with 404
|
|
425
|
+
// "Invite not found" because it keys on `email:<email>#<companyUid>`. Live
|
|
426
|
+
// smoke 2026-05-21 reproduced this against indigo.
|
|
427
|
+
it("wraps a bare email into the email-keyed membership shape", () => {
|
|
428
|
+
expect(
|
|
429
|
+
resolveRevokeTargetToMembershipKey("alice@example.com", "cmp_abc"),
|
|
430
|
+
).toBe("email:alice@example.com#cmp_abc");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("lowercases the email when wrapping", () => {
|
|
434
|
+
expect(
|
|
435
|
+
resolveRevokeTargetToMembershipKey("Alice@Example.COM", "cmp_abc"),
|
|
436
|
+
).toBe("email:alice@example.com#cmp_abc");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("wraps a bare personUid into the personUid-keyed membership shape", () => {
|
|
440
|
+
expect(resolveRevokeTargetToMembershipKey("prs_bob123", "cmp_abc")).toBe(
|
|
441
|
+
"prs_bob123#cmp_abc",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("passes through a full email-keyed membership key unchanged", () => {
|
|
446
|
+
expect(
|
|
447
|
+
resolveRevokeTargetToMembershipKey(
|
|
448
|
+
"email:alice@example.com#cmp_abc",
|
|
449
|
+
"cmp_other",
|
|
450
|
+
),
|
|
451
|
+
).toBe("email:alice@example.com#cmp_abc");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("passes through a full personUid-keyed membership key unchanged", () => {
|
|
455
|
+
expect(
|
|
456
|
+
resolveRevokeTargetToMembershipKey("prs_bob#cmp_abc", "cmp_other"),
|
|
457
|
+
).toBe("prs_bob#cmp_abc");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("passes through unrecognized strings (legacy inviteToken, garbage)", () => {
|
|
461
|
+
// A schemaVersion-1 inviteToken is opaque base64url; we can't tell it
|
|
462
|
+
// apart from garbage. Send it to the server and let the server 404 if
|
|
463
|
+
// it doesn't resolve.
|
|
464
|
+
expect(
|
|
465
|
+
resolveRevokeTargetToMembershipKey("some-opaque-token", "cmp_abc"),
|
|
466
|
+
).toBe("some-opaque-token");
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
340
470
|
// ---------------------------------------------------------------------------
|
|
341
471
|
// formatInviteHttpError
|
|
342
472
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -38,10 +38,30 @@ export interface InviteOptions {
|
|
|
38
38
|
token: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Outcome of `hq members invite`. Two shapes depending on server schema:
|
|
43
|
+
*
|
|
44
|
+
* - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
|
|
45
|
+
* invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
|
|
46
|
+
* is populated so the caller can print or paste it.
|
|
47
|
+
* - **schemaVersion 2+** (current production) — membership row is
|
|
48
|
+
* email-keyed and authoritative. There is no token; the invitee
|
|
49
|
+
* accepts by signing into HQ with the same email. `inviteToken` +
|
|
50
|
+
* `magicLink` are both `undefined`; the caller prints sign-in
|
|
51
|
+
* instructions instead.
|
|
52
|
+
*
|
|
53
|
+
* `membership` is always populated when the server returned 2xx.
|
|
54
|
+
*/
|
|
41
55
|
export interface InviteResult {
|
|
42
|
-
inviteToken
|
|
43
|
-
magicLink
|
|
44
|
-
membership: {
|
|
56
|
+
inviteToken?: string;
|
|
57
|
+
magicLink?: string;
|
|
58
|
+
membership: {
|
|
59
|
+
membershipKey?: string;
|
|
60
|
+
role: string;
|
|
61
|
+
status: string;
|
|
62
|
+
inviteToken?: string;
|
|
63
|
+
inviteeEmail?: string;
|
|
64
|
+
};
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export interface DetectedTarget {
|
|
@@ -137,24 +157,38 @@ export async function inviteMember(
|
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
const data = (await res.json()) as {
|
|
140
|
-
membership?: {
|
|
160
|
+
membership?: {
|
|
161
|
+
membershipKey?: string;
|
|
162
|
+
role: string;
|
|
163
|
+
status: string;
|
|
164
|
+
inviteToken?: string;
|
|
165
|
+
inviteeEmail?: string;
|
|
166
|
+
schemaVersion?: number;
|
|
167
|
+
};
|
|
141
168
|
inviteToken?: string;
|
|
142
169
|
};
|
|
143
|
-
|
|
144
|
-
// depending on vault-service version. Resolve from either; never emit
|
|
145
|
-
// `hq://accept/undefined` (a broken link that looks like success).
|
|
146
|
-
const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
|
|
147
|
-
if (!inviteToken) {
|
|
170
|
+
if (!data.membership) {
|
|
148
171
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
149
172
|
throw new Error(
|
|
150
|
-
`Invite
|
|
151
|
-
"
|
|
173
|
+
`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
|
|
174
|
+
"This is a server-side regression — file an issue.",
|
|
152
175
|
);
|
|
153
176
|
}
|
|
177
|
+
// Two server schemas in the wild:
|
|
178
|
+
// - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
|
|
179
|
+
// the invitee redeems via `hq://accept/{token}`.
|
|
180
|
+
// - Current (schemaVersion 2+): membership row is email-keyed and
|
|
181
|
+
// authoritative — there is no token. The invitee accepts by signing
|
|
182
|
+
// into HQ with the invited email. The CLI must NOT throw here
|
|
183
|
+
// (previously did: "response did not include an invite token") — the
|
|
184
|
+
// invite IS successfully created on the server; the caller just gets
|
|
185
|
+
// undefined for inviteToken/magicLink and prints sign-in instructions.
|
|
186
|
+
const inviteToken = data.inviteToken ?? data.membership.inviteToken;
|
|
154
187
|
return {
|
|
155
|
-
inviteToken
|
|
156
|
-
|
|
157
|
-
|
|
188
|
+
...(inviteToken
|
|
189
|
+
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
190
|
+
: {}),
|
|
191
|
+
membership: data.membership,
|
|
158
192
|
};
|
|
159
193
|
}
|
|
160
194
|
|
|
@@ -204,8 +238,45 @@ export async function listPendingInvites(
|
|
|
204
238
|
err.code,
|
|
205
239
|
);
|
|
206
240
|
}
|
|
207
|
-
|
|
208
|
-
|
|
241
|
+
// Server schema: `{ pending: [...] }`. Earlier dev branches used
|
|
242
|
+
// `{ invites: [...] }` which the CLI still accepts as a fallback for
|
|
243
|
+
// operators running staging stages that haven't caught up yet.
|
|
244
|
+
const data = (await res.json()) as {
|
|
245
|
+
pending?: PendingInvite[] | null;
|
|
246
|
+
invites?: PendingInvite[] | null;
|
|
247
|
+
};
|
|
248
|
+
return data?.pending ?? data?.invites ?? [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
253
|
+
* the server requires. Accepts three input forms:
|
|
254
|
+
*
|
|
255
|
+
* 1. Full membership key — already has `#<companyUid>`; passed through.
|
|
256
|
+
* Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
|
|
257
|
+
* 2. Bare email — wrap as `email:<email>#<companyUid>`.
|
|
258
|
+
* 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
|
|
259
|
+
* 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
|
|
260
|
+
* so the server can decide. Server may 404 if the token doesn't resolve.
|
|
261
|
+
*
|
|
262
|
+
* Pure function — no I/O — so it's trivially unit-testable. The previous
|
|
263
|
+
* shape sent the user's raw arg straight through, which meant `hq members
|
|
264
|
+
* revoke alice@example.com` always 404'd ("Invite not found") even when
|
|
265
|
+
* that exact email was just shown by `hq members list`.
|
|
266
|
+
*/
|
|
267
|
+
export function resolveRevokeTargetToMembershipKey(
|
|
268
|
+
arg: string,
|
|
269
|
+
companyUid: string,
|
|
270
|
+
): string {
|
|
271
|
+
if (arg.includes("#")) return arg;
|
|
272
|
+
const detected = detectTarget(arg);
|
|
273
|
+
if (detected?.type === "email") {
|
|
274
|
+
return `email:${detected.value}#${companyUid}`;
|
|
275
|
+
}
|
|
276
|
+
if (detected?.type === "person") {
|
|
277
|
+
return `${detected.value}#${companyUid}`;
|
|
278
|
+
}
|
|
279
|
+
return arg;
|
|
209
280
|
}
|
|
210
281
|
|
|
211
282
|
export async function revokeInvite(
|
|
@@ -275,14 +346,63 @@ export function registerMembersCommand(program: Command): void {
|
|
|
275
346
|
),
|
|
276
347
|
);
|
|
277
348
|
console.log();
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
349
|
+
if (result.magicLink) {
|
|
350
|
+
// Legacy server schema — magic-link redemption.
|
|
351
|
+
console.log(chalk.bold("Magic link:"));
|
|
352
|
+
console.log(` ${result.magicLink}`);
|
|
353
|
+
console.log();
|
|
354
|
+
console.log(
|
|
355
|
+
chalk.dim(
|
|
356
|
+
"Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
361
|
+
// No magic link to share; invitee accepts by signing into HQ.
|
|
362
|
+
//
|
|
363
|
+
// CRITICAL UX NOTE: this CLI command does NOT send any email.
|
|
364
|
+
// hq-pro only writes the DDB pending row; only the hq-console UI
|
|
365
|
+
// path triggers Resend. Operators who run `hq members invite`
|
|
366
|
+
// expecting an email to fly out get silently broken flows. The
|
|
367
|
+
// output below uses chalk.yellow + an explicit "no email sent"
|
|
368
|
+
// line so this never sneaks past again.
|
|
369
|
+
const inviteeEmail =
|
|
370
|
+
result.membership.inviteeEmail ??
|
|
371
|
+
(typeof target === "string" && target.includes("@")
|
|
372
|
+
? target
|
|
373
|
+
: undefined);
|
|
374
|
+
console.log(
|
|
375
|
+
chalk.yellow(
|
|
376
|
+
"⚠ No email was sent. `hq members invite` only creates the pending membership row.",
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
console.log();
|
|
380
|
+
console.log(chalk.bold("To complete the invite, do ONE of:"));
|
|
381
|
+
console.log(
|
|
382
|
+
` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
|
|
383
|
+
);
|
|
384
|
+
console.log(
|
|
385
|
+
` at https://hq.getindigo.ai with that email address.`,
|
|
386
|
+
);
|
|
387
|
+
console.log(
|
|
388
|
+
` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`,
|
|
389
|
+
);
|
|
390
|
+
console.log(
|
|
391
|
+
` invite instead — the UI path triggers an automated email via Resend.`,
|
|
392
|
+
);
|
|
393
|
+
console.log();
|
|
394
|
+
console.log(
|
|
395
|
+
chalk.dim(
|
|
396
|
+
"The pending membership row claims itself on the invitee's first sign-in.",
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
if (result.membership.membershipKey) {
|
|
400
|
+
console.log();
|
|
401
|
+
console.log(
|
|
402
|
+
chalk.dim(`Membership key: ${result.membership.membershipKey}`),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
286
406
|
} catch (err) {
|
|
287
407
|
if (err instanceof InviteHttpError) {
|
|
288
408
|
console.error(
|
|
@@ -365,16 +485,22 @@ export function registerMembersCommand(program: Command): void {
|
|
|
365
485
|
});
|
|
366
486
|
|
|
367
487
|
members
|
|
368
|
-
.command("revoke <
|
|
369
|
-
.description(
|
|
370
|
-
|
|
488
|
+
.command("revoke <target>")
|
|
489
|
+
.description(
|
|
490
|
+
"Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.",
|
|
491
|
+
)
|
|
492
|
+
.action(async (target: string) => {
|
|
371
493
|
try {
|
|
372
494
|
const token = await ensureCognitoToken();
|
|
373
495
|
const companySlug = members.opts().company as string | undefined;
|
|
374
496
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
375
497
|
|
|
376
|
-
|
|
377
|
-
|
|
498
|
+
const membershipKey = resolveRevokeTargetToMembershipKey(
|
|
499
|
+
target,
|
|
500
|
+
companyUid,
|
|
501
|
+
);
|
|
502
|
+
await revokeInvite(token, membershipKey, companyUid);
|
|
503
|
+
console.log(chalk.green(`Revoked invite '${membershipKey}'`));
|
|
378
504
|
} catch (err) {
|
|
379
505
|
if (err instanceof InviteHttpError) {
|
|
380
506
|
const msg =
|