@openpalm/lib 0.9.8 → 0.10.1
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/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
detectSecretBackend,
|
|
7
|
+
type ControlPlaneState,
|
|
8
|
+
ensureSecrets,
|
|
9
|
+
validatePassEntryName,
|
|
10
|
+
} from '../index.js';
|
|
11
|
+
import { PlaintextBackend, PassBackend } from './secret-backend.js';
|
|
12
|
+
import { generateRedactSchema } from './redact-schema.js';
|
|
13
|
+
import { getCoreSecretMappings } from './secret-mappings.js';
|
|
14
|
+
import { writeSecretProviderConfig } from './provider-config.js';
|
|
15
|
+
|
|
16
|
+
let rootDir = '';
|
|
17
|
+
|
|
18
|
+
function createState(): ControlPlaneState {
|
|
19
|
+
const vaultDir = join(rootDir, 'vault');
|
|
20
|
+
const dataDir = join(rootDir, 'data');
|
|
21
|
+
const configDir = join(rootDir, 'config');
|
|
22
|
+
const logsDir = join(rootDir, 'logs');
|
|
23
|
+
const cacheDir = join(rootDir, 'cache');
|
|
24
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
25
|
+
mkdirSync(dataDir, { recursive: true });
|
|
26
|
+
mkdirSync(configDir, { recursive: true });
|
|
27
|
+
mkdirSync(logsDir, { recursive: true });
|
|
28
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
adminToken: 'admin-token',
|
|
32
|
+
assistantToken: '',
|
|
33
|
+
setupToken: 'setup-token',
|
|
34
|
+
homeDir: rootDir,
|
|
35
|
+
configDir,
|
|
36
|
+
vaultDir,
|
|
37
|
+
dataDir,
|
|
38
|
+
logsDir,
|
|
39
|
+
cacheDir,
|
|
40
|
+
services: {},
|
|
41
|
+
artifacts: { compose: '' },
|
|
42
|
+
artifactMeta: [],
|
|
43
|
+
audit: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
rootDir = mkdtempSync(join(tmpdir(), 'openpalm-secret-backend-'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('secret backend', () => {
|
|
56
|
+
test('ensureSecrets repairs auth.json when Docker created it as a directory', () => {
|
|
57
|
+
const state = createState();
|
|
58
|
+
mkdirSync(join(state.vaultDir, 'stack', 'auth.json'), { recursive: true });
|
|
59
|
+
|
|
60
|
+
ensureSecrets(state);
|
|
61
|
+
|
|
62
|
+
const authJsonPath = join(state.vaultDir, 'stack', 'auth.json');
|
|
63
|
+
expect(lstatSync(authJsonPath).isFile()).toBe(true);
|
|
64
|
+
expect(readFileSync(authJsonPath, 'utf-8')).toBe('{}\n');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('detectSecretBackend defaults to plaintext and routes custom secrets into vault env files', async () => {
|
|
68
|
+
const state = createState();
|
|
69
|
+
ensureSecrets(state);
|
|
70
|
+
const backend = detectSecretBackend(state);
|
|
71
|
+
|
|
72
|
+
expect(backend.provider).toBe('plaintext');
|
|
73
|
+
|
|
74
|
+
const entry = await backend.write('openpalm/custom/example', 'very-secret');
|
|
75
|
+
expect(entry.provider).toBe('plaintext');
|
|
76
|
+
expect(entry.scope).toBe('user');
|
|
77
|
+
expect(await backend.exists('openpalm/custom/example')).toBe(true);
|
|
78
|
+
|
|
79
|
+
// Custom secrets are now written to stack.env (all secrets consolidated there)
|
|
80
|
+
const stackEnv = readFileSync(join(state.vaultDir, 'stack', 'stack.env'), 'utf-8');
|
|
81
|
+
expect(stackEnv).toContain('very-secret');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('validatePassEntryName rejects traversal and invalid characters', () => {
|
|
85
|
+
expect(() => validatePassEntryName('../bad')).toThrow();
|
|
86
|
+
expect(() => validatePassEntryName('openpalm/Bad Key')).toThrow();
|
|
87
|
+
expect(validatePassEntryName('openpalm/custom/good-key')).toBe('openpalm/custom/good-key');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('validatePassEntryName rejects empty after trim', () => {
|
|
91
|
+
expect(() => validatePassEntryName('')).toThrow('must not be empty');
|
|
92
|
+
expect(() => validatePassEntryName(' ')).toThrow('must not be empty');
|
|
93
|
+
expect(() => validatePassEntryName('///')).toThrow('must not be empty');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('validatePassEntryName rejects uppercase characters', () => {
|
|
97
|
+
expect(() => validatePassEntryName('openpalm/MyKey')).toThrow('invalid characters');
|
|
98
|
+
expect(() => validatePassEntryName('OPENPALM/key')).toThrow('invalid characters');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('validatePassEntryName handles multiple slashes and dots', () => {
|
|
102
|
+
expect(validatePassEntryName('openpalm/a/b/c')).toBe('openpalm/a/b/c');
|
|
103
|
+
expect(validatePassEntryName('openpalm/my.key')).toBe('openpalm/my.key');
|
|
104
|
+
expect(validatePassEntryName('openpalm/my_key')).toBe('openpalm/my_key');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('validatePassEntryName strips leading/trailing slashes', () => {
|
|
108
|
+
expect(validatePassEntryName('/openpalm/key/')).toBe('openpalm/key');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('PlaintextBackend', () => {
|
|
113
|
+
test('remove clears value for non-core secrets', async () => {
|
|
114
|
+
const state = createState();
|
|
115
|
+
ensureSecrets(state);
|
|
116
|
+
const backend = new PlaintextBackend(state);
|
|
117
|
+
|
|
118
|
+
await backend.write('openpalm/custom/temp', 'temp-value');
|
|
119
|
+
expect(await backend.exists('openpalm/custom/temp')).toBe(true);
|
|
120
|
+
|
|
121
|
+
await backend.remove('openpalm/custom/temp');
|
|
122
|
+
expect(await backend.exists('openpalm/custom/temp')).toBe(false);
|
|
123
|
+
|
|
124
|
+
// Value is cleared — entry shows present: false
|
|
125
|
+
const entries = await backend.list('openpalm/custom/');
|
|
126
|
+
const found = entries.find((e) => e.key === 'openpalm/custom/temp');
|
|
127
|
+
if (found) {
|
|
128
|
+
expect(found.present).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('remove clears value but keeps index for core secrets', async () => {
|
|
133
|
+
const state = createState();
|
|
134
|
+
ensureSecrets(state);
|
|
135
|
+
const backend = new PlaintextBackend(state);
|
|
136
|
+
|
|
137
|
+
// Write a core secret
|
|
138
|
+
await backend.write('openpalm/admin-token', 'my-token');
|
|
139
|
+
expect(await backend.exists('openpalm/admin-token')).toBe(true);
|
|
140
|
+
|
|
141
|
+
await backend.remove('openpalm/admin-token');
|
|
142
|
+
expect(await backend.exists('openpalm/admin-token')).toBe(false);
|
|
143
|
+
|
|
144
|
+
// Core secrets still appear in list (as present: false)
|
|
145
|
+
const entries = await backend.list('openpalm/');
|
|
146
|
+
const found = entries.find((e) => e.key === 'openpalm/admin-token');
|
|
147
|
+
expect(found).toBeDefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('list includes both core and indexed entries', async () => {
|
|
151
|
+
const state = createState();
|
|
152
|
+
ensureSecrets(state);
|
|
153
|
+
const backend = new PlaintextBackend(state);
|
|
154
|
+
|
|
155
|
+
await backend.write('openpalm/custom/my-key', 'value');
|
|
156
|
+
|
|
157
|
+
const entries = await backend.list();
|
|
158
|
+
const coreKeys = entries.filter((e) => e.kind === 'core');
|
|
159
|
+
const customKeys = entries.filter((e) => e.kind === 'custom');
|
|
160
|
+
|
|
161
|
+
expect(coreKeys.length).toBeGreaterThan(0);
|
|
162
|
+
expect(customKeys.length).toBeGreaterThan(0);
|
|
163
|
+
expect(customKeys.find((e) => e.key === 'openpalm/custom/my-key')).toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('generate creates a secret with random value', async () => {
|
|
167
|
+
const state = createState();
|
|
168
|
+
ensureSecrets(state);
|
|
169
|
+
const backend = new PlaintextBackend(state);
|
|
170
|
+
|
|
171
|
+
const entry = await backend.generate('openpalm/custom/generated', 64);
|
|
172
|
+
expect(entry.present).toBe(true);
|
|
173
|
+
expect(await backend.exists('openpalm/custom/generated')).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('PassBackend', () => {
|
|
178
|
+
test('constructor reads passPrefix from provider config', () => {
|
|
179
|
+
const state = createState();
|
|
180
|
+
writeSecretProviderConfig(state, {
|
|
181
|
+
provider: 'pass',
|
|
182
|
+
passwordStoreDir: '/tmp/test-pass-store',
|
|
183
|
+
passPrefix: 'myprefix',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const backend = new PassBackend(state);
|
|
187
|
+
expect(backend.provider).toBe('pass');
|
|
188
|
+
// Verify it doesn't throw with valid config
|
|
189
|
+
expect(backend.capabilities.generate).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('constructor uses default store dir when no config', () => {
|
|
193
|
+
const state = createState();
|
|
194
|
+
const backend = new PassBackend(state);
|
|
195
|
+
expect(backend.provider).toBe('pass');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('exists returns false for non-existent entries', async () => {
|
|
199
|
+
const state = createState();
|
|
200
|
+
const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
|
|
201
|
+
mkdirSync(storeDir, { recursive: true });
|
|
202
|
+
|
|
203
|
+
const backend = new PassBackend(state);
|
|
204
|
+
expect(await backend.exists('openpalm/nonexistent')).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('list returns empty array for empty store', async () => {
|
|
208
|
+
const state = createState();
|
|
209
|
+
const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
|
|
210
|
+
mkdirSync(storeDir, { recursive: true });
|
|
211
|
+
|
|
212
|
+
const backend = new PassBackend(state);
|
|
213
|
+
const entries = await backend.list();
|
|
214
|
+
expect(entries).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('list scopes to passPrefix subdirectory', async () => {
|
|
218
|
+
const state = createState();
|
|
219
|
+
const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
|
|
220
|
+
|
|
221
|
+
// Create fake .gpg files under the prefix subdirectory
|
|
222
|
+
const prefixDir = join(storeDir, 'myprefix', 'openpalm');
|
|
223
|
+
mkdirSync(prefixDir, { recursive: true });
|
|
224
|
+
writeFileSync(join(prefixDir, 'admin-token.gpg'), 'fake-gpg-data');
|
|
225
|
+
writeFileSync(join(prefixDir, 'assistant-token.gpg'), 'fake-gpg-data');
|
|
226
|
+
|
|
227
|
+
// Create a file outside the prefix (should not appear)
|
|
228
|
+
mkdirSync(join(storeDir, 'other'), { recursive: true });
|
|
229
|
+
writeFileSync(join(storeDir, 'other', 'secret.gpg'), 'fake');
|
|
230
|
+
|
|
231
|
+
writeSecretProviderConfig(state, {
|
|
232
|
+
provider: 'pass',
|
|
233
|
+
passwordStoreDir: storeDir,
|
|
234
|
+
passPrefix: 'myprefix',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const backend = new PassBackend(state);
|
|
238
|
+
const entries = await backend.list();
|
|
239
|
+
|
|
240
|
+
expect(entries).toHaveLength(2);
|
|
241
|
+
// Keys should be canonical (without prefix)
|
|
242
|
+
expect(entries[0]?.key).toBe('openpalm/admin-token');
|
|
243
|
+
expect(entries[1]?.key).toBe('openpalm/assistant-token');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('exists checks prefixed path in store', async () => {
|
|
247
|
+
const state = createState();
|
|
248
|
+
const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
|
|
249
|
+
const prefixDir = join(storeDir, 'myprefix');
|
|
250
|
+
mkdirSync(join(prefixDir, 'openpalm'), { recursive: true });
|
|
251
|
+
writeFileSync(join(prefixDir, 'openpalm', 'admin-token.gpg'), 'fake');
|
|
252
|
+
|
|
253
|
+
writeSecretProviderConfig(state, {
|
|
254
|
+
provider: 'pass',
|
|
255
|
+
passwordStoreDir: storeDir,
|
|
256
|
+
passPrefix: 'myprefix',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const backend = new PassBackend(state);
|
|
260
|
+
expect(await backend.exists('openpalm/admin-token')).toBe(true);
|
|
261
|
+
expect(await backend.exists('openpalm/nonexistent')).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('detectSecretBackend', () => {
|
|
266
|
+
test('returns PlaintextBackend by default', () => {
|
|
267
|
+
const state = createState();
|
|
268
|
+
const backend = detectSecretBackend(state);
|
|
269
|
+
expect(backend.provider).toBe('plaintext');
|
|
270
|
+
expect(backend).toBeInstanceOf(PlaintextBackend);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('returns PassBackend when provider.json has provider: pass', () => {
|
|
274
|
+
const state = createState();
|
|
275
|
+
writeSecretProviderConfig(state, {
|
|
276
|
+
provider: 'pass',
|
|
277
|
+
passwordStoreDir: '/tmp/test',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const backend = detectSecretBackend(state);
|
|
281
|
+
expect(backend.provider).toBe('pass');
|
|
282
|
+
expect(backend).toBeInstanceOf(PassBackend);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('returns PassBackend when schema contains @varlock/pass-plugin', () => {
|
|
286
|
+
const state = createState();
|
|
287
|
+
mkdirSync(join(state.vaultDir, 'user'), { recursive: true });
|
|
288
|
+
writeFileSync(
|
|
289
|
+
join(state.vaultDir, 'user', 'user.env.schema'),
|
|
290
|
+
'# @plugin(@varlock/pass-plugin)\nOPENAI_API_KEY=pass("openpalm/openai/api-key")\n',
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const backend = detectSecretBackend(state);
|
|
294
|
+
expect(backend.provider).toBe('pass');
|
|
295
|
+
expect(backend).toBeInstanceOf(PassBackend);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('returns PlaintextBackend when provider.json has provider: plaintext', () => {
|
|
299
|
+
const state = createState();
|
|
300
|
+
writeSecretProviderConfig(state, { provider: 'plaintext' });
|
|
301
|
+
|
|
302
|
+
const backend = detectSecretBackend(state);
|
|
303
|
+
expect(backend.provider).toBe('plaintext');
|
|
304
|
+
expect(backend).toBeInstanceOf(PlaintextBackend);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('generateRedactSchema', () => {
|
|
309
|
+
test('output includes all mapped env keys', () => {
|
|
310
|
+
const systemEnv: Record<string, string> = {};
|
|
311
|
+
const schema = generateRedactSchema(systemEnv);
|
|
312
|
+
|
|
313
|
+
// All static core mappings should be present
|
|
314
|
+
expect(schema).toContain('OP_ADMIN_TOKEN=');
|
|
315
|
+
expect(schema).toContain('OP_ASSISTANT_TOKEN=');
|
|
316
|
+
expect(schema).toContain('OP_MEMORY_TOKEN=');
|
|
317
|
+
expect(schema).toContain('OPENAI_API_KEY=');
|
|
318
|
+
expect(schema).toContain('ANTHROPIC_API_KEY=');
|
|
319
|
+
expect(schema).toContain('GROQ_API_KEY=');
|
|
320
|
+
expect(schema).toContain('MISTRAL_API_KEY=');
|
|
321
|
+
expect(schema).toContain('GOOGLE_API_KEY=');
|
|
322
|
+
expect(schema).toContain('MCP_API_KEY=');
|
|
323
|
+
expect(schema).toContain('EMBEDDING_API_KEY=');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('includes legacy aliases', () => {
|
|
327
|
+
const schema = generateRedactSchema({});
|
|
328
|
+
expect(schema).toContain('ADMIN_TOKEN=');
|
|
329
|
+
expect(schema).toContain('OP_OPENCODE_PASSWORD=');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('includes dynamic channel secrets', () => {
|
|
333
|
+
const systemEnv = {
|
|
334
|
+
CHANNEL_DISCORD_SECRET: 'abc123',
|
|
335
|
+
CHANNEL_SLACK_SECRET: 'def456',
|
|
336
|
+
};
|
|
337
|
+
const schema = generateRedactSchema(systemEnv);
|
|
338
|
+
expect(schema).toContain('CHANNEL_DISCORD_SECRET=');
|
|
339
|
+
expect(schema).toContain('CHANNEL_SLACK_SECRET=');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('has correct header format', () => {
|
|
343
|
+
const schema = generateRedactSchema({});
|
|
344
|
+
expect(schema).toContain('@defaultSensitive=true');
|
|
345
|
+
expect(schema).toContain('@defaultRequired=false');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('entries are sorted', () => {
|
|
349
|
+
const schema = generateRedactSchema({});
|
|
350
|
+
const lines = schema
|
|
351
|
+
.split('\n')
|
|
352
|
+
.filter((l) => l.match(/^[A-Z]/))
|
|
353
|
+
.map((l) => l.replace(/=.*$/, ''));
|
|
354
|
+
|
|
355
|
+
const sorted = [...lines].sort();
|
|
356
|
+
expect(lines).toEqual(sorted);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { join, normalize, resolve } from 'node:path';
|
|
6
|
+
import type { ControlPlaneState } from './types.js';
|
|
7
|
+
import {
|
|
8
|
+
classifySecretKey,
|
|
9
|
+
classifySecretScope,
|
|
10
|
+
ensurePlaintextSecretEntry,
|
|
11
|
+
findCoreSecretByKey,
|
|
12
|
+
getCoreSecretMappings,
|
|
13
|
+
readPlaintextSecretIndex,
|
|
14
|
+
removePlaintextSecretEntry,
|
|
15
|
+
type SecretEntryMetadata,
|
|
16
|
+
type SecretScope,
|
|
17
|
+
} from './secret-mappings.js';
|
|
18
|
+
import { readSecretProviderConfig } from './provider-config.js';
|
|
19
|
+
import {
|
|
20
|
+
readStackEnv,
|
|
21
|
+
updateSecretsEnv,
|
|
22
|
+
updateSystemSecretsEnv,
|
|
23
|
+
} from './secrets.js';
|
|
24
|
+
|
|
25
|
+
const execFile = promisify(execFileCb);
|
|
26
|
+
|
|
27
|
+
/** Run a command with stdin input, returning a promise. */
|
|
28
|
+
function execWithInput(
|
|
29
|
+
cmd: string,
|
|
30
|
+
args: string[],
|
|
31
|
+
input: string,
|
|
32
|
+
env: NodeJS.ProcessEnv,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
36
|
+
let stderr = '';
|
|
37
|
+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
38
|
+
child.on('error', reject);
|
|
39
|
+
child.on('close', (code) => {
|
|
40
|
+
if (code === 0) resolve();
|
|
41
|
+
else reject(new Error(`${cmd} exited with code ${code}: ${stderr}`));
|
|
42
|
+
});
|
|
43
|
+
child.stdin?.end(input);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ResolvedSecretTarget = {
|
|
48
|
+
key: string;
|
|
49
|
+
scope: SecretScope;
|
|
50
|
+
envKey?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type SecretBackendCapabilities = {
|
|
54
|
+
generate: boolean;
|
|
55
|
+
remove: boolean;
|
|
56
|
+
rename: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface SecretBackend {
|
|
60
|
+
readonly provider: 'plaintext' | 'pass';
|
|
61
|
+
readonly capabilities: SecretBackendCapabilities;
|
|
62
|
+
list(prefix?: string): Promise<SecretEntryMetadata[]>;
|
|
63
|
+
write(key: string, value: string): Promise<SecretEntryMetadata>;
|
|
64
|
+
generate(key: string, length?: number): Promise<SecretEntryMetadata>;
|
|
65
|
+
remove(key: string): Promise<void>;
|
|
66
|
+
exists(key: string): Promise<boolean>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function generateSecretValue(length = 32): string {
|
|
70
|
+
// Hex encoding produces two output characters per byte. Clamp to at least
|
|
71
|
+
// 16 bytes (32 hex chars) so generated secrets stay comfortably strong.
|
|
72
|
+
return randomBytes(Math.max(16, Math.ceil(length / 2))).toString('hex').slice(0, length);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolvePlaintextTarget(state: ControlPlaneState, key: string): ResolvedSecretTarget {
|
|
76
|
+
const systemEnv = readStackEnv(state.vaultDir);
|
|
77
|
+
const coreMapping = findCoreSecretByKey(key, systemEnv);
|
|
78
|
+
if (coreMapping) {
|
|
79
|
+
return { key, scope: coreMapping.scope, envKey: coreMapping.envKey };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const indexed = ensurePlaintextSecretEntry(state, key);
|
|
83
|
+
return { key, scope: indexed.scope, envKey: indexed.envKey };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function currentValueForTarget(state: ControlPlaneState, target: ResolvedSecretTarget): string {
|
|
87
|
+
if (!target.envKey) return '';
|
|
88
|
+
const env = target.scope === 'system'
|
|
89
|
+
? readStackEnv(state.vaultDir)
|
|
90
|
+
: readStackEnv(state.vaultDir);
|
|
91
|
+
return env[target.envKey] ?? '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class PlaintextBackend implements SecretBackend {
|
|
95
|
+
readonly provider = 'plaintext' as const;
|
|
96
|
+
readonly capabilities = { generate: true, remove: true, rename: false } as const;
|
|
97
|
+
|
|
98
|
+
constructor(private readonly state: ControlPlaneState) {}
|
|
99
|
+
|
|
100
|
+
async list(prefix = 'openpalm/'): Promise<SecretEntryMetadata[]> {
|
|
101
|
+
const userEnv = readStackEnv(this.state.vaultDir);
|
|
102
|
+
const systemEnv = readStackEnv(this.state.vaultDir);
|
|
103
|
+
const index = readPlaintextSecretIndex(this.state);
|
|
104
|
+
const entries: SecretEntryMetadata[] = [];
|
|
105
|
+
|
|
106
|
+
for (const mapping of getCoreSecretMappings(systemEnv)) {
|
|
107
|
+
if (!mapping.secretKey.startsWith(prefix)) continue;
|
|
108
|
+
const env = mapping.scope === 'system' ? systemEnv : userEnv;
|
|
109
|
+
entries.push({
|
|
110
|
+
key: mapping.secretKey,
|
|
111
|
+
scope: mapping.scope,
|
|
112
|
+
kind: 'core',
|
|
113
|
+
provider: this.provider,
|
|
114
|
+
present: Boolean(env[mapping.envKey]),
|
|
115
|
+
envKey: mapping.envKey,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const [key, entry] of Object.entries(index.entries)) {
|
|
120
|
+
if (!key.startsWith(prefix)) continue;
|
|
121
|
+
const env = entry.scope === 'system' ? systemEnv : userEnv;
|
|
122
|
+
entries.push({
|
|
123
|
+
key,
|
|
124
|
+
scope: entry.scope,
|
|
125
|
+
kind: entry.kind,
|
|
126
|
+
provider: this.provider,
|
|
127
|
+
present: Boolean(env[entry.envKey]),
|
|
128
|
+
envKey: entry.envKey,
|
|
129
|
+
updatedAt: entry.updatedAt,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async write(key: string, value: string): Promise<SecretEntryMetadata> {
|
|
138
|
+
const target = resolvePlaintextTarget(this.state, key);
|
|
139
|
+
if (!target.envKey) {
|
|
140
|
+
throw new Error(`Unable to resolve env key for secret ${key}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (target.scope === 'system') {
|
|
144
|
+
updateSystemSecretsEnv(this.state, { [target.envKey]: value });
|
|
145
|
+
} else {
|
|
146
|
+
updateSecretsEnv(this.state, { [target.envKey]: value });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
key,
|
|
151
|
+
scope: target.scope,
|
|
152
|
+
kind: key.startsWith('openpalm/component/') ? 'component' : key.startsWith('openpalm/custom/') ? 'custom' : 'core',
|
|
153
|
+
provider: this.provider,
|
|
154
|
+
present: true,
|
|
155
|
+
envKey: target.envKey,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async generate(key: string, length = 32): Promise<SecretEntryMetadata> {
|
|
160
|
+
return this.write(key, generateSecretValue(length));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async remove(key: string): Promise<void> {
|
|
164
|
+
const target = resolvePlaintextTarget(this.state, key);
|
|
165
|
+
if (target.envKey) {
|
|
166
|
+
if (target.scope === 'system') {
|
|
167
|
+
updateSystemSecretsEnv(this.state, { [target.envKey]: '' });
|
|
168
|
+
} else {
|
|
169
|
+
updateSecretsEnv(this.state, { [target.envKey]: '' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!findCoreSecretByKey(key, readStackEnv(this.state.vaultDir))) {
|
|
173
|
+
removePlaintextSecretEntry(this.state, key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async exists(key: string): Promise<boolean> {
|
|
178
|
+
const target = resolvePlaintextTarget(this.state, key);
|
|
179
|
+
return currentValueForTarget(this.state, target).length > 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function validatePassEntryName(entry: string): string {
|
|
184
|
+
const trimmed = entry.trim().replace(/^\/+|\/+$/g, '');
|
|
185
|
+
if (!trimmed) {
|
|
186
|
+
throw new Error('Secret key must not be empty');
|
|
187
|
+
}
|
|
188
|
+
if (trimmed.includes('..')) {
|
|
189
|
+
throw new Error('Secret key must not contain path traversal');
|
|
190
|
+
}
|
|
191
|
+
if (!/^[a-z0-9._/-]+$/.test(trimmed)) {
|
|
192
|
+
throw new Error('Secret key contains invalid characters');
|
|
193
|
+
}
|
|
194
|
+
return trimmed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function walkPassStore(dir: string, prefix = ''): string[] {
|
|
198
|
+
if (!existsSync(dir)) return [];
|
|
199
|
+
const entries: string[] = [];
|
|
200
|
+
for (const entry of readdirSync(dir)) {
|
|
201
|
+
const fullPath = join(dir, entry);
|
|
202
|
+
const stat = statSync(fullPath);
|
|
203
|
+
if (stat.isDirectory()) {
|
|
204
|
+
entries.push(...walkPassStore(fullPath, prefix ? `${prefix}/${entry}` : entry));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!entry.endsWith('.gpg')) continue;
|
|
208
|
+
const name = entry.replace(/\.gpg$/, '');
|
|
209
|
+
entries.push(prefix ? `${prefix}/${name}` : name);
|
|
210
|
+
}
|
|
211
|
+
return entries;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export class PassBackend implements SecretBackend {
|
|
215
|
+
readonly provider = 'pass' as const;
|
|
216
|
+
readonly capabilities = { generate: true, remove: true, rename: false } as const;
|
|
217
|
+
private readonly passwordStoreDir: string;
|
|
218
|
+
private readonly passPrefix: string;
|
|
219
|
+
|
|
220
|
+
constructor(private readonly state: ControlPlaneState) {
|
|
221
|
+
const config = readSecretProviderConfig(state);
|
|
222
|
+
this.passwordStoreDir = config?.passwordStoreDir ?? `${state.dataDir}/secrets/pass-store`;
|
|
223
|
+
this.passPrefix = config?.passPrefix ?? '';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private env(): NodeJS.ProcessEnv {
|
|
227
|
+
return {
|
|
228
|
+
...process.env,
|
|
229
|
+
PASSWORD_STORE_DIR: this.passwordStoreDir,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Prepend passPrefix to a canonical key for pass store operations. */
|
|
234
|
+
private prefixedEntry(canonicalKey: string): string {
|
|
235
|
+
const entry = validatePassEntryName(canonicalKey);
|
|
236
|
+
return this.passPrefix ? `${this.passPrefix}/${entry}` : entry;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private keyPath(key: string): string {
|
|
240
|
+
const prefixed = this.prefixedEntry(key);
|
|
241
|
+
const normalizedEntry = normalize(prefixed);
|
|
242
|
+
const resolvedPath = resolve(this.passwordStoreDir, `${normalizedEntry}.gpg`);
|
|
243
|
+
const resolvedStore = resolve(this.passwordStoreDir);
|
|
244
|
+
if (!resolvedPath.startsWith(`${resolvedStore}/`)) {
|
|
245
|
+
throw new Error('Secret key resolves outside the password store');
|
|
246
|
+
}
|
|
247
|
+
return resolvedPath;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async list(prefix = 'openpalm/'): Promise<SecretEntryMetadata[]> {
|
|
251
|
+
// Scope walk to the passPrefix subdirectory
|
|
252
|
+
const walkDir = this.passPrefix
|
|
253
|
+
? join(this.passwordStoreDir, this.passPrefix)
|
|
254
|
+
: this.passwordStoreDir;
|
|
255
|
+
return walkPassStore(walkDir)
|
|
256
|
+
.filter((entry) => entry.startsWith(prefix))
|
|
257
|
+
.sort((a, b) => a.localeCompare(b))
|
|
258
|
+
.map((key) => ({
|
|
259
|
+
key,
|
|
260
|
+
scope: classifySecretScope(key),
|
|
261
|
+
kind: classifySecretKey(key),
|
|
262
|
+
provider: this.provider,
|
|
263
|
+
present: true,
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async write(key: string, value: string): Promise<SecretEntryMetadata> {
|
|
268
|
+
const canonicalKey = validatePassEntryName(key);
|
|
269
|
+
const storeEntry = this.prefixedEntry(canonicalKey);
|
|
270
|
+
await execWithInput('pass', ['insert', '-m', '-f', storeEntry], `${value}\n`, this.env());
|
|
271
|
+
return {
|
|
272
|
+
key: canonicalKey,
|
|
273
|
+
scope: classifySecretScope(canonicalKey),
|
|
274
|
+
kind: classifySecretKey(canonicalKey),
|
|
275
|
+
provider: this.provider,
|
|
276
|
+
present: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async generate(key: string, length = 32): Promise<SecretEntryMetadata> {
|
|
281
|
+
const canonicalKey = validatePassEntryName(key);
|
|
282
|
+
const storeEntry = this.prefixedEntry(canonicalKey);
|
|
283
|
+
await execFile('pass', ['generate', '-n', '-f', storeEntry, String(length)], {
|
|
284
|
+
env: this.env(),
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
key: canonicalKey,
|
|
288
|
+
scope: classifySecretScope(canonicalKey),
|
|
289
|
+
kind: classifySecretKey(canonicalKey),
|
|
290
|
+
provider: this.provider,
|
|
291
|
+
present: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async remove(key: string): Promise<void> {
|
|
296
|
+
const storeEntry = this.prefixedEntry(key);
|
|
297
|
+
await execFile('pass', ['rm', '-f', storeEntry], {
|
|
298
|
+
env: this.env(),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async exists(key: string): Promise<boolean> {
|
|
303
|
+
return existsSync(this.keyPath(key));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function detectSecretBackend(state: ControlPlaneState): SecretBackend {
|
|
308
|
+
const providerConfig = readSecretProviderConfig(state);
|
|
309
|
+
if (providerConfig?.provider === 'pass') {
|
|
310
|
+
return new PassBackend(state);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const schemaPath of [`${state.vaultDir}/user/user.env.schema`, `${state.vaultDir}/stack/stack.env.schema`]) {
|
|
314
|
+
if (!existsSync(schemaPath)) continue;
|
|
315
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
316
|
+
if (content.includes('@varlock/pass-plugin')) {
|
|
317
|
+
return new PassBackend(state);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return new PlaintextBackend(state);
|
|
322
|
+
}
|