@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.
@@ -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
@@ -1,20 +1,34 @@
1
1
  import { ResolutionError } from 'varlock/plugin-lib';
2
2
  import type { Resolver } from 'varlock/plugin-lib';
3
- import { readCache, writeCache } from '../utils/secrets-cache.js';
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
- fetchBatch: (uid: string, names: string[]) => Promise<{
9
- secrets: Array<{ name: string; value: string }>;
10
- errors: Array<{ name: string; code: string; message?: string }>;
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
- writeCache(uid, s.name, s.value);
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) {
@@ -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 verifySha256(
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 TTL_MS = 5 * 60 * 1000;
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
- // Format: [8 bytes timestamp][12 bytes IV][16 bytes authTag][...ciphertext]
56
- const headerLen = 8 + IV_BYTES + AUTH_TAG_BYTES;
57
- if (raw.length < headerLen) return null;
60
+ let timestampMs: number;
61
+ let ttlMs: number;
62
+ let ivStart: number;
63
+ let authTagStart: number;
64
+ let ciphertextStart: number;
58
65
 
59
- const timestampMs = Number(raw.readBigInt64BE(0));
60
- if (Date.now() - timestampMs > TTL_MS) {
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(8, 8 + IV_BYTES);
66
- const authTag = raw.subarray(8 + IV_BYTES, headerLen);
67
- const ciphertext = raw.subarray(headerLen);
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(companyUid: string, name: string, value: string): void {
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}`;