@indigoai-us/hq-cli 5.255.0 → 5.256.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 +3 -0
- package/dist/commands/integrations-import.js +3 -1
- package/dist/commands/pack-install.js +3 -1
- package/dist/commands/run.js +10 -2
- package/dist/commands/secrets.d.ts +4 -1
- package/dist/commands/secrets.js +56 -19
- package/dist/run/hq-plugin.d.ts +4 -0
- package/dist/run/hq-plugin.js +30 -3
- package/dist/utils/cognito-session.d.ts +2 -0
- package/dist/utils/cognito-session.js +5 -0
- package/dist/utils/secret-load-availability.d.ts +8 -0
- package/dist/utils/secret-load-availability.js +54 -0
- package/dist/utils/secrets-cache.d.ts +7 -3
- package/dist/utils/secrets-cache.js +162 -14
- package/dist/utils/vault-api.d.ts +5 -1
- package/dist/utils/vault-api.js +82 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.256.0] — 2026-09-28
|
|
6
|
+
|
|
5
7
|
## [5.255.0] — 2026-09-28
|
|
6
8
|
|
|
7
9
|
### Changed
|
|
@@ -26,6 +28,7 @@
|
|
|
26
28
|
- `hq db sql --remote` is hidden because remote SQL is not available in this
|
|
27
29
|
CLI build. Existing scripts get a clear error, and local SQL remains
|
|
28
30
|
available without `--remote`.
|
|
31
|
+
- `hq secrets` and `hq run` now use cached secrets during a temporary server outage, up to each secret’s configured cache lifetime. The default is three hours; revoked and high-security secrets still fail closed.
|
|
29
32
|
|
|
30
33
|
## [5.253.0] — 2026-09-27
|
|
31
34
|
|
|
@@ -350,7 +350,9 @@ export function registerImportCommands(integrations) {
|
|
|
350
350
|
}
|
|
351
351
|
const secretNames = [...Object.values({ ...(manifest.env ?? {}), ...(manifest.headers ?? {}) }), ...(manifest.args ?? [])]
|
|
352
352
|
.flatMap((value) => [...value.matchAll(/\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*)\}/g)].map((match) => match[1]));
|
|
353
|
-
const secrets = await loadRevealedSecrets(token, companyUid, secretNames
|
|
353
|
+
const secrets = await loadRevealedSecrets(token, companyUid, secretNames, undefined, {
|
|
354
|
+
allowOfflineFallback: false,
|
|
355
|
+
});
|
|
354
356
|
const result = registerServer({
|
|
355
357
|
name: safeName,
|
|
356
358
|
manifest,
|
|
@@ -1421,7 +1421,9 @@ export async function makeInstallSecretResolver(secretNames, company) {
|
|
|
1421
1421
|
return () => null;
|
|
1422
1422
|
[scopeUid] = scopes;
|
|
1423
1423
|
}
|
|
1424
|
-
const authorized = await loadRevealedSecrets(token, scopeUid, names
|
|
1424
|
+
const authorized = await loadRevealedSecrets(token, scopeUid, names, undefined, {
|
|
1425
|
+
allowOfflineFallback: false,
|
|
1426
|
+
});
|
|
1425
1427
|
return (name) => authorized.get(name) ?? null;
|
|
1426
1428
|
}
|
|
1427
1429
|
catch {
|
package/dist/commands/run.js
CHANGED
|
@@ -2,9 +2,10 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import { internal } from 'varlock';
|
|
5
|
-
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
5
|
+
import { cognitoCachePrincipalUid, ensureCognitoToken } from '../utils/cognito-session.js';
|
|
6
6
|
import { apiKeyCapabilityGateMessage, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
|
|
7
7
|
import { computeSha256 } from '../utils/integrity.js';
|
|
8
|
+
import { isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from '../utils/secret-load-availability.js';
|
|
8
9
|
import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
|
|
9
10
|
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
10
11
|
import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
@@ -82,7 +83,9 @@ export function registerRunCommand(program) {
|
|
|
82
83
|
throw new Error('company slug not set. Add # @hqCompany("slug") to your .env.schema or pass --company <slug>.');
|
|
83
84
|
}
|
|
84
85
|
const token = await ensureCognitoToken();
|
|
85
|
-
const uid = await getCompanyUid(token, slug
|
|
86
|
+
const uid = await getCompanyUid(token, slug, undefined, {
|
|
87
|
+
allowOfflineScopeFallback: true,
|
|
88
|
+
});
|
|
86
89
|
const usage = await buildRunUsage(opts.script, opts.scriptId);
|
|
87
90
|
const fetchBatch = async (companyUid, names, requestUsage) => {
|
|
88
91
|
const res = await vaultApiFetch({
|
|
@@ -99,6 +102,9 @@ export function registerRunCommand(program) {
|
|
|
99
102
|
: typeof body.error === 'string'
|
|
100
103
|
? body.error
|
|
101
104
|
: res.statusText;
|
|
105
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
106
|
+
throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
|
|
107
|
+
}
|
|
102
108
|
if (res.status >= 400 &&
|
|
103
109
|
res.status < 500 &&
|
|
104
110
|
typeof body.code === 'string') {
|
|
@@ -110,6 +116,8 @@ export function registerRunCommand(program) {
|
|
|
110
116
|
};
|
|
111
117
|
const pluginOpts = {
|
|
112
118
|
companyOverride: opts.company,
|
|
119
|
+
principalUid: cognitoCachePrincipalUid(token) ?? undefined,
|
|
120
|
+
allowOfflineFallback: true,
|
|
113
121
|
resolveCompanyUid: async () => uid,
|
|
114
122
|
usage,
|
|
115
123
|
fetchBatch,
|
|
@@ -31,6 +31,7 @@ export interface SecretMetadata {
|
|
|
31
31
|
requiredAttestation?: string;
|
|
32
32
|
};
|
|
33
33
|
cacheTtlMs?: number;
|
|
34
|
+
revokedAt?: string | null;
|
|
34
35
|
}
|
|
35
36
|
export interface SecretLoadSuccessRow extends SecretMetadata {
|
|
36
37
|
name: string;
|
|
@@ -59,7 +60,9 @@ export declare function parseDestinationUrl(raw: string): {
|
|
|
59
60
|
};
|
|
60
61
|
export declare function collectSecretNames(value: string, previous?: string[]): string[];
|
|
61
62
|
export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
|
|
62
|
-
export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage
|
|
63
|
+
export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
|
|
64
|
+
allowOfflineFallback?: boolean;
|
|
65
|
+
}): Promise<Map<string, string>>;
|
|
63
66
|
/**
|
|
64
67
|
* Emit `hq secrets env` output and terminate deterministically.
|
|
65
68
|
*
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,13 +3,14 @@ import * as readline from "node:readline";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { writeSync } from "node:fs";
|
|
5
5
|
import * as nodePath from "node:path";
|
|
6
|
-
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
6
|
+
import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
7
|
import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
8
8
|
import { computeSha256 } from "../utils/integrity.js";
|
|
9
9
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
10
10
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
11
11
|
import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
|
|
12
12
|
import { HQ_API_KEY_PREFIX, formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
13
|
+
import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "../utils/secret-load-availability.js";
|
|
13
14
|
import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
|
|
14
15
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
15
16
|
/**
|
|
@@ -533,11 +534,11 @@ function renderPolicyScripts(scripts) {
|
|
|
533
534
|
// `env` through it stops those callers from emitting the warning while keeping
|
|
534
535
|
// identical UX and error text.
|
|
535
536
|
//
|
|
536
|
-
// Every value use through exec/env/reveal is server-authorized
|
|
537
|
-
// an encrypted disk-cache entry exists.
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// change takes effect on
|
|
537
|
+
// Every value use through exec/env/reveal is server-authorized when online,
|
|
538
|
+
// including when an encrypted disk-cache entry exists. Only explicitly opted-in
|
|
539
|
+
// exec/env callers may use principal-bound values during classified outages;
|
|
540
|
+
// install authorization and reveal remain online-only. This means a policy,
|
|
541
|
+
// ACL, tier, or script-approval change takes effect on the next online use.
|
|
541
542
|
// Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
|
|
542
543
|
// with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
|
|
543
544
|
// path used — never swallows a failure.
|
|
@@ -585,13 +586,16 @@ async function loadRevealedSecretsViaApiKey(token, keys) {
|
|
|
585
586
|
}
|
|
586
587
|
return resolved;
|
|
587
588
|
}
|
|
588
|
-
export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
589
|
+
export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
|
|
589
590
|
if (token.startsWith(HQ_API_KEY_PREFIX)) {
|
|
590
591
|
return loadRevealedSecretsViaApiKey(token, keys);
|
|
591
592
|
}
|
|
592
593
|
const resolved = new Map();
|
|
593
594
|
const requested = [...new Set(keys)];
|
|
594
|
-
const
|
|
595
|
+
const principalUid = cognitoCachePrincipalUid(token);
|
|
596
|
+
const cachedCandidates = new Set(principalUid
|
|
597
|
+
? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
|
|
598
|
+
: []);
|
|
595
599
|
try {
|
|
596
600
|
// The batch endpoint is the only non-capturing authorization path that also
|
|
597
601
|
// evaluates the supplied usage metadata. It covers warm and cold names in
|
|
@@ -618,6 +622,9 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
618
622
|
body.highSecurity === true) {
|
|
619
623
|
throw new Error(highSecuritySandboxOnlyMessage());
|
|
620
624
|
}
|
|
625
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
626
|
+
throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
|
|
627
|
+
}
|
|
621
628
|
if (res.status >= 400 &&
|
|
622
629
|
res.status < 500 &&
|
|
623
630
|
typeof body.code === "string") {
|
|
@@ -646,6 +653,14 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
646
653
|
throw new Error("Invalid secret load response from vault");
|
|
647
654
|
}
|
|
648
655
|
seenNames.add(s.name);
|
|
656
|
+
if (s.revokedAt != null) {
|
|
657
|
+
errorsByName.set(s.name, {
|
|
658
|
+
code: "revoked",
|
|
659
|
+
message: `Secret '${s.name}' has been revoked`,
|
|
660
|
+
});
|
|
661
|
+
removeCacheEntry(companyUid, s.name);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
649
664
|
if (s.value == null) {
|
|
650
665
|
errorsByName.set(s.name, {
|
|
651
666
|
code: "not_returned",
|
|
@@ -660,7 +675,7 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
660
675
|
typeof s.version === "number" &&
|
|
661
676
|
Number.isSafeInteger(s.version) &&
|
|
662
677
|
s.version > 0
|
|
663
|
-
? readCache(companyUid, s.name, s.version, cacheTtlMs)
|
|
678
|
+
? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
|
|
664
679
|
: null;
|
|
665
680
|
if (cached !== null) {
|
|
666
681
|
resolved.set(s.name, cached);
|
|
@@ -670,10 +685,12 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
670
685
|
if (typeof s.version === "number" &&
|
|
671
686
|
Number.isSafeInteger(s.version) &&
|
|
672
687
|
s.version > 0) {
|
|
673
|
-
|
|
688
|
+
if (principalUid) {
|
|
689
|
+
writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
|
|
690
|
+
}
|
|
674
691
|
}
|
|
675
|
-
else {
|
|
676
|
-
writeCache(companyUid, s.name, s.value, cacheTtlMs);
|
|
692
|
+
else if (principalUid) {
|
|
693
|
+
writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
|
|
677
694
|
}
|
|
678
695
|
}
|
|
679
696
|
else {
|
|
@@ -730,9 +747,29 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
730
747
|
return resolved;
|
|
731
748
|
}
|
|
732
749
|
catch (err) {
|
|
750
|
+
if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
|
|
751
|
+
const offline = new Map();
|
|
752
|
+
for (const key of requested) {
|
|
753
|
+
const cached = cachedCandidates.has(key)
|
|
754
|
+
? readCache(companyUid, key, principalUid)
|
|
755
|
+
: null;
|
|
756
|
+
if (cached === null) {
|
|
757
|
+
// Keep the same failure behavior for a cache miss, while leaving any
|
|
758
|
+
// still-valid entries untouched for a later retry.
|
|
759
|
+
throw err;
|
|
760
|
+
}
|
|
761
|
+
offline.set(key, cached);
|
|
762
|
+
}
|
|
763
|
+
console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
|
|
764
|
+
return offline;
|
|
765
|
+
}
|
|
766
|
+
if (isSecretLoadUnavailable(err)) {
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
733
769
|
// A transport failure, stale session, malformed response, or a later chunk
|
|
734
|
-
// failure
|
|
735
|
-
//
|
|
770
|
+
// failure is an explicit denial or unusable response. Evict the whole
|
|
771
|
+
// request before failing closed. Transient no-answer/5xx/429 failures above
|
|
772
|
+
// preserve entries and only use values still valid under their stored TTL.
|
|
736
773
|
for (const key of requested) {
|
|
737
774
|
removeCacheEntry(companyUid, key);
|
|
738
775
|
}
|
|
@@ -979,7 +1016,7 @@ export function registerSecretsCommand(program) {
|
|
|
979
1016
|
let revealError = null;
|
|
980
1017
|
if (opts.reveal) {
|
|
981
1018
|
try {
|
|
982
|
-
revealedValue = (await loadRevealedSecrets(token, companyUid, [name], await buildSecretUsage("reveal"))).get(name);
|
|
1019
|
+
revealedValue = (await loadRevealedSecrets(token, companyUid, [name], await buildSecretUsage("reveal"), { allowOfflineFallback: false })).get(name);
|
|
983
1020
|
}
|
|
984
1021
|
catch (err) {
|
|
985
1022
|
revealError = err instanceof Error ? err.message : String(err);
|
|
@@ -1460,10 +1497,10 @@ export function registerSecretsCommand(program) {
|
|
|
1460
1497
|
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets exec", secretsCredentialOptions());
|
|
1461
1498
|
const companyUid = cred.kind === "api-key"
|
|
1462
1499
|
? "__api_key__"
|
|
1463
|
-
: await getEntityUid(cred.token, scopeOpts(secrets.opts()));
|
|
1500
|
+
: await getEntityUid(cred.token, scopeOpts(secrets.opts()), { allowOfflineScopeFallback: true });
|
|
1464
1501
|
const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1465
1502
|
? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
|
|
1466
|
-
: undefined);
|
|
1503
|
+
: undefined, { allowOfflineFallback: true });
|
|
1467
1504
|
const secretEnv = {};
|
|
1468
1505
|
for (const key of keys) {
|
|
1469
1506
|
const value = revealed.get(key);
|
|
@@ -1511,10 +1548,10 @@ export function registerSecretsCommand(program) {
|
|
|
1511
1548
|
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
|
|
1512
1549
|
const companyUid = cred.kind === "api-key"
|
|
1513
1550
|
? "__api_key__"
|
|
1514
|
-
: await getEntityUid(cred.token, scopeOpts(secrets.opts()));
|
|
1551
|
+
: await getEntityUid(cred.token, scopeOpts(secrets.opts()), { allowOfflineScopeFallback: true });
|
|
1515
1552
|
const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
|
|
1516
1553
|
? await buildSecretUsage("env", opts.script, opts.scriptId)
|
|
1517
|
-
: undefined);
|
|
1554
|
+
: undefined, { allowOfflineFallback: true });
|
|
1518
1555
|
let payload = "";
|
|
1519
1556
|
for (const key of keys) {
|
|
1520
1557
|
const value = revealed.get(key);
|
package/dist/run/hq-plugin.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { SecretLoadResponse, SecretUsage } from '../commands/secrets.js';
|
|
2
2
|
export interface InstallHqPluginOpts {
|
|
3
3
|
companyOverride?: string;
|
|
4
|
+
/** Local cache identity derived from the current Cognito person/agent token. */
|
|
5
|
+
principalUid?: string;
|
|
6
|
+
/** Only hq run opts into encrypted offline values. */
|
|
7
|
+
allowOfflineFallback?: boolean;
|
|
4
8
|
resolveCompanyUid: (slug: string) => Promise<string>;
|
|
5
9
|
usage?: SecretUsage;
|
|
6
10
|
fetchBatch: (uid: string, names: string[], usage?: SecretUsage) => Promise<SecretLoadResponse>;
|
package/dist/run/hq-plugin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ResolutionError } from 'varlock/plugin-lib';
|
|
2
|
-
import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, } from '../utils/secrets-cache.js';
|
|
2
|
+
import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, } from '../utils/secrets-cache.js';
|
|
3
|
+
import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, } from '../utils/secret-load-availability.js';
|
|
3
4
|
function normalizeCacheTtlMs(secret) {
|
|
4
5
|
if (secret.tier === 'sensitive' ||
|
|
5
6
|
secret.tier === 'nuclear' ||
|
|
@@ -169,6 +170,14 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
|
|
|
169
170
|
throw new Error('Invalid secret load response from vault');
|
|
170
171
|
}
|
|
171
172
|
seenNames.add(s.name);
|
|
173
|
+
if (s.revokedAt != null) {
|
|
174
|
+
errorsByName.set(s.name, {
|
|
175
|
+
code: 'revoked',
|
|
176
|
+
message: `Secret '${s.name}' has been revoked`,
|
|
177
|
+
});
|
|
178
|
+
removeCacheEntry(uid, s.name);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
172
181
|
if (s.value == null) {
|
|
173
182
|
errorsByName.set(s.name, {
|
|
174
183
|
code: 'not_returned',
|
|
@@ -180,8 +189,8 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
|
|
|
180
189
|
returnedNames.add(s.name);
|
|
181
190
|
state.loadedSecretsByName.set(s.name, s.value);
|
|
182
191
|
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
183
|
-
if (cacheTtlMs > 0) {
|
|
184
|
-
writeCache(uid, s.name, s.value, cacheTtlMs, s.version);
|
|
192
|
+
if (cacheTtlMs > 0 && opts.principalUid) {
|
|
193
|
+
writeCache(uid, s.name, opts.principalUid, s.value, cacheTtlMs, s.version);
|
|
185
194
|
}
|
|
186
195
|
else {
|
|
187
196
|
removeCacheEntry(uid, s.name);
|
|
@@ -219,6 +228,24 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
|
|
|
219
228
|
catch (err) {
|
|
220
229
|
state.loadedSecretsByName.clear();
|
|
221
230
|
state.errorsByName = new Map();
|
|
231
|
+
if (isSecretLoadUnavailable(err) && opts.allowOfflineFallback === true && opts.principalUid) {
|
|
232
|
+
const cached = new Map();
|
|
233
|
+
for (const name of uniqueNames) {
|
|
234
|
+
const value = hasCacheEntry(uid, name, opts.principalUid)
|
|
235
|
+
? readCache(uid, name, opts.principalUid)
|
|
236
|
+
: null;
|
|
237
|
+
if (value === null) {
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
cached.set(name, value);
|
|
241
|
+
}
|
|
242
|
+
state.loadedSecretsByName = cached;
|
|
243
|
+
state.uid = uid;
|
|
244
|
+
console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (isSecretLoadUnavailable(err))
|
|
248
|
+
throw err;
|
|
222
249
|
for (const name of uniqueNames) {
|
|
223
250
|
removeCacheEntry(uid, name);
|
|
224
251
|
}
|
|
@@ -173,6 +173,8 @@ export declare function cognitoSubjectFromToken(token: string | undefined): {
|
|
|
173
173
|
sub: string | null;
|
|
174
174
|
entityUid: string | null;
|
|
175
175
|
};
|
|
176
|
+
/** Stable local cache identity for a Cognito person or agent principal. */
|
|
177
|
+
export declare function cognitoCachePrincipalUid(token: string | undefined): string | null;
|
|
176
178
|
/** Stable subject key. Null when neither sub nor entity uid is present. */
|
|
177
179
|
export declare function cognitoSubjectKey(tokens: {
|
|
178
180
|
idToken?: string;
|
|
@@ -434,6 +434,11 @@ export function cognitoSubjectFromToken(token) {
|
|
|
434
434
|
const entityUid = typeof entity === "string" && entity.length > 0 ? entity : null;
|
|
435
435
|
return { sub, entityUid };
|
|
436
436
|
}
|
|
437
|
+
/** Stable local cache identity for a Cognito person or agent principal. */
|
|
438
|
+
export function cognitoCachePrincipalUid(token) {
|
|
439
|
+
const claims = cognitoSubjectFromToken(token);
|
|
440
|
+
return claims.entityUid ?? claims.sub;
|
|
441
|
+
}
|
|
437
442
|
/** Stable subject key. Null when neither sub nor entity uid is present. */
|
|
438
443
|
export function cognitoSubjectKey(tokens) {
|
|
439
444
|
if (!tokens)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class SecretLoadUnavailableError extends Error {
|
|
2
|
+
readonly serverUnavailable = true;
|
|
3
|
+
constructor(message: string, options?: ErrorOptions);
|
|
4
|
+
}
|
|
5
|
+
export declare function isUnavailableSecretLoadHttpStatus(status: number): boolean;
|
|
6
|
+
export declare function isSecretLoadUnavailable(err: unknown): boolean;
|
|
7
|
+
export declare const CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE = "using cached secrets; server unavailable";
|
|
8
|
+
//# sourceMappingURL=secret-load-availability.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class SecretLoadUnavailableError extends Error {
|
|
2
|
+
serverUnavailable = true;
|
|
3
|
+
constructor(message, options) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = "SecretLoadUnavailableError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const TRANSIENT_NETWORK_CODES = new Set([
|
|
9
|
+
"ECONNABORTED",
|
|
10
|
+
"ECONNREFUSED",
|
|
11
|
+
"ECONNRESET",
|
|
12
|
+
"EHOSTUNREACH",
|
|
13
|
+
"ENETUNREACH",
|
|
14
|
+
"ENOTFOUND",
|
|
15
|
+
"EAI_AGAIN",
|
|
16
|
+
"ETIMEDOUT",
|
|
17
|
+
"EPIPE",
|
|
18
|
+
"UND_ERR_ABORTED",
|
|
19
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
20
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
21
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
22
|
+
"UND_ERR_SOCKET",
|
|
23
|
+
]);
|
|
24
|
+
export function isUnavailableSecretLoadHttpStatus(status) {
|
|
25
|
+
return status === 408 || status === 429 || (status >= 500 && status <= 599);
|
|
26
|
+
}
|
|
27
|
+
export function isSecretLoadUnavailable(err) {
|
|
28
|
+
if (err instanceof SecretLoadUnavailableError)
|
|
29
|
+
return true;
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
let current = err;
|
|
32
|
+
for (let depth = 0; depth < 4 && current && !seen.has(current); depth += 1) {
|
|
33
|
+
seen.add(current);
|
|
34
|
+
if (typeof current !== "object")
|
|
35
|
+
break;
|
|
36
|
+
const record = current;
|
|
37
|
+
if (typeof record.code === "string" &&
|
|
38
|
+
TRANSIENT_NETWORK_CODES.has(record.code)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (record.name === "TimeoutError" || record.name === "AbortError") {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (record instanceof TypeError &&
|
|
45
|
+
typeof record.message === "string" &&
|
|
46
|
+
/fetch failed|network error/i.test(record.message)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
current = record.cause;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
export const CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE = "using cached secrets; server unavailable";
|
|
54
|
+
//# sourceMappingURL=secret-load-availability.js.map
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
export declare const DEFAULT_SECRETS_CACHE_TTL_MS: number;
|
|
2
|
-
export declare function readCache(companyUid: string, name: string, expectedVersion?: number, maxAgeMs?: number): string | null;
|
|
2
|
+
export declare function readCache(companyUid: string, name: string, principalUid: string, expectedVersion?: number, maxAgeMs?: number): string | null;
|
|
3
3
|
/**
|
|
4
4
|
* Returns whether an unexpired entry exists without decrypting its plaintext.
|
|
5
5
|
* Callers use this only to decide whether to reauthorize a possible cache hit;
|
|
6
6
|
* they must complete that authorization before calling {@link readCache}.
|
|
7
7
|
*/
|
|
8
|
-
export declare function hasCacheEntry(companyUid: string, name: string): boolean;
|
|
9
|
-
export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number, version?: number): void;
|
|
8
|
+
export declare function hasCacheEntry(companyUid: string, name: string, principalUid: string): boolean;
|
|
9
|
+
export declare function writeCache(companyUid: string, name: string, principalUid: string, value: string, ttlMs?: number, version?: number): void;
|
|
10
|
+
/** Persist the last successfully resolved scope for a principal, encrypted and authenticated. */
|
|
11
|
+
export declare function writeCachedScopeUid(scopeKey: string, principalUid: string, scopeUid: string): void;
|
|
12
|
+
/** Return a still-valid scope UID only for the principal that wrote it. */
|
|
13
|
+
export declare function readCachedScopeUid(scopeKey: string, principalUid: string): string | null;
|
|
10
14
|
/**
|
|
11
15
|
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
12
16
|
* secrets-cache directory on disk. Install-time MCP registration may use an
|
|
@@ -6,9 +6,11 @@ const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
|
|
|
6
6
|
const KEY_PATH = path.join(CACHE_DIR, ".key");
|
|
7
7
|
const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
|
|
8
8
|
const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
|
|
9
|
-
const CACHE_VERSION_MARKER = Buffer.from("
|
|
9
|
+
const CACHE_VERSION_MARKER = Buffer.from("HQSCV3\0");
|
|
10
|
+
const PREVIOUS_CACHE_VERSION_MARKER = Buffer.from("HQSCV2\0");
|
|
10
11
|
const LEGACY_CACHE_VERSION_MARKER = Buffer.from("HQSCV1\0");
|
|
11
12
|
const CACHE_VERSION_MARKER_BYTES = CACHE_VERSION_MARKER.length;
|
|
13
|
+
const PRINCIPAL_DIGEST_BYTES = 32;
|
|
12
14
|
const LEGACY_TIMESTAMP_BYTES = 8;
|
|
13
15
|
const TIMESTAMP_BYTES = 8;
|
|
14
16
|
const TTL_BYTES = 8;
|
|
@@ -16,7 +18,9 @@ const CACHE_VERSION_BYTES = 8;
|
|
|
16
18
|
const ALGORITHM = "aes-256-gcm";
|
|
17
19
|
const IV_BYTES = 12;
|
|
18
20
|
const AUTH_TAG_BYTES = 16;
|
|
19
|
-
|
|
21
|
+
const SCOPE_CACHE_MAGIC = Buffer.from("HQSSV1\0");
|
|
22
|
+
const SCOPE_KEY_DIR = "scopes";
|
|
23
|
+
export const DEFAULT_SECRETS_CACHE_TTL_MS = 3 * 60 * 60 * 1000;
|
|
20
24
|
function ensureCacheDir(companyUid) {
|
|
21
25
|
const dir = path.join(CACHE_DIR, companyUid);
|
|
22
26
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -58,7 +62,10 @@ function readCacheVersion(raw, offset) {
|
|
|
58
62
|
return undefined;
|
|
59
63
|
return isCacheVersion(value) ? value : null;
|
|
60
64
|
}
|
|
61
|
-
function
|
|
65
|
+
function principalDigest(principalUid) {
|
|
66
|
+
return crypto.createHash("sha256").update(principalUid, "utf8").digest();
|
|
67
|
+
}
|
|
68
|
+
function cacheAad(header, companyUid, name, principalUid) {
|
|
62
69
|
const company = Buffer.from(companyUid, "utf8");
|
|
63
70
|
const secretName = Buffer.from(name, "utf8");
|
|
64
71
|
const companyLength = Buffer.alloc(4);
|
|
@@ -72,6 +79,7 @@ function cacheAad(header, companyUid, name) {
|
|
|
72
79
|
company,
|
|
73
80
|
nameLength,
|
|
74
81
|
secretName,
|
|
82
|
+
principalDigest(principalUid),
|
|
75
83
|
]);
|
|
76
84
|
}
|
|
77
85
|
function parseCacheHeader(raw) {
|
|
@@ -82,8 +90,9 @@ function parseCacheHeader(raw) {
|
|
|
82
90
|
let ivStart = baseHeaderBytes;
|
|
83
91
|
let version;
|
|
84
92
|
let identityAuthenticated = false;
|
|
93
|
+
let encodedPrincipalDigest;
|
|
85
94
|
if (raw.length >=
|
|
86
|
-
baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
95
|
+
baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + PRINCIPAL_DIGEST_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
87
96
|
raw
|
|
88
97
|
.subarray(baseHeaderBytes, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES)
|
|
89
98
|
.equals(CACHE_VERSION_MARKER)) {
|
|
@@ -91,9 +100,22 @@ function parseCacheHeader(raw) {
|
|
|
91
100
|
if (parsedVersion === null)
|
|
92
101
|
return null;
|
|
93
102
|
version = parsedVersion;
|
|
94
|
-
|
|
103
|
+
const principalStart = baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
|
|
104
|
+
encodedPrincipalDigest = raw.subarray(principalStart, principalStart + PRINCIPAL_DIGEST_BYTES);
|
|
105
|
+
ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + PRINCIPAL_DIGEST_BYTES;
|
|
95
106
|
identityAuthenticated = true;
|
|
96
107
|
}
|
|
108
|
+
else if (raw.length >=
|
|
109
|
+
baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
110
|
+
raw
|
|
111
|
+
.subarray(baseHeaderBytes, baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length)
|
|
112
|
+
.equals(PREVIOUS_CACHE_VERSION_MARKER)) {
|
|
113
|
+
const parsedVersion = readCacheVersion(raw, baseHeaderBytes + PREVIOUS_CACHE_VERSION_MARKER.length);
|
|
114
|
+
if (parsedVersion === null)
|
|
115
|
+
return null;
|
|
116
|
+
version = parsedVersion;
|
|
117
|
+
ivStart += PREVIOUS_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES;
|
|
118
|
+
}
|
|
97
119
|
else if (raw.length >=
|
|
98
120
|
baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
99
121
|
raw
|
|
@@ -114,6 +136,7 @@ function parseCacheHeader(raw) {
|
|
|
114
136
|
ttlMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES)),
|
|
115
137
|
version,
|
|
116
138
|
identityAuthenticated,
|
|
139
|
+
principalDigest: encodedPrincipalDigest,
|
|
117
140
|
ivStart,
|
|
118
141
|
authTagStart,
|
|
119
142
|
ciphertextStart,
|
|
@@ -136,8 +159,8 @@ function isExpired(header, maxAgeMs) {
|
|
|
136
159
|
const ageMs = Date.now() - header.timestampMs;
|
|
137
160
|
return !Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs;
|
|
138
161
|
}
|
|
139
|
-
export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
|
|
140
|
-
if (!validateInputs(companyUid, name))
|
|
162
|
+
export function readCache(companyUid, name, principalUid, expectedVersion, maxAgeMs) {
|
|
163
|
+
if (!validateInputs(companyUid, name) || !principalUid)
|
|
141
164
|
return null;
|
|
142
165
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
143
166
|
let raw;
|
|
@@ -159,6 +182,8 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
|
|
|
159
182
|
catch { /* ok */ }
|
|
160
183
|
return null;
|
|
161
184
|
}
|
|
185
|
+
if (!header.principalDigest?.equals(principalDigest(principalUid)))
|
|
186
|
+
return null;
|
|
162
187
|
const iv = raw.subarray(header.ivStart, header.authTagStart);
|
|
163
188
|
const authTag = raw.subarray(header.authTagStart, header.ciphertextStart);
|
|
164
189
|
const ciphertext = raw.subarray(header.ciphertextStart);
|
|
@@ -171,7 +196,7 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
|
|
|
171
196
|
}
|
|
172
197
|
try {
|
|
173
198
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
174
|
-
decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name));
|
|
199
|
+
decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name, principalUid));
|
|
175
200
|
decipher.setAuthTag(authTag);
|
|
176
201
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
177
202
|
return decrypted.toString("utf8");
|
|
@@ -189,8 +214,8 @@ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
|
|
|
189
214
|
* Callers use this only to decide whether to reauthorize a possible cache hit;
|
|
190
215
|
* they must complete that authorization before calling {@link readCache}.
|
|
191
216
|
*/
|
|
192
|
-
export function hasCacheEntry(companyUid, name) {
|
|
193
|
-
if (!validateInputs(companyUid, name))
|
|
217
|
+
export function hasCacheEntry(companyUid, name, principalUid) {
|
|
218
|
+
if (!validateInputs(companyUid, name) || !principalUid)
|
|
194
219
|
return false;
|
|
195
220
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
196
221
|
let raw;
|
|
@@ -203,18 +228,21 @@ export function hasCacheEntry(companyUid, name) {
|
|
|
203
228
|
const header = parseCacheHeader(raw);
|
|
204
229
|
if (!header)
|
|
205
230
|
return false;
|
|
206
|
-
if (!header.identityAuthenticated ||
|
|
231
|
+
if (!header.identityAuthenticated ||
|
|
232
|
+
isExpired(header)) {
|
|
207
233
|
try {
|
|
208
234
|
fs.unlinkSync(filePath);
|
|
209
235
|
}
|
|
210
236
|
catch { /* ok */ }
|
|
211
237
|
return false;
|
|
212
238
|
}
|
|
239
|
+
if (!header.principalDigest?.equals(principalDigest(principalUid)))
|
|
240
|
+
return false;
|
|
213
241
|
return true;
|
|
214
242
|
}
|
|
215
|
-
export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
|
|
243
|
+
export function writeCache(companyUid, name, principalUid, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
|
|
216
244
|
try {
|
|
217
|
-
if (!validateInputs(companyUid, name))
|
|
245
|
+
if (!validateInputs(companyUid, name) || !principalUid)
|
|
218
246
|
return;
|
|
219
247
|
if (ttlMs <= 0)
|
|
220
248
|
return;
|
|
@@ -233,9 +261,10 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
|
|
|
233
261
|
ttl,
|
|
234
262
|
CACHE_VERSION_MARKER,
|
|
235
263
|
versionBuffer,
|
|
264
|
+
principalDigest(principalUid),
|
|
236
265
|
]);
|
|
237
266
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
238
|
-
cipher.setAAD(cacheAad(header, companyUid, name));
|
|
267
|
+
cipher.setAAD(cacheAad(header, companyUid, name, principalUid));
|
|
239
268
|
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
240
269
|
const authTag = cipher.getAuthTag();
|
|
241
270
|
const out = Buffer.concat([
|
|
@@ -254,6 +283,125 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
|
|
|
254
283
|
// Cache write failure is non-fatal
|
|
255
284
|
}
|
|
256
285
|
}
|
|
286
|
+
function scopeCachePath(scopeKey) {
|
|
287
|
+
const name = crypto.createHash("sha256").update(scopeKey, "utf8").digest("hex");
|
|
288
|
+
return path.join(CACHE_DIR, SCOPE_KEY_DIR, name);
|
|
289
|
+
}
|
|
290
|
+
function scopeCacheAad(header, scopeKey, principalUid) {
|
|
291
|
+
const key = Buffer.from(scopeKey, "utf8");
|
|
292
|
+
const keyLength = Buffer.alloc(4);
|
|
293
|
+
keyLength.writeUInt32BE(key.length);
|
|
294
|
+
return Buffer.concat([
|
|
295
|
+
Buffer.from("HQSC-SCOPE-AAD-V1\0"),
|
|
296
|
+
header,
|
|
297
|
+
keyLength,
|
|
298
|
+
key,
|
|
299
|
+
principalDigest(principalUid),
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
function logScopeCacheFailure(operation, err) {
|
|
303
|
+
const code = err && typeof err === "object" && "code" in err &&
|
|
304
|
+
typeof err.code === "string" &&
|
|
305
|
+
/^[A-Z0-9_]+$/.test(err.code)
|
|
306
|
+
? err.code
|
|
307
|
+
: undefined;
|
|
308
|
+
console.warn(`Unable to ${operation} cached HQ scope UID`, code ? { code } : {});
|
|
309
|
+
}
|
|
310
|
+
function removeScopeCacheFile(filePath) {
|
|
311
|
+
try {
|
|
312
|
+
fs.unlinkSync(filePath);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (!(err &&
|
|
316
|
+
typeof err === "object" &&
|
|
317
|
+
"code" in err &&
|
|
318
|
+
err.code === "ENOENT")) {
|
|
319
|
+
logScopeCacheFailure("remove", err);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Persist the last successfully resolved scope for a principal, encrypted and authenticated. */
|
|
324
|
+
export function writeCachedScopeUid(scopeKey, principalUid, scopeUid) {
|
|
325
|
+
try {
|
|
326
|
+
if (!scopeKey || !principalUid || !/^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(scopeUid))
|
|
327
|
+
return;
|
|
328
|
+
fs.mkdirSync(path.join(CACHE_DIR, SCOPE_KEY_DIR), { recursive: true, mode: 0o700 });
|
|
329
|
+
const key = getOrCreateKey();
|
|
330
|
+
const timestamp = Buffer.alloc(8);
|
|
331
|
+
timestamp.writeBigInt64BE(BigInt(Date.now()));
|
|
332
|
+
const ttl = Buffer.alloc(8);
|
|
333
|
+
ttl.writeBigInt64BE(BigInt(DEFAULT_SECRETS_CACHE_TTL_MS));
|
|
334
|
+
const header = Buffer.concat([SCOPE_CACHE_MAGIC, timestamp, ttl, principalDigest(principalUid)]);
|
|
335
|
+
const iv = crypto.randomBytes(IV_BYTES);
|
|
336
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
337
|
+
cipher.setAAD(scopeCacheAad(header, scopeKey, principalUid));
|
|
338
|
+
const encrypted = Buffer.concat([cipher.update(scopeUid, "utf8"), cipher.final()]);
|
|
339
|
+
const authTag = cipher.getAuthTag();
|
|
340
|
+
const output = Buffer.concat([header, iv, authTag, encrypted]);
|
|
341
|
+
const filePath = scopeCachePath(scopeKey);
|
|
342
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
343
|
+
fs.writeFileSync(tmpPath, output, { mode: 0o600 });
|
|
344
|
+
fs.renameSync(tmpPath, filePath);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
// A scope-cache write failure must not change a successful online lookup.
|
|
348
|
+
logScopeCacheFailure("write", err);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Return a still-valid scope UID only for the principal that wrote it. */
|
|
352
|
+
export function readCachedScopeUid(scopeKey, principalUid) {
|
|
353
|
+
if (!scopeKey || !principalUid)
|
|
354
|
+
return null;
|
|
355
|
+
const headerBytes = SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES + TTL_BYTES + PRINCIPAL_DIGEST_BYTES;
|
|
356
|
+
const filePath = scopeCachePath(scopeKey);
|
|
357
|
+
let raw;
|
|
358
|
+
try {
|
|
359
|
+
raw = fs.readFileSync(filePath);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
if (err &&
|
|
363
|
+
typeof err === "object" &&
|
|
364
|
+
"code" in err &&
|
|
365
|
+
err.code === "ENOENT") {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
logScopeCacheFailure("read", err);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
if (raw.length < headerBytes + IV_BYTES + AUTH_TAG_BYTES || !raw.subarray(0, SCOPE_CACHE_MAGIC.length).equals(SCOPE_CACHE_MAGIC)) {
|
|
372
|
+
console.warn("Ignoring malformed cached HQ scope UID entry");
|
|
373
|
+
removeScopeCacheFile(filePath);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const encodedPrincipal = raw.subarray(SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES + TTL_BYTES, headerBytes);
|
|
377
|
+
const timestampMs = Number(raw.readBigInt64BE(SCOPE_CACHE_MAGIC.length));
|
|
378
|
+
const ttlMs = Number(raw.readBigInt64BE(SCOPE_CACHE_MAGIC.length + TIMESTAMP_BYTES));
|
|
379
|
+
const ageMs = Date.now() - timestampMs;
|
|
380
|
+
if (!encodedPrincipal.equals(principalDigest(principalUid)))
|
|
381
|
+
return null;
|
|
382
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs) {
|
|
383
|
+
removeScopeCacheFile(filePath);
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
const ivStart = headerBytes;
|
|
387
|
+
const authTagStart = ivStart + IV_BYTES;
|
|
388
|
+
const ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
389
|
+
try {
|
|
390
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, getOrCreateKey(), raw.subarray(ivStart, authTagStart));
|
|
391
|
+
decipher.setAAD(scopeCacheAad(raw.subarray(0, headerBytes), scopeKey, principalUid));
|
|
392
|
+
decipher.setAuthTag(raw.subarray(authTagStart, ciphertextStart));
|
|
393
|
+
const scopeUid = Buffer.concat([
|
|
394
|
+
decipher.update(raw.subarray(ciphertextStart)),
|
|
395
|
+
decipher.final(),
|
|
396
|
+
]).toString("utf8");
|
|
397
|
+
return /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(scopeUid) ? scopeUid : null;
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
logScopeCacheFailure("decrypt", err);
|
|
401
|
+
removeScopeCacheFile(filePath);
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
257
405
|
/**
|
|
258
406
|
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
259
407
|
* secrets-cache directory on disk. Install-time MCP registration may use an
|
|
@@ -67,10 +67,14 @@ export declare function getCompanyUid(token: string, companySlug: string | undef
|
|
|
67
67
|
* pass it when the caller is bound to another API (an enrolled agent's
|
|
68
68
|
* `apiBaseUrl`) so its token never goes to the default host.
|
|
69
69
|
*/
|
|
70
|
-
baseUrl?: string
|
|
70
|
+
baseUrl?: string, options?: {
|
|
71
|
+
allowOfflineScopeFallback?: boolean;
|
|
72
|
+
}): Promise<string>;
|
|
71
73
|
export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
|
|
72
74
|
export declare function getEntityUid(token: string, opts: {
|
|
73
75
|
personal?: boolean;
|
|
74
76
|
companySlug?: string;
|
|
77
|
+
}, options?: {
|
|
78
|
+
allowOfflineScopeFallback?: boolean;
|
|
75
79
|
}): Promise<string>;
|
|
76
80
|
//# sourceMappingURL=vault-api.d.ts.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
1
|
+
import { DEFAULT_VAULT_API_URL, cognitoCachePrincipalUid } from './cognito-session.js';
|
|
2
2
|
import { CLI_VERSION } from '../cli-version.js';
|
|
3
3
|
import { Sentry } from '../sentry.js';
|
|
4
4
|
import { AuthError } from './auth-error.js';
|
|
@@ -8,6 +8,8 @@ import { networkTransportErrorCode } from './network-transport-error.js';
|
|
|
8
8
|
import { planGateErrorFromResponse } from './plan-gate-error.js';
|
|
9
9
|
import { markExpectedUserError } from './expected-cli-error.js';
|
|
10
10
|
import { redactErrorText } from './redact-error-text.js';
|
|
11
|
+
import { isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError } from './secret-load-availability.js';
|
|
12
|
+
import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
|
|
11
13
|
/**
|
|
12
14
|
* Identity / company resolution lookups must never hang forever. These small
|
|
13
15
|
* GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
|
|
@@ -47,8 +49,9 @@ function identityLookupTimeoutMs() {
|
|
|
47
49
|
* call .json()/.text()/.arrayBuffer(). Non-JSON and non-ok responses are
|
|
48
50
|
* returned untouched (streamed binary downloads must never be buffered).
|
|
49
51
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
* On inspection errors, return the original response by default to preserve
|
|
53
|
+
* existing behavior. Secret-load callers opt into rethrowing body read errors
|
|
54
|
+
* so the load path can classify a broken stream for offline-cache fallback.
|
|
52
55
|
*
|
|
53
56
|
* Why not response.clone()? Cloning tees the undici body stream; when a
|
|
54
57
|
* caller never consumes the original Response body (many commands only check
|
|
@@ -56,7 +59,7 @@ function identityLookupTimeoutMs() {
|
|
|
56
59
|
* referenced and the process (or vitest worker / spawned CLI child) never
|
|
57
60
|
* exits on Linux CI. Reading once + re-wrapping fully drains the stream.
|
|
58
61
|
*/
|
|
59
|
-
async function peekPlanLimitStatus(response) {
|
|
62
|
+
async function peekPlanLimitStatus(response, options = {}) {
|
|
60
63
|
try {
|
|
61
64
|
if (!response.ok)
|
|
62
65
|
return response;
|
|
@@ -64,14 +67,7 @@ async function peekPlanLimitStatus(response) {
|
|
|
64
67
|
if (!ct.includes('application/json') && !ct.includes('+json')) {
|
|
65
68
|
return response;
|
|
66
69
|
}
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
buf = await response.arrayBuffer();
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// Body may be locked/errored; original is the only thing we can return.
|
|
73
|
-
return response;
|
|
74
|
-
}
|
|
70
|
+
const buf = await response.arrayBuffer();
|
|
75
71
|
try {
|
|
76
72
|
const text = new TextDecoder().decode(buf);
|
|
77
73
|
try {
|
|
@@ -92,9 +88,11 @@ async function peekPlanLimitStatus(response) {
|
|
|
92
88
|
headers: response.headers,
|
|
93
89
|
});
|
|
94
90
|
}
|
|
95
|
-
catch {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
91
|
+
catch (err) {
|
|
92
|
+
// Only secret-load callers need the body-read error for offline fallback.
|
|
93
|
+
// Other endpoints retain the pre-existing best-effort peek behavior.
|
|
94
|
+
if (options.throwOnBodyReadError)
|
|
95
|
+
throw err;
|
|
98
96
|
return response;
|
|
99
97
|
}
|
|
100
98
|
}
|
|
@@ -238,7 +236,21 @@ export async function vaultApiFetch(opts) {
|
|
|
238
236
|
throw planGate.error;
|
|
239
237
|
return planGate.response;
|
|
240
238
|
}
|
|
241
|
-
|
|
239
|
+
const isSecretLoad = method === "POST" && /^\/secrets\/[^/]+\/load$/.test(path);
|
|
240
|
+
try {
|
|
241
|
+
return await peekPlanLimitStatus(response, {
|
|
242
|
+
throwOnBodyReadError: isSecretLoad,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
// A successful /load response whose JSON stream breaks is a transient
|
|
247
|
+
// transport failure for offline-cache purposes, even when undici has
|
|
248
|
+
// already reduced its cause to a generic body-reader TypeError.
|
|
249
|
+
if (isSecretLoad) {
|
|
250
|
+
throw new SecretLoadUnavailableError("Failed to read the secret load response body", { cause: err });
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
242
254
|
}
|
|
243
255
|
/**
|
|
244
256
|
* Public (NONE-auth) GET against the vault API — no bearer token. The
|
|
@@ -324,6 +336,9 @@ async function resolveCompanyByUid(token, uid, baseUrl) {
|
|
|
324
336
|
if (!res.ok) {
|
|
325
337
|
raiseIfUnauthorized(res);
|
|
326
338
|
const body = (await res.json().catch(() => ({})));
|
|
339
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
340
|
+
throw new SecretLoadUnavailableError(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
|
|
341
|
+
}
|
|
327
342
|
throw new Error(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
|
|
328
343
|
}
|
|
329
344
|
const data = (await res.json());
|
|
@@ -492,6 +507,9 @@ async function resolveCompanyUid(token, ref, baseUrl) {
|
|
|
492
507
|
? " This is a temporary lookup failure, not a missing company. Retry the command."
|
|
493
508
|
: "";
|
|
494
509
|
const error = new Error(redactedSlugLookupMessage(`Failed to resolve company slug '${ref}': ${reason}.${hint}`));
|
|
510
|
+
if (retryable && (mine.kind === "transient" || isUnavailableSecretLoadHttpStatus(res.status))) {
|
|
511
|
+
throw new SecretLoadUnavailableError(error.message);
|
|
512
|
+
}
|
|
495
513
|
throw retryable ? error : markExpectedUserError(error);
|
|
496
514
|
}
|
|
497
515
|
const data = (await res.json());
|
|
@@ -506,6 +524,9 @@ async function resolveCompanyFromMemberships(token, baseUrl) {
|
|
|
506
524
|
});
|
|
507
525
|
if (!res.ok) {
|
|
508
526
|
const error = new Error("Failed to fetch memberships — run `hq login` and try again");
|
|
527
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
528
|
+
throw new SecretLoadUnavailableError(error.message);
|
|
529
|
+
}
|
|
509
530
|
throw res.status === 401 ? markExpectedUserError(error) : error;
|
|
510
531
|
}
|
|
511
532
|
const data = (await res.json());
|
|
@@ -529,7 +550,7 @@ export async function getCompanyUid(token, companySlug,
|
|
|
529
550
|
* pass it when the caller is bound to another API (an enrolled agent's
|
|
530
551
|
* `apiBaseUrl`) so its token never goes to the default host.
|
|
531
552
|
*/
|
|
532
|
-
baseUrl) {
|
|
553
|
+
baseUrl, options = {}) {
|
|
533
554
|
// Vault API keys are company-bound. Entity slug resolution has no keyed
|
|
534
555
|
// route, so under hqk_ we accept an explicit cmp_ uid (or HQ_COMPANY_UID)
|
|
535
556
|
// and otherwise fall through to keyed GET /v1/keys/membership/me.
|
|
@@ -544,10 +565,29 @@ baseUrl) {
|
|
|
544
565
|
}
|
|
545
566
|
return resolveCompanyFromMemberships(token, baseUrl);
|
|
546
567
|
}
|
|
547
|
-
if (
|
|
548
|
-
return
|
|
568
|
+
if (!options.allowOfflineScopeFallback) {
|
|
569
|
+
return companySlug
|
|
570
|
+
? resolveCompanyUid(token, companySlug, baseUrl)
|
|
571
|
+
: resolveCompanyFromMemberships(token, baseUrl);
|
|
572
|
+
}
|
|
573
|
+
const scopeKey = `company:${companySlug ?? "<active-membership>"}`;
|
|
574
|
+
const principalUid = cognitoCachePrincipalUid(token);
|
|
575
|
+
try {
|
|
576
|
+
const uid = companySlug
|
|
577
|
+
? await resolveCompanyUid(token, companySlug, baseUrl)
|
|
578
|
+
: await resolveCompanyFromMemberships(token, baseUrl);
|
|
579
|
+
if (principalUid)
|
|
580
|
+
writeCachedScopeUid(scopeKey, principalUid, uid);
|
|
581
|
+
return uid;
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
if (!principalUid || !isSecretLoadUnavailable(err))
|
|
585
|
+
throw err;
|
|
586
|
+
const cachedUid = readCachedScopeUid(scopeKey, principalUid);
|
|
587
|
+
if (!cachedUid)
|
|
588
|
+
throw err;
|
|
589
|
+
return cachedUid;
|
|
549
590
|
}
|
|
550
|
-
return resolveCompanyFromMemberships(token, baseUrl);
|
|
551
591
|
}
|
|
552
592
|
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
553
593
|
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
@@ -559,6 +599,9 @@ export async function resolveCallerPersonUid(token, baseUrl) {
|
|
|
559
599
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
560
600
|
});
|
|
561
601
|
if (!res.ok) {
|
|
602
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
603
|
+
throw new SecretLoadUnavailableError("Failed to fetch person entity — run `hq login` and try again");
|
|
604
|
+
}
|
|
562
605
|
throw new Error("Failed to fetch person entity — run `hq login` and try again");
|
|
563
606
|
}
|
|
564
607
|
const data = (await res.json());
|
|
@@ -578,10 +621,26 @@ export async function resolveCallerPersonUid(token, baseUrl) {
|
|
|
578
621
|
// Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
|
|
579
622
|
// `--personal` → caller's canonical person entity; else `--company <slug>` →
|
|
580
623
|
// resolved company UID; else fallback to single active company membership.
|
|
581
|
-
export async function getEntityUid(token, opts) {
|
|
624
|
+
export async function getEntityUid(token, opts, options = {}) {
|
|
582
625
|
if (opts.personal) {
|
|
583
|
-
|
|
626
|
+
if (!options.allowOfflineScopeFallback)
|
|
627
|
+
return resolveCallerPersonUid(token);
|
|
628
|
+
const principalUid = cognitoCachePrincipalUid(token);
|
|
629
|
+
try {
|
|
630
|
+
const uid = await resolveCallerPersonUid(token);
|
|
631
|
+
if (principalUid)
|
|
632
|
+
writeCachedScopeUid("personal", principalUid, uid);
|
|
633
|
+
return uid;
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
if (!principalUid || !isSecretLoadUnavailable(err))
|
|
637
|
+
throw err;
|
|
638
|
+
const cachedUid = readCachedScopeUid("personal", principalUid);
|
|
639
|
+
if (!cachedUid)
|
|
640
|
+
throw err;
|
|
641
|
+
return cachedUid;
|
|
642
|
+
}
|
|
584
643
|
}
|
|
585
|
-
return getCompanyUid(token, opts.companySlug);
|
|
644
|
+
return getCompanyUid(token, opts.companySlug, undefined, options);
|
|
586
645
|
}
|
|
587
646
|
//# sourceMappingURL=vault-api.js.map
|