@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 +23 -0
- package/dist/commands/files.js +33 -3
- package/dist/commands/members.d.ts +10 -0
- package/dist/commands/members.js +32 -11
- package/package.json +1 -1
- package/src/commands/files.test.ts +130 -0
- package/src/commands/files.ts +55 -3
- package/src/commands/members.test.ts +116 -0
- package/src/commands/members.ts +40 -15
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
|
package/dist/commands/files.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]="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
|
-
|
|
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=
|
|
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
|
package/dist/commands/members.js
CHANGED
|
@@ -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]="
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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("
|
|
290
|
-
.
|
|
291
|
-
.
|
|
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
|
-
|
|
297
|
-
|
|
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=
|
|
558
|
+
//# debugId=83a54ed2-3f52-5b6f-a784-09f78f488ddd
|
package/package.json
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/commands/files.ts
CHANGED
|
@@ -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
|
-
|
|
576
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
512
|
+
newRole: Role,
|
|
506
513
|
): Promise<void> {
|
|
507
|
-
if (!
|
|
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("
|
|
537
|
-
.
|
|
538
|
-
.
|
|
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
|
-
|
|
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 '${
|
|
576
|
+
chalk.green(`Updated role for '${target}' to ${role}`),
|
|
552
577
|
);
|
|
553
578
|
} catch (err) {
|
|
554
579
|
if (err instanceof InviteHttpError) {
|