@indigoai-us/hq-cli 5.47.9 → 5.47.11
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/members.js +38 -2
- package/dist/commands/run.js +34 -5
- package/dist/commands/secrets.d.ts +35 -1
- package/dist/commands/secrets.js +346 -22
- package/dist/run/hq-plugin.d.ts +4 -11
- package/dist/run/hq-plugin.js +22 -5
- package/dist/utils/integrity.d.ts +4 -0
- package/dist/utils/integrity.js +11 -7
- package/dist/utils/secrets-cache.d.ts +2 -1
- package/dist/utils/secrets-cache.js +42 -14
- package/package.json +1 -1
- package/src/commands/members.test.ts +92 -1
- package/src/commands/members.ts +61 -0
- package/src/commands/run.env-local.test.ts +5 -1
- package/src/commands/run.ts +40 -9
- package/src/commands/secrets.test.ts +394 -4
- package/src/commands/secrets.ts +574 -37
- package/src/run/hq-plugin.test.ts +31 -0
- package/src/run/hq-plugin.ts +33 -7
- package/src/utils/integrity.ts +13 -8
- package/src/utils/secrets-cache.ts +45 -11
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
vi.mock('../utils/secrets-cache.js', () => {
|
|
17
17
|
const store = new Map<string, string>();
|
|
18
18
|
return {
|
|
19
|
+
DEFAULT_SECRETS_CACHE_TTL_MS: 300000,
|
|
19
20
|
readCache: (uid: string, name: string): string | null =>
|
|
20
21
|
store.get(`${uid}\0${name}`) ?? null,
|
|
21
22
|
writeCache: (uid: string, name: string, value: string): void => {
|
|
@@ -73,6 +74,36 @@ describe('hq-plugin', () => {
|
|
|
73
74
|
expect(graph.getResolvedEnvObject().FOO).toBe('fixture-value');
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
it('zero-cache secrets still resolve from in-memory prewarm state within a single run', async () => {
|
|
78
|
+
const schemaPath = path.join(tmpDir, '.env.schema');
|
|
79
|
+
fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nFOO=hq()\n`);
|
|
80
|
+
|
|
81
|
+
const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
|
|
82
|
+
const mocks = makeMocks({
|
|
83
|
+
resolveCompanyUid: async () => uid,
|
|
84
|
+
fetchBatch: async (_uid, names) => ({
|
|
85
|
+
secrets: names.map((name) => ({
|
|
86
|
+
name,
|
|
87
|
+
value: 'memory-only-value',
|
|
88
|
+
cacheTtlMs: 0,
|
|
89
|
+
})),
|
|
90
|
+
errors: [],
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let state!: PluginState;
|
|
95
|
+
const graph = await internal.loadEnvGraph({
|
|
96
|
+
entryFilePaths: [schemaPath],
|
|
97
|
+
afterInit: async (g) => {
|
|
98
|
+
state = installHqPlugin(g, mocks);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
await prewarmHqSecrets(graph, mocks, state);
|
|
102
|
+
await graph.resolveEnvValues();
|
|
103
|
+
|
|
104
|
+
expect(graph.getResolvedEnvObject().FOO).toBe('memory-only-value');
|
|
105
|
+
});
|
|
106
|
+
|
|
76
107
|
it('missing-company-error: throws when no @hqCompany and no companyOverride', async () => {
|
|
77
108
|
const schemaPath = path.join(tmpDir, '.env.schema');
|
|
78
109
|
// Schema has hq() resolver but no @hqCompany annotation and no opts.companyOverride
|
package/src/run/hq-plugin.ts
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
import { ResolutionError } from 'varlock/plugin-lib';
|
|
2
2
|
import type { Resolver } from 'varlock/plugin-lib';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SECRETS_CACHE_TTL_MS,
|
|
5
|
+
readCache,
|
|
6
|
+
writeCache,
|
|
7
|
+
} from '../utils/secrets-cache.js';
|
|
8
|
+
import type { SecretLoadResponse, SecretUsage } from '../commands/secrets.js';
|
|
9
|
+
|
|
10
|
+
function normalizeCacheTtlMs(cacheTtlMs?: number): number {
|
|
11
|
+
return typeof cacheTtlMs === 'number'
|
|
12
|
+
? cacheTtlMs
|
|
13
|
+
: DEFAULT_SECRETS_CACHE_TTL_MS;
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
export interface InstallHqPluginOpts {
|
|
6
17
|
companyOverride?: string;
|
|
7
18
|
resolveCompanyUid: (slug: string) => Promise<string>;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
usage?: SecretUsage;
|
|
20
|
+
fetchBatch: (
|
|
21
|
+
uid: string,
|
|
22
|
+
names: string[],
|
|
23
|
+
usage?: SecretUsage,
|
|
24
|
+
) => Promise<SecretLoadResponse>;
|
|
12
25
|
}
|
|
13
26
|
|
|
14
27
|
export interface PluginState {
|
|
15
28
|
schemaCompanySlug: string | null;
|
|
16
29
|
uid: string | null;
|
|
17
30
|
errorsByName: Map<string, { code: string; message?: string }>;
|
|
31
|
+
loadedSecretsByName: Map<string, string>;
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPluginOpts) {
|
|
@@ -22,6 +36,7 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
|
|
|
22
36
|
schemaCompanySlug: null,
|
|
23
37
|
uid: null,
|
|
24
38
|
errorsByName: new Map(),
|
|
39
|
+
loadedSecretsByName: new Map(),
|
|
25
40
|
};
|
|
26
41
|
|
|
27
42
|
// varlock@1.0.0's plugin-lib.js omits the Resolver export (d.ts/JS mismatch);
|
|
@@ -80,6 +95,10 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
|
|
|
80
95
|
if (pluginState.uid == null) {
|
|
81
96
|
throw new ResolutionError('Internal error: prewarmHqSecrets was not called before resolveEnvValues');
|
|
82
97
|
}
|
|
98
|
+
const inMemory = pluginState.loadedSecretsByName.get(secretName);
|
|
99
|
+
if (inMemory != null) {
|
|
100
|
+
return inMemory;
|
|
101
|
+
}
|
|
83
102
|
const cached = readCache(pluginState.uid, secretName); // string | null
|
|
84
103
|
if (cached == null) {
|
|
85
104
|
throw new ResolutionError(`Internal error: pre-warm did not populate cache for "${secretName}"`);
|
|
@@ -160,10 +179,17 @@ export async function prewarmHqSecrets(
|
|
|
160
179
|
`hq run supports at most 100 hq() resolvers per schema; got ${uniqueNames.length}`,
|
|
161
180
|
);
|
|
162
181
|
}
|
|
163
|
-
const result = await opts.fetchBatch(uid, uniqueNames);
|
|
182
|
+
const result = await opts.fetchBatch(uid, uniqueNames, opts.usage);
|
|
164
183
|
|
|
165
184
|
for (const s of result.secrets) {
|
|
166
|
-
|
|
185
|
+
if (s.value == null) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
state.loadedSecretsByName.set(s.name, s.value);
|
|
189
|
+
const cacheTtlMs = normalizeCacheTtlMs(s.cacheTtlMs);
|
|
190
|
+
if (cacheTtlMs > 0) {
|
|
191
|
+
writeCache(uid, s.name, s.value, cacheTtlMs);
|
|
192
|
+
}
|
|
167
193
|
}
|
|
168
194
|
const errorsByName = new Map<string, { code: string; message?: string }>();
|
|
169
195
|
for (const e of result.errors) {
|
package/src/utils/integrity.ts
CHANGED
|
@@ -10,23 +10,28 @@ import { resolveDefaultHqRoot } from './cognito-session.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* Verify a file's SHA256 hash matches the expected value.
|
|
12
12
|
*/
|
|
13
|
-
export async function
|
|
14
|
-
filePath: string,
|
|
15
|
-
expectedHash: string
|
|
16
|
-
): Promise<boolean> {
|
|
13
|
+
export async function computeSha256(filePath: string): Promise<string> {
|
|
17
14
|
return new Promise((resolve, reject) => {
|
|
18
15
|
const hash = crypto.createHash('sha256');
|
|
19
16
|
const stream = fs.createReadStream(filePath);
|
|
20
17
|
|
|
21
18
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
22
|
-
stream.on('end', () =>
|
|
23
|
-
const computed = hash.digest('hex');
|
|
24
|
-
resolve(computed === expectedHash.toLowerCase());
|
|
25
|
-
});
|
|
19
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
26
20
|
stream.on('error', reject);
|
|
27
21
|
});
|
|
28
22
|
}
|
|
29
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Verify a file's SHA256 hash matches the expected value.
|
|
26
|
+
*/
|
|
27
|
+
export async function verifySha256(
|
|
28
|
+
filePath: string,
|
|
29
|
+
expectedHash: string
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
const computed = await computeSha256(filePath);
|
|
32
|
+
return computed === expectedHash.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* Verify an RSA signature of a SHA256 hash using the registry public key.
|
|
32
37
|
* The public key is expected at packages/.keys/registry-public.pem.
|
|
@@ -5,10 +5,15 @@ import * as os from "node:os";
|
|
|
5
5
|
|
|
6
6
|
const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
|
|
7
7
|
const KEY_PATH = path.join(CACHE_DIR, ".key");
|
|
8
|
-
const
|
|
8
|
+
const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
|
|
9
|
+
const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
|
|
10
|
+
const LEGACY_TIMESTAMP_BYTES = 8;
|
|
11
|
+
const TIMESTAMP_BYTES = 8;
|
|
12
|
+
const TTL_BYTES = 8;
|
|
9
13
|
const ALGORITHM = "aes-256-gcm";
|
|
10
14
|
const IV_BYTES = 12;
|
|
11
15
|
const AUTH_TAG_BYTES = 16;
|
|
16
|
+
export const DEFAULT_SECRETS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
12
17
|
|
|
13
18
|
function ensureCacheDir(companyUid: string): void {
|
|
14
19
|
const dir = path.join(CACHE_DIR, companyUid);
|
|
@@ -52,19 +57,40 @@ export function readCache(companyUid: string, name: string): string | null {
|
|
|
52
57
|
return null;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
let timestampMs: number;
|
|
61
|
+
let ttlMs: number;
|
|
62
|
+
let ivStart: number;
|
|
63
|
+
let authTagStart: number;
|
|
64
|
+
let ciphertextStart: number;
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
if (
|
|
67
|
+
raw.length >=
|
|
68
|
+
CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
69
|
+
raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)
|
|
70
|
+
) {
|
|
71
|
+
timestampMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES));
|
|
72
|
+
ttlMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES));
|
|
73
|
+
ivStart = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
|
|
74
|
+
authTagStart = ivStart + IV_BYTES;
|
|
75
|
+
ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
76
|
+
} else {
|
|
77
|
+
const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
|
|
78
|
+
if (raw.length < headerLen) return null;
|
|
79
|
+
timestampMs = Number(raw.readBigInt64BE(0));
|
|
80
|
+
ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS;
|
|
81
|
+
ivStart = LEGACY_TIMESTAMP_BYTES;
|
|
82
|
+
authTagStart = ivStart + IV_BYTES;
|
|
83
|
+
ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (ttlMs <= 0 || Date.now() - timestampMs > ttlMs) {
|
|
61
87
|
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
62
88
|
return null;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
const iv = raw.subarray(
|
|
66
|
-
const authTag = raw.subarray(
|
|
67
|
-
const ciphertext = raw.subarray(
|
|
91
|
+
const iv = raw.subarray(ivStart, authTagStart);
|
|
92
|
+
const authTag = raw.subarray(authTagStart, ciphertextStart);
|
|
93
|
+
const ciphertext = raw.subarray(ciphertextStart);
|
|
68
94
|
|
|
69
95
|
let key: Buffer;
|
|
70
96
|
try {
|
|
@@ -84,9 +110,15 @@ export function readCache(companyUid: string, name: string): string | null {
|
|
|
84
110
|
}
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
export function writeCache(
|
|
113
|
+
export function writeCache(
|
|
114
|
+
companyUid: string,
|
|
115
|
+
name: string,
|
|
116
|
+
value: string,
|
|
117
|
+
ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS,
|
|
118
|
+
): void {
|
|
88
119
|
try {
|
|
89
120
|
if (!validateInputs(companyUid, name)) return;
|
|
121
|
+
if (ttlMs <= 0) return;
|
|
90
122
|
ensureCacheDir(companyUid);
|
|
91
123
|
const key = getOrCreateKey();
|
|
92
124
|
const iv = crypto.randomBytes(IV_BYTES);
|
|
@@ -96,8 +128,10 @@ export function writeCache(companyUid: string, name: string, value: string): voi
|
|
|
96
128
|
|
|
97
129
|
const timestamp = Buffer.alloc(8);
|
|
98
130
|
timestamp.writeBigInt64BE(BigInt(Date.now()));
|
|
131
|
+
const ttl = Buffer.alloc(8);
|
|
132
|
+
ttl.writeBigInt64BE(BigInt(ttlMs));
|
|
99
133
|
|
|
100
|
-
const out = Buffer.concat([timestamp, iv, authTag, encrypted]);
|
|
134
|
+
const out = Buffer.concat([CACHE_FORMAT_MAGIC, timestamp, ttl, iv, authTag, encrypted]);
|
|
101
135
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
102
136
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
103
137
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|