@indigoai-us/hq-cli 5.51.0 → 5.52.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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.52.0]
6
+
7
+ ### Added
8
+
9
+ - **`hq members promote <target> <newRole>` (alias `set-role`) — change a
10
+ member's role from the CLI.** A discoverable runner for role changes via
11
+ `POST /membership/role`, replacing the undiscoverable, `admin|member`-only
12
+ `set-role` (now kept as an alias). Supports the full role set
13
+ (`owner|admin|member|guest`); `<target>` may be an email, a `prs_` personUid,
14
+ or a full membership key (same resolver as `revoke`). It is a GENERAL role
15
+ change — it can promote OR demote — and authorization (owner-or-admin; only
16
+ an owner may set a target to owner or change an owner) is enforced
17
+ server-side.
18
+ - **`hq files share --full` — glob-safe whole-vault access.** Granting prefixes
19
+ one at a time hit `PolicyBudgetExceeded` after a few (each prefix is a
20
+ distinct ARN in the member's DEFLATE-packed inline STS session policy), and
21
+ the intended single-`*` wildcard escape hatch was unreachable because an
22
+ unquoted `*` expands to local filenames and fails the one-prefix check.
23
+ `--full` is a glob-safe flag that performs the coalesced whole-vault wildcard
24
+ grant in a single policy entry.
25
+
26
+ ## [5.51.0]
27
+
5
28
  ### Fixed
6
29
 
7
30
  - **`hq secrets exec` / `hq secrets env` load via the batch endpoint, killing
@@ -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]="bc564dd4-d8c1-5d09-ad49-3bcd9f7328a6")}catch(e){}}();
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]="fc999359-c426-5277-ba96-5452ae1ed2e2")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import open from "open";
5
5
  import * as readline from "node:readline";
@@ -99,10 +99,35 @@ export function registerFilesCommand(program) {
99
99
  .description("Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
100
100
  .option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
101
101
  .option("--permission <level>", "Permission level (only with --with): read | write")
102
+ .option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
102
103
  .option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
103
104
  .option("--no-open", "Print the share-session URL but do not launch the browser")
104
105
  .action(async (paths, opts) => {
105
106
  try {
107
+ // Full-vault grant: a glob-safe affordance for "give this person the
108
+ // whole vault" so admins never have to quote a `*` (an unquoted glob
109
+ // expands to local filenames and instantly fails the one-prefix
110
+ // check). Maps to the single `*` wildcard grant, which the server
111
+ // coalesces to one policy entry — sidestepping the per-prefix STS
112
+ // session-policy budget. Defaults to write permission.
113
+ if (opts.full) {
114
+ if (opts.with === undefined) {
115
+ console.error(chalk.red("--full grants whole-vault access to a principal and requires --with <principal>."));
116
+ process.exit(1);
117
+ }
118
+ if (paths && paths.length > 0) {
119
+ console.error(chalk.red("--full grants the entire vault; do not also pass file paths."));
120
+ process.exit(1);
121
+ }
122
+ await runDirectGrant({
123
+ prefix: "*",
124
+ principal: opts.with,
125
+ permission: opts.permission ?? "write",
126
+ companySlug: files.opts().company,
127
+ fullVault: true,
128
+ });
129
+ return;
130
+ }
106
131
  if (!paths || paths.length === 0) {
107
132
  console.error(chalk.red("usage: hq files share <paths...> [--with <principal>]"));
108
133
  process.exit(1);
@@ -437,7 +462,12 @@ async function runDirectGrant(params) {
437
462
  const data = (await res.json());
438
463
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
439
464
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
440
- console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
465
+ if (params.fullVault) {
466
+ console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
467
+ }
468
+ else {
469
+ console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
470
+ }
441
471
  }
442
472
  async function runShareSession(params) {
443
473
  // Normalize every path through the shared prefix helper so a trailing
@@ -682,4 +712,4 @@ export async function runFilesDelete(params, deps = {}) {
682
712
  }
683
713
  }
684
714
  //# sourceMappingURL=files.js.map
685
- //# debugId=bc564dd4-d8c1-5d09-ad49-3bcd9f7328a6
715
+ //# debugId=fc999359-c426-5277-ba96-5452ae1ed2e2
@@ -149,5 +149,15 @@ export declare function listActiveMembers(token: string, companyUid: string): Pr
149
149
  */
150
150
  export declare function resolveRevokeTargetToMembershipKey(arg: string, companyUid: string): string;
151
151
  export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
152
+ /**
153
+ * Change a member's role via `POST /membership/role`, accepting the FULL role
154
+ * set (owner|admin|member|guest). This is a GENERAL role change — it can promote
155
+ * OR demote. Authorization (owner-or-admin `changeRoles`, owner-only
156
+ * promote-to-owner / change-an-owner) is enforced SERVER-side; this function
157
+ * only validates the role string locally and surfaces the server's error.
158
+ * Role string is validated BEFORE any network call so callers/tests can rely on
159
+ * a synchronous-shaped rejection for a bad role.
160
+ */
161
+ export declare function changeMemberRole(token: string, companyUid: string, membershipKey: string, newRole: Role): Promise<void>;
152
162
  export declare function registerMembersCommand(program: Command): void;
153
163
  //# sourceMappingURL=members.d.ts.map
@@ -1,12 +1,11 @@
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]="8244d720-094e-5d4e-b813-75086ed0849d")}catch(e){}}();
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]="83a54ed2-3f52-5b6f-a784-09f78f488ddd")}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";
6
6
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
7
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
8
  export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
9
- const VALID_MEMBER_SET_ROLES = new Set(["admin", "member"]);
10
9
  export function detectTarget(target) {
11
10
  if (EMAIL_PATTERN.test(target)) {
12
11
  return { type: "email", value: target.trim().toLowerCase() };
@@ -265,9 +264,18 @@ export async function revokeInvite(token, tokenOrKey, companyUid) {
265
264
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
266
265
  }
267
266
  }
268
- async function setMemberRole(token, companyUid, membershipKey, newRole) {
269
- if (!VALID_MEMBER_SET_ROLES.has(newRole)) {
270
- throw new Error(`Invalid role '${newRole}': must be one of admin, member`);
267
+ /**
268
+ * Change a member's role via `POST /membership/role`, accepting the FULL role
269
+ * set (owner|admin|member|guest). This is a GENERAL role change it can promote
270
+ * OR demote. Authorization (owner-or-admin `changeRoles`, owner-only
271
+ * promote-to-owner / change-an-owner) is enforced SERVER-side; this function
272
+ * only validates the role string locally and surfaces the server's error.
273
+ * Role string is validated BEFORE any network call so callers/tests can rely on
274
+ * a synchronous-shaped rejection for a bad role.
275
+ */
276
+ export async function changeMemberRole(token, companyUid, membershipKey, newRole) {
277
+ if (!VALID_ROLES.has(newRole)) {
278
+ throw new Error(`Invalid role '${newRole}': must be one of owner, admin, member, guest`);
271
279
  }
272
280
  const res = await vaultApiFetch({
273
281
  token,
@@ -285,16 +293,29 @@ export function registerMembersCommand(program) {
285
293
  .command("members")
286
294
  .description("Manage company memberships and invites")
287
295
  .option("--company <slug>", "Company slug (resolves to companyUid)");
296
+ // Canonical role-change command. `promote` is the discoverable verb users
297
+ // reach for; `set-role` is kept as an alias for the older name. The route is a
298
+ // GENERAL role change, so the help text and success message are honest that it
299
+ // can demote as well as promote. Authorization is enforced server-side.
288
300
  members
289
- .command("set-role <membershipKey> <role>")
290
- .description("Change a member or agent role to admin or member")
291
- .action(async (membershipKey, role) => {
301
+ .command("promote <target> <newRole>")
302
+ .alias("set-role")
303
+ .description("Change a member's role (owner|admin|member|guest) promotes OR demotes. " +
304
+ "<target> may be an email, a prs_ personUid, or a full membership key. " +
305
+ "Owner-or-admin only; setting a target to owner (or changing an owner) is owner-only. Server-enforced.")
306
+ .action(async (target, newRole) => {
292
307
  try {
308
+ const role = newRole.trim().toLowerCase();
309
+ if (!VALID_ROLES.has(role)) {
310
+ console.error(chalk.red(`Invalid role '${newRole}': must be one of owner, admin, member, guest`));
311
+ process.exit(1);
312
+ }
293
313
  const token = await ensureCognitoToken();
294
314
  const companySlug = members.opts().company;
295
315
  const companyUid = await getCompanyUid(token, companySlug);
296
- await setMemberRole(token, companyUid, membershipKey, role);
297
- console.log(chalk.green(`Updated role for '${membershipKey}' to ${role}`));
316
+ const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
317
+ await changeMemberRole(token, companyUid, membershipKey, role);
318
+ console.log(chalk.green(`Updated role for '${target}' to ${role}`));
298
319
  }
299
320
  catch (err) {
300
321
  if (err instanceof InviteHttpError) {
@@ -534,4 +555,4 @@ export function registerMembersCommand(program) {
534
555
  });
535
556
  }
536
557
  //# sourceMappingURL=members.js.map
537
- //# debugId=8244d720-094e-5d4e-b813-75086ed0849d
558
+ //# debugId=83a54ed2-3f52-5b6f-a784-09f78f488ddd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.51.0",
3
+ "version": "5.52.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -501,6 +501,136 @@ describe("hq files share — direct-grant fork (with --with)", () => {
501
501
  expect(errs).toMatch(/--permission is required/);
502
502
  expect(fetchSpy).not.toHaveBeenCalled();
503
503
  });
504
+
505
+ it("--full --with email grants the '*' wildcard at the default write permission (no glob to quote)", async () => {
506
+ // 1) /membership/me for company resolution
507
+ fetchSpy.mockResolvedValueOnce(
508
+ jsonResponse(200, {
509
+ memberships: [
510
+ {
511
+ membershipKey: "k1",
512
+ companyUid: "cmp_acme",
513
+ role: "member",
514
+ status: "active",
515
+ },
516
+ ],
517
+ }),
518
+ );
519
+ // 2) POST /files/cmp_acme/acl/grant
520
+ fetchSpy.mockResolvedValueOnce(
521
+ jsonResponse(200, { acl: { path: "*" } }),
522
+ );
523
+
524
+ const program = buildProgram();
525
+ // No positional path, no --permission — --full supplies prefix '*' and
526
+ // defaults permission to write.
527
+ await program.parseAsync(
528
+ ["files", "share", "--full", "--with", "user@example.com"],
529
+ { from: "user" },
530
+ );
531
+
532
+ const mintCalls = fetchSpy.mock.calls.filter((c) =>
533
+ String(c[0]).includes("/share-session"),
534
+ );
535
+ expect(mintCalls).toHaveLength(0);
536
+
537
+ const grantCall = fetchSpy.mock.calls.find((c) =>
538
+ String(c[0]).includes("/acl/grant"),
539
+ );
540
+ expect(grantCall).toBeDefined();
541
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
542
+ expect(body).toEqual({
543
+ prefix: "*",
544
+ granteeType: "email",
545
+ granteeId: "user@example.com",
546
+ permission: "write",
547
+ });
548
+
549
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
550
+ expect(printed).toMatch(/ENTIRE vault/);
551
+ expect(open).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it("--full honors an explicit --permission read (read-only full vault)", async () => {
555
+ fetchSpy.mockResolvedValueOnce(
556
+ jsonResponse(200, {
557
+ memberships: [
558
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
559
+ ],
560
+ }),
561
+ );
562
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
563
+
564
+ const program = buildProgram();
565
+ await program.parseAsync(
566
+ ["files", "share", "--full", "--with", "user@example.com", "--permission", "read"],
567
+ { from: "user" },
568
+ );
569
+
570
+ const grantCall = fetchSpy.mock.calls.find((c) =>
571
+ String(c[0]).includes("/acl/grant"),
572
+ );
573
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
574
+ expect(body).toEqual({
575
+ prefix: "*",
576
+ granteeType: "email",
577
+ granteeId: "user@example.com",
578
+ permission: "read",
579
+ });
580
+ });
581
+
582
+ it("--full composes with @all (whole vault for the whole company → company-wide '*')", async () => {
583
+ fetchSpy.mockResolvedValueOnce(
584
+ jsonResponse(200, {
585
+ memberships: [
586
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
587
+ ],
588
+ }),
589
+ );
590
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
591
+
592
+ const program = buildProgram();
593
+ await program.parseAsync(
594
+ ["files", "share", "--full", "--with", "@all"],
595
+ { from: "user" },
596
+ );
597
+
598
+ const grantCall = fetchSpy.mock.calls.find((c) =>
599
+ String(c[0]).includes("/acl/grant"),
600
+ );
601
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
602
+ expect(body).toEqual({
603
+ prefix: "*",
604
+ granteeType: "company-wide",
605
+ granteeId: "",
606
+ permission: "write",
607
+ });
608
+ });
609
+
610
+ it("rejects --full without --with (and never calls the network)", async () => {
611
+ const program = buildProgram();
612
+ await expect(
613
+ program.parseAsync(["files", "share", "--full"], { from: "user" }),
614
+ ).rejects.toThrow(/__EXIT__:1/);
615
+
616
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
617
+ expect(errs).toMatch(/--full.*requires --with/);
618
+ expect(fetchSpy).not.toHaveBeenCalled();
619
+ });
620
+
621
+ it("rejects --full when file paths are also passed", async () => {
622
+ const program = buildProgram();
623
+ await expect(
624
+ program.parseAsync(
625
+ ["files", "share", "somePath", "--full", "--with", "user@example.com"],
626
+ { from: "user" },
627
+ ),
628
+ ).rejects.toThrow(/__EXIT__:1/);
629
+
630
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
631
+ expect(errs).toMatch(/entire vault; do not also pass file paths/);
632
+ expect(fetchSpy).not.toHaveBeenCalled();
633
+ });
504
634
  });
505
635
 
506
636
  // ---------------------------------------------------------------------------
@@ -135,6 +135,10 @@ export function registerFilesCommand(program: Command): Command {
135
135
  "Email address, group id, or '@all' to share with every active company member",
136
136
  )
137
137
  .option("--permission <level>", "Permission level (only with --with): read | write")
138
+ .option(
139
+ "--full",
140
+ "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.",
141
+ )
138
142
  .option(
139
143
  "--expires <duration>",
140
144
  "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
@@ -146,11 +150,45 @@ export function registerFilesCommand(program: Command): Command {
146
150
  opts: {
147
151
  with?: string;
148
152
  permission?: string;
153
+ full?: boolean;
149
154
  expires?: string;
150
155
  open: boolean;
151
156
  },
152
157
  ) => {
153
158
  try {
159
+ // Full-vault grant: a glob-safe affordance for "give this person the
160
+ // whole vault" so admins never have to quote a `*` (an unquoted glob
161
+ // expands to local filenames and instantly fails the one-prefix
162
+ // check). Maps to the single `*` wildcard grant, which the server
163
+ // coalesces to one policy entry — sidestepping the per-prefix STS
164
+ // session-policy budget. Defaults to write permission.
165
+ if (opts.full) {
166
+ if (opts.with === undefined) {
167
+ console.error(
168
+ chalk.red(
169
+ "--full grants whole-vault access to a principal and requires --with <principal>.",
170
+ ),
171
+ );
172
+ process.exit(1);
173
+ }
174
+ if (paths && paths.length > 0) {
175
+ console.error(
176
+ chalk.red(
177
+ "--full grants the entire vault; do not also pass file paths.",
178
+ ),
179
+ );
180
+ process.exit(1);
181
+ }
182
+ await runDirectGrant({
183
+ prefix: "*",
184
+ principal: opts.with,
185
+ permission: opts.permission ?? "write",
186
+ companySlug: files.opts().company as string | undefined,
187
+ fullVault: true,
188
+ });
189
+ return;
190
+ }
191
+
154
192
  if (!paths || paths.length === 0) {
155
193
  console.error(
156
194
  chalk.red("usage: hq files share <paths...> [--with <principal>]"),
@@ -472,6 +510,12 @@ interface DirectGrantParams {
472
510
  principal: string;
473
511
  permission: string | undefined;
474
512
  companySlug: string | undefined;
513
+ /**
514
+ * Set by the `--full` affordance: the grant targets the whole vault (the
515
+ * `*` wildcard prefix). Only affects the success message wording — the
516
+ * request shape is identical to any other prefix grant.
517
+ */
518
+ fullVault?: boolean;
475
519
  }
476
520
 
477
521
  async function runDirectGrant(params: DirectGrantParams): Promise<void> {
@@ -572,9 +616,17 @@ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
572
616
  };
573
617
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
574
618
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
575
- console.log(
576
- chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
577
- );
619
+ if (params.fullVault) {
620
+ console.log(
621
+ chalk.green(
622
+ `${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`,
623
+ ),
624
+ );
625
+ } else {
626
+ console.log(
627
+ chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
628
+ );
629
+ }
578
630
  }
579
631
 
580
632
  // ---------------------------------------------------------------------------
@@ -34,6 +34,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
34
34
  import { getCompanyUid } from "../utils/vault-api.js";
35
35
  import {
36
36
  InviteHttpError,
37
+ changeMemberRole,
37
38
  detectTarget,
38
39
  formatInviteHttpError,
39
40
  getCallerPersonUid,
@@ -998,6 +999,121 @@ describe("registerMembersCommand set-role", () => {
998
999
  });
999
1000
  });
1000
1001
 
1002
+ // ---------------------------------------------------------------------------
1003
+ // changeMemberRole + the unified `promote` command (alias `set-role`)
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ describe("changeMemberRole", () => {
1007
+ it("rejects an invalid role WITHOUT making a network call", async () => {
1008
+ await expect(
1009
+ changeMemberRole(
1010
+ "test-token",
1011
+ "cmp_acme",
1012
+ "prs_x#cmp_acme",
1013
+ "superuser" as never,
1014
+ ),
1015
+ ).rejects.toThrow("must be one of owner, admin, member, guest");
1016
+ expect(fetchSpy).not.toHaveBeenCalled();
1017
+ });
1018
+
1019
+ it("accepts the full role set and POSTs to /membership/role", async () => {
1020
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1021
+ await changeMemberRole("test-token", "cmp_acme", "prs_x#cmp_acme", "guest");
1022
+ const call = fetchSpy.mock.calls[0];
1023
+ expect(String(call[0])).toMatch(/\/membership\/role$/);
1024
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
1025
+ expect(body).toEqual({
1026
+ companyUid: "cmp_acme",
1027
+ membershipKey: "prs_x#cmp_acme",
1028
+ newRole: "guest",
1029
+ });
1030
+ });
1031
+ });
1032
+
1033
+ describe("registerMembersCommand promote", () => {
1034
+ it("resolves an email target and promotes to a full-set role the old set-role couldn't (admin)", async () => {
1035
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1036
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1037
+
1038
+ await buildMembersProgram().parseAsync(
1039
+ ["members", "--company", "acme", "promote", "alice@example.com", "admin"],
1040
+ { from: "user" },
1041
+ );
1042
+
1043
+ const call = fetchSpy.mock.calls[0];
1044
+ expect(String(call[0])).toMatch(/\/membership\/role$/);
1045
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
1046
+ expect(body).toEqual({
1047
+ companyUid: "cmp_acme",
1048
+ membershipKey: "email:alice@example.com#cmp_acme",
1049
+ newRole: "admin",
1050
+ });
1051
+ expect(logSpy).toHaveBeenCalledWith(
1052
+ expect.stringContaining("Updated role for 'alice@example.com' to admin"),
1053
+ );
1054
+ });
1055
+
1056
+ it("forwards a personUid target and the guest role (beyond set-role's admin|member cap)", async () => {
1057
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1058
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
1059
+
1060
+ await buildMembersProgram().parseAsync(
1061
+ ["members", "--company", "acme", "promote", "prs_bob", "guest"],
1062
+ { from: "user" },
1063
+ );
1064
+
1065
+ const body = JSON.parse(
1066
+ (fetchSpy.mock.calls[0]?.[1]?.body as string) ?? "{}",
1067
+ );
1068
+ expect(body).toEqual({
1069
+ companyUid: "cmp_acme",
1070
+ membershipKey: "prs_bob#cmp_acme",
1071
+ newRole: "guest",
1072
+ });
1073
+ });
1074
+
1075
+ it("rejects an invalid role at the CLI and exits non-zero without a network call", async () => {
1076
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1077
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1078
+ throw new Error(`__EXIT__:${code ?? 0}`);
1079
+ }) as never);
1080
+
1081
+ await expect(
1082
+ buildMembersProgram().parseAsync(
1083
+ ["members", "--company", "acme", "promote", "alice@example.com", "wizard"],
1084
+ { from: "user" },
1085
+ ),
1086
+ ).rejects.toThrow("__EXIT__:1");
1087
+
1088
+ expect(errSpy).toHaveBeenCalledWith(
1089
+ expect.stringContaining("must be one of owner, admin, member, guest"),
1090
+ );
1091
+ expect(fetchSpy).not.toHaveBeenCalled();
1092
+ });
1093
+
1094
+ it("surfaces a backend 403 verbatim (server-enforced authorization)", async () => {
1095
+ fetchSpy.mockResolvedValueOnce(
1096
+ jsonResponse(403, { error: "Only an owner may promote a member to owner" }),
1097
+ );
1098
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1099
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
1100
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1101
+ throw new Error(`__EXIT__:${code ?? 0}`);
1102
+ }) as never);
1103
+
1104
+ await expect(
1105
+ buildMembersProgram().parseAsync(
1106
+ ["members", "--company", "acme", "promote", "prs_bob", "owner"],
1107
+ { from: "user" },
1108
+ ),
1109
+ ).rejects.toThrow("__EXIT__:1");
1110
+
1111
+ expect(errSpy).toHaveBeenCalledWith(
1112
+ expect.stringContaining("Only an owner may promote a member to owner"),
1113
+ );
1114
+ });
1115
+ });
1116
+
1001
1117
  // ---------------------------------------------------------------------------
1002
1118
  // resolveRevokeTargetToMembershipKey
1003
1119
  // ---------------------------------------------------------------------------
@@ -6,10 +6,8 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
6
6
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
7
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
8
  export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
9
- const VALID_MEMBER_SET_ROLES = new Set(["admin", "member"]);
10
9
 
11
10
  export type Role = "owner" | "admin" | "member" | "guest";
12
- type MemberSetRole = "admin" | "member";
13
11
 
14
12
  export interface PendingInvite {
15
13
  membershipKey: string;
@@ -498,15 +496,24 @@ export async function revokeInvite(
498
496
  }
499
497
  }
500
498
 
501
- async function setMemberRole(
499
+ /**
500
+ * Change a member's role via `POST /membership/role`, accepting the FULL role
501
+ * set (owner|admin|member|guest). This is a GENERAL role change — it can promote
502
+ * OR demote. Authorization (owner-or-admin `changeRoles`, owner-only
503
+ * promote-to-owner / change-an-owner) is enforced SERVER-side; this function
504
+ * only validates the role string locally and surfaces the server's error.
505
+ * Role string is validated BEFORE any network call so callers/tests can rely on
506
+ * a synchronous-shaped rejection for a bad role.
507
+ */
508
+ export async function changeMemberRole(
502
509
  token: string,
503
510
  companyUid: string,
504
511
  membershipKey: string,
505
- newRole: MemberSetRole,
512
+ newRole: Role,
506
513
  ): Promise<void> {
507
- if (!VALID_MEMBER_SET_ROLES.has(newRole)) {
514
+ if (!VALID_ROLES.has(newRole)) {
508
515
  throw new Error(
509
- `Invalid role '${newRole}': must be one of admin, member`,
516
+ `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
510
517
  );
511
518
  }
512
519
 
@@ -532,23 +539,41 @@ export function registerMembersCommand(program: Command): void {
532
539
  .description("Manage company memberships and invites")
533
540
  .option("--company <slug>", "Company slug (resolves to companyUid)");
534
541
 
542
+ // Canonical role-change command. `promote` is the discoverable verb users
543
+ // reach for; `set-role` is kept as an alias for the older name. The route is a
544
+ // GENERAL role change, so the help text and success message are honest that it
545
+ // can demote as well as promote. Authorization is enforced server-side.
535
546
  members
536
- .command("set-role <membershipKey> <role>")
537
- .description("Change a member or agent role to admin or member")
538
- .action(async (membershipKey: string, role: string) => {
547
+ .command("promote <target> <newRole>")
548
+ .alias("set-role")
549
+ .description(
550
+ "Change a member's role (owner|admin|member|guest) — promotes OR demotes. " +
551
+ "<target> may be an email, a prs_ personUid, or a full membership key. " +
552
+ "Owner-or-admin only; setting a target to owner (or changing an owner) is owner-only. Server-enforced.",
553
+ )
554
+ .action(async (target: string, newRole: string) => {
539
555
  try {
556
+ const role = newRole.trim().toLowerCase();
557
+ if (!VALID_ROLES.has(role)) {
558
+ console.error(
559
+ chalk.red(
560
+ `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
561
+ ),
562
+ );
563
+ process.exit(1);
564
+ }
565
+
540
566
  const token = await ensureCognitoToken();
541
567
  const companySlug = members.opts().company as string | undefined;
542
568
  const companyUid = await getCompanyUid(token, companySlug);
543
-
544
- await setMemberRole(
545
- token,
569
+ const membershipKey = resolveRevokeTargetToMembershipKey(
570
+ target,
546
571
  companyUid,
547
- membershipKey,
548
- role as MemberSetRole,
549
572
  );
573
+
574
+ await changeMemberRole(token, companyUid, membershipKey, role as Role);
550
575
  console.log(
551
- chalk.green(`Updated role for '${membershipKey}' to ${role}`),
576
+ chalk.green(`Updated role for '${target}' to ${role}`),
552
577
  );
553
578
  } catch (err) {
554
579
  if (err instanceof InviteHttpError) {