@indigoai-us/hq-cli 5.10.1 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +42 -3
- package/dist/commands/secrets.d.ts +2 -2
- package/dist/commands/secrets.js +33 -25
- package/dist/utils/vault-api.d.ts +5 -0
- package/dist/utils/vault-api.js +35 -2
- package/package.json +1 -1
- package/src/commands/cloud.ts +40 -0
- package/src/commands/secrets.ts +86 -23
- package/src/utils/vault-api.test.ts +111 -0
- package/src/utils/vault-api.ts +43 -0
package/dist/commands/cloud.js
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!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]="
|
|
16
|
+
!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]="213f074e-13c2-5b95-9519-813adf9adfa9")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
|
-
import { share, sync, readJournal, getJournalPath, } from "@indigoai-us/hq-cloud";
|
|
20
|
+
import { share, sync, readJournal, getJournalPath, loadCachedTokens, } from "@indigoai-us/hq-cloud";
|
|
21
21
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
22
22
|
export function registerCloudCommands(program) {
|
|
23
23
|
program
|
|
@@ -85,6 +85,13 @@ export function registerCloudCommands(program) {
|
|
|
85
85
|
const onEvent = jsonMode
|
|
86
86
|
? (event) => emitJson(event)
|
|
87
87
|
: undefined;
|
|
88
|
+
// Stamp every uploaded object's S3 user metadata with the syncing
|
|
89
|
+
// user's Cognito identity (`Metadata['created-by']`). The hq-console
|
|
90
|
+
// vault UI's CREATED BY column reads this back via HEAD; without it,
|
|
91
|
+
// every row renders `—`. Resolved best-effort from the cached
|
|
92
|
+
// idToken — pre-vended `--creds-from-stdin` paths still get author
|
|
93
|
+
// attribution as long as the caller is logged in locally.
|
|
94
|
+
const author = resolveUploadAuthorFromCache();
|
|
88
95
|
const result = await share({
|
|
89
96
|
paths: targetPaths,
|
|
90
97
|
company: options.company,
|
|
@@ -94,6 +101,7 @@ export function registerCloudCommands(program) {
|
|
|
94
101
|
entityContext,
|
|
95
102
|
hqRoot: options.hqRoot,
|
|
96
103
|
onEvent,
|
|
104
|
+
...(author ? { author } : {}),
|
|
97
105
|
});
|
|
98
106
|
if (jsonMode) {
|
|
99
107
|
// Synthetic terminal event so subprocess consumers can read final
|
|
@@ -226,5 +234,36 @@ async function readAllStdin() {
|
|
|
226
234
|
}
|
|
227
235
|
return Buffer.concat(chunks).toString("utf8");
|
|
228
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
|
|
239
|
+
* Cognito idToken. Returns `undefined` when no tokens are cached or the
|
|
240
|
+
* token is missing the required claims — share() then skips the metadata
|
|
241
|
+
* stamp gracefully (not an error).
|
|
242
|
+
*
|
|
243
|
+
* We deliberately decode the JWT here instead of verifying it: Cognito
|
|
244
|
+
* already verified at issuance, and we only use the public claims to
|
|
245
|
+
* label the upload's S3 user metadata (no auth decision rides on it).
|
|
246
|
+
*/
|
|
247
|
+
function resolveUploadAuthorFromCache() {
|
|
248
|
+
const tokens = loadCachedTokens();
|
|
249
|
+
if (!tokens?.idToken)
|
|
250
|
+
return undefined;
|
|
251
|
+
const parts = tokens.idToken.split(".");
|
|
252
|
+
if (parts.length !== 3)
|
|
253
|
+
return undefined;
|
|
254
|
+
try {
|
|
255
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
256
|
+
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
257
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
258
|
+
const claims = JSON.parse(json);
|
|
259
|
+
if (claims.sub && claims.email) {
|
|
260
|
+
return { userSub: claims.sub, email: claims.email };
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
229
268
|
//# sourceMappingURL=cloud.js.map
|
|
230
|
-
//# debugId=
|
|
269
|
+
//# debugId=213f074e-13c2-5b95-9519-813adf9adfa9
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
2
|
+
import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
|
|
3
3
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
4
|
-
export { vaultApiFetch, getCompanyUid };
|
|
4
|
+
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
5
5
|
export declare function registerSecretsCommand(program: Command): void;
|
|
6
6
|
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
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]="217ceadb-01cd-5778-9cc0-6f6d895d8d67")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
7
|
import { readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
8
8
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
|
|
9
|
-
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
10
|
-
export { vaultApiFetch, getCompanyUid };
|
|
9
|
+
import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
|
|
10
|
+
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
11
|
+
function scopeOpts(opts) {
|
|
12
|
+
if (opts.personal && opts.company) {
|
|
13
|
+
console.error(chalk.red("Error: --personal cannot be combined with --company."));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
return { personal: !!opts.personal, companySlug: opts.company };
|
|
17
|
+
}
|
|
18
|
+
function rejectIfPersonal(opts, action) {
|
|
19
|
+
if (opts.personal) {
|
|
20
|
+
console.error(chalk.red(`Error: ${action} is not supported with --personal.`));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
11
24
|
function shellSingleQuote(value) {
|
|
12
25
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
13
26
|
}
|
|
@@ -115,7 +128,8 @@ export function registerSecretsCommand(program) {
|
|
|
115
128
|
const secrets = program
|
|
116
129
|
.command("secrets")
|
|
117
130
|
.description("Manage secrets in HQ vault (SSM Parameter Store)")
|
|
118
|
-
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
131
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
132
|
+
.option("--personal", "Operate on the caller's personal vault (no sharing)");
|
|
119
133
|
secrets
|
|
120
134
|
.command("set <name>")
|
|
121
135
|
.description("Create or update a secret")
|
|
@@ -150,8 +164,7 @@ export function registerSecretsCommand(program) {
|
|
|
150
164
|
process.exit(1);
|
|
151
165
|
}
|
|
152
166
|
const token = await ensureCognitoToken();
|
|
153
|
-
const
|
|
154
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
167
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
155
168
|
const res = await vaultApiFetch({
|
|
156
169
|
token,
|
|
157
170
|
path: `/secrets/${encodeURIComponent(companyUid)}`,
|
|
@@ -178,8 +191,7 @@ export function registerSecretsCommand(program) {
|
|
|
178
191
|
.action(async (name, opts) => {
|
|
179
192
|
try {
|
|
180
193
|
const token = await ensureCognitoToken();
|
|
181
|
-
const
|
|
182
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
194
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
183
195
|
const query = {};
|
|
184
196
|
if (opts.reveal) {
|
|
185
197
|
query.reveal = "true";
|
|
@@ -233,8 +245,7 @@ export function registerSecretsCommand(program) {
|
|
|
233
245
|
normalizedPrefix = normalized;
|
|
234
246
|
}
|
|
235
247
|
const token = await ensureCognitoToken();
|
|
236
|
-
const
|
|
237
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
248
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
238
249
|
const query = {};
|
|
239
250
|
if (normalizedPrefix) {
|
|
240
251
|
query.prefix = normalizedPrefix;
|
|
@@ -298,8 +309,7 @@ export function registerSecretsCommand(program) {
|
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
311
|
const token = await ensureCognitoToken();
|
|
301
|
-
const
|
|
302
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
312
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
303
313
|
const res = await vaultApiFetch({
|
|
304
314
|
token,
|
|
305
315
|
path: buildSecretNamePath(companyUid, name),
|
|
@@ -350,8 +360,7 @@ export function registerSecretsCommand(program) {
|
|
|
350
360
|
}
|
|
351
361
|
}
|
|
352
362
|
const token = await ensureCognitoToken();
|
|
353
|
-
const
|
|
354
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
363
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
355
364
|
const revealed = await Promise.all(keys.map(async (key) => {
|
|
356
365
|
const cached = readCache(companyUid, key);
|
|
357
366
|
if (cached !== null) {
|
|
@@ -420,8 +429,7 @@ export function registerSecretsCommand(program) {
|
|
|
420
429
|
}
|
|
421
430
|
}
|
|
422
431
|
const token = await ensureCognitoToken();
|
|
423
|
-
const
|
|
424
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
432
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
425
433
|
const revealed = await Promise.all(keys.map(async (key) => {
|
|
426
434
|
const cached = readCache(companyUid, key);
|
|
427
435
|
if (cached !== null) {
|
|
@@ -459,6 +467,7 @@ export function registerSecretsCommand(program) {
|
|
|
459
467
|
.option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
|
|
460
468
|
.action(async (name, opts) => {
|
|
461
469
|
try {
|
|
470
|
+
rejectIfPersonal(secrets.opts(), "generate-link");
|
|
462
471
|
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
463
472
|
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
|
|
464
473
|
process.exit(1);
|
|
@@ -474,8 +483,7 @@ export function registerSecretsCommand(program) {
|
|
|
474
483
|
process.exit(1);
|
|
475
484
|
}
|
|
476
485
|
const token = await ensureCognitoToken();
|
|
477
|
-
const
|
|
478
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
486
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
479
487
|
const res = await vaultApiFetch({
|
|
480
488
|
token,
|
|
481
489
|
path: buildSecretNamePath(companyUid, name),
|
|
@@ -507,6 +515,7 @@ export function registerSecretsCommand(program) {
|
|
|
507
515
|
.requiredOption("--permission <level>", "Permission level: read | write | admin")
|
|
508
516
|
.action(async (path, opts) => {
|
|
509
517
|
try {
|
|
518
|
+
rejectIfPersonal(secrets.opts(), "share");
|
|
510
519
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
511
520
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
512
521
|
process.exit(1);
|
|
@@ -523,8 +532,7 @@ export function registerSecretsCommand(program) {
|
|
|
523
532
|
const granteeType = isEmail ? "email" : "group";
|
|
524
533
|
const granteeId = opts.with;
|
|
525
534
|
const token = await ensureCognitoToken();
|
|
526
|
-
const
|
|
527
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
535
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
528
536
|
const res = await vaultApiFetch({
|
|
529
537
|
token,
|
|
530
538
|
path: `/secrets/${encodeURIComponent(companyUid)}/acl/grant`,
|
|
@@ -566,6 +574,7 @@ export function registerSecretsCommand(program) {
|
|
|
566
574
|
.requiredOption("--from <principal>", "Email address or group id to remove")
|
|
567
575
|
.action(async (path, opts) => {
|
|
568
576
|
try {
|
|
577
|
+
rejectIfPersonal(secrets.opts(), "unshare");
|
|
569
578
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
570
579
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
571
580
|
process.exit(1);
|
|
@@ -578,8 +587,7 @@ export function registerSecretsCommand(program) {
|
|
|
578
587
|
const granteeType = isEmailFrom ? "email" : "group";
|
|
579
588
|
const granteeId = opts.from;
|
|
580
589
|
const token = await ensureCognitoToken();
|
|
581
|
-
const
|
|
582
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
590
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
583
591
|
const res = await vaultApiFetch({
|
|
584
592
|
token,
|
|
585
593
|
path: `/secrets/${encodeURIComponent(companyUid)}/acl/revoke`,
|
|
@@ -618,13 +626,13 @@ export function registerSecretsCommand(program) {
|
|
|
618
626
|
.description("Show the ACL (access control list) for a secret path")
|
|
619
627
|
.action(async (path) => {
|
|
620
628
|
try {
|
|
629
|
+
rejectIfPersonal(secrets.opts(), "acl");
|
|
621
630
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
622
631
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
623
632
|
process.exit(1);
|
|
624
633
|
}
|
|
625
634
|
const token = await ensureCognitoToken();
|
|
626
|
-
const
|
|
627
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
635
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
628
636
|
const secretPath = path;
|
|
629
637
|
const res = await vaultApiFetch({
|
|
630
638
|
token,
|
|
@@ -705,4 +713,4 @@ export function registerSecretsCommand(program) {
|
|
|
705
713
|
});
|
|
706
714
|
}
|
|
707
715
|
//# sourceMappingURL=secrets.js.map
|
|
708
|
-
//# debugId=
|
|
716
|
+
//# debugId=217ceadb-01cd-5778-9cc0-6f6d895d8d67
|
|
@@ -7,4 +7,9 @@ export interface VaultApiOptions {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function vaultApiFetch(opts: VaultApiOptions): Promise<Response>;
|
|
9
9
|
export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
|
|
10
|
+
export declare function resolveCallerPersonUid(token: string): Promise<string>;
|
|
11
|
+
export declare function getEntityUid(token: string, opts: {
|
|
12
|
+
personal?: boolean;
|
|
13
|
+
companySlug?: string;
|
|
14
|
+
}): Promise<string>;
|
|
10
15
|
//# sourceMappingURL=vault-api.d.ts.map
|
package/dist/utils/vault-api.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]="05d73a12-54b0-559c-a964-de7dff2d48eb")}catch(e){}}();
|
|
3
3
|
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
4
4
|
export async function vaultApiFetch(opts) {
|
|
5
5
|
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
@@ -54,5 +54,38 @@ export async function getCompanyUid(token, companySlug) {
|
|
|
54
54
|
}
|
|
55
55
|
return resolveCompanyFromMemberships(token);
|
|
56
56
|
}
|
|
57
|
+
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
58
|
+
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
59
|
+
export async function resolveCallerPersonUid(token) {
|
|
60
|
+
const res = await vaultApiFetch({
|
|
61
|
+
token,
|
|
62
|
+
path: '/entity/by-type/person',
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|
|
66
|
+
}
|
|
67
|
+
const data = (await res.json());
|
|
68
|
+
const persons = (data.entities ?? []).filter((e) => e.type === 'person');
|
|
69
|
+
if (persons.length === 0) {
|
|
70
|
+
throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
|
|
71
|
+
}
|
|
72
|
+
persons.sort((a, b) => {
|
|
73
|
+
const ac = a.createdAt ?? '';
|
|
74
|
+
const bc = b.createdAt ?? '';
|
|
75
|
+
if (ac !== bc)
|
|
76
|
+
return ac < bc ? -1 : 1;
|
|
77
|
+
return a.uid < b.uid ? -1 : 1;
|
|
78
|
+
});
|
|
79
|
+
return persons[0].uid;
|
|
80
|
+
}
|
|
81
|
+
// Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
|
|
82
|
+
// `--personal` → caller's canonical person entity; else `--company <slug>` →
|
|
83
|
+
// resolved company UID; else fallback to single active company membership.
|
|
84
|
+
export async function getEntityUid(token, opts) {
|
|
85
|
+
if (opts.personal) {
|
|
86
|
+
return resolveCallerPersonUid(token);
|
|
87
|
+
}
|
|
88
|
+
return getCompanyUid(token, opts.companySlug);
|
|
89
|
+
}
|
|
57
90
|
//# sourceMappingURL=vault-api.js.map
|
|
58
|
-
//# debugId=
|
|
91
|
+
//# debugId=05d73a12-54b0-559c-a964-de7dff2d48eb
|
package/package.json
CHANGED
package/src/commands/cloud.ts
CHANGED
|
@@ -23,9 +23,11 @@ import {
|
|
|
23
23
|
sync,
|
|
24
24
|
readJournal,
|
|
25
25
|
getJournalPath,
|
|
26
|
+
loadCachedTokens,
|
|
26
27
|
type ConflictStrategy,
|
|
27
28
|
type EntityContext,
|
|
28
29
|
type SyncProgressEvent,
|
|
30
|
+
type UploadAuthor,
|
|
29
31
|
} from "@indigoai-us/hq-cloud";
|
|
30
32
|
|
|
31
33
|
import {
|
|
@@ -146,6 +148,14 @@ export function registerCloudCommands(program: Command): void {
|
|
|
146
148
|
emitJson(event as unknown as Record<string, unknown>)
|
|
147
149
|
: undefined;
|
|
148
150
|
|
|
151
|
+
// Stamp every uploaded object's S3 user metadata with the syncing
|
|
152
|
+
// user's Cognito identity (`Metadata['created-by']`). The hq-console
|
|
153
|
+
// vault UI's CREATED BY column reads this back via HEAD; without it,
|
|
154
|
+
// every row renders `—`. Resolved best-effort from the cached
|
|
155
|
+
// idToken — pre-vended `--creds-from-stdin` paths still get author
|
|
156
|
+
// attribution as long as the caller is logged in locally.
|
|
157
|
+
const author = resolveUploadAuthorFromCache();
|
|
158
|
+
|
|
149
159
|
const result = await share({
|
|
150
160
|
paths: targetPaths,
|
|
151
161
|
company: options.company,
|
|
@@ -155,6 +165,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
155
165
|
entityContext,
|
|
156
166
|
hqRoot: options.hqRoot,
|
|
157
167
|
onEvent,
|
|
168
|
+
...(author ? { author } : {}),
|
|
158
169
|
});
|
|
159
170
|
|
|
160
171
|
if (jsonMode) {
|
|
@@ -343,3 +354,32 @@ async function readAllStdin(): Promise<string> {
|
|
|
343
354
|
}
|
|
344
355
|
return Buffer.concat(chunks).toString("utf8");
|
|
345
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
|
|
360
|
+
* Cognito idToken. Returns `undefined` when no tokens are cached or the
|
|
361
|
+
* token is missing the required claims — share() then skips the metadata
|
|
362
|
+
* stamp gracefully (not an error).
|
|
363
|
+
*
|
|
364
|
+
* We deliberately decode the JWT here instead of verifying it: Cognito
|
|
365
|
+
* already verified at issuance, and we only use the public claims to
|
|
366
|
+
* label the upload's S3 user metadata (no auth decision rides on it).
|
|
367
|
+
*/
|
|
368
|
+
function resolveUploadAuthorFromCache(): UploadAuthor | undefined {
|
|
369
|
+
const tokens = loadCachedTokens();
|
|
370
|
+
if (!tokens?.idToken) return undefined;
|
|
371
|
+
const parts = tokens.idToken.split(".");
|
|
372
|
+
if (parts.length !== 3) return undefined;
|
|
373
|
+
try {
|
|
374
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
375
|
+
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
376
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
377
|
+
const claims = JSON.parse(json) as { sub?: string; email?: string };
|
|
378
|
+
if (claims.sub && claims.email) {
|
|
379
|
+
return { userSub: claims.sub, email: claims.email };
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/commands/secrets.ts
CHANGED
|
@@ -10,9 +10,40 @@ import {
|
|
|
10
10
|
clearAllCache,
|
|
11
11
|
} from "../utils/secrets-cache.js";
|
|
12
12
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
vaultApiFetch,
|
|
15
|
+
getCompanyUid,
|
|
16
|
+
getEntityUid,
|
|
17
|
+
} from "../utils/vault-api.js";
|
|
14
18
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
15
|
-
export { vaultApiFetch, getCompanyUid };
|
|
19
|
+
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
20
|
+
|
|
21
|
+
interface SecretsScopeOpts {
|
|
22
|
+
company?: string;
|
|
23
|
+
personal?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scopeOpts(opts: SecretsScopeOpts): {
|
|
27
|
+
personal: boolean;
|
|
28
|
+
companySlug: string | undefined;
|
|
29
|
+
} {
|
|
30
|
+
if (opts.personal && opts.company) {
|
|
31
|
+
console.error(
|
|
32
|
+
chalk.red("Error: --personal cannot be combined with --company."),
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
return { personal: !!opts.personal, companySlug: opts.company };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rejectIfPersonal(opts: SecretsScopeOpts, action: string): void {
|
|
40
|
+
if (opts.personal) {
|
|
41
|
+
console.error(
|
|
42
|
+
chalk.red(`Error: ${action} is not supported with --personal.`),
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
16
47
|
|
|
17
48
|
function shellSingleQuote(value: string): string {
|
|
18
49
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
@@ -125,7 +156,11 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
125
156
|
const secrets = program
|
|
126
157
|
.command("secrets")
|
|
127
158
|
.description("Manage secrets in HQ vault (SSM Parameter Store)")
|
|
128
|
-
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
159
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
160
|
+
.option(
|
|
161
|
+
"--personal",
|
|
162
|
+
"Operate on the caller's personal vault (no sharing)",
|
|
163
|
+
);
|
|
129
164
|
|
|
130
165
|
secrets
|
|
131
166
|
.command("set <name>")
|
|
@@ -164,8 +199,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
const token = await ensureCognitoToken();
|
|
167
|
-
const
|
|
168
|
-
|
|
202
|
+
const companyUid = await getEntityUid(
|
|
203
|
+
token,
|
|
204
|
+
scopeOpts(secrets.opts()),
|
|
205
|
+
);
|
|
169
206
|
|
|
170
207
|
const res = await vaultApiFetch({
|
|
171
208
|
token,
|
|
@@ -200,8 +237,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
200
237
|
.action(async (name: string, opts: { reveal?: boolean }) => {
|
|
201
238
|
try {
|
|
202
239
|
const token = await ensureCognitoToken();
|
|
203
|
-
const
|
|
204
|
-
|
|
240
|
+
const companyUid = await getEntityUid(
|
|
241
|
+
token,
|
|
242
|
+
scopeOpts(secrets.opts()),
|
|
243
|
+
);
|
|
205
244
|
|
|
206
245
|
const query: Record<string, string> = {};
|
|
207
246
|
if (opts.reveal) {
|
|
@@ -283,8 +322,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
283
322
|
}
|
|
284
323
|
|
|
285
324
|
const token = await ensureCognitoToken();
|
|
286
|
-
const
|
|
287
|
-
|
|
325
|
+
const companyUid = await getEntityUid(
|
|
326
|
+
token,
|
|
327
|
+
scopeOpts(secrets.opts()),
|
|
328
|
+
);
|
|
288
329
|
|
|
289
330
|
const query: Record<string, string> = {};
|
|
290
331
|
if (normalizedPrefix) {
|
|
@@ -364,8 +405,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
364
405
|
}
|
|
365
406
|
|
|
366
407
|
const token = await ensureCognitoToken();
|
|
367
|
-
const
|
|
368
|
-
|
|
408
|
+
const companyUid = await getEntityUid(
|
|
409
|
+
token,
|
|
410
|
+
scopeOpts(secrets.opts()),
|
|
411
|
+
);
|
|
369
412
|
|
|
370
413
|
const res = await vaultApiFetch({
|
|
371
414
|
token,
|
|
@@ -427,8 +470,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
427
470
|
}
|
|
428
471
|
|
|
429
472
|
const token = await ensureCognitoToken();
|
|
430
|
-
const
|
|
431
|
-
|
|
473
|
+
const companyUid = await getEntityUid(
|
|
474
|
+
token,
|
|
475
|
+
scopeOpts(secrets.opts()),
|
|
476
|
+
);
|
|
432
477
|
|
|
433
478
|
const revealed = await Promise.all(
|
|
434
479
|
keys.map(async (key) => {
|
|
@@ -518,8 +563,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
518
563
|
}
|
|
519
564
|
|
|
520
565
|
const token = await ensureCognitoToken();
|
|
521
|
-
const
|
|
522
|
-
|
|
566
|
+
const companyUid = await getEntityUid(
|
|
567
|
+
token,
|
|
568
|
+
scopeOpts(secrets.opts()),
|
|
569
|
+
);
|
|
523
570
|
|
|
524
571
|
const revealed = await Promise.all(
|
|
525
572
|
keys.map(async (key) => {
|
|
@@ -568,6 +615,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
568
615
|
.option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
|
|
569
616
|
.action(async (name: string, opts: { expires: string }) => {
|
|
570
617
|
try {
|
|
618
|
+
rejectIfPersonal(secrets.opts(), "generate-link");
|
|
619
|
+
|
|
571
620
|
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
572
621
|
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
|
|
573
622
|
process.exit(1);
|
|
@@ -586,8 +635,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
586
635
|
}
|
|
587
636
|
|
|
588
637
|
const token = await ensureCognitoToken();
|
|
589
|
-
const
|
|
590
|
-
|
|
638
|
+
const companyUid = await getEntityUid(
|
|
639
|
+
token,
|
|
640
|
+
scopeOpts(secrets.opts()),
|
|
641
|
+
);
|
|
591
642
|
|
|
592
643
|
const res = await vaultApiFetch({
|
|
593
644
|
token,
|
|
@@ -632,6 +683,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
632
683
|
.requiredOption("--permission <level>", "Permission level: read | write | admin")
|
|
633
684
|
.action(async (path: string, opts: { with: string; permission: string }) => {
|
|
634
685
|
try {
|
|
686
|
+
rejectIfPersonal(secrets.opts(), "share");
|
|
687
|
+
|
|
635
688
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
636
689
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
637
690
|
process.exit(1);
|
|
@@ -651,8 +704,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
651
704
|
const granteeId = opts.with;
|
|
652
705
|
|
|
653
706
|
const token = await ensureCognitoToken();
|
|
654
|
-
const
|
|
655
|
-
|
|
707
|
+
const companyUid = await getEntityUid(
|
|
708
|
+
token,
|
|
709
|
+
scopeOpts(secrets.opts()),
|
|
710
|
+
);
|
|
656
711
|
|
|
657
712
|
const res = await vaultApiFetch({
|
|
658
713
|
token,
|
|
@@ -695,6 +750,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
695
750
|
.requiredOption("--from <principal>", "Email address or group id to remove")
|
|
696
751
|
.action(async (path: string, opts: { from: string }) => {
|
|
697
752
|
try {
|
|
753
|
+
rejectIfPersonal(secrets.opts(), "unshare");
|
|
754
|
+
|
|
698
755
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
699
756
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
700
757
|
process.exit(1);
|
|
@@ -709,8 +766,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
709
766
|
const granteeId = opts.from;
|
|
710
767
|
|
|
711
768
|
const token = await ensureCognitoToken();
|
|
712
|
-
const
|
|
713
|
-
|
|
769
|
+
const companyUid = await getEntityUid(
|
|
770
|
+
token,
|
|
771
|
+
scopeOpts(secrets.opts()),
|
|
772
|
+
);
|
|
714
773
|
|
|
715
774
|
const res = await vaultApiFetch({
|
|
716
775
|
token,
|
|
@@ -751,14 +810,18 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
751
810
|
.description("Show the ACL (access control list) for a secret path")
|
|
752
811
|
.action(async (path: string) => {
|
|
753
812
|
try {
|
|
813
|
+
rejectIfPersonal(secrets.opts(), "acl");
|
|
814
|
+
|
|
754
815
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
755
816
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
756
817
|
process.exit(1);
|
|
757
818
|
}
|
|
758
819
|
|
|
759
820
|
const token = await ensureCognitoToken();
|
|
760
|
-
const
|
|
761
|
-
|
|
821
|
+
const companyUid = await getEntityUid(
|
|
822
|
+
token,
|
|
823
|
+
scopeOpts(secrets.opts()),
|
|
824
|
+
);
|
|
762
825
|
|
|
763
826
|
const secretPath = path;
|
|
764
827
|
const res = await vaultApiFetch({
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { getEntityUid, resolveCallerPersonUid } from './vault-api.js';
|
|
4
|
+
|
|
5
|
+
const fetchMock = vi.fn();
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
function mockResponse(status: number, body: unknown): Response {
|
|
9
|
+
return new Response(JSON.stringify(body), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fetchMock.mockReset();
|
|
17
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
globalThis.fetch = originalFetch;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('resolveCallerPersonUid', () => {
|
|
25
|
+
it('returns the canonical person uid (oldest createdAt, uid tie-break)', async () => {
|
|
26
|
+
fetchMock.mockResolvedValueOnce(
|
|
27
|
+
mockResponse(200, {
|
|
28
|
+
entities: [
|
|
29
|
+
{ uid: 'prs_b', type: 'person', createdAt: '2026-01-02T00:00:00Z' },
|
|
30
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
31
|
+
{ uid: 'prs_c', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
32
|
+
],
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const uid = await resolveCallerPersonUid('tok');
|
|
37
|
+
expect(uid).toBe('prs_a');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws when the caller has no person entity', async () => {
|
|
41
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, { entities: [] }));
|
|
42
|
+
await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
|
|
43
|
+
/No person entity/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws when the API returns an error status', async () => {
|
|
48
|
+
fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauth' }));
|
|
49
|
+
await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
|
|
50
|
+
/Failed to fetch person entity/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('filters out non-person entries before sorting', async () => {
|
|
55
|
+
fetchMock.mockResolvedValueOnce(
|
|
56
|
+
mockResponse(200, {
|
|
57
|
+
entities: [
|
|
58
|
+
{ uid: 'cmp_a', type: 'company', createdAt: '2025-01-01T00:00:00Z' },
|
|
59
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
const uid = await resolveCallerPersonUid('tok');
|
|
64
|
+
expect(uid).toBe('prs_a');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getEntityUid', () => {
|
|
69
|
+
it('routes to person resolution when personal=true', async () => {
|
|
70
|
+
fetchMock.mockResolvedValueOnce(
|
|
71
|
+
mockResponse(200, {
|
|
72
|
+
entities: [
|
|
73
|
+
{ uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
const uid = await getEntityUid('tok', { personal: true });
|
|
78
|
+
expect(uid).toBe('prs_a');
|
|
79
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
80
|
+
expect(url).toMatch(/\/entity\/by-type\/person/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('routes to company-slug resolution when companySlug is set', async () => {
|
|
84
|
+
fetchMock.mockResolvedValueOnce(
|
|
85
|
+
mockResponse(200, { entity: { uid: 'cmp_acme' } }),
|
|
86
|
+
);
|
|
87
|
+
const uid = await getEntityUid('tok', { companySlug: 'acme' });
|
|
88
|
+
expect(uid).toBe('cmp_acme');
|
|
89
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
90
|
+
expect(url).toMatch(/\/entity\/by-slug\/company\/acme/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to membership lookup when neither personal nor slug is set', async () => {
|
|
94
|
+
fetchMock.mockResolvedValueOnce(
|
|
95
|
+
mockResponse(200, {
|
|
96
|
+
memberships: [
|
|
97
|
+
{
|
|
98
|
+
companyUid: 'cmp_only',
|
|
99
|
+
role: 'member',
|
|
100
|
+
status: 'active',
|
|
101
|
+
membershipKey: 'k',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const uid = await getEntityUid('tok', {});
|
|
107
|
+
expect(uid).toBe('cmp_only');
|
|
108
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
109
|
+
expect(url).toMatch(/\/membership\/me/);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -78,3 +78,46 @@ export async function getCompanyUid(
|
|
|
78
78
|
}
|
|
79
79
|
return resolveCompanyFromMemberships(token);
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
interface PersonEntity {
|
|
83
|
+
uid: string;
|
|
84
|
+
type: string;
|
|
85
|
+
createdAt?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
89
|
+
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
90
|
+
export async function resolveCallerPersonUid(token: string): Promise<string> {
|
|
91
|
+
const res = await vaultApiFetch({
|
|
92
|
+
token,
|
|
93
|
+
path: '/entity/by-type/person',
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|
|
97
|
+
}
|
|
98
|
+
const data = (await res.json()) as { entities: PersonEntity[] };
|
|
99
|
+
const persons = (data.entities ?? []).filter((e) => e.type === 'person');
|
|
100
|
+
if (persons.length === 0) {
|
|
101
|
+
throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
|
|
102
|
+
}
|
|
103
|
+
persons.sort((a, b) => {
|
|
104
|
+
const ac = a.createdAt ?? '';
|
|
105
|
+
const bc = b.createdAt ?? '';
|
|
106
|
+
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
107
|
+
return a.uid < b.uid ? -1 : 1;
|
|
108
|
+
});
|
|
109
|
+
return persons[0].uid;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
|
|
113
|
+
// `--personal` → caller's canonical person entity; else `--company <slug>` →
|
|
114
|
+
// resolved company UID; else fallback to single active company membership.
|
|
115
|
+
export async function getEntityUid(
|
|
116
|
+
token: string,
|
|
117
|
+
opts: { personal?: boolean; companySlug?: string },
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
if (opts.personal) {
|
|
120
|
+
return resolveCallerPersonUid(token);
|
|
121
|
+
}
|
|
122
|
+
return getCompanyUid(token, opts.companySlug);
|
|
123
|
+
}
|