@indigoai-us/hq-cli 5.18.2 → 5.19.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 +43 -0
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +28 -2
- package/dist/commands/files-browse.d.ts +42 -5
- package/dist/commands/files-browse.js +130 -26
- package/dist/commands/members.d.ts +17 -0
- package/dist/commands/members.js +53 -11
- package/package.json +1 -1
- package/src/commands/cloud.selectors.test.ts +53 -0
- package/src/commands/cloud.ts +31 -0
- package/src/commands/files-browse.test.ts +212 -0
- package/src/commands/files-browse.ts +219 -41
- package/src/commands/members.test.ts +53 -0
- package/src/commands/members.ts +69 -9
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
buildVaultConfig,
|
|
58
58
|
} from "../utils/cognito-session.js";
|
|
59
59
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
60
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
60
61
|
|
|
61
62
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
62
63
|
|
|
@@ -99,8 +100,17 @@ export type S3ClientFactory = (input: {
|
|
|
99
100
|
};
|
|
100
101
|
}) => FilesBrowseS3Client;
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* ACL provenance for a single listed key.
|
|
105
|
+
* - `shared-with-you`: an explicit grant the caller holds covers the key.
|
|
106
|
+
* - `role-bypass`: the caller has no covering explicit grant, but
|
|
107
|
+
* owner/admin role widened the browse-vend policy to include it.
|
|
108
|
+
* - `personal-vault`: the key lives in the caller's own person-entity
|
|
109
|
+
* vault, where no grants graph applies — the caller is the only
|
|
110
|
+
* principal with access by construction. Emitted only when
|
|
111
|
+
* `runBrowse({ personalMode: true })`.
|
|
112
|
+
*/
|
|
113
|
+
export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
|
|
104
114
|
|
|
105
115
|
export interface BrowseRow {
|
|
106
116
|
key: string;
|
|
@@ -201,10 +211,30 @@ export function formatBrowseTable(rows: BrowseRow[]): string {
|
|
|
201
211
|
// ── Orchestrators ───────────────────────────────────────────────────────────
|
|
202
212
|
|
|
203
213
|
export interface RunBrowseInput {
|
|
204
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Vault path prefix.
|
|
216
|
+
* - Company mode (`personalMode: false | undefined`): must start with
|
|
217
|
+
* `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
|
|
218
|
+
* - Personal mode (`personalMode: true`): bucket-relative; empty string
|
|
219
|
+
* lists the whole personal vault root.
|
|
220
|
+
*/
|
|
205
221
|
pathPrefix: string;
|
|
206
|
-
/** Caller-overridden company slug (defaults to slug parsed from path). */
|
|
222
|
+
/** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
|
|
207
223
|
companySlug?: string;
|
|
224
|
+
/**
|
|
225
|
+
* Personal-vault mode. Skips the `companies/<slug>/` path requirement,
|
|
226
|
+
* resolves the entity via `entity.get(personalUid)` instead of the
|
|
227
|
+
* company namespace, omits the explicit-grants fetch (no grants graph
|
|
228
|
+
* on a person bucket), and marks every row's `aclSource` as
|
|
229
|
+
* `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
|
|
230
|
+
*/
|
|
231
|
+
personalMode?: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Canonical person-entity UID (e.g. `prs_…`). Required when
|
|
234
|
+
* `personalMode: true`; ignored otherwise. Caller resolves via
|
|
235
|
+
* `resolveCanonicalPersonUid` to keep this orchestrator pure.
|
|
236
|
+
*/
|
|
237
|
+
personalUid?: string;
|
|
208
238
|
vaultClient: FilesBrowseVaultClient;
|
|
209
239
|
s3Factory: S3ClientFactory;
|
|
210
240
|
region: string;
|
|
@@ -227,25 +257,52 @@ export interface RunBrowseResult {
|
|
|
227
257
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
228
258
|
*/
|
|
229
259
|
export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
|
|
230
|
-
const { pathPrefix, vaultClient, s3Factory, region } = input;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
|
|
261
|
+
|
|
262
|
+
// Branch by mode. Company mode parses slug from path and looks up by
|
|
263
|
+
// namespace; personal mode resolves the entity directly by the supplied
|
|
264
|
+
// person UID and skips the slug + grants machinery (a person bucket has
|
|
265
|
+
// no grants graph — the owner is the only principal). The vend call is
|
|
266
|
+
// identical for both modes once we have the entity in hand.
|
|
267
|
+
let bucket: string;
|
|
268
|
+
let entityUid: string;
|
|
269
|
+
if (personalMode) {
|
|
270
|
+
if (!input.personalUid) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"runBrowse: personalMode requires personalUid. Resolve via " +
|
|
273
|
+
"resolveCanonicalPersonUid() before calling.",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
277
|
+
if (!entity.bucketName) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Personal entity '${input.personalUid}' has no provisioned bucket.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
entityUid = entity.uid;
|
|
283
|
+
bucket = entity.bucketName;
|
|
284
|
+
} else {
|
|
285
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
286
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
287
|
+
if (!entity) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (!entity.bucketName) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
entityUid = entity.uid;
|
|
298
|
+
bucket = entity.bucketName;
|
|
243
299
|
}
|
|
244
|
-
const companyUid = entity.uid;
|
|
245
|
-
const bucket = entity.bucketName;
|
|
246
300
|
|
|
247
301
|
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
248
|
-
// into the role-bypass-allowed code path on the server (US-009).
|
|
302
|
+
// into the role-bypass-allowed code path on the server (US-009). The
|
|
303
|
+
// personal mode vends against the person entity which is owner-only by
|
|
304
|
+
// construction; the vend response shape is identical so downstream
|
|
305
|
+
// S3Client construction doesn't branch.
|
|
249
306
|
const vend = await vaultClient.vend({
|
|
250
307
|
paths: [pathPrefix],
|
|
251
308
|
operations: "read-only",
|
|
@@ -262,8 +319,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
262
319
|
});
|
|
263
320
|
|
|
264
321
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
265
|
-
// is O(grants) without N round-trips.
|
|
266
|
-
|
|
322
|
+
// is O(grants) without N round-trips. Skipped in personal mode — the
|
|
323
|
+
// grants graph is a company concept; a person bucket marks every row
|
|
324
|
+
// as `"personal-vault"` directly.
|
|
325
|
+
const grants = personalMode
|
|
326
|
+
? ([] as ExplicitGrant[])
|
|
327
|
+
: await vaultClient.listMyExplicitGrants(entityUid);
|
|
267
328
|
|
|
268
329
|
const rows: BrowseRow[] = [];
|
|
269
330
|
let continuationToken: string | undefined;
|
|
@@ -285,7 +346,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
285
346
|
key: obj.Key,
|
|
286
347
|
size: obj.Size ?? 0,
|
|
287
348
|
lastModified: obj.LastModified,
|
|
288
|
-
aclSource: classifyAclSource(obj.Key, grants),
|
|
349
|
+
aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
|
|
289
350
|
});
|
|
290
351
|
}
|
|
291
352
|
|
|
@@ -296,7 +357,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
296
357
|
}
|
|
297
358
|
|
|
298
359
|
export interface RunCatInput {
|
|
299
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* Single vault key.
|
|
362
|
+
* - Company mode: must be a `companies/<slug>/...` path.
|
|
363
|
+
* - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
|
|
364
|
+
*/
|
|
300
365
|
key: string;
|
|
301
366
|
/**
|
|
302
367
|
* Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
|
|
@@ -305,6 +370,10 @@ export interface RunCatInput {
|
|
|
305
370
|
out?: string;
|
|
306
371
|
hqRoot: string;
|
|
307
372
|
companySlug?: string;
|
|
373
|
+
/** Personal-vault mode — see `RunBrowseInput.personalMode`. */
|
|
374
|
+
personalMode?: boolean;
|
|
375
|
+
/** Canonical person-entity UID; required when `personalMode: true`. */
|
|
376
|
+
personalUid?: string;
|
|
308
377
|
vaultClient: FilesBrowseVaultClient;
|
|
309
378
|
s3Factory: S3ClientFactory;
|
|
310
379
|
region: string;
|
|
@@ -324,8 +393,7 @@ export interface RunCatResult {
|
|
|
324
393
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
325
394
|
*/
|
|
326
395
|
export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
327
|
-
const { key, vaultClient, s3Factory, region, hqRoot } = input;
|
|
328
|
-
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
396
|
+
const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
|
|
329
397
|
|
|
330
398
|
// Acceptance 5: refuse BEFORE vending — no point pulling credentials
|
|
331
399
|
// for a request we're already going to abort.
|
|
@@ -334,16 +402,37 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
334
402
|
absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
|
|
335
403
|
}
|
|
336
404
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
405
|
+
// Same branch logic as runBrowse — see that function's doc-block for
|
|
406
|
+
// the personal-vs-company rationale.
|
|
407
|
+
let bucket: string;
|
|
408
|
+
if (personalMode) {
|
|
409
|
+
if (!input.personalUid) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"runCat: personalMode requires personalUid. Resolve via " +
|
|
412
|
+
"resolveCanonicalPersonUid() before calling.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
416
|
+
if (!entity.bucketName) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`Personal entity '${input.personalUid}' has no provisioned bucket.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
bucket = entity.bucketName;
|
|
422
|
+
} else {
|
|
423
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
424
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
425
|
+
if (!entity) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (!entity.bucketName) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
bucket = entity.bucketName;
|
|
347
436
|
}
|
|
348
437
|
|
|
349
438
|
const vend = await vaultClient.vend({
|
|
@@ -362,7 +451,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
362
451
|
});
|
|
363
452
|
|
|
364
453
|
const resp = (await s3.send(
|
|
365
|
-
new GetObjectCommand({ Bucket:
|
|
454
|
+
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
|
366
455
|
)) as GetObjectCommandOutput;
|
|
367
456
|
|
|
368
457
|
if (!resp.Body) {
|
|
@@ -403,6 +492,13 @@ const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
|
403
492
|
interface FilesBrowseCliOptions {
|
|
404
493
|
company?: string;
|
|
405
494
|
hqRoot: string;
|
|
495
|
+
/**
|
|
496
|
+
* Personal-vault mode (hq-cli#26). Skips the `companies/<slug>/`
|
|
497
|
+
* requirement on the path arg, resolves the entity from the caller's
|
|
498
|
+
* canonical person UID, and emits rows tagged `personal-vault`.
|
|
499
|
+
* Mutually exclusive with `--company`.
|
|
500
|
+
*/
|
|
501
|
+
personal?: boolean;
|
|
406
502
|
}
|
|
407
503
|
|
|
408
504
|
interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
@@ -417,25 +513,69 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
|
417
513
|
*/
|
|
418
514
|
export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
419
515
|
filesCmd
|
|
420
|
-
.command("browse
|
|
516
|
+
.command("browse [path]")
|
|
421
517
|
.description(
|
|
422
|
-
"List vault objects under
|
|
518
|
+
"List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
|
|
423
519
|
)
|
|
424
520
|
.option(
|
|
425
521
|
"--company <slug>",
|
|
426
522
|
"Company slug (defaults to the slug parsed from <path>)",
|
|
427
523
|
)
|
|
524
|
+
.option(
|
|
525
|
+
"--personal",
|
|
526
|
+
"Browse the caller's canonical personal vault. [path] is treated as " +
|
|
527
|
+
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
528
|
+
"with --company.",
|
|
529
|
+
)
|
|
428
530
|
.option(
|
|
429
531
|
"--hq-root <path>",
|
|
430
532
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
431
533
|
DEFAULT_HQ_ROOT,
|
|
432
534
|
)
|
|
433
|
-
.action(async (pathArg: string, options: FilesBrowseCliOptions) => {
|
|
535
|
+
.action(async (pathArg: string | undefined, options: FilesBrowseCliOptions) => {
|
|
434
536
|
try {
|
|
537
|
+
if (options.personal && options.company) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
435
543
|
const accessToken = await ensureCognitoToken();
|
|
436
544
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
437
545
|
const client = new VaultClient(vaultConfig);
|
|
438
546
|
|
|
547
|
+
if (options.personal) {
|
|
548
|
+
// Personal-vault path. Resolve the caller's canonical person
|
|
549
|
+
// entity once; the orchestrator does the bucket lookup + vend.
|
|
550
|
+
// Empty [path] → list bucket root.
|
|
551
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
552
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
553
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
554
|
+
getEntity: async () => null,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const result = await runBrowse({
|
|
558
|
+
pathPrefix: pathArg ?? "",
|
|
559
|
+
personalMode: true,
|
|
560
|
+
personalUid,
|
|
561
|
+
vaultClient: client,
|
|
562
|
+
s3Factory: defaultS3Factory,
|
|
563
|
+
region: DEFAULT_COGNITO.region,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
console.log(formatBrowseTable(result.rows));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Company path. [path] is required here — the slug parse needs it.
|
|
571
|
+
if (!pathArg) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
"browse: [path] is required when --personal is not set. " +
|
|
574
|
+
"Pass a companies/<slug>/... path, or add --personal to " +
|
|
575
|
+
"browse your personal vault.",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
439
579
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
440
580
|
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
441
581
|
|
|
@@ -496,7 +636,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
496
636
|
filesCmd
|
|
497
637
|
.command("cat <path>")
|
|
498
638
|
.description(
|
|
499
|
-
"Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.",
|
|
639
|
+
"Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.",
|
|
500
640
|
)
|
|
501
641
|
.option(
|
|
502
642
|
"--out <file>",
|
|
@@ -506,6 +646,11 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
506
646
|
"--company <slug>",
|
|
507
647
|
"Company slug (defaults to the slug parsed from <path>)",
|
|
508
648
|
)
|
|
649
|
+
.option(
|
|
650
|
+
"--personal",
|
|
651
|
+
"Read from the caller's canonical personal vault. <path> is treated as " +
|
|
652
|
+
"bucket-relative. Mutually exclusive with --company.",
|
|
653
|
+
)
|
|
509
654
|
.option(
|
|
510
655
|
"--hq-root <path>",
|
|
511
656
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
@@ -513,10 +658,43 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
513
658
|
)
|
|
514
659
|
.action(async (keyArg: string, options: FilesCatCliOptions) => {
|
|
515
660
|
try {
|
|
661
|
+
if (options.personal && options.company) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
516
667
|
const accessToken = await ensureCognitoToken();
|
|
517
668
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
518
669
|
const client = new VaultClient(vaultConfig);
|
|
519
670
|
|
|
671
|
+
if (options.personal) {
|
|
672
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
673
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
674
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
675
|
+
getEntity: async () => null,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
const result = await runCat({
|
|
679
|
+
key: keyArg,
|
|
680
|
+
out: options.out,
|
|
681
|
+
hqRoot: options.hqRoot,
|
|
682
|
+
personalMode: true,
|
|
683
|
+
personalUid,
|
|
684
|
+
vaultClient: client,
|
|
685
|
+
s3Factory: defaultS3Factory,
|
|
686
|
+
region: DEFAULT_COGNITO.region,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
if (result.destination.kind === "file") {
|
|
690
|
+
console.error(
|
|
691
|
+
chalk.green("✓"),
|
|
692
|
+
`Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
520
698
|
const slug = options.company ?? parseCompanySlugFromPath(keyArg);
|
|
521
699
|
if (options.company !== undefined) {
|
|
522
700
|
const fromPath = (() => {
|
|
@@ -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
|
|
|
@@ -414,6 +415,58 @@ describe("revokeInvite", () => {
|
|
|
414
415
|
});
|
|
415
416
|
});
|
|
416
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
|
+
|
|
417
470
|
// ---------------------------------------------------------------------------
|
|
418
471
|
// formatInviteHttpError
|
|
419
472
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -248,6 +248,37 @@ export async function listPendingInvites(
|
|
|
248
248
|
return data?.pending ?? data?.invites ?? [];
|
|
249
249
|
}
|
|
250
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;
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
export async function revokeInvite(
|
|
252
283
|
token: string,
|
|
253
284
|
tokenOrKey: string,
|
|
@@ -328,24 +359,47 @@ export function registerMembersCommand(program: Command): void {
|
|
|
328
359
|
} else {
|
|
329
360
|
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
330
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.
|
|
331
369
|
const inviteeEmail =
|
|
332
370
|
result.membership.inviteeEmail ??
|
|
333
371
|
(typeof target === "string" && target.includes("@")
|
|
334
372
|
? target
|
|
335
373
|
: undefined);
|
|
336
|
-
console.log(chalk.bold("Next step:"));
|
|
337
374
|
console.log(
|
|
338
|
-
|
|
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`,
|
|
339
389
|
);
|
|
390
|
+
console.log(
|
|
391
|
+
` invite instead — the UI path triggers an automated email via Resend.`,
|
|
392
|
+
);
|
|
393
|
+
console.log();
|
|
340
394
|
console.log(
|
|
341
395
|
chalk.dim(
|
|
342
|
-
"
|
|
396
|
+
"The pending membership row claims itself on the invitee's first sign-in.",
|
|
343
397
|
),
|
|
344
398
|
);
|
|
345
399
|
if (result.membership.membershipKey) {
|
|
346
400
|
console.log();
|
|
347
401
|
console.log(
|
|
348
|
-
chalk.dim(`
|
|
402
|
+
chalk.dim(`Membership key: ${result.membership.membershipKey}`),
|
|
349
403
|
);
|
|
350
404
|
}
|
|
351
405
|
}
|
|
@@ -431,16 +485,22 @@ export function registerMembersCommand(program: Command): void {
|
|
|
431
485
|
});
|
|
432
486
|
|
|
433
487
|
members
|
|
434
|
-
.command("revoke <
|
|
435
|
-
.description(
|
|
436
|
-
|
|
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) => {
|
|
437
493
|
try {
|
|
438
494
|
const token = await ensureCognitoToken();
|
|
439
495
|
const companySlug = members.opts().company as string | undefined;
|
|
440
496
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
441
497
|
|
|
442
|
-
|
|
443
|
-
|
|
498
|
+
const membershipKey = resolveRevokeTargetToMembershipKey(
|
|
499
|
+
target,
|
|
500
|
+
companyUid,
|
|
501
|
+
);
|
|
502
|
+
await revokeInvite(token, membershipKey, companyUid);
|
|
503
|
+
console.log(chalk.green(`Revoked invite '${membershipKey}'`));
|
|
444
504
|
} catch (err) {
|
|
445
505
|
if (err instanceof InviteHttpError) {
|
|
446
506
|
const msg =
|