@joinremba/beacon 0.1.0 → 0.4.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/cli.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { loadConfig, type BeaconConfigFile } from "./cli-config";
4
4
  import { generateEnvExample } from "./cli-config";
5
5
  import { runCheck } from "./cli-config";
6
+ import { encryptEnv, decryptEnv } from "./encryption";
6
7
  import { color, icon, formatCheckResult, formatSummary, suggestKeys } from "./cli-format";
7
8
 
8
9
  interface ParsedArgs {
@@ -10,6 +11,9 @@ interface ParsedArgs {
10
11
  configPath: string;
11
12
  output: string;
12
13
  profile?: string;
14
+ key?: string;
15
+ input?: string;
16
+ allProfiles: boolean;
13
17
  wantsHelp: boolean;
14
18
  helpTopic: string;
15
19
  }
@@ -18,9 +22,10 @@ function parseArgs(argv: string[]): ParsedArgs {
18
22
  const args: ParsedArgs = {
19
23
  command: "",
20
24
  configPath: "",
21
- output: ".env.example",
25
+ output: "",
22
26
  wantsHelp: false,
23
27
  helpTopic: "",
28
+ allProfiles: false,
24
29
  };
25
30
 
26
31
  let i = 0;
@@ -49,6 +54,14 @@ function parseArgs(argv: string[]): ParsedArgs {
49
54
  } else if (arg === "--profile") {
50
55
  i++;
51
56
  args.profile = argv[i] ?? undefined;
57
+ } else if (arg === "--all-profiles") {
58
+ args.allProfiles = true;
59
+ } else if (arg === "--key") {
60
+ i++;
61
+ args.key = argv[i] ?? undefined;
62
+ } else if (arg === "-i" || arg === "--input") {
63
+ i++;
64
+ args.input = argv[i] ?? ".env";
52
65
  } else if (arg === "--help" || arg === "-h") {
53
66
  args.wantsHelp = true;
54
67
  } else if (args.command === "" && !arg.startsWith("-") && !args.wantsHelp) {
@@ -71,6 +84,13 @@ async function main() {
71
84
  }
72
85
 
73
86
  if (args.command === "") {
87
+ if (args.configPath || args.profile || args.key || args.input || args.allProfiles) {
88
+ console.error(
89
+ ` ${icon.fail} Missing command. Use ${color.cyan("beacon <command> [options]")}`
90
+ );
91
+ console.error(` Run ${color.cyan("beacon --help")} to see available commands`);
92
+ process.exit(1);
93
+ }
74
94
  printHelp("");
75
95
  return;
76
96
  }
@@ -82,6 +102,21 @@ async function main() {
82
102
  case "check":
83
103
  await handleCheck(args);
84
104
  break;
105
+ case "encrypt":
106
+ await handleEncrypt(args);
107
+ break;
108
+ case "decrypt":
109
+ await handleDecrypt(args);
110
+ break;
111
+ case "rotate":
112
+ handleRotate(args);
113
+ return;
114
+ case "drift":
115
+ await handleDrift(args);
116
+ break;
117
+ case "docker":
118
+ await handleDockerCheck(args);
119
+ break;
85
120
  default:
86
121
  console.error(` ${icon.fail} Unknown command: ${color.bold(args.command)}`);
87
122
  printHelp("");
@@ -94,25 +129,61 @@ async function handleInit(args: ParsedArgs) {
94
129
 
95
130
  try {
96
131
  config = await loadConfig(args.configPath || undefined);
97
- } catch {
132
+ } catch (err) {
133
+ if (args.configPath) {
134
+ console.error(` ${icon.fail} ${(err as Error).message}`);
135
+ process.exit(1);
136
+ }
98
137
  config = { schema: {} };
138
+ console.warn(` ${icon.info} No config file found. Creating ${color.bold(".beaconrc.json")}...`);
139
+ const template = {
140
+ schema: {
141
+ DATABASE_URL: { type: "url", required: true, description: "PostgreSQL connection string" },
142
+ PORT: { type: "port", default: 3000, description: "HTTP server port" },
143
+ },
144
+ profiles: {
145
+ production: {
146
+ DB_HOST: { type: "host", required: true, description: "Production DB hostname" },
147
+ },
148
+ staging: {
149
+ DB_HOST: { type: "host", required: true, description: "Staging DB hostname" },
150
+ },
151
+ },
152
+ };
153
+ await Bun.write(".beaconrc.json", JSON.stringify(template, null, 2) + "\n");
154
+ console.log(` ${icon.pass} Created ${color.bold(".beaconrc.json")}`);
155
+ console.log(` Edit it to define your schema and profiles, then run:`);
156
+ console.log(
157
+ ` ${color.cyan("bunx beacon init")} ${color.grey("# default profile")}`
158
+ );
159
+ console.log(
160
+ ` ${color.cyan("bunx beacon init --profile production")} ${color.grey("# production")}`
161
+ );
162
+ console.log(
163
+ ` ${color.cyan("bunx beacon init --all-profiles")} ${color.grey("# all profiles")}`
164
+ );
165
+ process.exit(0);
99
166
  }
100
167
 
101
- const profile = args.profile;
102
- const example = generateEnvExample(config, profile);
103
- await Bun.write(args.output, example);
104
- console.log(` ${icon.pass} Generated ${color.bold(args.output)}`);
105
- if (profile) {
106
- console.log(` Profile: ${color.cyan(profile)}`);
168
+ const profilesToGenerate = args.allProfiles
169
+ ? [undefined, ...Object.keys(config.profiles ?? {})]
170
+ : [args.profile];
171
+
172
+ for (const profile of profilesToGenerate) {
173
+ const output = profile
174
+ ? args.output || `.env.example.${profile}`
175
+ : args.output || ".env.example";
176
+ const example = generateEnvExample(config, profile);
177
+ await Bun.write(output, example);
178
+ console.log(` ${icon.pass} Generated ${color.bold(output)}`);
179
+ if (profile) {
180
+ console.log(` Profile: ${color.cyan(profile)}`);
181
+ }
107
182
  }
108
183
 
109
- const count = Object.keys(
110
- profile && config.profiles?.[profile]
111
- ? { ...config.schema, ...config.profiles[profile] }
112
- : config.schema
113
- ).length;
114
- if (count > 0) {
115
- console.log(` ${count} variable(s) documented`);
184
+ const schemaKeys = Object.keys(config.schema).length;
185
+ if (schemaKeys > 0) {
186
+ console.log(` ${schemaKeys} variable(s) documented`);
116
187
  }
117
188
  process.exit(0);
118
189
  }
@@ -159,6 +230,204 @@ async function handleCheck(args: ParsedArgs) {
159
230
  process.exit(0);
160
231
  }
161
232
 
233
+ async function handleEncrypt(args: ParsedArgs) {
234
+ const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
235
+ if (!encryptionKey) {
236
+ console.error(
237
+ ` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
238
+ );
239
+ process.exit(1);
240
+ }
241
+
242
+ const inputPath = args.input ?? ".env";
243
+ const file = Bun.file(inputPath);
244
+ const exists = await file.exists();
245
+ if (!exists) {
246
+ console.error(` ${icon.fail} Input file not found: ${inputPath}`);
247
+ process.exit(1);
248
+ }
249
+
250
+ const content = await file.text();
251
+ const encrypted = await encryptEnv(content, encryptionKey);
252
+ const outputPath = args.output;
253
+ await Bun.write(outputPath, encrypted);
254
+ console.log(` ${icon.pass} Encrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
255
+ process.exit(0);
256
+ }
257
+
258
+ async function handleDecrypt(args: ParsedArgs) {
259
+ const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
260
+ if (!encryptionKey) {
261
+ console.error(
262
+ ` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
263
+ );
264
+ process.exit(1);
265
+ }
266
+
267
+ const inputPath = args.input ?? ".env.encrypted";
268
+ const file = Bun.file(inputPath);
269
+ const exists = await file.exists();
270
+ if (!exists) {
271
+ console.error(` ${icon.fail} Encrypted file not found: ${inputPath}`);
272
+ process.exit(1);
273
+ }
274
+
275
+ const encryptedBase64 = (await file.text()).trim();
276
+ const decrypted = await decryptEnv(encryptedBase64, encryptionKey);
277
+ const outputPath = args.output;
278
+ await Bun.write(outputPath, decrypted);
279
+ console.log(` ${icon.pass} Decrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
280
+ process.exit(0);
281
+ }
282
+
283
+ function handleRotate(_args: ParsedArgs) {
284
+ console.log(`
285
+ ${color.bold("Secret Rotation Checklist")}
286
+
287
+ Follow these steps to rotate a secret safely:
288
+
289
+ ${color.bold("1.")} Generate a new secret value
290
+ Use: openssl rand -base64 32
291
+
292
+ ${color.bold("2.")} Deploy the new secret alongside the old one
293
+ Add the new value to your env/secret store (e.g. DATABASE_URL_NEW)
294
+ Your app should accept both old and new values during the transition
295
+
296
+ ${color.bold("3.")} Update all consumers to use the new secret
297
+ Deploy config changes pointing to the new secret
298
+
299
+ ${color.bold("4.")} Verify everything works
300
+ Run: ${color.cyan("bunx beacon check")}
301
+ Check logs, metrics, and error rates
302
+
303
+ ${color.bold("5.")} Revoke the old secret
304
+ Remove the old value from your env/secret store
305
+ Rotate any credentials in external services
306
+
307
+ ${color.bold("6.")} Audit
308
+ Confirm no services still reference the old secret
309
+ Update docs and secrets inventory
310
+
311
+ ${color.dim("Pro tip: Use beacon encrypt to store secrets safely in git.")}
312
+ ${color.dim(" Set BEACON_ENCRYPTION_KEY in your deployment env.")}
313
+ `);
314
+ }
315
+
316
+ async function handleDrift(args: ParsedArgs) {
317
+ let config: BeaconConfigFile;
318
+ try {
319
+ config = await loadConfig(args.configPath || undefined);
320
+ } catch (err) {
321
+ console.error(` ${icon.fail} ${(err as Error).message}`);
322
+ process.exit(1);
323
+ }
324
+
325
+ const profile = args.profile;
326
+ const mergedSchema = { ...config.schema };
327
+ if (profile && config.profiles?.[profile]) {
328
+ Object.assign(mergedSchema, config.profiles[profile]);
329
+ }
330
+
331
+ const driftResults: Array<{ key: string; expected: string; actual: string }> = [];
332
+
333
+ for (const [key, entry] of Object.entries(mergedSchema)) {
334
+ const raw = process.env[key];
335
+ const isField = "type" in entry;
336
+ const required = isField ? (entry as { required?: boolean }).required !== false : true;
337
+
338
+ if (raw === undefined || raw === "") {
339
+ if (required) {
340
+ driftResults.push({ key, expected: "set", actual: "not set" });
341
+ }
342
+ continue;
343
+ }
344
+
345
+ if (isField) {
346
+ const field = entry as { type?: string; values?: readonly string[] };
347
+ if (field.type === "enum" && field.values && !field.values.includes(raw)) {
348
+ driftResults.push({
349
+ key,
350
+ expected: `one of: ${field.values.join(" | ")}`,
351
+ actual: raw,
352
+ });
353
+ }
354
+ }
355
+ }
356
+
357
+ if (driftResults.length === 0) {
358
+ console.log(` ${icon.pass} ${color.bold("No config drift detected")}`);
359
+ process.exit(0);
360
+ }
361
+
362
+ console.log(` ${icon.fail} ${color.bold("Config drift detected:")}\n`);
363
+ for (const d of driftResults) {
364
+ console.log(` ${color.yellow("⚠")} ${color.bold(d.key)}`);
365
+ console.log(` Expected: ${color.cyan(d.expected)}`);
366
+ console.log(` Actual: ${color.red(d.actual)}`);
367
+ console.log();
368
+ }
369
+ process.exit(1);
370
+ }
371
+
372
+ async function handleDockerCheck(args: ParsedArgs) {
373
+ const isDocker = await Bun.file("/.dockerenv")
374
+ .exists()
375
+ .catch(() => false);
376
+ const isK8s = process.env.KUBERNETES_SERVICE_HOST !== undefined;
377
+
378
+ console.log(` ${icon.info} ${color.bold("Environment Detection")}\n`);
379
+
380
+ if (isDocker) {
381
+ console.log(` ${icon.pass} Running inside Docker container`);
382
+ } else {
383
+ console.log(` ${color.dim("○")} Not detected as Docker`);
384
+ }
385
+
386
+ if (isK8s) {
387
+ console.log(` ${icon.pass} Running inside Kubernetes pod`);
388
+ console.log(
389
+ ` KUBERNETES_SERVICE_HOST=${color.cyan(process.env.KUBERNETES_SERVICE_HOST ?? "")}`
390
+ );
391
+ } else {
392
+ console.log(` ${color.dim("○")} Not detected as Kubernetes`);
393
+ }
394
+
395
+ console.log();
396
+
397
+ const dockerVars = ["DOCKER_HOST", "CONTAINER_NAME", "COMPOSE_PROJECT_NAME"];
398
+ const k8sVars = ["KUBERNETES_SERVICE_HOST", "KUBERNETES_SERVICE_PORT"];
399
+
400
+ if (isDocker || isK8s) {
401
+ console.log(` ${icon.info} ${color.bold("Container Env Vars Found")}\n`);
402
+ const relevant = [...(isDocker ? dockerVars : []), ...(isK8s ? k8sVars : [])];
403
+ for (const v of relevant) {
404
+ if (process.env[v]) {
405
+ console.log(` ${color.green("✓")} ${v}=${color.dim(process.env[v] ?? "")}`);
406
+ }
407
+ }
408
+ console.log();
409
+ }
410
+
411
+ let config: BeaconConfigFile | undefined;
412
+ try {
413
+ config = await loadConfig(args.configPath || undefined);
414
+ } catch {
415
+ // no config file, just show env info
416
+ }
417
+
418
+ if (config) {
419
+ console.log(` ${icon.info} ${color.bold("Running beacon check against schema")}\n`);
420
+ const result = await runCheck(config, args.profile);
421
+ process.stdout.write(formatCheckResult(result.results));
422
+ const passed = result.results.filter((r) => r.status === "ok").length;
423
+ const failed = result.results.filter((r) => r.status !== "ok").length;
424
+ process.stdout.write(formatSummary(passed, failed));
425
+ if (failed > 0) process.exit(1);
426
+ }
427
+
428
+ process.exit(0);
429
+ }
430
+
162
431
  function printHelp(command: string) {
163
432
  if (command === "init") {
164
433
  console.log(`
@@ -172,19 +441,130 @@ ${color.bold("DESCRIPTION")}
172
441
 
173
442
  ${color.bold("OPTIONS")}
174
443
  -c, --config <path> Path to config file
175
- ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
444
+ ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
176
445
  -o, --output <path> Output file for init
177
- ${color.dim("(default: .env.example)")}
178
- --profile <name> Profile to merge (staging, production, etc.)
446
+ ${color.dim("(default: .env.example or .env.example.<profile>)")}
447
+ --profile <name> Generate for a specific profile
448
+ --all-profiles Generate .env.example for every profile
179
449
 
180
450
  ${color.bold("EXAMPLES")}
181
451
  beacon init ${color.grey("# generate .env.example")}
182
- beacon init --profile production ${color.grey("# merge production profile")}
452
+ beacon init --profile production ${color.grey("# generate .env.example.production")}
453
+ beacon init --all-profiles ${color.grey("# generate for all profiles")}
183
454
  beacon init -c ./config/beacon.json ${color.grey("# custom config path")}
184
455
  `);
185
456
  return;
186
457
  }
187
458
 
459
+ if (command === "rotate") {
460
+ console.log(`
461
+ ${color.bold("USAGE")}
462
+ beacon rotate
463
+
464
+ ${color.bold("DESCRIPTION")}
465
+ Print a step-by-step secret rotation checklist.
466
+ Follow it to rotate any secret (DB credentials, API keys, etc.).
467
+
468
+ ${color.bold("EXAMPLES")}
469
+ beacon rotate
470
+ `);
471
+ return;
472
+ }
473
+
474
+ if (command === "drift") {
475
+ console.log(`
476
+ ${color.bold("USAGE")}
477
+ beacon drift [options]
478
+
479
+ ${color.bold("DESCRIPTION")}
480
+ Detect config drift — compares your actual environment against
481
+ the schema defined in your beacon config file.
482
+ Reports missing variables, type mismatches, and unexpected values.
483
+
484
+ ${color.bold("OPTIONS")}
485
+ -c, --config <path> Path to config file
486
+ ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
487
+ --profile <name> Profile to merge (staging, production, etc.)
488
+
489
+ ${color.bold("EXAMPLES")}
490
+ beacon drift
491
+ beacon drift --profile production
492
+ `);
493
+ return;
494
+ }
495
+
496
+ if (command === "docker") {
497
+ console.log(`
498
+ ${color.bold("USAGE")}
499
+ beacon docker [options]
500
+
501
+ ${color.bold("DESCRIPTION")}
502
+ Validate the environment in Docker and Kubernetes contexts.
503
+ Detects the container runtime, checks common container env vars,
504
+ and runs a full beacon check against your schema.
505
+
506
+ ${color.bold("OPTIONS")}
507
+ -c, --config <path> Path to config file
508
+ ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
509
+ --profile <name> Profile to merge
510
+
511
+ ${color.bold("EXAMPLES")}
512
+ beacon docker
513
+ beacon docker --profile staging
514
+ `);
515
+ return;
516
+ }
517
+
518
+ if (command === "encrypt") {
519
+ console.log(`
520
+ ${color.bold("USAGE")}
521
+ beacon encrypt [options]
522
+
523
+ ${color.bold("DESCRIPTION")}
524
+ Encrypt a .env file so secrets can be committed safely.
525
+ Requires a 256-bit encryption key (passed via --key or BEACON_ENCRYPTION_KEY).
526
+
527
+ ${color.bold("OPTIONS")}
528
+ -i, --input <path> Input .env file
529
+ ${color.dim("(default: .env)")}
530
+ -o, --output <path> Output encrypted file
531
+ ${color.dim("(default: .env.encrypted)")}
532
+ --key <key> Encryption key
533
+ ${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
534
+
535
+ ${color.bold("EXAMPLES")}
536
+ beacon encrypt ${color.grey("# encrypt .env → .env.encrypted")}
537
+ beacon encrypt -i .env.prod -o .env.prod.encrypted ${color.grey("# custom paths")}
538
+ BEACON_ENCRYPTION_KEY=... beacon encrypt ${color.grey("# key from env")}
539
+ `);
540
+ return;
541
+ }
542
+
543
+ if (command === "decrypt") {
544
+ console.log(`
545
+ ${color.bold("USAGE")}
546
+ beacon decrypt [options]
547
+
548
+ ${color.bold("DESCRIPTION")}
549
+ Decrypt a .env.encrypted file back to plaintext.
550
+ Requires the same key used during encryption.
551
+
552
+ ${color.bold("OPTIONS")}
553
+ -i, --input <path> Input encrypted file
554
+ ${color.dim("(default: .env.encrypted)")}
555
+ -o, --output <path> Output .env file
556
+ ${color.dim("(default: .env)")}
557
+ --key <key> Encryption key
558
+ ${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
559
+
560
+ ${color.bold("EXAMPLES")}
561
+ beacon decrypt ${color.grey("# decrypt .env.encrypted → .env")}
562
+ beacon decrypt -i .env.prod.encrypted -o .env.prod ${color.grey("# custom paths")}
563
+ BEACON_ENCRYPTION_KEY=... beacon decrypt ${color.grey("# key from env")}
564
+ `);
565
+ return;
566
+ }
567
+
188
568
  if (command === "check") {
189
569
  console.log(`
190
570
  ${color.bold("USAGE")}
@@ -218,9 +598,14 @@ ${color.bold("USAGE")}
218
598
  beacon <command> [options]
219
599
 
220
600
  ${color.bold("COMMANDS")}
221
- init ${color.dim("Generate .env.example from your config")}
222
- check ${color.dim("Validate current environment against your schema")}
223
- help ${color.dim("Show help for a specific command")}
601
+ init ${color.dim("Generate .env.example from your config")}
602
+ check ${color.dim("Validate current environment against your schema")}
603
+ encrypt ${color.dim("Encrypt .env file for safe committing")}
604
+ decrypt ${color.dim("Decrypt .env.encrypted back to plaintext")}
605
+ rotate ${color.dim("Print secret rotation checklist")}
606
+ drift ${color.dim("Detect config drift between schema and env")}
607
+ docker ${color.dim("Validate env in Docker/Kubernetes contexts")}
608
+ help ${color.dim("Show help for a specific command")}
224
609
 
225
610
  ${color.bold("OPTIONS")}
226
611
  -c, --config <path> Path to config file (default: .beaconrc.json or beacon.config.json)
@@ -0,0 +1,24 @@
1
+ import { test, expect } from "bun:test";
2
+ import { encryptEnv, decryptEnv } from "./encryption";
3
+
4
+ test("encrypts and decrypts env content", async () => {
5
+ const key = "test-encryption-key-32bytes!";
6
+ const envContent = `DATABASE_URL=postgres://localhost/mydb
7
+ API_KEY=supersecret123
8
+ PORT=3000`;
9
+
10
+ const encrypted = await encryptEnv(envContent, key);
11
+ expect(typeof encrypted).toBe("string");
12
+ expect(encrypted.length).toBeGreaterThan(0);
13
+
14
+ const decrypted = await decryptEnv(encrypted, key);
15
+ expect(decrypted).toBe(envContent);
16
+ });
17
+
18
+ test("rejects wrong key", async () => {
19
+ const key = "correct-key-32bytes!!!!!";
20
+ const envContent = "SECRET=value";
21
+ const encrypted = await encryptEnv(envContent, key);
22
+ const result = await decryptEnv(encrypted, "wrong-key-32bytes!!!!!!").catch(() => "error");
23
+ expect(result).toBe("error");
24
+ });
@@ -0,0 +1,59 @@
1
+ const ALGORITHM = "AES-GCM";
2
+ const IV_LENGTH = 12;
3
+ const TAG_LENGTH = 128;
4
+
5
+ async function deriveKey(password: string): Promise<CryptoKey> {
6
+ const keyMaterial = await crypto.subtle.importKey(
7
+ "raw",
8
+ new TextEncoder().encode(password),
9
+ "HKDF",
10
+ false,
11
+ ["deriveKey"]
12
+ );
13
+ return crypto.subtle.deriveKey(
14
+ {
15
+ name: "HKDF",
16
+ hash: "SHA-256",
17
+ salt: new Uint8Array(32),
18
+ info: new TextEncoder().encode("beacon-env-encryption-v1"),
19
+ },
20
+ keyMaterial,
21
+ { name: ALGORITHM, length: 256 },
22
+ false,
23
+ ["encrypt", "decrypt"]
24
+ );
25
+ }
26
+
27
+ export async function encryptEnv(envContent: string, key: string): Promise<string> {
28
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
29
+ const cryptoKey = await deriveKey(key);
30
+ const data = new TextEncoder().encode(envContent);
31
+
32
+ const encrypted = await crypto.subtle.encrypt(
33
+ { name: ALGORITHM, iv, tagLength: TAG_LENGTH },
34
+ cryptoKey,
35
+ data
36
+ );
37
+
38
+ const combined = new Uint8Array(IV_LENGTH + encrypted.byteLength);
39
+ combined.set(iv, 0);
40
+ combined.set(new Uint8Array(encrypted), IV_LENGTH);
41
+
42
+ return Buffer.from(combined).toString("base64");
43
+ }
44
+
45
+ export async function decryptEnv(encryptedBase64: string, key: string): Promise<string> {
46
+ const combined = Buffer.from(encryptedBase64, "base64");
47
+ const iv = combined.slice(0, IV_LENGTH);
48
+ const data = combined.slice(IV_LENGTH);
49
+
50
+ const cryptoKey = await deriveKey(key);
51
+
52
+ const decrypted = await crypto.subtle.decrypt(
53
+ { name: ALGORITHM, iv, tagLength: TAG_LENGTH },
54
+ cryptoKey,
55
+ data
56
+ );
57
+
58
+ return new TextDecoder().decode(decrypted);
59
+ }