@saasak/tool-env 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.js ADDED
@@ -0,0 +1,491 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+ import { decrypt, isEncrypted } from "./utils-crypto.js";
4
+ import { buildEnv } from "./utils-env.js";
5
+ import { findMonorepoPackages } from "./utils-pkg.js";
6
+
7
+ /**
8
+ * @typedef {Object.<string, string>} EnvRecord
9
+ * Key-value pairs of environment variable name to value
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} LoadOptions
14
+ * @property {string} [targetEnv] - Target environment (e.g. 'dev', 'production')
15
+ * @property {string} [secret] - Secret for decryption (or file://path to read from file)
16
+ * @property {string} [envPath] - Path to env.json file (default: '.env.json')
17
+ * @property {string} [cwd] - Working directory (default: process.cwd())
18
+ * @property {boolean} [applyToProcess] - Whether to apply to process.env (default: true)
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} LoadEnvJsonOptions
23
+ * @property {string} [secret] - Secret for decryption (or file://path)
24
+ * @property {string} [targetEnv] - Target environment
25
+ * @property {string} [envPath] - Path to env.json file (default: '.env.json')
26
+ * @property {string} [cwd] - Working directory (default: process.cwd())
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} VerifyResult
31
+ * @property {boolean} success - Whether verification passed
32
+ * @property {string|null} [path] - Path to the failed encrypted value
33
+ * @property {string} [error] - Error message if verification failed
34
+ */
35
+
36
+ /** @type {string} */
37
+ const DEFAULT_ENV = "dev";
38
+
39
+ /**
40
+ * Environment variables that must never be injected by .env.json content.
41
+ * These control wrenv's own behavior and must not be overridden.
42
+ * @type {Set<string>}
43
+ */
44
+ export const RESERVED_ENV_VARS = new Set([
45
+ "WRENV_TARGET",
46
+ "WRENV_SECRET",
47
+ "TARGET_ENV",
48
+ "TARGET_SECRET",
49
+ ]);
50
+
51
+ /**
52
+ * Mapping of environment name aliases to canonical names
53
+ * Note: 'all' is a special target that writes all environments (write command only)
54
+ * @type {Object.<string, string>}
55
+ */
56
+ const targets = {
57
+ development: "dev",
58
+ dev: "dev",
59
+ staging: "preprod",
60
+ pp: "preprod",
61
+ preprod: "preprod",
62
+ prod: "production",
63
+ production: "production",
64
+ all: "all",
65
+ };
66
+
67
+ /**
68
+ * Mapping of internal environment names to conventional output file names
69
+ * Used when writing .env files with 'all' target
70
+ * @type {Object.<string, string>}
71
+ */
72
+ const outputNames = {
73
+ dev: "development",
74
+ preprod: "staging",
75
+ production: "production",
76
+ };
77
+
78
+ /**
79
+ * Get the conventional output file name for an internal environment name
80
+ * Falls back to the internal name if no mapping exists
81
+ * @param {string} internalName - Internal environment name (e.g. 'dev', 'preprod')
82
+ * @returns {string} - Conventional output name (e.g. 'development', 'staging')
83
+ */
84
+ export function getOutputName(internalName) {
85
+ return outputNames[internalName] || internalName;
86
+ }
87
+
88
+ /**
89
+ * Resolve target environment with fallback chain:
90
+ * param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
91
+ * @param {string} [targetParam] - Explicit target environment parameter
92
+ * @returns {string} - Resolved target environment
93
+ */
94
+ export function resolveTarget(targetParam) {
95
+ if (targetParam) {
96
+ return targets[targetParam] || targetParam;
97
+ }
98
+
99
+ const envTarget = process.env.WRENV_TARGET || process.env.TARGET_ENV;
100
+ if (envTarget) {
101
+ return targets[envTarget] || envTarget;
102
+ }
103
+
104
+ const nodeEnv = process.env.NODE_ENV;
105
+ if (nodeEnv && targets[nodeEnv]) {
106
+ return targets[nodeEnv];
107
+ }
108
+
109
+ return DEFAULT_ENV;
110
+ }
111
+
112
+ /**
113
+ * Resolve target environment aliases:
114
+ * param -> allowedEnvs
115
+ * @param {string[]} [allowedEnvs] - 4rray of allowed environments to resolve against (e.g. ['dev', 'preprod', 'production'])
116
+ * @returns {string[]} - Resolved target sliases environment
117
+ */
118
+ export function resolveTargetAliases(allowedEnvs) {
119
+ const allowedAliases = allowedEnvs.flatMap((env) =>
120
+ Object.entries(targets)
121
+ .find(([_, val]) => val === env)
122
+ .map(([alias]) => alias),
123
+ );
124
+
125
+ return Array.from(new Set(allowedAliases));
126
+ }
127
+
128
+ /**
129
+ * Resolve secret with unified fallback chain:
130
+ * param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> null
131
+ *
132
+ * If reading from stdin or a file:// path fails, falls back to env vars.
133
+ * A bare string that is not a readable file is treated as a literal secret.
134
+ *
135
+ * @param {string} [secretParam] - Secret source: "stdin", "file://path", file path, or literal string
136
+ * @returns {string} - Resolved secret or empty string if not provided
137
+ */
138
+ export function resolveSecret(secretParam) {
139
+ if (secretParam) {
140
+ if (secretParam === "stdin") {
141
+ try {
142
+ const value = fs.readFileSync(0, "utf8").trim();
143
+ if (value) return value;
144
+ } catch (_) {
145
+ // fall through to env vars
146
+ }
147
+ } else if (secretParam.startsWith("file://")) {
148
+ try {
149
+ const value = fs.readFileSync(secretParam.slice(7), "utf8").trim();
150
+ if (value) return value;
151
+ } catch (_) {
152
+ // fall through to env vars
153
+ }
154
+ } else {
155
+ try {
156
+ return fs.readFileSync(secretParam, "utf8").trim();
157
+ } catch (_) {
158
+ // Not a readable file path — treat as literal secret string
159
+ return secretParam;
160
+ }
161
+ }
162
+ }
163
+
164
+ return process.env.WRENV_SECRET || process.env.TARGET_SECRET || "";
165
+ }
166
+
167
+ /**
168
+ * Parse .env file content into key-value pairs
169
+ * Supports standard KEY=value format with basic quote handling
170
+ * @param {string} content - Raw .env file content
171
+ * @returns {EnvRecord} - Parsed key-value pairs
172
+ */
173
+ export function parseEnvFile(content) {
174
+ /** @type {EnvRecord} */
175
+ const result = {};
176
+ const lines = content.split("\n");
177
+
178
+ for (const line of lines) {
179
+ const trimmed = line.trim();
180
+
181
+ // Skip empty lines and comments
182
+ if (!trimmed || trimmed.startsWith("#")) {
183
+ continue;
184
+ }
185
+
186
+ // Find the first = sign
187
+ const eqIndex = trimmed.indexOf("=");
188
+ if (eqIndex === -1) {
189
+ continue;
190
+ }
191
+
192
+ const key = trimmed.slice(0, eqIndex).trim();
193
+ let value = trimmed.slice(eqIndex + 1).trim();
194
+
195
+ // Remove surrounding quotes if present
196
+ if (
197
+ (value.startsWith('"') && value.endsWith('"')) ||
198
+ (value.startsWith("'") && value.endsWith("'"))
199
+ ) {
200
+ value = value.slice(1, -1);
201
+ }
202
+
203
+ if (key) {
204
+ result[key] = value;
205
+ }
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * Find the nearest package.json traversing upward from cwd and return its name (without scope)
213
+ * @param {string} cwd - Starting directory
214
+ * @param {string} scope - Package scope (e.g. '@saasak')
215
+ * @returns {string} - Package name without scope, or 'root' as fallback
216
+ */
217
+ function findNearestPackageName(cwd, scope) {
218
+ let currentDir = cwd;
219
+
220
+ while (currentDir !== path.dirname(currentDir)) {
221
+ // Stop at filesystem root
222
+ const pkgPath = path.join(currentDir, "package.json");
223
+ if (fs.existsSync(pkgPath)) {
224
+ try {
225
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
226
+ if (pkg.name) {
227
+ // Return name without scope prefix
228
+ return pkg.name.replace(`${scope}/`, "");
229
+ }
230
+ } catch (e) {
231
+ // Continue traversing upward
232
+ }
233
+ }
234
+ currentDir = path.dirname(currentDir);
235
+ }
236
+
237
+ return "root"; // fallback
238
+ }
239
+
240
+ /**
241
+ * Load .env.local file if it exists
242
+ * @param {string} [cwd] - Working directory (default: process.cwd())
243
+ * @returns {EnvRecord} - Local overrides or empty object
244
+ */
245
+ export function loadLocalOverrides(cwd = process.cwd()) {
246
+ const localEnvPath = path.join(cwd, ".env.local");
247
+
248
+ if (!fs.existsSync(localEnvPath)) {
249
+ return {};
250
+ }
251
+
252
+ try {
253
+ const content = fs.readFileSync(localEnvPath, "utf8");
254
+ return parseEnvFile(content);
255
+ } catch (error) {
256
+ return {};
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Find the monorepo root by looking for the env.json file, traversing upward from cwd
262
+ * @param {string} cwd - Starting directory
263
+ * @param {string} envPath - Name of the env file (e.g. '.env.json')
264
+ * @returns {string|null} - Path to monorepo root, or null if not found
265
+ */
266
+ function findMonorepoRoot(cwd, envPath) {
267
+ let currentDir = cwd;
268
+
269
+ while (currentDir !== path.dirname(currentDir)) {
270
+ const envFilePath = path.join(currentDir, envPath);
271
+ if (fs.existsSync(envFilePath)) {
272
+ return currentDir;
273
+ }
274
+ currentDir = path.dirname(currentDir);
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * Load environment configuration from env.json file
282
+ * Automatically detects the current package and returns its specific env vars
283
+ * @param {LoadEnvJsonOptions} [options] - Options object
284
+ * @returns {EnvRecord} - Decrypted environment variables for the current package
285
+ * @throws {Error} - If env file not found or target env not allowed
286
+ */
287
+ export function loadEnvJson(options = {}) {
288
+ const {
289
+ secret: secretParam,
290
+ targetEnv: targetParam,
291
+ envPath = ".env.json",
292
+ cwd = process.cwd(),
293
+ } = options;
294
+
295
+ const secret = resolveSecret(secretParam);
296
+ const targetEnv = resolveTarget(targetParam);
297
+
298
+ // Find the monorepo root (where env.json lives)
299
+ const monorepoRoot = findMonorepoRoot(cwd, envPath);
300
+ if (!monorepoRoot) {
301
+ throw new Error(`No env file found (searched upward from ${cwd})`);
302
+ }
303
+
304
+ const envFile = path.join(monorepoRoot, envPath);
305
+ const envContent = fs.readFileSync(envFile, "utf8");
306
+ const env = JSON.parse(envContent);
307
+
308
+ const allowedEnvs =
309
+ env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
310
+ const resolvedTarget = allowedEnvs.find((e) => e === targetEnv);
311
+
312
+ if (!resolvedTarget) {
313
+ throw new Error(
314
+ `Target env "${targetEnv}" is not allowed. Allowed: ${allowedEnvs.join(", ")}`,
315
+ );
316
+ }
317
+
318
+ // Get scope from monorepo root package.json
319
+ const rootPkgPath = path.join(monorepoRoot, "package.json");
320
+ let scope = "";
321
+ /** @type {string[]} */
322
+ let depsName = ["root"];
323
+
324
+ if (fs.existsSync(rootPkgPath)) {
325
+ try {
326
+ const rootPkgContent = fs.readFileSync(rootPkgPath, "utf8");
327
+ const rootPkg = JSON.parse(rootPkgContent);
328
+ scope = rootPkg.name.split("/")[0];
329
+
330
+ const foundPackages = findMonorepoPackages(monorepoRoot, scope);
331
+ depsName = [
332
+ ...foundPackages.map((pkg) => pkg.name.replace(`${scope}/`, "")),
333
+ "root",
334
+ ];
335
+ } catch (error) {
336
+ // Not a monorepo or error parsing, use default
337
+ }
338
+ }
339
+
340
+ const allEnvs = buildEnv(secret, resolvedTarget, env, depsName);
341
+
342
+ // Find the current package name from nearest package.json
343
+ const currentPackageName = findNearestPackageName(cwd, scope);
344
+
345
+ // Return env for current package (with fallback to root)
346
+ return allEnvs[currentPackageName] || allEnvs.root || {};
347
+ }
348
+
349
+ /**
350
+ * Main load function - loads env.json and applies .env.local overrides
351
+ * Applies values to process.env and returns the final config object
352
+ * @param {LoadOptions} [options] - Options object
353
+ * @returns {EnvRecord} - Final environment configuration
354
+ * @throws {Error} - If env file not found or target env not allowed
355
+ * @example
356
+ * // Basic usage - loads from cwd, applies to process.env
357
+ * const config = load();
358
+ *
359
+ * @example
360
+ * // With options
361
+ * const config = load({
362
+ * targetEnv: 'production',
363
+ * secret: 'file://./secret.txt',
364
+ * applyToProcess: false
365
+ * });
366
+ */
367
+ export function load(options = {}) {
368
+ const {
369
+ targetEnv,
370
+ secret,
371
+ envPath,
372
+ cwd = process.cwd(),
373
+ applyToProcess = true,
374
+ } = options;
375
+
376
+ // Resolve target first to determine if we're in dev mode
377
+ const resolvedTarget = resolveTarget(targetEnv);
378
+
379
+ // Load env.json config
380
+ const envConfig = loadEnvJson({
381
+ secret,
382
+ targetEnv: resolvedTarget,
383
+ envPath,
384
+ cwd,
385
+ });
386
+
387
+ // Load .env.local overrides only in dev mode (security: prevent local overrides in production)
388
+ const localOverrides =
389
+ resolvedTarget === "dev" ? loadLocalOverrides(cwd) : {};
390
+
391
+ // Merge: env.json values, then .env.local overrides (local takes precedence)
392
+ // Note: .env.local only overrides env.json values, not existing process.env
393
+ /** @type {EnvRecord} */
394
+ const finalConfig = { ...envConfig };
395
+
396
+ for (const [key, value] of Object.entries(localOverrides)) {
397
+ if (Object.prototype.hasOwnProperty.call(envConfig, key)) {
398
+ finalConfig[key] = value;
399
+ }
400
+ }
401
+
402
+ // - Reserved vars (WRENV_*, TARGET_*) are never injected
403
+ // - NODE_ENV is only injected if not already set
404
+ for (const key of RESERVED_ENV_VARS) {
405
+ delete finalConfig[key];
406
+ }
407
+ if (process.env.NODE_ENV) {
408
+ delete finalConfig.NODE_ENV;
409
+ }
410
+
411
+ // Apply to process.env if requested
412
+ if (applyToProcess) {
413
+ for (const [key, value] of Object.entries(finalConfig)) {
414
+ process.env[key] = value;
415
+ }
416
+ }
417
+
418
+ return finalConfig;
419
+ }
420
+
421
+ /**
422
+ * @typedef {Object} EncryptedValueInfo
423
+ * @property {string} path - Path to the encrypted value (e.g. 'variables.API_KEY.dev')
424
+ * @property {string} value - The encrypted value
425
+ */
426
+
427
+ /**
428
+ * Verify that all encrypted values in the env can be decrypted with the provided secret
429
+ * This prevents mixed-encryption scenarios where different passwords are used
430
+ * @param {Object} env - The env configuration object (parsed env.json)
431
+ * @param {string} secret - The secret to use for decryption (already resolved, not file:// path)
432
+ * @returns {VerifyResult} - Verification result with success status
433
+ */
434
+ export function verifyCanDecrypt(env, secret) {
435
+ /** @type {EncryptedValueInfo[]} */
436
+ const encryptedValues = [];
437
+
438
+ if (!secret) {
439
+ return { success: false, path: null, error: "No secret provided" };
440
+ }
441
+
442
+ // Collect all encrypted values from variables
443
+ if (env.variables) {
444
+ for (const [varName, varValue] of Object.entries(env.variables)) {
445
+ for (const [envKey, envVal] of Object.entries(varValue)) {
446
+ if (typeof envVal === "string" && isEncrypted(envVal)) {
447
+ encryptedValues.push({
448
+ path: `variables.${varName}.${envKey}`,
449
+ value: envVal,
450
+ });
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ // Collect all encrypted values from overrides
457
+ if (env.overrides) {
458
+ for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
459
+ for (const [varName, varValue] of Object.entries(pkgOverrides)) {
460
+ for (const [envKey, envVal] of Object.entries(varValue)) {
461
+ if (typeof envVal === "string" && isEncrypted(envVal)) {
462
+ encryptedValues.push({
463
+ path: `overrides.${pkgName}.${varName}.${envKey}`,
464
+ value: envVal,
465
+ });
466
+ }
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // If no encrypted values exist, verification passes (first-time encryption)
473
+ if (encryptedValues.length === 0) {
474
+ return { success: true };
475
+ }
476
+
477
+ // Try to decrypt each encrypted value
478
+ for (const { path, value } of encryptedValues) {
479
+ try {
480
+ decrypt(value, secret);
481
+ } catch (error) {
482
+ return {
483
+ success: false,
484
+ path,
485
+ error: error.message,
486
+ };
487
+ }
488
+ }
489
+
490
+ return { success: true };
491
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ /** Key-value pairs of environment variable name to value */
2
+ export type EnvRecord = Record<string, string>;
3
+
4
+ export interface LoadOptions {
5
+ /** Target environment (e.g. 'dev', 'preprod', 'production') */
6
+ targetEnv?: string;
7
+ /** Secret for decryption: "stdin", "file://path", file path, or literal string */
8
+ secret?: string;
9
+ /** Path to env.json file (default: '.env.json') */
10
+ envPath?: string;
11
+ /** Working directory (default: process.cwd()) */
12
+ cwd?: string;
13
+ /** Whether to apply resolved vars to process.env (default: true) */
14
+ applyToProcess?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Load environment variables from .env.json with optional .env.local overrides.
19
+ * By default, applies resolved variables to process.env.
20
+ */
21
+ export function load(options?: LoadOptions): EnvRecord;
22
+
23
+ /**
24
+ * Load environment configuration from .env.json without .env.local overrides.
25
+ */
26
+ export function loadEnvJson(options?: {
27
+ secret?: string;
28
+ targetEnv?: string;
29
+ envPath?: string;
30
+ cwd?: string;
31
+ }): EnvRecord;
32
+
33
+ /** Load .env.local overrides if the file exists */
34
+ export function loadLocalOverrides(cwd?: string): EnvRecord;
35
+
36
+ /** Parse .env file content into key-value pairs */
37
+ export function parseEnvFile(content: string): EnvRecord;
38
+
39
+ /**
40
+ * Resolve target environment with fallback chain:
41
+ * param -> WRENV_TARGET -> TARGET_ENV -> NODE_ENV (mapped) -> 'dev'
42
+ */
43
+ export function resolveTarget(targetParam?: string): string;
44
+
45
+ /**
46
+ * Resolve secret with fallback chain:
47
+ * param (stdin | file:// | file path | literal) -> WRENV_SECRET -> TARGET_SECRET -> ''
48
+ */
49
+ export function resolveSecret(secretParam?: string): string;
50
+
51
+ /** Get the conventional output file name for an internal environment name */
52
+ export function getOutputName(internalName: string): string;
53
+
54
+ /** Verify that all encrypted values can be decrypted with the provided secret */
55
+ export function verifyCanDecrypt(env: object, secret: string): {
56
+ success: boolean;
57
+ path?: string | null;
58
+ error?: string;
59
+ };
60
+
61
+ /** Encrypt a plaintext value with the given secret */
62
+ export function encrypt(value: string, secret: string): string;
63
+
64
+ /** Decrypt an encrypted value with the given secret */
65
+ export function decrypt(value: string, secret: string): string;
66
+
67
+ /** Check if a value is encrypted */
68
+ export function isEncrypted(value: string): boolean;
69
+
70
+ /** Build environment variables for all packages from an env config */
71
+ export function buildEnv(
72
+ secret: string,
73
+ target: string,
74
+ env: object,
75
+ names: string[],
76
+ ): Record<string, EnvRecord>;
77
+
78
+ /** Find all packages in a monorepo */
79
+ export function findMonorepoPackages(
80
+ root: string,
81
+ scope: string,
82
+ ): Array<{ name: string; dir: string }>;
package/src/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @saasak/tool-env - Environment variable management for monorepos
3
+ *
4
+ * Main module exports for programmatic usage.
5
+ *
6
+ * @example
7
+ * // Basic usage - load env vars and apply to process.env
8
+ * import { load } from '@saasak/tool-env';
9
+ * const config = load();
10
+ *
11
+ * @example
12
+ * // Load with specific options
13
+ * import { load } from '@saasak/tool-env';
14
+ * const config = load({
15
+ * targetEnv: 'production',
16
+ * secret: 'file://./secret.txt',
17
+ * applyToProcess: false
18
+ * });
19
+ *
20
+ * @module @saasak/tool-env
21
+ */
22
+
23
+ // Core functions for loading and resolving environment configuration
24
+ export {
25
+ load,
26
+ loadEnvJson,
27
+ loadLocalOverrides,
28
+ parseEnvFile,
29
+ resolveTarget,
30
+ resolveSecret,
31
+ verifyCanDecrypt,
32
+ getOutputName
33
+ } from './core.js';
34
+
35
+ // Re-export crypto utilities for encryption/decryption
36
+ export { encrypt, decrypt, isEncrypted } from './utils-crypto.js';
37
+
38
+ // Re-export env building utilities
39
+ export { buildEnv } from './utils-env.js';
40
+
41
+ // Re-export package utilities
42
+ export { findMonorepoPackages } from './utils-pkg.js';
@@ -3,18 +3,29 @@ import crypto from 'crypto';
3
3
  // Constants for AES-256-GCM encryption
4
4
  // GCM provides authenticated encryption (confidentiality + integrity)
5
5
  // Using 256-bit keys for strong security
6
+
7
+ /** @type {string} */
6
8
  const ENCRYPTION_PREFIX = '$enc$';
9
+ /** @type {string} */
7
10
  const ALGORITHM = 'aes-256-gcm';
8
- const IV_LENGTH = 16; // 128 bits
9
- const SALT_LENGTH = 64; // 512 bits
10
- const TAG_LENGTH = 16; // 128 bits
11
- const KEY_LENGTH = 32; // 256 bits
12
- const ITERATIONS = 100000; // PBKDF2 iterations
11
+ /** @type {number} 128 bits */
12
+ const IV_LENGTH = 16;
13
+ /** @type {number} 512 bits */
14
+ const SALT_LENGTH = 64;
15
+ /** @type {number} 128 bits */
16
+ const TAG_LENGTH = 16;
17
+ /** @type {number} 256 bits */
18
+ const KEY_LENGTH = 32;
19
+ /** @type {number} PBKDF2 iterations */
20
+ const ITERATIONS = 100000;
13
21
 
14
22
  /**
15
23
  * Derive a 256-bit key from a secret using PBKDF2
16
24
  * This ensures consistent key generation from variable-length secrets
17
25
  * and adds computational cost to prevent brute-force attacks
26
+ * @param {string} secret - The secret passphrase
27
+ * @param {Buffer} salt - Random salt for key derivation
28
+ * @returns {Buffer} - Derived 256-bit key
18
29
  */
19
30
  function deriveKey(secret, salt) {
20
31
  return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
@@ -24,6 +35,9 @@ function deriveKey(secret, salt) {
24
35
  * Encrypt a string value using AES-256-GCM
25
36
  * Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
26
37
  * Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
38
+ * @param {string} plaintext - The string to encrypt
39
+ * @param {string} secret - The secret passphrase for encryption
40
+ * @returns {string} - Encrypted string with $enc$ prefix, or original value if empty
27
41
  */
28
42
  export function encrypt(plaintext, secret) {
29
43
  if (!plaintext) return plaintext;
@@ -50,6 +64,10 @@ export function encrypt(plaintext, secret) {
50
64
  * Decrypt an encrypted string
51
65
  * Strips the prefix and decodes the base64 payload
52
66
  * GCM authentication tag ensures the data hasn't been tampered with
67
+ * @param {string} encrypted - The encrypted string (with $enc$ prefix)
68
+ * @param {string} secret - The secret passphrase for decryption
69
+ * @returns {string} - Decrypted plaintext, or original value if not encrypted
70
+ * @throws {Error} - If decryption fails (wrong secret or corrupted data)
53
71
  */
54
72
  export function decrypt(encrypted, secret) {
55
73
  if (!encrypted || !isEncrypted(encrypted)) {
@@ -83,8 +101,9 @@ export function decrypt(encrypted, secret) {
83
101
  /**
84
102
  * Check if a string is encrypted by looking for the prefix
85
103
  * This allows the system to detect encrypted values without attempting decryption
104
+ * @param {unknown} value - The value to check
105
+ * @returns {boolean} - True if the value is an encrypted string
86
106
  */
87
107
  export function isEncrypted(value) {
88
108
  return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
89
109
  }
90
-