@outputai/credentials 0.1.4-dev.0 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/credentials",
3
- "version": "0.1.4-dev.0",
3
+ "version": "0.1.4",
4
4
  "description": "Encrypted credentials management for Output.ai workflows",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "js-yaml": "4.1.1"
18
18
  },
19
19
  "peerDependencies": {
20
- "@outputai/core": "0.1.4-dev.0"
20
+ "@outputai/core": "0.1.4"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/js-yaml": "4.0.9"
@@ -1,6 +0,0 @@
1
- export declare const resolveCredentialRefs: () => string[];
2
- export declare const credentials: {
3
- get: (path: string, defaultValue?: unknown) => unknown;
4
- require: (path: string) => unknown;
5
- _reset: () => void;
6
- };
@@ -1,87 +0,0 @@
1
- import { dirname } from 'node:path';
2
- import { MissingCredentialError } from './errors.js';
3
- import { getExecutionContext } from '@outputai/core/sdk_activity_integration';
4
- import { deepMerge } from '@outputai/core/sdk_utils';
5
- import { getProvider } from './provider_registry.js';
6
- const getNestedValue = (obj, dotPath) => dotPath.split('.').reduce((acc, part) => acc?.[part], obj);
7
- const detectEnvironment = () => {
8
- const env = process.env.NODE_ENV;
9
- return (env === 'production' || env === 'development') ? env : undefined;
10
- };
11
- const GLOBAL_CACHE_KEY = Symbol('global');
12
- const cache = new Map();
13
- const loadGlobal = () => {
14
- if (cache.has(GLOBAL_CACHE_KEY)) {
15
- return cache.get(GLOBAL_CACHE_KEY);
16
- }
17
- const data = getProvider().loadGlobal({ environment: detectEnvironment() });
18
- cache.set(GLOBAL_CACHE_KEY, data);
19
- return data;
20
- };
21
- const loadForWorkflow = (workflowName, workflowDir) => {
22
- if (cache.has(workflowName)) {
23
- return cache.get(workflowName);
24
- }
25
- const globalData = loadGlobal();
26
- const workflowData = getProvider().loadForWorkflow({
27
- workflowName,
28
- workflowDir,
29
- environment: detectEnvironment()
30
- });
31
- const merged = workflowData ? deepMerge(globalData, workflowData) : globalData;
32
- cache.set(workflowName, merged);
33
- return merged;
34
- };
35
- const getWorkflowContext = () => {
36
- const ctx = getExecutionContext();
37
- if (!ctx) {
38
- return { workflowName: undefined, workflowDir: undefined };
39
- }
40
- return {
41
- workflowName: ctx.workflow.name,
42
- workflowDir: dirname(ctx.workflow.filename)
43
- };
44
- };
45
- const load = () => {
46
- const { workflowName, workflowDir } = getWorkflowContext();
47
- if (!workflowName) {
48
- return loadGlobal();
49
- }
50
- return loadForWorkflow(workflowName, workflowDir);
51
- };
52
- const CREDENTIAL_REF_PREFIX = 'credential:';
53
- export const resolveCredentialRefs = () => {
54
- const refs = Object.entries(process.env)
55
- .filter(([, v]) => typeof v === 'string' && v.startsWith(CREDENTIAL_REF_PREFIX));
56
- if (refs.length === 0) {
57
- return [];
58
- }
59
- const data = loadGlobal();
60
- const resolved = [];
61
- for (const [envVar, value] of refs) {
62
- const credPath = value.slice(CREDENTIAL_REF_PREFIX.length);
63
- const credValue = getNestedValue(data, credPath);
64
- if (typeof credValue === 'string' && credValue.length > 0) {
65
- process.env[envVar] = credValue;
66
- resolved.push(envVar);
67
- }
68
- }
69
- return resolved;
70
- };
71
- export const credentials = {
72
- get: (path, defaultValue = undefined) => {
73
- const data = load();
74
- const value = getNestedValue(data, path);
75
- return value !== undefined ? value : defaultValue;
76
- },
77
- require: (path) => {
78
- const value = credentials.get(path);
79
- if (value === undefined || value === null) {
80
- throw new MissingCredentialError(path);
81
- }
82
- return value;
83
- },
84
- _reset: () => {
85
- cache.clear();
86
- }
87
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,392 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { dump as stringifyYaml } from 'js-yaml';
3
- import { encrypt, generateKey } from './encryption.js';
4
- const YAML_CONTENT = stringifyYaml({
5
- anthropic: { api_key: 'sk-ant-test' },
6
- aws: { region: 'us-east-1', secret: 'aws-secret' }
7
- });
8
- const WORKFLOW_YAML = stringifyYaml({
9
- anthropic: { api_key: 'sk-ant-workflow' },
10
- stripe: { secret_key: 'sk-stripe-wf' }
11
- });
12
- const SAVED_ENV = {};
13
- const SAVED_ARGV2 = process.argv[2];
14
- const ENV_KEYS = [
15
- 'OUTPUT_CREDENTIALS_KEY', 'OUTPUT_CREDENTIALS_KEY_PRODUCTION',
16
- 'OUTPUT_CREDENTIALS_KEY_DEVELOPMENT', 'OUTPUT_CREDENTIALS_KEY_MY_WORKFLOW',
17
- 'NODE_ENV'
18
- ];
19
- const saveEnv = () => ENV_KEYS.forEach(k => {
20
- SAVED_ENV[k] = process.env[k];
21
- });
22
- const restoreEnv = () => ENV_KEYS.forEach(k => {
23
- if (SAVED_ENV[k] === undefined) {
24
- delete process.env[k];
25
- }
26
- else {
27
- process.env[k] = SAVED_ENV[k];
28
- }
29
- });
30
- const clearEnv = () => ENV_KEYS.forEach(k => delete process.env[k]);
31
- const loadCredentials = async () => {
32
- vi.resetModules();
33
- const mod = await import('./index.js');
34
- return mod.credentials;
35
- };
36
- describe('credentials module', () => {
37
- const key = generateKey();
38
- const ciphertext = encrypt(YAML_CONTENT, key);
39
- beforeEach(() => {
40
- saveEnv();
41
- clearEnv();
42
- });
43
- afterEach(() => {
44
- restoreEnv();
45
- process.argv[2] = SAVED_ARGV2;
46
- });
47
- describe('get', () => {
48
- it('should return nested value from decrypted YAML', async () => {
49
- process.env.OUTPUT_CREDENTIALS_KEY = key;
50
- vi.doMock('node:fs', () => ({
51
- readFileSync: () => ciphertext,
52
- existsSync: (path) => path.endsWith('credentials.yml.enc')
53
- }));
54
- const credentials = await loadCredentials();
55
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
56
- expect(credentials.get('aws.region')).toBe('us-east-1');
57
- });
58
- it('should return default value for missing path', async () => {
59
- process.env.OUTPUT_CREDENTIALS_KEY = key;
60
- vi.doMock('node:fs', () => ({
61
- readFileSync: () => ciphertext,
62
- existsSync: (path) => path.endsWith('credentials.yml.enc')
63
- }));
64
- const credentials = await loadCredentials();
65
- expect(credentials.get('nonexistent.key', 'fallback')).toBe('fallback');
66
- });
67
- it('should return undefined when path is missing and no default', async () => {
68
- process.env.OUTPUT_CREDENTIALS_KEY = key;
69
- vi.doMock('node:fs', () => ({
70
- readFileSync: () => ciphertext,
71
- existsSync: (path) => path.endsWith('credentials.yml.enc')
72
- }));
73
- const credentials = await loadCredentials();
74
- expect(credentials.get('nonexistent')).toBeUndefined();
75
- });
76
- it('should return empty object when no credentials file exists', async () => {
77
- vi.doMock('node:fs', () => ({
78
- readFileSync: vi.fn(),
79
- existsSync: () => false
80
- }));
81
- const credentials = await loadCredentials();
82
- expect(credentials.get('anything')).toBeUndefined();
83
- expect(credentials.get('anything', 'default')).toBe('default');
84
- });
85
- });
86
- describe('require', () => {
87
- it('should return value when credential exists', async () => {
88
- process.env.OUTPUT_CREDENTIALS_KEY = key;
89
- vi.doMock('node:fs', () => ({
90
- readFileSync: () => ciphertext,
91
- existsSync: (path) => path.endsWith('credentials.yml.enc')
92
- }));
93
- const credentials = await loadCredentials();
94
- expect(credentials.require('aws.secret')).toBe('aws-secret');
95
- });
96
- it('should throw MissingCredentialError when credential is missing', async () => {
97
- process.env.OUTPUT_CREDENTIALS_KEY = key;
98
- vi.doMock('node:fs', () => ({
99
- readFileSync: () => ciphertext,
100
- existsSync: (path) => path.endsWith('credentials.yml.enc')
101
- }));
102
- const credentials = await loadCredentials();
103
- expect(() => credentials.require('missing.path'))
104
- .toThrow('Required credential not found: "missing.path"');
105
- });
106
- });
107
- describe('key resolution', () => {
108
- it('should prefer env var over key file', async () => {
109
- process.env.OUTPUT_CREDENTIALS_KEY = key;
110
- const readFileSync = vi.fn().mockReturnValue(ciphertext);
111
- vi.doMock('node:fs', () => ({
112
- readFileSync,
113
- existsSync: (path) => path.endsWith('credentials.yml.enc')
114
- }));
115
- const credentials = await loadCredentials();
116
- credentials.get('anthropic.api_key');
117
- const keyFileCalls = readFileSync.mock.calls.filter((c) => c[0].endsWith('.key'));
118
- expect(keyFileCalls).toHaveLength(0);
119
- });
120
- it('should fall back to key file when env var is not set', async () => {
121
- const readFileSync = vi.fn().mockImplementation((path) => {
122
- if (path.endsWith('.key')) {
123
- return key;
124
- }
125
- return ciphertext;
126
- });
127
- vi.doMock('node:fs', () => ({
128
- readFileSync,
129
- existsSync: () => true
130
- }));
131
- const credentials = await loadCredentials();
132
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
133
- });
134
- it('should throw MissingKeyError when no key source is available', async () => {
135
- vi.doMock('node:fs', () => ({
136
- readFileSync: vi.fn(),
137
- existsSync: (path) => path.endsWith('credentials.yml.enc')
138
- }));
139
- const credentials = await loadCredentials();
140
- expect(() => credentials.get('anything')).toThrow('No credentials key found');
141
- });
142
- });
143
- describe('environment detection', () => {
144
- it('should read environment-specific file when NODE_ENV=production', async () => {
145
- process.env.NODE_ENV = 'production';
146
- process.env.OUTPUT_CREDENTIALS_KEY_PRODUCTION = key;
147
- vi.doMock('node:fs', () => ({
148
- readFileSync: () => ciphertext,
149
- existsSync: (path) => path.includes('credentials/production.yml.enc')
150
- }));
151
- const credentials = await loadCredentials();
152
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
153
- });
154
- it('should fall back to default when env-specific file does not exist', async () => {
155
- process.env.NODE_ENV = 'development';
156
- process.env.OUTPUT_CREDENTIALS_KEY = key;
157
- vi.doMock('node:fs', () => ({
158
- readFileSync: () => ciphertext,
159
- existsSync: (path) => path.endsWith('credentials.yml.enc') && !path.includes('credentials/')
160
- }));
161
- const credentials = await loadCredentials();
162
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
163
- });
164
- });
165
- describe('base directory resolution', () => {
166
- it('should use process.argv[2] when it is an absolute path', async () => {
167
- process.argv[2] = '/app/test_workflows';
168
- process.env.OUTPUT_CREDENTIALS_KEY = key;
169
- const paths = [];
170
- vi.doMock('node:fs', () => ({
171
- readFileSync: () => ciphertext,
172
- existsSync: (path) => {
173
- paths.push(path);
174
- return path.endsWith('credentials.yml.enc');
175
- }
176
- }));
177
- const credentials = await loadCredentials();
178
- credentials.get('anthropic.api_key');
179
- expect(paths.some(p => p.startsWith('/app/test_workflows/'))).toBe(true);
180
- });
181
- it('should fall back to process.cwd() when argv[2] is not absolute', async () => {
182
- process.argv[2] = 'credentials';
183
- process.env.OUTPUT_CREDENTIALS_KEY = key;
184
- const paths = [];
185
- vi.doMock('node:fs', () => ({
186
- readFileSync: () => ciphertext,
187
- existsSync: (path) => {
188
- paths.push(path);
189
- return path.endsWith('credentials.yml.enc');
190
- }
191
- }));
192
- const credentials = await loadCredentials();
193
- credentials.get('anthropic.api_key');
194
- expect(paths.some(p => p.startsWith(process.cwd()))).toBe(true);
195
- expect(paths.every(p => !p.startsWith('credentials/'))).toBe(true);
196
- });
197
- });
198
- describe('caching', () => {
199
- it('should cache loaded credentials across multiple get calls', async () => {
200
- process.env.OUTPUT_CREDENTIALS_KEY = key;
201
- const readFileSync = vi.fn().mockReturnValue(ciphertext);
202
- vi.doMock('node:fs', () => ({
203
- readFileSync,
204
- existsSync: (path) => path.endsWith('credentials.yml.enc')
205
- }));
206
- const credentials = await loadCredentials();
207
- credentials.get('anthropic.api_key');
208
- credentials.get('aws.region');
209
- credentials.get('aws.secret');
210
- expect(readFileSync).toHaveBeenCalledTimes(1);
211
- });
212
- it('should re-read after _reset()', async () => {
213
- process.env.OUTPUT_CREDENTIALS_KEY = key;
214
- const readFileSync = vi.fn().mockReturnValue(ciphertext);
215
- vi.doMock('node:fs', () => ({
216
- readFileSync,
217
- existsSync: (path) => path.endsWith('credentials.yml.enc')
218
- }));
219
- const credentials = await loadCredentials();
220
- credentials.get('anthropic.api_key');
221
- credentials._reset();
222
- credentials.get('anthropic.api_key');
223
- expect(readFileSync).toHaveBeenCalledTimes(2);
224
- });
225
- });
226
- describe('per-workflow credentials', () => {
227
- const workflowKey = generateKey();
228
- const workflowCiphertext = encrypt(WORKFLOW_YAML, workflowKey);
229
- it('should load workflow-specific credentials and deep-merge with global', async () => {
230
- process.env.OUTPUT_CREDENTIALS_KEY = key;
231
- vi.doMock('node:fs', () => ({
232
- readFileSync: (path) => {
233
- if (path.includes('/workflows/my_workflow/credentials.yml.enc')) {
234
- return workflowCiphertext;
235
- }
236
- if (path.includes('/workflows/my_workflow/credentials.key')) {
237
- return workflowKey;
238
- }
239
- return ciphertext;
240
- },
241
- existsSync: (path) => path.endsWith('credentials.yml.enc') ||
242
- path.includes('/workflows/my_workflow/credentials.key')
243
- }));
244
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
245
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'my_workflow', filename: '/app/src/workflows/my_workflow/workflow.ts' } })
246
- }));
247
- const credentials = await loadCredentials();
248
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-workflow');
249
- expect(credentials.get('stripe.secret_key')).toBe('sk-stripe-wf');
250
- expect(credentials.get('aws.region')).toBe('us-east-1');
251
- });
252
- it('should fall back to global when workflow has no credentials file', async () => {
253
- process.env.OUTPUT_CREDENTIALS_KEY = key;
254
- vi.doMock('node:fs', () => ({
255
- readFileSync: () => ciphertext,
256
- existsSync: (path) => path.endsWith('credentials.yml.enc') && !path.includes('/workflows/')
257
- }));
258
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
259
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'simple', filename: '/app/src/workflows/simple/workflow.ts' } })
260
- }));
261
- const credentials = await loadCredentials();
262
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
263
- });
264
- it('should use global key when workflow has no key file', async () => {
265
- process.env.OUTPUT_CREDENTIALS_KEY = key;
266
- const sharedCiphertext = encrypt(WORKFLOW_YAML, key);
267
- vi.doMock('node:fs', () => ({
268
- readFileSync: (path) => {
269
- if (path.includes('/workflows/my_workflow/credentials.yml.enc')) {
270
- return sharedCiphertext;
271
- }
272
- return ciphertext;
273
- },
274
- existsSync: (path) => {
275
- if (path.includes('/workflows/my_workflow/credentials.key')) {
276
- return false;
277
- }
278
- return path.endsWith('credentials.yml.enc');
279
- }
280
- }));
281
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
282
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'my_workflow', filename: '/app/src/workflows/my_workflow/workflow.ts' } })
283
- }));
284
- const credentials = await loadCredentials();
285
- expect(credentials.get('stripe.secret_key')).toBe('sk-stripe-wf');
286
- });
287
- it('should use workflow-specific env var key when set', async () => {
288
- process.env.OUTPUT_CREDENTIALS_KEY = key;
289
- process.env.OUTPUT_CREDENTIALS_KEY_MY_WORKFLOW = workflowKey;
290
- vi.doMock('node:fs', () => ({
291
- readFileSync: (path) => {
292
- if (path.includes('/workflows/my_workflow/credentials.yml.enc')) {
293
- return workflowCiphertext;
294
- }
295
- return ciphertext;
296
- },
297
- existsSync: (path) => path.endsWith('credentials.yml.enc')
298
- }));
299
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
300
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'my_workflow', filename: '/app/src/workflows/my_workflow/workflow.ts' } })
301
- }));
302
- const credentials = await loadCredentials();
303
- expect(credentials.get('stripe.secret_key')).toBe('sk-stripe-wf');
304
- });
305
- it('should isolate cache between different workflows', async () => {
306
- process.env.OUTPUT_CREDENTIALS_KEY = key;
307
- const otherYaml = stringifyYaml({ custom: { value: 'other-wf' } });
308
- const otherCiphertext = encrypt(otherYaml, key);
309
- vi.doMock('node:fs', () => ({
310
- readFileSync: (path) => {
311
- if (path.includes('/workflows/workflow_a/credentials.yml.enc')) {
312
- return encrypt(stringifyYaml({ custom: { value: 'wf-a' } }), key);
313
- }
314
- if (path.includes('/workflows/workflow_b/credentials.yml.enc')) {
315
- return otherCiphertext;
316
- }
317
- return ciphertext;
318
- },
319
- existsSync: (path) => path.endsWith('credentials.yml.enc')
320
- }));
321
- const ctx = { name: undefined, filename: undefined };
322
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
323
- getExecutionContext: () => ctx.name ? { workflow: { id: 'test-id', name: ctx.name, filename: ctx.filename } } : null
324
- }));
325
- const credentials = await loadCredentials();
326
- ctx.name = 'workflow_a';
327
- ctx.filename = '/app/src/workflows/workflow_a/workflow.ts';
328
- expect(credentials.get('custom.value')).toBe('wf-a');
329
- ctx.name = 'workflow_b';
330
- ctx.filename = '/app/src/workflows/workflow_b/workflow.ts';
331
- expect(credentials.get('custom.value')).toBe('other-wf');
332
- });
333
- it('should use global credentials when outside activity context', async () => {
334
- process.env.OUTPUT_CREDENTIALS_KEY = key;
335
- vi.doMock('node:fs', () => ({
336
- readFileSync: () => ciphertext,
337
- existsSync: (path) => path.endsWith('credentials.yml.enc')
338
- }));
339
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
340
- getExecutionContext: () => null
341
- }));
342
- const credentials = await loadCredentials();
343
- expect(credentials.get('anthropic.api_key')).toBe('sk-ant-test');
344
- });
345
- it('should clear all cached scopes on _reset()', async () => {
346
- process.env.OUTPUT_CREDENTIALS_KEY = key;
347
- const readFileSync = vi.fn().mockReturnValue(ciphertext);
348
- vi.doMock('node:fs', () => ({
349
- readFileSync,
350
- existsSync: (path) => path.endsWith('credentials.yml.enc') && !path.includes('/workflows/')
351
- }));
352
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
353
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'test_wf', filename: '/app/src/workflows/test_wf/workflow.ts' } })
354
- }));
355
- const credentials = await loadCredentials();
356
- credentials.get('anthropic.api_key');
357
- credentials._reset();
358
- credentials.get('anthropic.api_key');
359
- expect(readFileSync).toHaveBeenCalledTimes(2);
360
- });
361
- });
362
- describe('custom provider', () => {
363
- it('should use a custom provider when set via registry', async () => {
364
- const customProvider = {
365
- loadGlobal: () => ({ custom: { key: 'from-provider' } }),
366
- loadForWorkflow: () => null
367
- };
368
- vi.doMock('./encrypted_yaml_provider.js', () => ({
369
- encryptedYamlProvider: customProvider
370
- }));
371
- const credentials = await loadCredentials();
372
- expect(credentials.get('custom.key')).toBe('from-provider');
373
- });
374
- it('should deep-merge workflow provider data over global', async () => {
375
- const customProvider = {
376
- loadGlobal: () => ({ shared: 'global', base: { a: 1, b: 2 } }),
377
- loadForWorkflow: () => ({ base: { b: 99 }, extra: 'wf-only' })
378
- };
379
- vi.doMock('./encrypted_yaml_provider.js', () => ({
380
- encryptedYamlProvider: customProvider
381
- }));
382
- vi.doMock('@outputai/core/sdk_activity_integration', () => ({
383
- getExecutionContext: () => ({ workflow: { id: 'test-id', name: 'test', filename: '/app/workflows/test/workflow.ts' } })
384
- }));
385
- const credentials = await loadCredentials();
386
- expect(credentials.get('shared')).toBe('global');
387
- expect(credentials.get('base.a')).toBe(1);
388
- expect(credentials.get('base.b')).toBe(99);
389
- expect(credentials.get('extra')).toBe('wf-only');
390
- });
391
- });
392
- });
@@ -1,2 +0,0 @@
1
- import type { CredentialsProvider } from './types.js';
2
- export declare const encryptedYamlProvider: CredentialsProvider;
@@ -1,59 +0,0 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { load as parseYaml } from 'js-yaml';
3
- import { decrypt } from './encryption.js';
4
- import { MissingKeyError } from './errors.js';
5
- import { resolveKeyEnvVar, resolveCredentialsPath, resolveKeyPath, resolveWorkflowCredentialsPath, resolveWorkflowKeyPath, resolveWorkflowKeyEnvVar } from './paths.js';
6
- const resolveBaseDir = () => {
7
- const arg = process.argv[2];
8
- return (arg && arg.startsWith('/')) ? arg : process.cwd();
9
- };
10
- const resolveKey = (environment) => {
11
- const envVarName = resolveKeyEnvVar(environment);
12
- if (process.env[envVarName]) {
13
- return process.env[envVarName];
14
- }
15
- const keyFilePath = resolveKeyPath(resolveBaseDir(), environment);
16
- if (existsSync(keyFilePath)) {
17
- return readFileSync(keyFilePath, 'utf8').trim();
18
- }
19
- throw new MissingKeyError(environment);
20
- };
21
- const resolveWorkflowKey = (workflowName, workflowDir) => {
22
- const envVarName = resolveWorkflowKeyEnvVar(workflowName);
23
- if (process.env[envVarName]) {
24
- return process.env[envVarName];
25
- }
26
- const keyPath = resolveWorkflowKeyPath(workflowDir);
27
- if (existsSync(keyPath)) {
28
- return readFileSync(keyPath, 'utf8').trim();
29
- }
30
- return resolveKey(undefined);
31
- };
32
- const decryptYaml = (credPath, key) => parseYaml(decrypt(readFileSync(credPath, 'utf8').trim(), key)) || {};
33
- export const encryptedYamlProvider = {
34
- loadGlobal: ({ environment }) => {
35
- const baseDir = resolveBaseDir();
36
- const credPath = resolveCredentialsPath(baseDir, environment);
37
- if (!existsSync(credPath)) {
38
- if (environment) {
39
- const defaultPath = resolveCredentialsPath(baseDir, undefined);
40
- if (existsSync(defaultPath)) {
41
- return decryptYaml(defaultPath, resolveKey(undefined));
42
- }
43
- }
44
- return {};
45
- }
46
- return decryptYaml(credPath, resolveKey(environment));
47
- },
48
- loadForWorkflow: ({ workflowName, workflowDir }) => {
49
- if (!workflowDir) {
50
- return null;
51
- }
52
- const credPath = resolveWorkflowCredentialsPath(workflowDir);
53
- if (!existsSync(credPath)) {
54
- return null;
55
- }
56
- const key = resolveWorkflowKey(workflowName, workflowDir);
57
- return decryptYaml(credPath, key);
58
- }
59
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,135 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { dump as stringifyYaml } from 'js-yaml';
3
- import { encrypt, generateKey } from './encryption.js';
4
- const YAML_CONTENT = stringifyYaml({
5
- db: { host: 'localhost', password: 'secret' }
6
- });
7
- const SAVED_ENV = {};
8
- const SAVED_ARGV2 = process.argv[2];
9
- const ENV_KEYS = [
10
- 'OUTPUT_CREDENTIALS_KEY', 'OUTPUT_CREDENTIALS_KEY_PRODUCTION',
11
- 'OUTPUT_CREDENTIALS_KEY_MY_WORKFLOW', 'NODE_ENV'
12
- ];
13
- const saveEnv = () => ENV_KEYS.forEach(k => {
14
- SAVED_ENV[k] = process.env[k];
15
- });
16
- const restoreEnv = () => ENV_KEYS.forEach(k => {
17
- if (SAVED_ENV[k] === undefined) {
18
- delete process.env[k];
19
- }
20
- else {
21
- process.env[k] = SAVED_ENV[k];
22
- }
23
- });
24
- const clearEnv = () => ENV_KEYS.forEach(k => delete process.env[k]);
25
- const loadProvider = async () => {
26
- vi.resetModules();
27
- const mod = await import('./encrypted_yaml_provider.js');
28
- return mod.encryptedYamlProvider;
29
- };
30
- describe('encrypted YAML provider', () => {
31
- const key = generateKey();
32
- const ciphertext = encrypt(YAML_CONTENT, key);
33
- beforeEach(() => {
34
- saveEnv();
35
- clearEnv();
36
- });
37
- afterEach(() => {
38
- restoreEnv();
39
- process.argv[2] = SAVED_ARGV2;
40
- });
41
- describe('loadGlobal', () => {
42
- it('should decrypt and parse YAML credentials', async () => {
43
- process.env.OUTPUT_CREDENTIALS_KEY = key;
44
- vi.doMock('node:fs', () => ({
45
- readFileSync: () => ciphertext,
46
- existsSync: (path) => path.endsWith('credentials.yml.enc')
47
- }));
48
- const provider = await loadProvider();
49
- const result = provider.loadGlobal({ environment: undefined });
50
- expect(result).toEqual({ db: { host: 'localhost', password: 'secret' } });
51
- });
52
- it('should return empty object when no credentials file exists', async () => {
53
- vi.doMock('node:fs', () => ({
54
- readFileSync: vi.fn(),
55
- existsSync: () => false
56
- }));
57
- const provider = await loadProvider();
58
- expect(provider.loadGlobal({ environment: undefined })).toEqual({});
59
- });
60
- it('should use environment-specific path when provided', async () => {
61
- process.env.OUTPUT_CREDENTIALS_KEY_PRODUCTION = key;
62
- const paths = [];
63
- vi.doMock('node:fs', () => ({
64
- readFileSync: () => ciphertext,
65
- existsSync: (path) => {
66
- paths.push(path);
67
- return path.includes('credentials/production.yml.enc');
68
- }
69
- }));
70
- const provider = await loadProvider();
71
- provider.loadGlobal({ environment: 'production' });
72
- expect(paths.some(p => p.includes('credentials/production.yml.enc')))
73
- .toBe(true);
74
- });
75
- it('should throw MissingKeyError when no key available', async () => {
76
- vi.doMock('node:fs', () => ({
77
- readFileSync: () => ciphertext,
78
- existsSync: (path) => path.endsWith('credentials.yml.enc')
79
- }));
80
- const provider = await loadProvider();
81
- expect(() => provider.loadGlobal({ environment: undefined }))
82
- .toThrow('No credentials key found');
83
- });
84
- });
85
- describe('loadForWorkflow', () => {
86
- it('should return null when workflowDir is undefined', async () => {
87
- const provider = await loadProvider();
88
- const result = provider.loadForWorkflow({
89
- workflowName: 'test',
90
- workflowDir: undefined
91
- });
92
- expect(result).toBeNull();
93
- });
94
- it('should return null when no workflow credentials file exists', async () => {
95
- vi.doMock('node:fs', () => ({
96
- readFileSync: vi.fn(),
97
- existsSync: () => false
98
- }));
99
- const provider = await loadProvider();
100
- const result = provider.loadForWorkflow({
101
- workflowName: 'test',
102
- workflowDir: '/app/workflows/test'
103
- });
104
- expect(result).toBeNull();
105
- });
106
- it('should decrypt workflow-specific credentials', async () => {
107
- process.env.OUTPUT_CREDENTIALS_KEY = key;
108
- vi.doMock('node:fs', () => ({
109
- readFileSync: () => ciphertext,
110
- existsSync: (path) => path.endsWith('credentials.yml.enc')
111
- }));
112
- const provider = await loadProvider();
113
- const result = provider.loadForWorkflow({
114
- workflowName: 'my_workflow',
115
- workflowDir: '/app/workflows/my_workflow'
116
- });
117
- expect(result).toEqual({ db: { host: 'localhost', password: 'secret' } });
118
- });
119
- it('should use workflow-specific env var key when set', async () => {
120
- const workflowKey = generateKey();
121
- const workflowCipher = encrypt(YAML_CONTENT, workflowKey);
122
- process.env.OUTPUT_CREDENTIALS_KEY_MY_WORKFLOW = workflowKey;
123
- vi.doMock('node:fs', () => ({
124
- readFileSync: () => workflowCipher,
125
- existsSync: (path) => path.endsWith('credentials.yml.enc')
126
- }));
127
- const provider = await loadProvider();
128
- const result = provider.loadForWorkflow({
129
- workflowName: 'my_workflow',
130
- workflowDir: '/app/workflows/my_workflow'
131
- });
132
- expect(result).toEqual({ db: { host: 'localhost', password: 'secret' } });
133
- });
134
- });
135
- });
@@ -1,3 +0,0 @@
1
- export declare const generateKey: () => string;
2
- export declare const encrypt: (plaintext: string, keyHex: string) => string;
3
- export declare const decrypt: (ciphertext: string, keyHex: string) => string;
@@ -1,6 +0,0 @@
1
- import { gcm } from '@noble/ciphers/aes.js';
2
- import { managedNonce, randomBytes, hexToBytes, bytesToHex } from '@noble/ciphers/utils.js';
3
- const aes = managedNonce(gcm);
4
- export const generateKey = () => bytesToHex(randomBytes(32));
5
- export const encrypt = (plaintext, keyHex) => Buffer.from(aes(hexToBytes(keyHex)).encrypt(new TextEncoder().encode(plaintext))).toString('base64');
6
- export const decrypt = (ciphertext, keyHex) => new TextDecoder().decode(aes(hexToBytes(keyHex)).decrypt(Buffer.from(ciphertext, 'base64')));
@@ -1 +0,0 @@
1
- export {};
@@ -1,75 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { generateKey, encrypt, decrypt } from './encryption.js';
3
- describe('encryption module', () => {
4
- describe('generateKey', () => {
5
- it('should return a 64-character hex string', () => {
6
- const key = generateKey();
7
- expect(key).toHaveLength(64);
8
- expect(key).toMatch(/^[0-9a-f]{64}$/);
9
- });
10
- it('should produce unique keys on each call', () => {
11
- const keys = Array.from({ length: 10 }, () => generateKey());
12
- const unique = new Set(keys);
13
- expect(unique.size).toBe(10);
14
- });
15
- });
16
- describe('encrypt + decrypt', () => {
17
- it('should round-trip plaintext correctly', () => {
18
- const key = generateKey();
19
- const plaintext = 'hello world: this is a secret!';
20
- const ciphertext = encrypt(plaintext, key);
21
- const decrypted = decrypt(ciphertext, key);
22
- expect(decrypted).toBe(plaintext);
23
- });
24
- it('should handle empty plaintext', () => {
25
- const key = generateKey();
26
- const ciphertext = encrypt('', key);
27
- const decrypted = decrypt(ciphertext, key);
28
- expect(decrypted).toBe('');
29
- });
30
- it('should handle unicode content', () => {
31
- const key = generateKey();
32
- const plaintext = 'api_key: sk-ant-🔑\nregion: 日本語テスト';
33
- const ciphertext = encrypt(plaintext, key);
34
- const decrypted = decrypt(ciphertext, key);
35
- expect(decrypted).toBe(plaintext);
36
- });
37
- it('should produce a valid base64 string', () => {
38
- const key = generateKey();
39
- const ciphertext = encrypt('test', key);
40
- expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow();
41
- const decoded = Buffer.from(ciphertext, 'base64');
42
- expect(decoded.length).toBeGreaterThan(28);
43
- });
44
- it('should produce different ciphertext for same plaintext + key (random IV)', () => {
45
- const key = generateKey();
46
- const plaintext = 'same content';
47
- const ct1 = encrypt(plaintext, key);
48
- const ct2 = encrypt(plaintext, key);
49
- expect(ct1).not.toBe(ct2);
50
- expect(decrypt(ct1, key)).toBe(plaintext);
51
- expect(decrypt(ct2, key)).toBe(plaintext);
52
- });
53
- });
54
- describe('decrypt error cases', () => {
55
- it('should throw when decrypting with wrong key', () => {
56
- const key1 = generateKey();
57
- const key2 = generateKey();
58
- const ciphertext = encrypt('secret', key1);
59
- expect(() => decrypt(ciphertext, key2)).toThrow();
60
- });
61
- it('should throw when ciphertext is too short', () => {
62
- const key = generateKey();
63
- const tooShort = Buffer.from('short').toString('base64');
64
- expect(() => decrypt(tooShort, key)).toThrow();
65
- });
66
- it('should throw when ciphertext has been tampered with', () => {
67
- const key = generateKey();
68
- const ciphertext = encrypt('secret', key);
69
- const buf = Buffer.from(ciphertext, 'base64');
70
- buf[20] = (buf[20] + 1) % 256;
71
- const tampered = buf.toString('base64');
72
- expect(() => decrypt(tampered, key)).toThrow();
73
- });
74
- });
75
- });
package/dist/errors.d.ts DELETED
@@ -1,6 +0,0 @@
1
- export declare class MissingKeyError extends Error {
2
- constructor(environment?: string | null);
3
- }
4
- export declare class MissingCredentialError extends Error {
5
- constructor(path: string);
6
- }
package/dist/errors.js DELETED
@@ -1,16 +0,0 @@
1
- export class MissingKeyError extends Error {
2
- constructor(environment) {
3
- const envVar = environment ?
4
- `OUTPUT_CREDENTIALS_KEY_${environment.toUpperCase()}` :
5
- 'OUTPUT_CREDENTIALS_KEY';
6
- const keyFile = environment ?
7
- `config/credentials/${environment}.key` :
8
- 'config/credentials.key';
9
- super(`No credentials key found. Set ${envVar} env var or create ${keyFile}.`);
10
- }
11
- }
12
- export class MissingCredentialError extends Error {
13
- constructor(path) {
14
- super(`Required credential not found: "${path}".`);
15
- }
16
- }
package/dist/index.d.ts DELETED
@@ -1,7 +0,0 @@
1
- export { credentials, resolveCredentialRefs } from './credentials.js';
2
- export { setProvider, getProvider } from './provider_registry.js';
3
- export { encryptedYamlProvider } from './encrypted_yaml_provider.js';
4
- export { encrypt, decrypt, generateKey } from './encryption.js';
5
- export { MissingCredentialError, MissingKeyError } from './errors.js';
6
- export { getNestedValue, resolveCredentialsPath, resolveKeyPath, resolveKeyEnvVar, resolveWorkflowCredentialsPath, resolveWorkflowKeyPath, resolveWorkflowKeyEnvVar } from './paths.js';
7
- export type { CredentialsProvider, GlobalContext, WorkflowContext } from './types.js';
package/dist/index.js DELETED
@@ -1,15 +0,0 @@
1
- import { setProvider } from './provider_registry.js';
2
- import { encryptedYamlProvider } from './encrypted_yaml_provider.js';
3
- import { onBeforeStart } from '@outputai/core/hooks';
4
- import { resolveCredentialRefs } from './credentials.js';
5
- // Auto-configure the default provider when the barrel is imported.
6
- // This keeps provider_registry.ts free of node:fs imports (sandbox-safe).
7
- setProvider(encryptedYamlProvider);
8
- // Resolve credential: env var references at worker startup.
9
- onBeforeStart(resolveCredentialRefs);
10
- export { credentials, resolveCredentialRefs } from './credentials.js';
11
- export { setProvider, getProvider } from './provider_registry.js';
12
- export { encryptedYamlProvider } from './encrypted_yaml_provider.js';
13
- export { encrypt, decrypt, generateKey } from './encryption.js';
14
- export { MissingCredentialError, MissingKeyError } from './errors.js';
15
- export { getNestedValue, resolveCredentialsPath, resolveKeyPath, resolveKeyEnvVar, resolveWorkflowCredentialsPath, resolveWorkflowKeyPath, resolveWorkflowKeyEnvVar } from './paths.js';
package/dist/paths.d.ts DELETED
@@ -1,7 +0,0 @@
1
- export declare const resolveKeyEnvVar: (environment?: string) => string;
2
- export declare const resolveWorkflowKeyEnvVar: (workflowName: string) => string;
3
- export declare const resolveCredentialsPath: (baseDir: string, environment?: string) => string;
4
- export declare const resolveKeyPath: (baseDir: string, environment?: string) => string;
5
- export declare const resolveWorkflowCredentialsPath: (workflowDir: string) => string;
6
- export declare const resolveWorkflowKeyPath: (workflowDir: string) => string;
7
- export declare const getNestedValue: (obj: unknown, dotPath: string) => unknown;
package/dist/paths.js DELETED
@@ -1,14 +0,0 @@
1
- import { resolve } from 'node:path';
2
- export const resolveKeyEnvVar = (environment) => environment ?
3
- `OUTPUT_CREDENTIALS_KEY_${environment.toUpperCase()}` :
4
- 'OUTPUT_CREDENTIALS_KEY';
5
- export const resolveWorkflowKeyEnvVar = (workflowName) => `OUTPUT_CREDENTIALS_KEY_${workflowName.toUpperCase()}`;
6
- export const resolveCredentialsPath = (baseDir, environment) => environment ?
7
- resolve(baseDir, `config/credentials/${environment}.yml.enc`) :
8
- resolve(baseDir, 'config/credentials.yml.enc');
9
- export const resolveKeyPath = (baseDir, environment) => environment ?
10
- resolve(baseDir, `config/credentials/${environment}.key`) :
11
- resolve(baseDir, 'config/credentials.key');
12
- export const resolveWorkflowCredentialsPath = (workflowDir) => resolve(workflowDir, 'credentials.yml.enc');
13
- export const resolveWorkflowKeyPath = (workflowDir) => resolve(workflowDir, 'credentials.key');
14
- export const getNestedValue = (obj, dotPath) => dotPath.split('.').reduce((acc, part) => acc && typeof acc === 'object' ? acc[part] : undefined, obj);
@@ -1,3 +0,0 @@
1
- import type { CredentialsProvider } from './types.js';
2
- export declare const getProvider: () => CredentialsProvider;
3
- export declare const setProvider: (provider: CredentialsProvider) => void;
@@ -1,10 +0,0 @@
1
- const registry = { provider: null };
2
- export const getProvider = () => {
3
- if (!registry.provider) {
4
- throw new Error('No credentials provider configured. Call setProvider() first.');
5
- }
6
- return registry.provider;
7
- };
8
- export const setProvider = (provider) => {
9
- registry.provider = provider;
10
- };
package/dist/types.d.ts DELETED
@@ -1,12 +0,0 @@
1
- export interface GlobalContext {
2
- environment: string | undefined;
3
- }
4
- export interface WorkflowContext {
5
- workflowName: string;
6
- workflowDir: string | undefined;
7
- environment?: string | undefined;
8
- }
9
- export interface CredentialsProvider {
10
- loadGlobal(context: GlobalContext): Record<string, unknown>;
11
- loadForWorkflow(context: WorkflowContext): Record<string, unknown> | null;
12
- }
package/dist/types.js DELETED
@@ -1 +0,0 @@
1
- export {};