@arki/env 0.0.9 → 0.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/CHANGELOG.md +196 -0
- package/README.md +231 -62
- package/dist/api.json +597 -0
- package/dist/core/define-env.d.ts +29 -4
- package/dist/core/define-env.d.ts.map +1 -1
- package/dist/core/define-env.js +85 -15
- package/dist/core/define-env.js.map +1 -1
- package/dist/core/describe.d.ts +29 -0
- package/dist/core/describe.d.ts.map +1 -0
- package/dist/core/describe.js +25 -0
- package/dist/core/describe.js.map +1 -0
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +7 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +6 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/secret.d.ts +56 -0
- package/dist/core/secret.d.ts.map +1 -0
- package/dist/core/secret.js +85 -0
- package/dist/core/secret.js.map +1 -0
- package/dist/errors.json +128 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/node/codec.d.ts +63 -0
- package/dist/node/codec.d.ts.map +1 -0
- package/dist/node/codec.js +182 -0
- package/dist/node/codec.js.map +1 -0
- package/dist/node/edit.d.ts +112 -0
- package/dist/node/edit.d.ts.map +1 -0
- package/dist/node/edit.js +268 -0
- package/dist/node/edit.js.map +1 -0
- package/dist/node/errors.d.ts +119 -0
- package/dist/node/errors.d.ts.map +1 -0
- package/dist/node/errors.js +144 -0
- package/dist/node/errors.js.map +1 -0
- package/dist/node/files.d.ts +9 -0
- package/dist/node/files.d.ts.map +1 -0
- package/dist/node/files.js +35 -0
- package/dist/node/files.js.map +1 -0
- package/dist/node/index.d.ts +20 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +13 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/key-schemas.d.ts +25 -0
- package/dist/node/key-schemas.d.ts.map +1 -0
- package/dist/node/key-schemas.js +38 -0
- package/dist/node/key-schemas.js.map +1 -0
- package/dist/node/keys.d.ts +54 -0
- package/dist/node/keys.d.ts.map +1 -0
- package/dist/node/keys.js +76 -0
- package/dist/node/keys.js.map +1 -0
- package/dist/node/load.d.ts +103 -0
- package/dist/node/load.d.ts.map +1 -0
- package/dist/node/load.js +306 -0
- package/dist/node/load.js.map +1 -0
- package/dist/node/operation-telemetry.d.ts +3 -0
- package/dist/node/operation-telemetry.d.ts.map +1 -0
- package/dist/node/operation-telemetry.js +53 -0
- package/dist/node/operation-telemetry.js.map +1 -0
- package/dist/node/references.d.ts +16 -0
- package/dist/node/references.d.ts.map +1 -0
- package/dist/node/references.js +72 -0
- package/dist/node/references.js.map +1 -0
- package/dist/node/register.d.ts +2 -0
- package/dist/node/register.d.ts.map +1 -0
- package/dist/node/register.js +19 -0
- package/dist/node/register.js.map +1 -0
- package/dist/{contracts → presets}/common.d.ts +1 -1
- package/dist/presets/common.d.ts.map +1 -0
- package/dist/{contracts → presets}/common.js +4 -8
- package/dist/presets/common.js.map +1 -0
- package/dist/{contracts → presets}/index.d.ts +5 -5
- package/dist/presets/index.d.ts.map +1 -0
- package/dist/presets/index.js +15 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/{contracts → presets}/parsing.d.ts +2 -2
- package/dist/presets/parsing.d.ts.map +1 -0
- package/dist/{contracts → presets}/parsing.js +13 -5
- package/dist/presets/parsing.js.map +1 -0
- package/dist/{contracts → presets}/urls.d.ts +1 -1
- package/dist/presets/urls.d.ts.map +1 -0
- package/dist/{contracts → presets}/urls.js +1 -1
- package/dist/presets/urls.js.map +1 -0
- package/docs/agent-guide.md +121 -0
- package/docs/upgrades/0.0.11-to-0.1.0.md +62 -0
- package/docs/upgrades/0.1.0-to-0.2.0.md +73 -0
- package/docs/upgrades/typed-node-outcomes.md +13 -0
- package/llms.txt +30 -0
- package/package.json +27 -20
- package/src/core/define-env.ts +101 -20
- package/src/core/describe.ts +52 -0
- package/src/core/errors.ts +14 -0
- package/src/core/index.ts +7 -2
- package/src/core/secret.ts +127 -0
- package/src/index.ts +3 -2
- package/src/node/codec.ts +220 -0
- package/src/node/edit.ts +279 -0
- package/src/node/errors.ts +147 -0
- package/src/node/files.ts +41 -0
- package/src/node/index.ts +48 -0
- package/src/node/key-schemas.ts +44 -0
- package/src/node/keys.ts +93 -0
- package/src/node/load.ts +386 -0
- package/src/node/operation-telemetry.ts +56 -0
- package/src/node/references.ts +90 -0
- package/src/node/register.ts +18 -0
- package/src/{contracts → presets}/common.ts +4 -8
- package/src/presets/index.ts +18 -0
- package/src/{contracts → presets}/parsing.ts +12 -5
- package/src/{contracts → presets}/urls.ts +1 -1
- package/dist/contracts/common.d.ts.map +0 -1
- package/dist/contracts/common.js.map +0 -1
- package/dist/contracts/index.d.ts.map +0 -1
- package/dist/contracts/index.js +0 -14
- package/dist/contracts/index.js.map +0 -1
- package/dist/contracts/parsing.d.ts.map +0 -1
- package/dist/contracts/parsing.js.map +0 -1
- package/dist/contracts/urls.d.ts.map +0 -1
- package/dist/contracts/urls.js.map +0 -1
- package/dist/dot.d.ts +0 -67
- package/dist/dot.d.ts.map +0 -1
- package/dist/dot.js +0 -65
- package/dist/dot.js.map +0 -1
- package/src/contracts/index.ts +0 -17
- package/src/dot.ts +0 -94
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dotenvx 2.x file format: parse and edit `.env` text without losing a byte of the lines you
|
|
3
|
+
* did not touch, and encrypt or decrypt one value.
|
|
4
|
+
*
|
|
5
|
+
* A value is encrypted as `encrypted:` + base64 of ECIES over secp256k1 (HKDF-SHA256, AES-256-GCM,
|
|
6
|
+
* an ephemeral key per value), exactly what `eciesjs` produces with its defaults. The same bytes are
|
|
7
|
+
* what dotenvx writes, so files move freely between `dot env` and the `dotenvx` CLI.
|
|
8
|
+
*/
|
|
9
|
+
import { Buffer } from 'node:buffer';
|
|
10
|
+
import { decrypt, encrypt, PrivateKey } from 'eciesjs';
|
|
11
|
+
import { Config } from 'eciesjs/config';
|
|
12
|
+
|
|
13
|
+
import { attempt, isFailure } from '@arki/failure';
|
|
14
|
+
import { ensure } from '@arki/schema';
|
|
15
|
+
|
|
16
|
+
import type { EnvPrivateKey, EnvPublicKey } from './key-schemas.js';
|
|
17
|
+
import { ENCRYPTED_PREFIX } from '../core/define-env.js';
|
|
18
|
+
import { DecryptionFailed, EncryptionFailed, KeyGenerationFailed, UnrepresentableValue } from './errors.js';
|
|
19
|
+
import { envPrivateKeySchema, envPublicKeySchema } from './key-schemas.js';
|
|
20
|
+
|
|
21
|
+
export { ENCRYPTED_PREFIX } from '../core/define-env.js';
|
|
22
|
+
|
|
23
|
+
/** Pinned so a process that changes the global `ECIES_CONFIG` cannot change our bytes. */
|
|
24
|
+
const DOTENVX_ECIES = new Config();
|
|
25
|
+
|
|
26
|
+
const KEY_PATTERN = /^[ \t]*(?:export[ \t]+)?([\w.-]+)[ \t]*=(.*)$/;
|
|
27
|
+
const QUOTES = ['"', "'", '`'] as const;
|
|
28
|
+
|
|
29
|
+
/** One `KEY=value` assignment: its decoded value and the lines it spans (`end` exclusive). */
|
|
30
|
+
export type EnvEntry = {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly value: string;
|
|
33
|
+
readonly start: number;
|
|
34
|
+
readonly end: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A parsed `.env` file: its lines as written, and every assignment in order. */
|
|
38
|
+
export type EnvDocument = {
|
|
39
|
+
readonly lines: readonly string[];
|
|
40
|
+
readonly entries: readonly EnvEntry[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A keypair as dotenvx writes it: a compressed public key (66 hex chars), a private key (64). */
|
|
44
|
+
export type EnvKeypair = { readonly publicKey: EnvPublicKey; readonly privateKey: EnvPrivateKey };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse `.env` text the way `dotenv` reads it: `export` prefixes, `#` comments, single, double and
|
|
48
|
+
* backtick quotes, multi-line quoted values, and `\n` / `\r` expanded inside double quotes. An
|
|
49
|
+
* unquoted value ends at the first `#`. A key that appears twice keeps both entries; readers take the
|
|
50
|
+
* last one, as dotenv does.
|
|
51
|
+
*/
|
|
52
|
+
export function parseEnv(text: string): EnvDocument {
|
|
53
|
+
const lines = text.replaceAll('\r\n', '\n').split('\n');
|
|
54
|
+
const entries: EnvEntry[] = [];
|
|
55
|
+
for (let index = 0; index < lines.length; index++) {
|
|
56
|
+
const match = KEY_PATTERN.exec(lines[index] ?? '');
|
|
57
|
+
if (match === null) continue;
|
|
58
|
+
const key = match[1] ?? '';
|
|
59
|
+
let raw = (match[2] ?? '').trimStart();
|
|
60
|
+
const start = index;
|
|
61
|
+
const quote = QUOTES.find(candidate => raw.startsWith(candidate));
|
|
62
|
+
if (quote !== undefined) {
|
|
63
|
+
while (closingQuote(raw, quote) === -1 && index + 1 < lines.length) {
|
|
64
|
+
index++;
|
|
65
|
+
raw += `\n${lines[index] ?? ''}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
entries.push({ key, value: decodeValue(raw, quote), start, end: index + 1 });
|
|
69
|
+
}
|
|
70
|
+
return { lines, entries };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The last value of `key`, or `undefined`. */
|
|
74
|
+
export function readEnvValue(document: EnvDocument, key: string): string | undefined {
|
|
75
|
+
return document.entries.findLast(entry => entry.key === key)?.value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every key with its last value, in first-seen order. */
|
|
79
|
+
export function envValues(document: EnvDocument): Map<string, EnvEntry> {
|
|
80
|
+
const values = new Map<string, EnvEntry>();
|
|
81
|
+
for (const entry of document.entries) values.set(entry.key, entry);
|
|
82
|
+
return values;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The text with `key` set to `value`. An existing assignment (the last one) is rewritten in place;
|
|
87
|
+
* a new key is appended. Every other line is kept as written.
|
|
88
|
+
*/
|
|
89
|
+
export function setEnvValue(text: string, key: string, value: string) {
|
|
90
|
+
const document = parseEnv(text);
|
|
91
|
+
const encoded = encodeValue(value);
|
|
92
|
+
if (isFailure(encoded)) return encoded;
|
|
93
|
+
const line = `${key}=${encoded}`;
|
|
94
|
+
const existing = document.entries.findLast(entry => entry.key === key);
|
|
95
|
+
if (existing === undefined) {
|
|
96
|
+
const body = document.lines.join('\n');
|
|
97
|
+
const separator = body === '' || body.endsWith('\n') ? '' : '\n';
|
|
98
|
+
return `${body}${separator}${line}\n`;
|
|
99
|
+
}
|
|
100
|
+
return [...document.lines.slice(0, existing.start), line, ...document.lines.slice(existing.end)].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The text without any assignment of `key`. */
|
|
104
|
+
export function removeEnvValue(text: string, key: string): string {
|
|
105
|
+
const document = parseEnv(text);
|
|
106
|
+
const dropped = new Set<number>();
|
|
107
|
+
for (const entry of document.entries) {
|
|
108
|
+
if (entry.key !== key) continue;
|
|
109
|
+
for (let line = entry.start; line < entry.end; line++) dropped.add(line);
|
|
110
|
+
}
|
|
111
|
+
return document.lines.filter((_, index) => !dropped.has(index)).join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True for a value written by {@link encryptValue} (or by dotenvx). */
|
|
115
|
+
export function isEncrypted(value: string): boolean {
|
|
116
|
+
return value.startsWith(ENCRYPTED_PREFIX);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Encrypt `value` to a compressed or uncompressed secp256k1 public key (hex). */
|
|
120
|
+
export function encryptValue(value: string, publicKey: EnvPublicKey) {
|
|
121
|
+
return attempt(
|
|
122
|
+
() => {
|
|
123
|
+
const sealed = encrypt(publicKey, new TextEncoder().encode(value), DOTENVX_ECIES);
|
|
124
|
+
return `${ENCRYPTED_PREFIX}${Buffer.from(sealed).toString('base64')}`;
|
|
125
|
+
},
|
|
126
|
+
cause => EncryptionFailed({ cause }),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Decrypt authenticated UTF-8, or return DecryptionFailed when the ciphertext/key does not match. */
|
|
131
|
+
export function decryptValue(value: string, privateKey: EnvPrivateKey) {
|
|
132
|
+
if (!isEncrypted(value)) return DecryptionFailed();
|
|
133
|
+
return attempt(
|
|
134
|
+
() => {
|
|
135
|
+
const sealed = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64');
|
|
136
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(decrypt(privateKey, sealed, DOTENVX_ECIES));
|
|
137
|
+
},
|
|
138
|
+
cause => DecryptionFailed({ cause }),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A new random keypair. */
|
|
143
|
+
export function generateKeypair() {
|
|
144
|
+
return attempt(
|
|
145
|
+
() => {
|
|
146
|
+
const secret = new PrivateKey(undefined, DOTENVX_ECIES.ellipticCurve);
|
|
147
|
+
return {
|
|
148
|
+
publicKey: ensure(secret.publicKey.toHex(true), envPublicKeySchema),
|
|
149
|
+
privateKey: ensure(secret.toHex(), envPrivateKeySchema),
|
|
150
|
+
} satisfies EnvKeypair;
|
|
151
|
+
},
|
|
152
|
+
cause => KeyGenerationFailed({ cause }),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The compressed public key of an already validated private key. */
|
|
157
|
+
export function publicKeyOf(privateKey: EnvPrivateKey): EnvPublicKey {
|
|
158
|
+
return ensure(PrivateKey.fromHex(privateKey, DOTENVX_ECIES.ellipticCurve).publicKey.toHex(true), envPublicKeySchema);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The key names dotenvx uses for a file: `.env` → `DOTENV_PUBLIC_KEY` / `DOTENV_PRIVATE_KEY`,
|
|
163
|
+
* `.env.production` → `..._PRODUCTION`, `.env.ci.local` → `..._CI_LOCAL`.
|
|
164
|
+
*/
|
|
165
|
+
export function keyNamesFor(fileName: string): { readonly publicKey: string; readonly privateKey: string } {
|
|
166
|
+
const base = fileName.toLowerCase().replace(/\.txt$/, '');
|
|
167
|
+
const suffix = base.startsWith('.env.')
|
|
168
|
+
? `_${base
|
|
169
|
+
.slice('.env.'.length)
|
|
170
|
+
.replaceAll(/[^a-z0-9]+/g, '_')
|
|
171
|
+
.toUpperCase()}`
|
|
172
|
+
: '';
|
|
173
|
+
return { publicKey: `DOTENV_PUBLIC_KEY${suffix}`, privateKey: `DOTENV_PRIVATE_KEY${suffix}` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True for the `DOTENV_PUBLIC_KEY*` / `DOTENV_PRIVATE_KEY*` names, which are never app values. */
|
|
177
|
+
export function isKeyName(key: string): boolean {
|
|
178
|
+
return key.startsWith('DOTENV_PUBLIC_KEY') || key.startsWith('DOTENV_PRIVATE_KEY');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The dotenvx header for a file: banner, public key, and a `# <file>` line. */
|
|
182
|
+
export function envHeader(fileName: string, publicKey: EnvPublicKey): string {
|
|
183
|
+
return [
|
|
184
|
+
'#/-------------------[DOTENV_PUBLIC_KEY]--------------------/',
|
|
185
|
+
'#/ public-key encryption for .env files /',
|
|
186
|
+
'#/ [how it works](https://dotenvx.com/encryption) /',
|
|
187
|
+
'#/----------------------------------------------------------/',
|
|
188
|
+
`${keyNamesFor(fileName).publicKey}="${publicKey}"`,
|
|
189
|
+
'',
|
|
190
|
+
`# ${fileName}`,
|
|
191
|
+
].join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function closingQuote(raw: string, quote: string): number {
|
|
195
|
+
for (let index = 1; index < raw.length; index++) {
|
|
196
|
+
if (raw[index] === quote && raw[index - 1] !== '\\') return index;
|
|
197
|
+
}
|
|
198
|
+
return -1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function decodeValue(raw: string, quote: string | undefined): string {
|
|
202
|
+
if (quote === undefined) return raw.split('#')[0]?.trim() ?? '';
|
|
203
|
+
const close = closingQuote(raw, quote);
|
|
204
|
+
const inner = close === -1 ? raw.slice(1) : raw.slice(1, close);
|
|
205
|
+
return quote === '"' ? inner.replaceAll('\\n', '\n').replaceAll('\\r', '\r') : inner;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The first quoting that reads back as exactly `value`, in one entry: double quotes (newlines as
|
|
210
|
+
* `\n`), then single, then backticks. A value no quoting can carry (it holds every quote character
|
|
211
|
+
* and a line break, say) is refused rather than written in a form that could inject other keys.
|
|
212
|
+
*/
|
|
213
|
+
function encodeValue(value: string) {
|
|
214
|
+
const candidates = [`"${value.replaceAll('\r', '\\r').replaceAll('\n', '\\n')}"`, `'${value}'`, `\`${value}\``];
|
|
215
|
+
for (const candidate of candidates) {
|
|
216
|
+
const document = parseEnv(`K=${candidate}`);
|
|
217
|
+
if (document.entries.length === 1 && document.entries[0]?.value === value) return candidate;
|
|
218
|
+
}
|
|
219
|
+
return UnrepresentableValue();
|
|
220
|
+
}
|
package/src/node/edit.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File edits behind `dot env init | set | encrypt | decrypt | rotate`. Each write goes to a temp file
|
|
3
|
+
* first and is renamed into place, so an interrupted command never leaves half a file.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { InvalidInput, isFailure } from '@arki/failure';
|
|
9
|
+
|
|
10
|
+
import type { EnvPrivateKey, EnvPublicKey } from './key-schemas.js';
|
|
11
|
+
import {
|
|
12
|
+
decryptValue,
|
|
13
|
+
encryptValue,
|
|
14
|
+
envHeader,
|
|
15
|
+
envValues,
|
|
16
|
+
generateKeypair,
|
|
17
|
+
isEncrypted,
|
|
18
|
+
isKeyName,
|
|
19
|
+
keyNamesFor,
|
|
20
|
+
parseEnv,
|
|
21
|
+
publicKeyOf,
|
|
22
|
+
readEnvValue,
|
|
23
|
+
setEnvValue,
|
|
24
|
+
} from './codec.js';
|
|
25
|
+
import {
|
|
26
|
+
DecryptionFailed,
|
|
27
|
+
FileWriteFailed,
|
|
28
|
+
MissingPrivateKey,
|
|
29
|
+
MissingPublicKey,
|
|
30
|
+
RotationIncomplete,
|
|
31
|
+
} from './errors.js';
|
|
32
|
+
import { readEnvText, writeEnvText } from './files.js';
|
|
33
|
+
import { parsePublicKey } from './key-schemas.js';
|
|
34
|
+
import { findPrivateKey, KEYS_FILE, keysFileCandidates, writePrivateKey } from './keys.js';
|
|
35
|
+
import { nodeOperation } from './operation-telemetry.js';
|
|
36
|
+
|
|
37
|
+
/** The public key in a file's header, or `undefined` when it has none. */
|
|
38
|
+
export function publicKeyIn(file: string) {
|
|
39
|
+
if (!existsSync(file)) return;
|
|
40
|
+
const text = readEnvText(file);
|
|
41
|
+
if (isFailure(text)) return text;
|
|
42
|
+
const key = readEnvValue(parseEnv(text), keyNamesFor(path.basename(file)).publicKey);
|
|
43
|
+
if (key === undefined) return;
|
|
44
|
+
return parsePublicKey(key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What `initEnvFile` did. */
|
|
48
|
+
export type InitResult = {
|
|
49
|
+
readonly file: string;
|
|
50
|
+
readonly publicKey: EnvPublicKey;
|
|
51
|
+
/** `false` when the file already had a header. */
|
|
52
|
+
readonly headerAdded: boolean;
|
|
53
|
+
/** Where a new private key was written; `undefined` when an existing key was reused. */
|
|
54
|
+
readonly keysWritten?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Give `file` a dotenvx header, creating the file if needed. Reuses the private key of the same
|
|
59
|
+
* environment when one is already reachable (so every `.env.development` in a repo shares one key);
|
|
60
|
+
* otherwise generates a keypair and stores the private key in `<root>/.env.keys`.
|
|
61
|
+
*/
|
|
62
|
+
export function initEnvFile(
|
|
63
|
+
file: string,
|
|
64
|
+
options: { readonly root: string; readonly env?: Readonly<Record<string, string | undefined>> },
|
|
65
|
+
) {
|
|
66
|
+
return nodeOperation('init', () => {
|
|
67
|
+
const existing = publicKeyIn(file);
|
|
68
|
+
if (isFailure(existing)) return existing;
|
|
69
|
+
if (existing !== undefined) return { file, publicKey: existing, headerAdded: false } satisfies InitResult;
|
|
70
|
+
const names = keyNamesFor(path.basename(file));
|
|
71
|
+
const found = findPrivateKey(file, { root: options.root, env: options.env ?? process.env });
|
|
72
|
+
if (isFailure(found)) return found;
|
|
73
|
+
const reused = found?.keys[0];
|
|
74
|
+
let publicKey: EnvPublicKey;
|
|
75
|
+
let keysWritten: string | undefined;
|
|
76
|
+
if (reused === undefined) {
|
|
77
|
+
const pair = generateKeypair();
|
|
78
|
+
if (isFailure(pair)) return pair;
|
|
79
|
+
keysWritten = path.join(options.root, KEYS_FILE);
|
|
80
|
+
const written = writePrivateKey(keysWritten, names.privateKey, pair.privateKey, path.basename(file));
|
|
81
|
+
if (isFailure(written)) return written;
|
|
82
|
+
publicKey = pair.publicKey;
|
|
83
|
+
} else {
|
|
84
|
+
publicKey = publicKeyOf(reused);
|
|
85
|
+
}
|
|
86
|
+
const body = existsSync(file) ? readEnvText(file) : '';
|
|
87
|
+
if (isFailure(body)) return body;
|
|
88
|
+
const written = writeEnvText(file, `${envHeader(path.basename(file), publicKey)}\n${body}`);
|
|
89
|
+
if (isFailure(written)) return written;
|
|
90
|
+
return keysWritten === undefined
|
|
91
|
+
? { file, publicKey, headerAdded: true }
|
|
92
|
+
: { file, publicKey, headerAdded: true, keysWritten };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Set `key` in `file`, encrypted to the file's public key unless `plain`. Returns `false` when the
|
|
98
|
+
* file already held exactly this plaintext value (an encrypted value is always rewritten, because
|
|
99
|
+
* the same plaintext never encrypts to the same bytes).
|
|
100
|
+
*/
|
|
101
|
+
export function setEnvFileValue(file: string, key: string, value: string, options: { readonly plain: boolean }) {
|
|
102
|
+
return nodeOperation('set', () => {
|
|
103
|
+
const text = existsSync(file) ? readEnvText(file) : '';
|
|
104
|
+
if (isFailure(text)) return text;
|
|
105
|
+
let stored = value;
|
|
106
|
+
if (!options.plain) {
|
|
107
|
+
const publicKey = readEnvValue(parseEnv(text), keyNamesFor(path.basename(file)).publicKey);
|
|
108
|
+
if (publicKey === undefined) return MissingPublicKey({ file });
|
|
109
|
+
const parsedKey = parsePublicKey(publicKey);
|
|
110
|
+
if (isFailure(parsedKey)) return parsedKey;
|
|
111
|
+
const encrypted = encryptValue(value, parsedKey);
|
|
112
|
+
if (isFailure(encrypted)) return encrypted;
|
|
113
|
+
stored = encrypted;
|
|
114
|
+
}
|
|
115
|
+
const next = setEnvValue(text, key, stored);
|
|
116
|
+
if (isFailure(next)) return next;
|
|
117
|
+
if (next === text) return false;
|
|
118
|
+
const written = writeEnvText(file, next);
|
|
119
|
+
return isFailure(written) ? written : true;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Encrypt plaintext values in place: the listed keys, or every value. Returns the keys encrypted. */
|
|
124
|
+
export function encryptEnvFile(file: string, keys?: readonly string[]) {
|
|
125
|
+
return nodeOperation('encrypt_file', () => {
|
|
126
|
+
const text = readEnvText(file);
|
|
127
|
+
if (isFailure(text)) return text;
|
|
128
|
+
const publicKey = readEnvValue(parseEnv(text), keyNamesFor(path.basename(file)).publicKey);
|
|
129
|
+
if (publicKey === undefined) return MissingPublicKey({ file });
|
|
130
|
+
const parsedKey = parsePublicKey(publicKey);
|
|
131
|
+
if (isFailure(parsedKey)) return parsedKey;
|
|
132
|
+
let next = text;
|
|
133
|
+
const done: string[] = [];
|
|
134
|
+
for (const [key, entry] of envValues(parseEnv(text))) {
|
|
135
|
+
if (isKeyName(key) || isEncrypted(entry.value) || (keys !== undefined && !keys.includes(key))) continue;
|
|
136
|
+
const encrypted = encryptValue(entry.value, parsedKey);
|
|
137
|
+
if (isFailure(encrypted)) return encrypted;
|
|
138
|
+
const updated = setEnvValue(next, key, encrypted);
|
|
139
|
+
if (isFailure(updated)) return updated;
|
|
140
|
+
next = updated;
|
|
141
|
+
done.push(key);
|
|
142
|
+
}
|
|
143
|
+
if (done.length > 0) {
|
|
144
|
+
const written = writeEnvText(file, next);
|
|
145
|
+
if (isFailure(written)) return written;
|
|
146
|
+
}
|
|
147
|
+
return done;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Decrypt values in place with `privateKeys`: the listed keys, or every value. Returns the keys decrypted. */
|
|
152
|
+
export function decryptEnvFile(file: string, privateKeys: readonly EnvPrivateKey[], keys?: readonly string[]) {
|
|
153
|
+
return nodeOperation('decrypt_file', () => {
|
|
154
|
+
const text = readEnvText(file);
|
|
155
|
+
if (isFailure(text)) return text;
|
|
156
|
+
let next = text;
|
|
157
|
+
const done: string[] = [];
|
|
158
|
+
for (const [key, entry] of envValues(parseEnv(text))) {
|
|
159
|
+
if (!isEncrypted(entry.value) || (keys !== undefined && !keys.includes(key))) continue;
|
|
160
|
+
const decrypted = decryptWithAny(entry.value, privateKeys);
|
|
161
|
+
if (isFailure(decrypted)) return decrypted;
|
|
162
|
+
const updated = setEnvValue(next, key, decrypted);
|
|
163
|
+
if (isFailure(updated)) return updated;
|
|
164
|
+
next = updated;
|
|
165
|
+
done.push(key);
|
|
166
|
+
}
|
|
167
|
+
if (done.length > 0) {
|
|
168
|
+
const written = writeEnvText(file, next);
|
|
169
|
+
if (isFailure(written)) return written;
|
|
170
|
+
}
|
|
171
|
+
return done;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What `rotateEnvFiles` did. */
|
|
176
|
+
export type RotateResult = {
|
|
177
|
+
readonly privateKeyName: string;
|
|
178
|
+
readonly files: readonly { readonly file: string; readonly values: number }[];
|
|
179
|
+
/** Where the new private key was written. */
|
|
180
|
+
readonly keysFile: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Re-encrypt every file of one environment to a new keypair, in one step: decrypt everything first
|
|
185
|
+
* (so a wrong key changes nothing), then write each file, then the new private key. All `files` must
|
|
186
|
+
* share a file name (`.env.production`), because they share a key name.
|
|
187
|
+
*/
|
|
188
|
+
export function rotateEnvFiles(
|
|
189
|
+
files: readonly string[],
|
|
190
|
+
options: { readonly root: string; readonly env?: Readonly<Record<string, string | undefined>> },
|
|
191
|
+
) {
|
|
192
|
+
const first = files[0];
|
|
193
|
+
if (first === undefined) return InvalidInput({ issues: [{ message: 'No files to rotate.' }] });
|
|
194
|
+
const names = keyNamesFor(path.basename(first));
|
|
195
|
+
if (files.some(file => keyNamesFor(path.basename(file)).privateKey !== names.privateKey)) {
|
|
196
|
+
return InvalidInput({ issues: [{ message: 'Rotate takes files of one environment at a time.' }] });
|
|
197
|
+
}
|
|
198
|
+
return nodeOperation('rotate', () => {
|
|
199
|
+
const pair = generateKeypair();
|
|
200
|
+
if (isFailure(pair)) return pair;
|
|
201
|
+
const previousKeys = new Set<EnvPrivateKey>();
|
|
202
|
+
const keysFile = path.join(options.root, KEYS_FILE);
|
|
203
|
+
const keyStores = new Set([keysFile]);
|
|
204
|
+
const plans: { file: string; next: string; values: number }[] = [];
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
for (const candidate of keysFileCandidates(file, options.root)) {
|
|
207
|
+
if (existsSync(candidate)) keyStores.add(candidate);
|
|
208
|
+
}
|
|
209
|
+
const text = readEnvText(file);
|
|
210
|
+
if (isFailure(text)) return text;
|
|
211
|
+
const found = findPrivateKey(file, { root: options.root, env: options.env ?? process.env });
|
|
212
|
+
if (isFailure(found)) return found;
|
|
213
|
+
for (const key of found?.keys ?? []) previousKeys.add(key);
|
|
214
|
+
let next = text;
|
|
215
|
+
let values = 0;
|
|
216
|
+
for (const [key, entry] of envValues(parseEnv(text))) {
|
|
217
|
+
let value: string;
|
|
218
|
+
if (key === names.publicKey) value = pair.publicKey;
|
|
219
|
+
else if (isEncrypted(entry.value)) {
|
|
220
|
+
if (found === undefined) return MissingPrivateKey({ file });
|
|
221
|
+
const decrypted = decryptWithAny(entry.value, found.keys);
|
|
222
|
+
if (isFailure(decrypted)) return decrypted;
|
|
223
|
+
const encrypted = encryptValue(decrypted, pair.publicKey);
|
|
224
|
+
if (isFailure(encrypted)) return encrypted;
|
|
225
|
+
value = encrypted;
|
|
226
|
+
values++;
|
|
227
|
+
} else continue;
|
|
228
|
+
const updated = setEnvValue(next, key, value);
|
|
229
|
+
if (isFailure(updated)) return updated;
|
|
230
|
+
next = updated;
|
|
231
|
+
}
|
|
232
|
+
if (readEnvValue(parseEnv(text), names.publicKey) === undefined)
|
|
233
|
+
next = `${envHeader(path.basename(file), pair.publicKey)}\n${next}`;
|
|
234
|
+
plans.push({ file, next, values });
|
|
235
|
+
}
|
|
236
|
+
// Persist both generations BEFORE any ciphertext changes. An interruption at any later point
|
|
237
|
+
// leaves every old/new file recoverable from this key store, including a partial rotation.
|
|
238
|
+
for (const store of keyStores) {
|
|
239
|
+
const staged = writePrivateKey(store, names.privateKey, pair.privateKey, path.basename(first), {
|
|
240
|
+
preserve: [...previousKeys],
|
|
241
|
+
});
|
|
242
|
+
if (isFailure(staged)) return staged;
|
|
243
|
+
}
|
|
244
|
+
let completedFiles = 0;
|
|
245
|
+
for (const plan of plans) {
|
|
246
|
+
const written = writeEnvText(plan.file, plan.next);
|
|
247
|
+
if (isFailure(written))
|
|
248
|
+
return RotationIncomplete({ keysFile, completedFiles, totalFiles: plans.length }, { cause: written });
|
|
249
|
+
completedFiles++;
|
|
250
|
+
}
|
|
251
|
+
// Do not retire the old generation until every committed file reads back exactly as planned.
|
|
252
|
+
for (const plan of plans) {
|
|
253
|
+
const confirmed = readEnvText(plan.file);
|
|
254
|
+
if (isFailure(confirmed) || confirmed !== plan.next)
|
|
255
|
+
return RotationIncomplete(
|
|
256
|
+
{ keysFile, completedFiles, totalFiles: plans.length },
|
|
257
|
+
{ cause: isFailure(confirmed) ? confirmed : FileWriteFailed({ file: plan.file }) },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const store of keyStores) {
|
|
261
|
+
const finalized = writePrivateKey(store, names.privateKey, pair.privateKey, path.basename(first));
|
|
262
|
+
if (isFailure(finalized))
|
|
263
|
+
return RotationIncomplete({ keysFile, completedFiles, totalFiles: plans.length }, { cause: finalized });
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
privateKeyName: names.privateKey,
|
|
267
|
+
files: plans.map(({ file, values }) => ({ file, values })),
|
|
268
|
+
keysFile,
|
|
269
|
+
} satisfies RotateResult;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function decryptWithAny(value: string, privateKeys: readonly EnvPrivateKey[]) {
|
|
274
|
+
for (const privateKey of privateKeys) {
|
|
275
|
+
const decrypted = decryptValue(value, privateKey);
|
|
276
|
+
if (!isFailure(decrypted)) return decrypted;
|
|
277
|
+
}
|
|
278
|
+
return DecryptionFailed();
|
|
279
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { defineFailure } from '@arki/failure';
|
|
4
|
+
|
|
5
|
+
/** A dotenv value has no lossless quoting.
|
|
6
|
+
* @fix Use encryption or a value supported by dotenv quoting.
|
|
7
|
+
* @verify bun run test
|
|
8
|
+
*/
|
|
9
|
+
export const UnrepresentableValue = defineFailure('env.unrepresentable_value', {
|
|
10
|
+
message: 'The value cannot be stored losslessly in dotenv text',
|
|
11
|
+
status: 422,
|
|
12
|
+
remediation: 'Encrypt the value or remove the unsupported combination of quotes and line breaks.',
|
|
13
|
+
});
|
|
14
|
+
/** Cryptographic encryption failed.
|
|
15
|
+
* @fix Verify the public key and runtime crypto support.
|
|
16
|
+
* @verify bun run test
|
|
17
|
+
*/
|
|
18
|
+
export const EncryptionFailed = defineFailure('env.encryption_failed', {
|
|
19
|
+
message: 'Cannot encrypt the env value',
|
|
20
|
+
status: 500,
|
|
21
|
+
remediation: 'Verify the public key and runtime crypto support.',
|
|
22
|
+
});
|
|
23
|
+
/** Ciphertext authentication or decoding failed.
|
|
24
|
+
* @fix Restore the correct private key or reset the damaged value.
|
|
25
|
+
* @verify bun run test
|
|
26
|
+
*/
|
|
27
|
+
export const DecryptionFailed = defineFailure('env.decryption_failed', {
|
|
28
|
+
message: 'Cannot decrypt the env value',
|
|
29
|
+
status: 422,
|
|
30
|
+
remediation: 'Restore the correct private key or reset the damaged encrypted value.',
|
|
31
|
+
});
|
|
32
|
+
/** The runtime could not generate a keypair.
|
|
33
|
+
* @fix Restore runtime crypto support.
|
|
34
|
+
* @verify bun run test
|
|
35
|
+
*/
|
|
36
|
+
export const KeyGenerationFailed = defineFailure('env.key_generation_failed', {
|
|
37
|
+
message: 'Cannot generate an env keypair',
|
|
38
|
+
status: 500,
|
|
39
|
+
remediation: 'Restore runtime crypto support before retrying.',
|
|
40
|
+
});
|
|
41
|
+
/** An env file could not be read.
|
|
42
|
+
* @fix Check the path, file type and access permissions.
|
|
43
|
+
* @verify bun run test
|
|
44
|
+
*/
|
|
45
|
+
export const FileReadFailed = defineFailure('env.file_read_failed', {
|
|
46
|
+
data: z.object({ file: z.string() }),
|
|
47
|
+
message: 'Cannot read env file {file}',
|
|
48
|
+
status: 500,
|
|
49
|
+
remediation: 'Check the file path, type and access permissions.',
|
|
50
|
+
});
|
|
51
|
+
/** An env file could not be replaced.
|
|
52
|
+
* @fix Check disk space and parent-directory permissions.
|
|
53
|
+
* @verify bun run test
|
|
54
|
+
*/
|
|
55
|
+
export const FileWriteFailed = defineFailure('env.file_write_failed', {
|
|
56
|
+
data: z.object({ file: z.string() }),
|
|
57
|
+
message: 'Cannot write env file {file}',
|
|
58
|
+
status: 500,
|
|
59
|
+
remediation: 'Check disk space and parent-directory permissions before retrying.',
|
|
60
|
+
});
|
|
61
|
+
/** The target file has no public key.
|
|
62
|
+
* @fix Initialize the file before encrypting values.
|
|
63
|
+
* @verify bun run test
|
|
64
|
+
*/
|
|
65
|
+
export const MissingPublicKey = defineFailure('env.missing_public_key', {
|
|
66
|
+
data: z.object({ file: z.string() }),
|
|
67
|
+
message: 'Env file {file} has no public key header',
|
|
68
|
+
remediation: 'Initialize the file before encrypting values.',
|
|
69
|
+
});
|
|
70
|
+
/** No private key is available.
|
|
71
|
+
* @fix Restore the environment key from its backup.
|
|
72
|
+
* @verify bun run test
|
|
73
|
+
*/
|
|
74
|
+
export const MissingPrivateKey = defineFailure('env.missing_private_key', {
|
|
75
|
+
data: z.object({ file: z.string() }),
|
|
76
|
+
message: 'No private key is available for {file}',
|
|
77
|
+
remediation: 'Restore the environment private key from its backup or shell variable.',
|
|
78
|
+
});
|
|
79
|
+
/** Rotation stopped after its recovery keys were saved.
|
|
80
|
+
* @fix Keep the staged key file and retry rotation.
|
|
81
|
+
* @verify bun run test
|
|
82
|
+
*/
|
|
83
|
+
export const RotationIncomplete = defineFailure('env.rotation_incomplete', {
|
|
84
|
+
data: z.object({
|
|
85
|
+
keysFile: z.string(),
|
|
86
|
+
completedFiles: z.number().int().nonnegative(),
|
|
87
|
+
totalFiles: z.number().int().nonnegative(),
|
|
88
|
+
}),
|
|
89
|
+
message: 'Env rotation stopped; recovery keys are saved in {keysFile}',
|
|
90
|
+
status: 500,
|
|
91
|
+
remediation:
|
|
92
|
+
'Keep the staged key file: it contains keys for both old and new ciphertext. Repair the filesystem and retry rotation.',
|
|
93
|
+
});
|
|
94
|
+
/** A secret provider rejected a read.
|
|
95
|
+
* @fix Check the provider CLI installation, login and reference.
|
|
96
|
+
* @verify bun run test
|
|
97
|
+
*/
|
|
98
|
+
export const ReferenceFailed = defineFailure('env.reference_failed', {
|
|
99
|
+
data: z.object({ scheme: z.string() }),
|
|
100
|
+
message: 'Cannot resolve the {scheme} secret reference',
|
|
101
|
+
status: 502,
|
|
102
|
+
remediation: 'Check the provider CLI installation, login and reference; inspect the local cause privately.',
|
|
103
|
+
});
|
|
104
|
+
/** Loading was cancelled.
|
|
105
|
+
* @fix Handle cancellation at the calling boundary.
|
|
106
|
+
* @verify bun run test
|
|
107
|
+
*/
|
|
108
|
+
export const EnvCancelled = defineFailure('env.cancelled', {
|
|
109
|
+
message: 'Env loading was cancelled',
|
|
110
|
+
status: 499,
|
|
111
|
+
remediation: 'Honor cancellation at the calling boundary.',
|
|
112
|
+
});
|
|
113
|
+
/** The aggregate load deadline elapsed.
|
|
114
|
+
* @fix Increase the budget or restore provider responsiveness.
|
|
115
|
+
* @verify bun run test
|
|
116
|
+
*/
|
|
117
|
+
export const EnvDeadline = defineFailure('env.deadline', {
|
|
118
|
+
message: 'The env loading deadline elapsed',
|
|
119
|
+
status: 504,
|
|
120
|
+
remediation: 'Increase the load budget or restore provider responsiveness.',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Loader problem codes are derived from their canonical definitions. */
|
|
124
|
+
export const ENV_FILES_ERROR_CODES = {
|
|
125
|
+
missingPrivateKey: MissingPrivateKey.code,
|
|
126
|
+
cannotDecrypt: DecryptionFailed.code,
|
|
127
|
+
unresolvedReference: ReferenceFailed.code,
|
|
128
|
+
cannotRead: FileReadFailed.code,
|
|
129
|
+
cancelled: EnvCancelled.code,
|
|
130
|
+
deadline: EnvDeadline.code,
|
|
131
|
+
} as const;
|
|
132
|
+
const problem = z.object({
|
|
133
|
+
code: z.enum(Object.values(ENV_FILES_ERROR_CODES)),
|
|
134
|
+
message: z.string(),
|
|
135
|
+
file: z.string(),
|
|
136
|
+
key: z.string().optional(),
|
|
137
|
+
});
|
|
138
|
+
/** One or more env entries could not be loaded. Public data excludes values and raw causes.
|
|
139
|
+
* @fix Address each named problem before starting the app.
|
|
140
|
+
* @verify bun run test
|
|
141
|
+
*/
|
|
142
|
+
export const EnvFilesFailed = defineFailure('env.files_failed', {
|
|
143
|
+
data: z.object({ environment: z.string(), errors: z.array(problem) }),
|
|
144
|
+
message: 'Cannot load the env files',
|
|
145
|
+
status: 500,
|
|
146
|
+
remediation: 'Address each named file/key problem before starting the application.',
|
|
147
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { attempt } from '@arki/failure';
|
|
6
|
+
|
|
7
|
+
import { FileReadFailed, FileWriteFailed } from './errors.js';
|
|
8
|
+
import { nodeOperation } from './operation-telemetry.js';
|
|
9
|
+
|
|
10
|
+
/** Foreign filesystem errors stay private; public outcomes identify only the file. */
|
|
11
|
+
export function readEnvText(file: string) {
|
|
12
|
+
return nodeOperation('file.read', () =>
|
|
13
|
+
attempt(
|
|
14
|
+
() => readFileSync(file, 'utf8'),
|
|
15
|
+
error => FileReadFailed({ file }, { cause: error }),
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Each replacement is atomic; owner-only new files and key stores never follow a planted temp symlink. */
|
|
21
|
+
export function writeEnvText(file: string, text: string, privateFile = false) {
|
|
22
|
+
return nodeOperation('file.write', () => {
|
|
23
|
+
let temp: string | undefined;
|
|
24
|
+
try {
|
|
25
|
+
const mode = privateFile ? 0o600 : existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
|
|
26
|
+
temp = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
27
|
+
writeFileSync(temp, text, { flag: 'wx', mode });
|
|
28
|
+
renameSync(temp, file);
|
|
29
|
+
return;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (temp !== undefined) {
|
|
32
|
+
try {
|
|
33
|
+
unlinkSync(temp);
|
|
34
|
+
} catch {
|
|
35
|
+
/* The failed write may not have created a file. */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return FileWriteFailed({ file }, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|