@indigoai-us/hq-cli 5.77.11 → 5.77.12
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 +9 -0
- package/dist/commands/api-keys.js +53 -10
- package/dist/commands/secrets.js +127 -21
- package/dist/utils/resolve-vault-credential.d.ts +30 -0
- package/dist/utils/resolve-vault-credential.js +48 -0
- package/package.json +1 -1
- package/src/commands/api-keys.test.ts +75 -1
- package/src/commands/api-keys.ts +86 -10
- package/src/commands/secrets.test.ts +133 -0
- package/src/commands/secrets.ts +172 -29
- package/src/utils/resolve-vault-credential.test.ts +69 -0
- package/src/utils/resolve-vault-credential.ts +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.77.12]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `HQ_API_KEY` fail-closed consume path: `hqk_…` keys route `hq secrets get`
|
|
10
|
+
/ `exec` / `env` through vault key fetch; Cognito-only commands hard-error.
|
|
11
|
+
- `hq api-keys create --deploy-app` (repeatable) and deploy-app column in list
|
|
12
|
+
output for identity-bound deploy keys. (#270)
|
|
13
|
+
|
|
5
14
|
## [5.77.11]
|
|
6
15
|
|
|
7
16
|
### Fixed
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
3
|
+
import { assertCognitoOnlyCommand } from "../utils/resolve-vault-credential.js";
|
|
3
4
|
import { getCompanyUid, vaultApiFetch } from "./secrets.js";
|
|
5
|
+
async function requireCognitoForApiKeys(label) {
|
|
6
|
+
assertCognitoOnlyCommand(label);
|
|
7
|
+
return ensureCognitoToken();
|
|
8
|
+
}
|
|
4
9
|
function collectRepeatedOption(value, previous) {
|
|
5
10
|
return [...previous, value];
|
|
6
11
|
}
|
|
@@ -10,6 +15,11 @@ function formatMaybe(value) {
|
|
|
10
15
|
function formatPrefixes(prefixes) {
|
|
11
16
|
return prefixes.length > 0 ? prefixes.join(", ") : "-";
|
|
12
17
|
}
|
|
18
|
+
function formatDeployApps(deploy) {
|
|
19
|
+
if (!deploy?.apps?.length)
|
|
20
|
+
return "-";
|
|
21
|
+
return deploy.apps.join(", ");
|
|
22
|
+
}
|
|
13
23
|
function parsePermission(value) {
|
|
14
24
|
if (value === "read" || value === "write" || value === "admin") {
|
|
15
25
|
return value;
|
|
@@ -51,6 +61,7 @@ function renderApiKeysTable(apiKeys) {
|
|
|
51
61
|
name: apiKey.name,
|
|
52
62
|
permission: apiKey.scope.permission,
|
|
53
63
|
prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
|
|
64
|
+
deployApps: formatDeployApps(apiKey.scope.deploy),
|
|
54
65
|
status: apiKey.status,
|
|
55
66
|
lastUsedAt: formatMaybe(apiKey.lastUsedAt),
|
|
56
67
|
expiresAt: formatMaybe(apiKey.expiresAt),
|
|
@@ -59,6 +70,7 @@ function renderApiKeysTable(apiKeys) {
|
|
|
59
70
|
const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
|
|
60
71
|
const permissionWidth = Math.max(10, ...rows.map((row) => row.permission.length));
|
|
61
72
|
const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
|
|
73
|
+
const deployWidth = Math.max(6, ...rows.map((row) => row.deployApps.length));
|
|
62
74
|
const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
|
|
63
75
|
const lastUsedWidth = Math.max(11, ...rows.map((row) => row.lastUsedAt.length));
|
|
64
76
|
const expiresWidth = Math.max(10, ...rows.map((row) => row.expiresAt.length));
|
|
@@ -67,6 +79,7 @@ function renderApiKeysTable(apiKeys) {
|
|
|
67
79
|
"NAME".padEnd(nameWidth),
|
|
68
80
|
"PERMISSION".padEnd(permissionWidth),
|
|
69
81
|
"PREFIXES".padEnd(prefixesWidth),
|
|
82
|
+
"DEPLOY".padEnd(deployWidth),
|
|
70
83
|
"STATUS".padEnd(statusWidth),
|
|
71
84
|
"LAST USED".padEnd(lastUsedWidth),
|
|
72
85
|
"EXPIRES".padEnd(expiresWidth),
|
|
@@ -78,6 +91,7 @@ function renderApiKeysTable(apiKeys) {
|
|
|
78
91
|
row.name.padEnd(nameWidth),
|
|
79
92
|
row.permission.padEnd(permissionWidth),
|
|
80
93
|
row.prefixes.padEnd(prefixesWidth),
|
|
94
|
+
row.deployApps.padEnd(deployWidth),
|
|
81
95
|
row.status.padEnd(statusWidth),
|
|
82
96
|
row.lastUsedAt.padEnd(lastUsedWidth),
|
|
83
97
|
row.expiresAt.padEnd(expiresWidth),
|
|
@@ -91,20 +105,21 @@ export function registerApiKeysCommand(program) {
|
|
|
91
105
|
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
92
106
|
apiKeys
|
|
93
107
|
.command("create")
|
|
94
|
-
.description("Create a new API key")
|
|
108
|
+
.description("Create a new API key (vault secrets and/or scoped deploy via --deploy-app)")
|
|
95
109
|
.requiredOption("--name <label>", "Human-readable label for the API key")
|
|
96
|
-
.option("--scope <prefix>", "Allowed prefix (repeatable)", collectRepeatedOption, [])
|
|
97
|
-
.option("--
|
|
110
|
+
.option("--scope <prefix>", "Allowed secret prefix (repeatable)", collectRepeatedOption, [])
|
|
111
|
+
.option("--deploy-app <id>", "Deploy app id/slug allowed for publish (repeatable)", collectRepeatedOption, [])
|
|
112
|
+
.option("--permission <level>", "Secret permission level: read | write | admin (required when --scope is set)", "read")
|
|
98
113
|
.option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
|
|
99
114
|
.action(async (opts) => {
|
|
100
115
|
try {
|
|
101
|
-
if (opts.scope.length === 0) {
|
|
102
|
-
console.error(chalk.red("Error: at least one --scope <prefix>
|
|
116
|
+
if (opts.scope.length === 0 && opts.deployApp.length === 0) {
|
|
117
|
+
console.error(chalk.red("Error: provide at least one --scope <prefix> and/or --deploy-app <id>."));
|
|
103
118
|
process.exit(1);
|
|
104
119
|
}
|
|
105
120
|
const permission = parsePermission(opts.permission);
|
|
106
121
|
const expiresAt = parseExpires(opts.expires);
|
|
107
|
-
const token = await
|
|
122
|
+
const token = await requireCognitoForApiKeys("api-keys create");
|
|
108
123
|
const companyUid = await getCompanyUid(token, apiKeys.opts().company);
|
|
109
124
|
const res = await vaultApiFetch({
|
|
110
125
|
token,
|
|
@@ -113,8 +128,20 @@ export function registerApiKeysCommand(program) {
|
|
|
113
128
|
body: {
|
|
114
129
|
companyUid,
|
|
115
130
|
name: opts.name,
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
...(opts.scope.length > 0
|
|
132
|
+
? { allowedPrefixes: opts.scope, permission }
|
|
133
|
+
: {}),
|
|
134
|
+
...(opts.deployApp.length > 0
|
|
135
|
+
? {
|
|
136
|
+
deploy: {
|
|
137
|
+
apps: opts.deployApp,
|
|
138
|
+
capabilities: ["deploy:write"],
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
...(opts.scope.length === 0 && opts.deployApp.length > 0
|
|
143
|
+
? { permission }
|
|
144
|
+
: {}),
|
|
118
145
|
...(expiresAt ? { expiresAt } : {}),
|
|
119
146
|
},
|
|
120
147
|
});
|
|
@@ -131,10 +158,26 @@ export function registerApiKeysCommand(program) {
|
|
|
131
158
|
console.log(` Company: ${data.apiKey.companyUid}`);
|
|
132
159
|
console.log(` Permission: ${data.apiKey.scope.permission}`);
|
|
133
160
|
console.log(` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`);
|
|
161
|
+
console.log(` Deploy apps: ${formatDeployApps(data.apiKey.scope.deploy)}`);
|
|
134
162
|
console.log(` Status: ${data.apiKey.status}`);
|
|
135
163
|
console.log(` Created: ${data.apiKey.createdAt}`);
|
|
136
164
|
console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
|
|
137
165
|
console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
|
|
166
|
+
console.log("");
|
|
167
|
+
console.log(chalk.bold("Usage"));
|
|
168
|
+
console.log(" This key acts as you (Cognito identity), limited to the scopes above.");
|
|
169
|
+
console.log(" Export it for automation (never falls back to a session):");
|
|
170
|
+
console.log(`\n export HQ_API_KEY='${data.key.value}'\n`);
|
|
171
|
+
console.log(" Vault secrets:");
|
|
172
|
+
console.log(" hq secrets get <NAME> --reveal");
|
|
173
|
+
console.log(" hq secrets exec --only <NAME> -- <command>");
|
|
174
|
+
console.log(" Or HTTP: POST /v1/keys/secrets/fetch with Authorization: Bearer <key>");
|
|
175
|
+
if (data.apiKey.scope.deploy?.apps?.length) {
|
|
176
|
+
console.log(" Deploy (scoped apps only):");
|
|
177
|
+
console.log(" Authorization: Bearer <key> against the hq-deploy API");
|
|
178
|
+
console.log(chalk.dim(" Cannot change access-mode, password, or mint hqd_ keys."));
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.dim(" Unsupported under HQ_API_KEY: secrets list/set/share/acl (use a Cognito session)."));
|
|
138
181
|
}
|
|
139
182
|
catch (err) {
|
|
140
183
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -146,7 +189,7 @@ export function registerApiKeysCommand(program) {
|
|
|
146
189
|
.description("List API keys for a company")
|
|
147
190
|
.action(async () => {
|
|
148
191
|
try {
|
|
149
|
-
const token = await
|
|
192
|
+
const token = await requireCognitoForApiKeys("api-keys list");
|
|
150
193
|
const companyUid = await getCompanyUid(token, apiKeys.opts().company);
|
|
151
194
|
const res = await vaultApiFetch({
|
|
152
195
|
token,
|
|
@@ -173,7 +216,7 @@ export function registerApiKeysCommand(program) {
|
|
|
173
216
|
.description("Revoke an API key")
|
|
174
217
|
.action(async (keyId) => {
|
|
175
218
|
try {
|
|
176
|
-
const token = await
|
|
219
|
+
const token = await requireCognitoForApiKeys("api-keys revoke");
|
|
177
220
|
const res = await vaultApiFetch({
|
|
178
221
|
token,
|
|
179
222
|
path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
|
package/dist/commands/secrets.js
CHANGED
|
@@ -8,8 +8,14 @@ import { computeSha256 } from "../utils/integrity.js";
|
|
|
8
8
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
9
9
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
10
10
|
import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
|
|
11
|
+
import { HQ_API_KEY_PREFIX, assertCognitoOnlyCommand, resolveVaultCredential, } from "../utils/resolve-vault-credential.js";
|
|
11
12
|
import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
|
|
12
13
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
14
|
+
/** Cognito session for secrets commands that do not support HQ_API_KEY. */
|
|
15
|
+
async function requireCognitoTokenForSecrets(commandLabel) {
|
|
16
|
+
assertCognitoOnlyCommand(commandLabel);
|
|
17
|
+
return ensureCognitoToken();
|
|
18
|
+
}
|
|
13
19
|
function scopeOpts(opts) {
|
|
14
20
|
if (opts.personal && opts.company) {
|
|
15
21
|
console.error(chalk.red("Error: --personal cannot be combined with --company."));
|
|
@@ -424,7 +430,54 @@ function renderPolicyScripts(scripts) {
|
|
|
424
430
|
// Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
|
|
425
431
|
// with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
|
|
426
432
|
// path used — never swallows a failure.
|
|
433
|
+
async function loadRevealedSecretsViaApiKey(token, keys) {
|
|
434
|
+
const resolved = new Map();
|
|
435
|
+
const requested = [...new Set(keys)];
|
|
436
|
+
const cacheScope = "__api_key__";
|
|
437
|
+
for (const name of requested) {
|
|
438
|
+
const res = await vaultApiFetch({
|
|
439
|
+
token,
|
|
440
|
+
path: "/v1/keys/secrets/fetch",
|
|
441
|
+
method: "POST",
|
|
442
|
+
body: { name },
|
|
443
|
+
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
444
|
+
});
|
|
445
|
+
const body = (await res.json().catch(() => ({})));
|
|
446
|
+
if (!res.ok) {
|
|
447
|
+
if (res.status === 404) {
|
|
448
|
+
throw new Error(`Failed to fetch secret '${name}': Secret not found`);
|
|
449
|
+
}
|
|
450
|
+
if (res.status === 403) {
|
|
451
|
+
const message = typeof body.error === "string"
|
|
452
|
+
? body.error
|
|
453
|
+
: typeof body.message === "string"
|
|
454
|
+
? body.message
|
|
455
|
+
: "No read permission";
|
|
456
|
+
if (body.highSecurity === true) {
|
|
457
|
+
throw new Error(highSecuritySandboxOnlyMessage(name));
|
|
458
|
+
}
|
|
459
|
+
throw new Error(`Failed to fetch secret '${name}': ${message}`);
|
|
460
|
+
}
|
|
461
|
+
if (res.status === 401) {
|
|
462
|
+
throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
|
|
465
|
+
}
|
|
466
|
+
const secret = typeof body.secret === "object" && body.secret !== null
|
|
467
|
+
? body.secret
|
|
468
|
+
: null;
|
|
469
|
+
if (typeof secret?.value !== "string") {
|
|
470
|
+
throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
|
|
471
|
+
}
|
|
472
|
+
removeCacheEntry(cacheScope, name);
|
|
473
|
+
resolved.set(name, secret.value);
|
|
474
|
+
}
|
|
475
|
+
return resolved;
|
|
476
|
+
}
|
|
427
477
|
export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
478
|
+
if (token.startsWith(HQ_API_KEY_PREFIX)) {
|
|
479
|
+
return loadRevealedSecretsViaApiKey(token, keys);
|
|
480
|
+
}
|
|
428
481
|
const resolved = new Map();
|
|
429
482
|
const requested = [...new Set(keys)];
|
|
430
483
|
try {
|
|
@@ -632,7 +685,7 @@ export function registerSecretsCommand(program) {
|
|
|
632
685
|
console.error(chalk.red(`Secret value exceeds 4096-byte SSM limit (got ${Buffer.byteLength(value, "utf8")} bytes).`));
|
|
633
686
|
process.exit(1);
|
|
634
687
|
}
|
|
635
|
-
const token = await
|
|
688
|
+
const token = await requireCognitoTokenForSecrets("secrets set");
|
|
636
689
|
const scope = scopeOpts(secrets.opts());
|
|
637
690
|
const companyUid = await getEntityUid(token, scope);
|
|
638
691
|
const scopeLabel = describeSecretsScope({
|
|
@@ -677,7 +730,52 @@ export function registerSecretsCommand(program) {
|
|
|
677
730
|
.option("--reveal", "Include the decrypted secret value")
|
|
678
731
|
.action(async (name, opts) => {
|
|
679
732
|
try {
|
|
680
|
-
const
|
|
733
|
+
const cred = await resolveVaultCredential();
|
|
734
|
+
if (cred.kind === "api-key") {
|
|
735
|
+
const res = await vaultApiFetch({
|
|
736
|
+
token: cred.token,
|
|
737
|
+
path: "/v1/keys/secrets/fetch",
|
|
738
|
+
method: "POST",
|
|
739
|
+
body: { name },
|
|
740
|
+
});
|
|
741
|
+
const body = (await res.json().catch(() => ({})));
|
|
742
|
+
if (!res.ok) {
|
|
743
|
+
if (res.status === 403 && body.highSecurity === true) {
|
|
744
|
+
console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
747
|
+
console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
|
|
748
|
+
process.exit(1);
|
|
749
|
+
}
|
|
750
|
+
const secret = typeof body.secret === "object" && body.secret !== null
|
|
751
|
+
? body.secret
|
|
752
|
+
: null;
|
|
753
|
+
if (!secret || typeof secret.name !== "string") {
|
|
754
|
+
console.error(chalk.red("Failed to get secret: malformed response"));
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
console.log(chalk.bold(`Secret: ${secret.name}`));
|
|
758
|
+
if (secret.lastModifiedDate) {
|
|
759
|
+
console.log(` Last Modified: ${secret.lastModifiedDate}`);
|
|
760
|
+
}
|
|
761
|
+
if (secret.version != null) {
|
|
762
|
+
console.log(` Version: ${secret.version}`);
|
|
763
|
+
}
|
|
764
|
+
console.log(` Tier: ${normalizeSecretTier(secret.tier)}`);
|
|
765
|
+
console.log(` Script Lock: ${normalizeScriptLockMode(secret.scriptLock?.mode)}`);
|
|
766
|
+
if (opts.reveal) {
|
|
767
|
+
if (typeof secret.value !== "string") {
|
|
768
|
+
console.error(chalk.red("Failed to get secret: reveal requested but response omitted secret.value"));
|
|
769
|
+
process.exit(1);
|
|
770
|
+
}
|
|
771
|
+
console.log(` Value: ${secret.value}`);
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
console.log(` Value: ${chalk.dim("[REDACTED]")}`);
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const token = cred.token;
|
|
681
779
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
682
780
|
const res = await vaultApiFetch({
|
|
683
781
|
token,
|
|
@@ -748,7 +846,7 @@ export function registerSecretsCommand(program) {
|
|
|
748
846
|
.option("--quiet", "Suppress the present/absent line (use the exit code only)")
|
|
749
847
|
.action(async (name, opts) => {
|
|
750
848
|
try {
|
|
751
|
-
const token = await
|
|
849
|
+
const token = await requireCognitoTokenForSecrets("secrets exists");
|
|
752
850
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
753
851
|
const res = await vaultApiFetch({
|
|
754
852
|
token,
|
|
@@ -791,7 +889,7 @@ export function registerSecretsCommand(program) {
|
|
|
791
889
|
}
|
|
792
890
|
normalizedPrefix = normalized;
|
|
793
891
|
}
|
|
794
|
-
const token = await
|
|
892
|
+
const token = await requireCognitoTokenForSecrets("secrets list");
|
|
795
893
|
const scope = scopeOpts(secrets.opts());
|
|
796
894
|
const companyUid = await getEntityUid(token, scope);
|
|
797
895
|
const scopeLabel = describeSecretsScope({
|
|
@@ -865,7 +963,7 @@ export function registerSecretsCommand(program) {
|
|
|
865
963
|
console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
866
964
|
process.exit(1);
|
|
867
965
|
}
|
|
868
|
-
const token = await
|
|
966
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
869
967
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
870
968
|
const res = await vaultApiFetch({
|
|
871
969
|
token,
|
|
@@ -926,7 +1024,7 @@ export function registerSecretsCommand(program) {
|
|
|
926
1024
|
console.error(chalk.red("Error: provide at least one of --tier or --lock-script."));
|
|
927
1025
|
process.exit(1);
|
|
928
1026
|
}
|
|
929
|
-
const token = await
|
|
1027
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
930
1028
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
931
1029
|
const res = await vaultApiFetch({
|
|
932
1030
|
token,
|
|
@@ -971,7 +1069,7 @@ export function registerSecretsCommand(program) {
|
|
|
971
1069
|
process.exit(1);
|
|
972
1070
|
}
|
|
973
1071
|
const usage = await buildSecretUsage("exec", opts.script, opts.id, opts.attestation);
|
|
974
|
-
const token = await
|
|
1072
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
975
1073
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
976
1074
|
const res = await vaultApiFetch({
|
|
977
1075
|
token,
|
|
@@ -1009,7 +1107,7 @@ export function registerSecretsCommand(program) {
|
|
|
1009
1107
|
console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
1010
1108
|
process.exit(1);
|
|
1011
1109
|
}
|
|
1012
|
-
const token = await
|
|
1110
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1013
1111
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1014
1112
|
const res = await vaultApiFetch({
|
|
1015
1113
|
token,
|
|
@@ -1040,7 +1138,7 @@ export function registerSecretsCommand(program) {
|
|
|
1040
1138
|
console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
1041
1139
|
process.exit(1);
|
|
1042
1140
|
}
|
|
1043
|
-
const token = await
|
|
1141
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1044
1142
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1045
1143
|
const res = await vaultApiFetch({
|
|
1046
1144
|
token,
|
|
@@ -1087,7 +1185,7 @@ export function registerSecretsCommand(program) {
|
|
|
1087
1185
|
return;
|
|
1088
1186
|
}
|
|
1089
1187
|
}
|
|
1090
|
-
const token = await
|
|
1188
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1091
1189
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1092
1190
|
const res = await vaultApiFetch({
|
|
1093
1191
|
token,
|
|
@@ -1131,7 +1229,7 @@ export function registerSecretsCommand(program) {
|
|
|
1131
1229
|
process.exit(1);
|
|
1132
1230
|
}
|
|
1133
1231
|
const keys = parseSecretNameList(opts.only);
|
|
1134
|
-
const token = await
|
|
1232
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1135
1233
|
const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
|
|
1136
1234
|
const companyUid = await getEntityUid(token, scope);
|
|
1137
1235
|
const client = new SandboxRunnerClient();
|
|
@@ -1184,9 +1282,13 @@ export function registerSecretsCommand(program) {
|
|
|
1184
1282
|
process.exit(1);
|
|
1185
1283
|
}
|
|
1186
1284
|
const keys = parseSecretNameList(_opts.only);
|
|
1187
|
-
const
|
|
1188
|
-
const companyUid =
|
|
1189
|
-
|
|
1285
|
+
const cred = await resolveVaultCredential();
|
|
1286
|
+
const companyUid = cred.kind === "api-key"
|
|
1287
|
+
? "__api_key__"
|
|
1288
|
+
: await getEntityUid(cred.token, scopeOpts(secrets.opts()));
|
|
1289
|
+
const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1290
|
+
? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
|
|
1291
|
+
: undefined);
|
|
1190
1292
|
const secretEnv = {};
|
|
1191
1293
|
for (const key of keys) {
|
|
1192
1294
|
const value = revealed.get(key);
|
|
@@ -1231,9 +1333,13 @@ export function registerSecretsCommand(program) {
|
|
|
1231
1333
|
console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
|
|
1232
1334
|
}
|
|
1233
1335
|
const keys = parseSecretNameList(opts.only);
|
|
1234
|
-
const
|
|
1235
|
-
const companyUid =
|
|
1236
|
-
|
|
1336
|
+
const cred = await resolveVaultCredential();
|
|
1337
|
+
const companyUid = cred.kind === "api-key"
|
|
1338
|
+
? "__api_key__"
|
|
1339
|
+
: await getEntityUid(cred.token, scopeOpts(secrets.opts()));
|
|
1340
|
+
const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1341
|
+
? await buildSecretUsage("env", opts.script, opts.scriptId)
|
|
1342
|
+
: undefined);
|
|
1237
1343
|
for (const key of keys) {
|
|
1238
1344
|
const value = revealed.get(key);
|
|
1239
1345
|
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
@@ -1270,7 +1376,7 @@ export function registerSecretsCommand(program) {
|
|
|
1270
1376
|
console.error(chalk.red("Maximum expiry is 7 days (7d)."));
|
|
1271
1377
|
process.exit(1);
|
|
1272
1378
|
}
|
|
1273
|
-
const token = await
|
|
1379
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1274
1380
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1275
1381
|
const res = await vaultApiFetch({
|
|
1276
1382
|
token,
|
|
@@ -1316,7 +1422,7 @@ export function registerSecretsCommand(program) {
|
|
|
1316
1422
|
if (!principal) {
|
|
1317
1423
|
process.exit(1);
|
|
1318
1424
|
}
|
|
1319
|
-
const token = await
|
|
1425
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1320
1426
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1321
1427
|
const res = await vaultApiFetch({
|
|
1322
1428
|
token,
|
|
@@ -1373,7 +1479,7 @@ export function registerSecretsCommand(program) {
|
|
|
1373
1479
|
if (!principal) {
|
|
1374
1480
|
process.exit(1);
|
|
1375
1481
|
}
|
|
1376
|
-
const token = await
|
|
1482
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1377
1483
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1378
1484
|
const res = await vaultApiFetch({
|
|
1379
1485
|
token,
|
|
@@ -1422,7 +1528,7 @@ export function registerSecretsCommand(program) {
|
|
|
1422
1528
|
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)`));
|
|
1423
1529
|
process.exit(1);
|
|
1424
1530
|
}
|
|
1425
|
-
const token = await
|
|
1531
|
+
const token = await requireCognitoTokenForSecrets("secrets");
|
|
1426
1532
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
1427
1533
|
const secretPath = path;
|
|
1428
1534
|
const res = await vaultApiFetch({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Vault API keys issued by `hq api-keys create` (hq-pro). */
|
|
2
|
+
export declare const HQ_API_KEY_PREFIX = "hqk_";
|
|
3
|
+
export type VaultCredential = {
|
|
4
|
+
kind: "api-key";
|
|
5
|
+
token: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "cognito";
|
|
8
|
+
token: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
|
|
12
|
+
* Does not validate prefix — use {@link resolveVaultCredential} for that.
|
|
13
|
+
*/
|
|
14
|
+
export declare function peekHqApiKey(): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve vault auth for CLI commands.
|
|
17
|
+
*
|
|
18
|
+
* When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
|
|
19
|
+
* and Cognito is never used as a fallback (fail-closed). When unset, uses the
|
|
20
|
+
* cached Cognito session (interactive login if needed).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveVaultCredential(options?: {
|
|
23
|
+
interactive?: boolean;
|
|
24
|
+
}): Promise<VaultCredential>;
|
|
25
|
+
/**
|
|
26
|
+
* Throw when HQ_API_KEY is set but the command only supports Cognito sessions
|
|
27
|
+
* (list, set, ACL, api-keys admin, etc.).
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertCognitoOnlyCommand(commandLabel: string): void;
|
|
30
|
+
//# sourceMappingURL=resolve-vault-credential.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ensureCognitoToken } from "./cognito-session.js";
|
|
2
|
+
/** Vault API keys issued by `hq api-keys create` (hq-pro). */
|
|
3
|
+
export const HQ_API_KEY_PREFIX = "hqk_";
|
|
4
|
+
/**
|
|
5
|
+
* Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
|
|
6
|
+
* Does not validate prefix — use {@link resolveVaultCredential} for that.
|
|
7
|
+
*/
|
|
8
|
+
export function peekHqApiKey() {
|
|
9
|
+
const raw = process.env.HQ_API_KEY;
|
|
10
|
+
if (raw === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve vault auth for CLI commands.
|
|
17
|
+
*
|
|
18
|
+
* When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
|
|
19
|
+
* and Cognito is never used as a fallback (fail-closed). When unset, uses the
|
|
20
|
+
* cached Cognito session (interactive login if needed).
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveVaultCredential(options) {
|
|
23
|
+
const apiKey = peekHqApiKey();
|
|
24
|
+
if (apiKey !== undefined) {
|
|
25
|
+
if (!apiKey.startsWith(HQ_API_KEY_PREFIX)) {
|
|
26
|
+
throw new Error(`HQ_API_KEY must start with '${HQ_API_KEY_PREFIX}' (vault API key). ` +
|
|
27
|
+
`Got a value that is not a vault key — refusing to fall back to Cognito. ` +
|
|
28
|
+
`Unset HQ_API_KEY to use your session, or create a key with \`hq api-keys create\`.`);
|
|
29
|
+
}
|
|
30
|
+
return { kind: "api-key", token: apiKey };
|
|
31
|
+
}
|
|
32
|
+
const token = await ensureCognitoToken({
|
|
33
|
+
interactive: options?.interactive,
|
|
34
|
+
});
|
|
35
|
+
return { kind: "cognito", token };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Throw when HQ_API_KEY is set but the command only supports Cognito sessions
|
|
39
|
+
* (list, set, ACL, api-keys admin, etc.).
|
|
40
|
+
*/
|
|
41
|
+
export function assertCognitoOnlyCommand(commandLabel) {
|
|
42
|
+
if (peekHqApiKey() === undefined)
|
|
43
|
+
return;
|
|
44
|
+
throw new Error(`HQ_API_KEY is set; '${commandLabel}' is not supported for API keys. ` +
|
|
45
|
+
`API keys support scoped secret reads via \`hq secrets get\`, \`hq secrets exec\`, ` +
|
|
46
|
+
`and \`hq secrets env\`. Unset HQ_API_KEY to use your Cognito session.`);
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=resolve-vault-credential.js.map
|
package/package.json
CHANGED
|
@@ -126,10 +126,78 @@ describe("hq api-keys create", () => {
|
|
|
126
126
|
.map((call) => call.map((value) => String(value)).join(" "))
|
|
127
127
|
.join("\n");
|
|
128
128
|
expect(printed).toContain("Store this key now");
|
|
129
|
-
expect(printed.
|
|
129
|
+
expect(printed).toContain("export HQ_API_KEY=");
|
|
130
|
+
expect(printed).toContain("hq secrets get");
|
|
131
|
+
// Key value appears in Store line + export line only (not in list metadata).
|
|
132
|
+
expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(2);
|
|
130
133
|
expect(errSpy).not.toHaveBeenCalled();
|
|
131
134
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
132
135
|
});
|
|
136
|
+
|
|
137
|
+
it("includes deploy scope when --deploy-app is set", async () => {
|
|
138
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
139
|
+
jsonResponse({
|
|
140
|
+
apiKey: {
|
|
141
|
+
keyId: "key_deploy",
|
|
142
|
+
companyUid: "cmp_acme",
|
|
143
|
+
name: "Deploy key",
|
|
144
|
+
scope: {
|
|
145
|
+
allowedPrefixes: ["CI"],
|
|
146
|
+
permission: "read",
|
|
147
|
+
deploy: {
|
|
148
|
+
apps: ["my-app", "other-app"],
|
|
149
|
+
capabilities: ["deploy:write"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
status: "active",
|
|
153
|
+
createdAt: "2026-06-19T12:00:00.000Z",
|
|
154
|
+
lastUsedAt: null,
|
|
155
|
+
expiresAt: null,
|
|
156
|
+
},
|
|
157
|
+
key: { value: "hqk_deploy_secret" },
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const program = buildProgram();
|
|
162
|
+
await program.parseAsync(
|
|
163
|
+
[
|
|
164
|
+
"api-keys",
|
|
165
|
+
"create",
|
|
166
|
+
"--name",
|
|
167
|
+
"Deploy key",
|
|
168
|
+
"--scope",
|
|
169
|
+
"CI",
|
|
170
|
+
"--deploy-app",
|
|
171
|
+
"my-app",
|
|
172
|
+
"--deploy-app",
|
|
173
|
+
"other-app",
|
|
174
|
+
],
|
|
175
|
+
{ from: "user" },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
179
|
+
token: "test-token",
|
|
180
|
+
path: "/v1/api-keys",
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: {
|
|
183
|
+
companyUid: "cmp_acme",
|
|
184
|
+
name: "Deploy key",
|
|
185
|
+
allowedPrefixes: ["CI"],
|
|
186
|
+
permission: "read",
|
|
187
|
+
deploy: {
|
|
188
|
+
apps: ["my-app", "other-app"],
|
|
189
|
+
capabilities: ["deploy:write"],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const printed = logSpy.mock.calls
|
|
195
|
+
.map((call) => call.map((value) => String(value)).join(" "))
|
|
196
|
+
.join("\n");
|
|
197
|
+
expect(printed).toContain("Deploy apps:");
|
|
198
|
+
expect(printed).toContain("my-app, other-app");
|
|
199
|
+
expect(printed).toContain("hq-deploy API");
|
|
200
|
+
});
|
|
133
201
|
});
|
|
134
202
|
|
|
135
203
|
describe("hq api-keys list", () => {
|
|
@@ -145,6 +213,10 @@ describe("hq api-keys list", () => {
|
|
|
145
213
|
scope: {
|
|
146
214
|
allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
|
|
147
215
|
permission: "read",
|
|
216
|
+
deploy: {
|
|
217
|
+
apps: ["preview-app"],
|
|
218
|
+
capabilities: ["deploy:write"],
|
|
219
|
+
},
|
|
148
220
|
},
|
|
149
221
|
status: "active",
|
|
150
222
|
createdAt: "2026-06-19T12:00:00.000Z",
|
|
@@ -173,9 +245,11 @@ describe("hq api-keys list", () => {
|
|
|
173
245
|
.map((call) => call.map((value) => String(value)).join(" "))
|
|
174
246
|
.join("\n");
|
|
175
247
|
expect(printed).toContain("KEY ID");
|
|
248
|
+
expect(printed).toContain("DEPLOY");
|
|
176
249
|
expect(printed).toContain("key_123");
|
|
177
250
|
expect(printed).toContain("CI key");
|
|
178
251
|
expect(printed).toContain("HQ_PRO/HQ_PROD, HQ_PRO/HQ_DEV");
|
|
252
|
+
expect(printed).toContain("preview-app");
|
|
179
253
|
expect(printed).not.toContain("hqk_should_not_print");
|
|
180
254
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
181
255
|
});
|