@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,73 @@
|
|
|
1
|
+
# Upgrade 0.1.0 to 0.2.0
|
|
2
|
+
|
|
3
|
+
## The Dot adapter moved to `@arki/dot/env`
|
|
4
|
+
|
|
5
|
+
`@arki/env/dot` is gone. `@arki/env` no longer lists `@arki/dot` as a peer; `@arki/dot` now depends on `@arki/env`.
|
|
6
|
+
|
|
7
|
+
Before:
|
|
8
|
+
|
|
9
|
+
```ts skip="import path removed in 0.2.0"
|
|
10
|
+
import { env } from '@arki/env/dot';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
After:
|
|
14
|
+
|
|
15
|
+
```ts skip="needs a Dot app"
|
|
16
|
+
import { env } from '@arki/dot/env';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`env()`, `ENV_PLUGIN_ERROR_CODES`, `EnvDotOptions` and `EnvServices` are unchanged. `ARKI_ENV_PLUGIN_E001` keeps its code.
|
|
20
|
+
|
|
21
|
+
## `contracts` is now `presets`
|
|
22
|
+
|
|
23
|
+
`@arki/env/contracts` is now `@arki/env/presets`. In Arki, `./contract` names the browser-safe entry for oRPC contracts (A-41), and these are ready-made variable validators, so they took a name that says that. The validators themselves are unchanged.
|
|
24
|
+
|
|
25
|
+
| Before | After |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `@arki/env/contracts` | `@arki/env/presets` |
|
|
28
|
+
| `contracts` (also from `@arki/env` and `@arki/env/core`) | `presets` |
|
|
29
|
+
| `commonContracts`, `urlContracts`, `parseContracts` | `commonPresets`, `urlPresets`, `parsePresets` |
|
|
30
|
+
|
|
31
|
+
Before:
|
|
32
|
+
|
|
33
|
+
```ts skip="import path removed in 0.2.0"
|
|
34
|
+
import { contracts } from '@arki/env/contracts';
|
|
35
|
+
|
|
36
|
+
const port = contracts.port(3000);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
After:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { presets } from '@arki/env/presets';
|
|
43
|
+
|
|
44
|
+
const port = presets.port(3000);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## `formatIssues` is public
|
|
48
|
+
|
|
49
|
+
`@arki/env/core` now exports `formatIssues(name, issues)`, the formatter `defineEnv` uses for its error message, so adapters can build the same message.
|
|
50
|
+
|
|
51
|
+
## Encrypted env files replace `dotenv-cli`
|
|
52
|
+
|
|
53
|
+
New `@arki/env/node` loads committed `.env.<environment>` files with encrypted values. Apps load them with `dot env run --` (from `@arki/dot`) or `import '@arki/env/node/register'`.
|
|
54
|
+
|
|
55
|
+
Before (`package.json`):
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{ "scripts": { "with-env": "dotenv -e ../../../.env -e ../.env --" } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
After:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "scripts": { "with-env": "dot env run --" } }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Remove `dotenv-cli` and add `@arki/dot` as a dev dependency. Turn an old `.env` into an encrypted file with `dot env encrypt --from .env --all`. Then back up `.env.keys` (`dot env keys push --vault <vault>`) and delete the old file.
|
|
68
|
+
|
|
69
|
+
Behaviour that changes:
|
|
70
|
+
|
|
71
|
+
- The real environment wins over every file. `dotenv --override` has no equivalent.
|
|
72
|
+
- `defineEnv` now fails on a value that still starts with `encrypted:`. Before, ciphertext passed as a string.
|
|
73
|
+
- Files load from the repository root down to the working directory, by `NODE_ENV`. A bare `.env` is no longer read; `dot env check` warns about one.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Typed node operations
|
|
2
|
+
|
|
3
|
+
Node filesystem, crypto and provider operations return canonical failures. Replace exception-only handling with `isFailure(result)` checks and propagate the exact returned failure. `defineEnv` remains a lazy boot declaration whose invalid configuration throws.
|
|
4
|
+
|
|
5
|
+
`generateKeypair()` returns a keypair or `KeyGenerationFailed`. Parse external public/private strings with `parsePublicKey` / `parsePrivateKey`; their validated domains are distinct. Pass these keys to `encryptValue`, `decryptValue`, `publicKeyOf` and `writePrivateKey`. Encryption/decryption and lossless text edits return typed failures instead of throwing expected rejection.
|
|
6
|
+
|
|
7
|
+
`loadEnvFiles()` returns its successful report, `EnvFilesFailed`, `EnvCancelled` or `EnvDeadline`. Read `EnvFilesFailed.data.errors`; problem codes are now dotted canonical codes (`env.missing_private_key`, `env.decryption_failed`, `env.reference_failed`, `env.file_read_failed`). Failure applies no file values. Do not serialize successful report values or private failure causes. `formatEnvFilesErrors` accepts `EnvFilesFailed`. Explicit `node/register` converts these outcomes to a boot exception.
|
|
8
|
+
|
|
9
|
+
`timeoutMs` defaults to one 30-second aggregate budget. Custom synchronous resolvers accept a second options argument with remaining timeout and optional signal, and return a string or canonical provider/cancellation/deadline failure. Thrown custom resolver errors are sanitized at the loader boundary. Abort is checked between synchronous callbacks; native child processes enforce the timeout.
|
|
10
|
+
|
|
11
|
+
Storage/ciphertext algorithms and bytes remain dotenvx-compatible. Rotation stages the root and every existing candidate local `.env.keys` store before any ciphertext changes. Each variable temporarily stores the existing comma-separated multi-key shape: new key first, followed by previous generations. Each file remains decryptable during partial rotation. Keep that file after `RotationIncomplete`, repair the filesystem and retry with the staged keys. Previous keys retire only after every new file reads back exactly as planned. Atomic temp-file replacement protects against partial writes on process interruption; it is not an fsync/power-loss durability guarantee.
|
|
12
|
+
|
|
13
|
+
Node load/edit/provider execution emits OpenTelemetry spans and durations without installing an SDK. Configure telemetry in the application; no path, reference, value, key or raw cause is recorded.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @arki/env
|
|
2
|
+
|
|
3
|
+
> Typed environment validation and explicit Node file/key operations. `defineEnv()` validates lazily with any Standard Schema, `secret()` keeps values out of logs, and `@arki/dot/env` validates at Dot boot.
|
|
4
|
+
|
|
5
|
+
## Import paths
|
|
6
|
+
|
|
7
|
+
<!-- llms:start -->
|
|
8
|
+
- `@arki/env`: The `presets` namespace of ready-made variable shapes, for `defineEnv()`. `presets`.
|
|
9
|
+
- `@arki/env/core`: `defineEnv()` plus `secret()`, `reveal()` and `Redacted` for typed, lazily validated environments. `Redacted`, `SECRET_SCHEMA`, `defineEnv`, `describeEnv`, `formatIssues`, `isSecretSchema`, `presets`, `reveal`, `secret`; types `EnvDescription`, `EnvKeyDescription`.
|
|
10
|
+
- `@arki/env/presets`: Ready-made Zod presets for ports, URLs, lists, JSON and other common variable shapes. `commonPresets`, `parsePresets`, `presets`, `urlPresets`.
|
|
11
|
+
- `@arki/env/node`: Encrypted env files in the dotenvx 2.x format, for Node, Bun and Deno: load them into the environment (`loadEnvFiles`), edit them (`initEnvFile`, `setEnvFileValue`, ...), and the codec underneath. `DEFAULT_RESOLVERS`, `DecryptionFailed`, `ENCRYPTED_PREFIX`, `ENV_FILES_ERROR_CODES`, `EncryptionFailed`, `EnvCancelled`, `EnvDeadline`, `EnvFilesFailed`, `FileReadFailed`, `FileWriteFailed`, `KEYS_FILE`, `KeyGenerationFailed`; and 53 more.
|
|
12
|
+
- `@arki/env/node/register`: Side-effect entry: load the env files into `process.env` as this module is imported.
|
|
13
|
+
<!-- llms:end -->
|
|
14
|
+
|
|
15
|
+
## Start here
|
|
16
|
+
- [Agent guide](docs/agent-guide.md): read first.
|
|
17
|
+
- [Verify](docs/agent-guide.md#verify): the commands to run after a change.
|
|
18
|
+
- [README](README.md): the long overview.
|
|
19
|
+
|
|
20
|
+
## By task
|
|
21
|
+
- [Define the environment of an app](docs/agent-guide.md#define-the-environment-of-an-app)
|
|
22
|
+
- [Keep a secret out of logs](docs/agent-guide.md#keep-a-secret-out-of-logs)
|
|
23
|
+
- [Build a Docker image without secrets](docs/agent-guide.md#build-a-docker-image-without-secrets)
|
|
24
|
+
- [Validate at boot in a Dot app](docs/agent-guide.md#validate-at-boot-in-a-dot-app)
|
|
25
|
+
|
|
26
|
+
## Rules that bite
|
|
27
|
+
- Validation runs everywhere. Only `SKIP_ENV_VALIDATION=1` (exactly `1`) skips it.
|
|
28
|
+
- Read a secret with `reveal(env.KEY)`. It prints as `[redacted]` otherwise.
|
|
29
|
+
- Pass `runtimeEnv` on engines with no `process`.
|
|
30
|
+
- Schemas validate synchronously; the first read validates, not the import.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arki/env",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Type-safe environment variable parsing for ARKI — defineEnv() built on @t3-oss/env-core with Zod
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Type-safe environment variable parsing for ARKI — defineEnv() built on @t3-oss/env-core with Zod presets for common variable shapes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"config"
|
|
22
22
|
],
|
|
23
23
|
"type": "module",
|
|
24
|
-
"sideEffects":
|
|
24
|
+
"sideEffects": [
|
|
25
|
+
"./dist/node/register.js",
|
|
26
|
+
"./src/node/register.ts"
|
|
27
|
+
],
|
|
25
28
|
"exports": {
|
|
26
29
|
".": {
|
|
27
30
|
"types": "./dist/index.d.ts",
|
|
@@ -33,15 +36,20 @@
|
|
|
33
36
|
"import": "./dist/core/index.js",
|
|
34
37
|
"default": "./dist/core/index.js"
|
|
35
38
|
},
|
|
36
|
-
"./
|
|
37
|
-
"types": "./dist/
|
|
38
|
-
"import": "./dist/
|
|
39
|
-
"default": "./dist/
|
|
39
|
+
"./presets": {
|
|
40
|
+
"types": "./dist/presets/index.d.ts",
|
|
41
|
+
"import": "./dist/presets/index.js",
|
|
42
|
+
"default": "./dist/presets/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./node": {
|
|
45
|
+
"types": "./dist/node/index.d.ts",
|
|
46
|
+
"import": "./dist/node/index.js",
|
|
47
|
+
"default": "./dist/node/index.js"
|
|
40
48
|
},
|
|
41
|
-
"./
|
|
42
|
-
"types": "./dist/
|
|
43
|
-
"import": "./dist/
|
|
44
|
-
"default": "./dist/
|
|
49
|
+
"./node/register": {
|
|
50
|
+
"types": "./dist/node/register.d.ts",
|
|
51
|
+
"import": "./dist/node/register.js",
|
|
52
|
+
"default": "./dist/node/register.js"
|
|
45
53
|
},
|
|
46
54
|
"./package.json": "./package.json"
|
|
47
55
|
},
|
|
@@ -55,21 +63,20 @@
|
|
|
55
63
|
"!src/**/__tests__/**",
|
|
56
64
|
"README.md",
|
|
57
65
|
"LICENSE",
|
|
66
|
+
"CHANGELOG.md",
|
|
67
|
+
"llms.txt",
|
|
68
|
+
"docs/agent-guide.md",
|
|
69
|
+
"docs/upgrades/**",
|
|
58
70
|
"package.json"
|
|
59
71
|
],
|
|
60
72
|
"dependencies": {
|
|
61
|
-
"@arki/
|
|
73
|
+
"@arki/failure": "0.3.0",
|
|
74
|
+
"@arki/schema": "0.3.0",
|
|
75
|
+
"@opentelemetry/api": "^1.9.1",
|
|
62
76
|
"@t3-oss/env-core": "^0.13.11",
|
|
77
|
+
"eciesjs": "^0.5.0",
|
|
63
78
|
"zod": "4.6.5"
|
|
64
79
|
},
|
|
65
|
-
"peerDependencies": {
|
|
66
|
-
"@arki/dot": "^0.5.0"
|
|
67
|
-
},
|
|
68
|
-
"peerDependenciesMeta": {
|
|
69
|
-
"@arki/dot": {
|
|
70
|
-
"optional": true
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
80
|
"publishConfig": {
|
|
74
81
|
"access": "public"
|
|
75
82
|
}
|
package/src/core/define-env.ts
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
import type { CreateEnv, DefaultCombinedSchema } from '@t3-oss/env-core';
|
|
2
|
-
import type { ZodType } from 'zod';
|
|
3
2
|
import { createEnv } from '@t3-oss/env-core';
|
|
4
3
|
|
|
4
|
+
import type { StandardSchemaV1 } from '@arki/schema';
|
|
5
|
+
import { InvalidInput, orThrow, UnexpectedFailure } from '@arki/failure';
|
|
6
|
+
import { parse, pathOf } from '@arki/schema';
|
|
7
|
+
|
|
8
|
+
import { registerEnvDescription } from './describe.js';
|
|
9
|
+
import { InvalidEnvironment } from './errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The process environment, or `undefined` on engines with no `process` (Hermes, browsers, Deno without
|
|
13
|
+
* Node compat). Reads through `globalThis` so a missing global is not a ReferenceError. Called only when
|
|
14
|
+
* the env is first read, never at import.
|
|
15
|
+
*/
|
|
16
|
+
export function processEnv(): Record<string, string | undefined> | undefined {
|
|
17
|
+
// Typed as optional on purpose: the Node typings say `process` always exists, Hermes says otherwise.
|
|
18
|
+
const scope: { process?: { env: Record<string, string | undefined> } } = globalThis;
|
|
19
|
+
return scope.process?.env;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The prefix of a value encrypted in a dotenvx-format env file (`@arki/env/node`). */
|
|
23
|
+
export const ENCRYPTED_PREFIX = 'encrypted:';
|
|
24
|
+
|
|
25
|
+
const STILL_ENCRYPTED_MESSAGE =
|
|
26
|
+
'still encrypted: start the process with `dot env run -- <cmd>`, or import `@arki/env/node/register` first';
|
|
27
|
+
|
|
5
28
|
/** The issue shape t3-oss hands to `onValidationError`, derived so it stays in sync with the lib. */
|
|
6
29
|
type ValidationIssues = Parameters<NonNullable<Parameters<typeof createEnv>[0]['onValidationError']>>[0];
|
|
7
30
|
|
|
@@ -10,12 +33,10 @@ type ValidationIssues = Parameters<NonNullable<Parameters<typeof createEnv>[0]['
|
|
|
10
33
|
* logs `issues` as a raw object, which platforms like Coolify collapse to `[Object]` — so the var
|
|
11
34
|
* that actually failed never reaches the logs. A flat ` path: message` list survives transport.
|
|
12
35
|
*/
|
|
13
|
-
function formatIssues(name: string | undefined, issues: ValidationIssues): string {
|
|
36
|
+
export function formatIssues(name: string | undefined, issues: ValidationIssues): string {
|
|
14
37
|
const label = name ? `[${name}] ` : '';
|
|
15
38
|
const lines = issues.map(issue => {
|
|
16
|
-
const path = (issue.
|
|
17
|
-
.map(seg => (typeof seg === 'object' && seg !== null && 'key' in seg ? String(seg.key) : String(seg)))
|
|
18
|
-
.join('.');
|
|
39
|
+
const path = pathOf(issue).map(String).join('.');
|
|
19
40
|
return ` ${path || '(root)'}: ${issue.message}`;
|
|
20
41
|
});
|
|
21
42
|
return `❌ ${label}Invalid environment variables:\n${lines.join('\n')}`;
|
|
@@ -24,17 +45,22 @@ function formatIssues(name: string | undefined, issues: ValidationIssues): strin
|
|
|
24
45
|
/**
|
|
25
46
|
* Thin, type-preserving wrapper over `@t3-oss/env-core`'s `createEnv`. Generic over the server,
|
|
26
47
|
* client, shared and composed schemas so the parsed env keeps its exact inferred types — the wrapper
|
|
27
|
-
* adds the ARKI defaults (auto `runtimeEnv`,
|
|
28
|
-
* handler) without widening any value to `unknown`.
|
|
48
|
+
* adds the ARKI defaults (auto `runtimeEnv`, validation on in every environment, a legible labelled
|
|
49
|
+
* error handler) without widening any value to `unknown`.
|
|
50
|
+
*
|
|
51
|
+
* Every schema is a Standard Schema (Zod, Valibot, ArkType, or a hand-written `~standard` object); each
|
|
52
|
+
* value's type is the schema's OUTPUT, so defaults and coercions in the caller's schemas still apply.
|
|
53
|
+
* Validation is synchronous: a schema whose `validate` returns a Promise or throws makes the first read
|
|
54
|
+
* throw `UnexpectedFailure` with the canonical schema failure, never a validation error.
|
|
29
55
|
*
|
|
30
56
|
* `TPrefix` is kept as a string-literal generic on purpose: `createEnv` uses it to enforce that
|
|
31
57
|
* client keys (and only client keys) carry the prefix. Widening it to `string` would make every
|
|
32
58
|
* server key collapse to a type-level `ErrorMessage`, so the prefix must stay a literal.
|
|
33
59
|
*/
|
|
34
60
|
export function defineEnv<
|
|
35
|
-
TServer extends Record<string,
|
|
36
|
-
TClient extends Record<string,
|
|
37
|
-
TShared extends Record<string,
|
|
61
|
+
TServer extends Record<string, StandardSchemaV1>,
|
|
62
|
+
TClient extends Record<string, StandardSchemaV1> = NonNullable<unknown>,
|
|
63
|
+
TShared extends Record<string, StandardSchemaV1> = NonNullable<unknown>,
|
|
38
64
|
const TPrefix extends string = '',
|
|
39
65
|
const TExtends extends Record<string, unknown>[] = [],
|
|
40
66
|
>(config: {
|
|
@@ -64,6 +90,12 @@ export function defineEnv<
|
|
|
64
90
|
*/
|
|
65
91
|
runtimeEnv?: Record<string, string | boolean | number | undefined>;
|
|
66
92
|
options?: {
|
|
93
|
+
/**
|
|
94
|
+
* `true` skips schema validation: values pass through raw. Default: validate on the first read,
|
|
95
|
+
* in every environment, unless the process sets `SKIP_ENV_VALIDATION=1` (exactly `1`). That
|
|
96
|
+
* variable is the only implicit opt-out, meant for Docker build stages that prerender without
|
|
97
|
+
* secrets. `CI`, lint and `NODE_ENV` never skip.
|
|
98
|
+
*/
|
|
67
99
|
skipValidation?: boolean;
|
|
68
100
|
/**
|
|
69
101
|
* Treat empty-string env values as `undefined` so defaults apply (e.g. `PORT=` in `.env`, or a
|
|
@@ -84,8 +116,11 @@ export function defineEnv<
|
|
|
84
116
|
// consumed (boot, a request, a job). This is the same import/observe split
|
|
85
117
|
// drizzle tables have: define everywhere, observe only at use.
|
|
86
118
|
const build = (): Built<Result> => {
|
|
87
|
-
|
|
88
|
-
|
|
119
|
+
// Validate everywhere the app runs, production included. The only implicit opt-out is the
|
|
120
|
+
// purpose-named SKIP_ENV_VALIDATION=1 (Docker build stages without secrets). Ambient variables
|
|
121
|
+
// like CI or the npm script name must never turn validation off: that would boot production on
|
|
122
|
+
// an unchecked config.
|
|
123
|
+
const skipValidation = config.options?.skipValidation ?? processEnv()?.SKIP_ENV_VALIDATION === '1';
|
|
89
124
|
|
|
90
125
|
// Use the caller's explicit runtimeEnv when supplied (Vite client vars via import.meta.env, or
|
|
91
126
|
// fallback aliases). Otherwise auto-build from every declared key (server + client + shared);
|
|
@@ -95,14 +130,15 @@ export function defineEnv<
|
|
|
95
130
|
runtimeEnv = config.runtimeEnv;
|
|
96
131
|
} else {
|
|
97
132
|
runtimeEnv = {};
|
|
133
|
+
const source = processEnv();
|
|
98
134
|
for (const key of Object.keys(config.server)) {
|
|
99
|
-
runtimeEnv[key] =
|
|
135
|
+
runtimeEnv[key] = source?.[key];
|
|
100
136
|
}
|
|
101
137
|
for (const key of Object.keys(config.client ?? {})) {
|
|
102
|
-
runtimeEnv[key] =
|
|
138
|
+
runtimeEnv[key] = source?.[key];
|
|
103
139
|
}
|
|
104
140
|
for (const key of Object.keys(config.shared ?? {})) {
|
|
105
|
-
runtimeEnv[key] =
|
|
141
|
+
runtimeEnv[key] = source?.[key];
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
144
|
|
|
@@ -111,9 +147,20 @@ export function defineEnv<
|
|
|
111
147
|
((issues: ValidationIssues): never => {
|
|
112
148
|
const message = formatIssues(config.name, issues);
|
|
113
149
|
console.error(message);
|
|
114
|
-
|
|
150
|
+
// A configuration fault at boot: a typed failure, so a catcher can match on the code.
|
|
151
|
+
throw new UnexpectedFailure(InvalidEnvironment({ report: message }));
|
|
115
152
|
});
|
|
116
153
|
|
|
154
|
+
// A value that is still `encrypted:` means the env files were not loaded (Bun auto-loads them
|
|
155
|
+
// as they are, ciphertext included). A string schema would accept it, so refuse it here, by name.
|
|
156
|
+
if (!skipValidation) {
|
|
157
|
+
const stillEncrypted = Object.keys(runtimeEnv).filter(key => {
|
|
158
|
+
const value = runtimeEnv[key];
|
|
159
|
+
return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
|
|
160
|
+
});
|
|
161
|
+
if (stillEncrypted.length > 0) onValidationError(stillEncrypted.map(key => ({ message: STILL_ENCRYPTED_MESSAGE, path: [key] })));
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
// `createEnv`'s `server`/`client` params are conditional on `clientPrefix` being a *concrete*
|
|
118
165
|
// string literal; inside a generic wrapper `TPrefix` is abstract, so the body cannot be verified
|
|
119
166
|
// for all prefixes and the call argument must be asserted once. The PUBLIC contract stays exact:
|
|
@@ -121,10 +168,10 @@ export function defineEnv<
|
|
|
121
168
|
// caller's `server`/`client`/`extends` are fully type-checked against `TServer`/`TClient`/`TExtends`
|
|
122
169
|
// at the call site. This is the only seam where types are asserted — value types are never widened.
|
|
123
170
|
const options = {
|
|
124
|
-
shared: config.shared
|
|
171
|
+
shared: syncSchemas(config.shared),
|
|
125
172
|
extends: config.extends,
|
|
126
|
-
server: config.server,
|
|
127
|
-
client: config.client
|
|
173
|
+
server: syncSchemas(config.server),
|
|
174
|
+
client: syncSchemas(config.client),
|
|
128
175
|
clientPrefix: config.clientPrefix ?? '',
|
|
129
176
|
runtimeEnv,
|
|
130
177
|
skipValidation,
|
|
@@ -142,7 +189,41 @@ export function defineEnv<
|
|
|
142
189
|
return { env, isPrivate };
|
|
143
190
|
};
|
|
144
191
|
|
|
145
|
-
|
|
192
|
+
const env = lazyEnv(build);
|
|
193
|
+
registerEnvDescription(env, {
|
|
194
|
+
...(config.name === undefined ? {} : { name: config.name }),
|
|
195
|
+
server: config.server,
|
|
196
|
+
...(config.client === undefined ? {} : { client: config.client }),
|
|
197
|
+
...(config.shared === undefined ? {} : { shared: config.shared }),
|
|
198
|
+
...(config.extends === undefined ? {} : { extends: config.extends }),
|
|
199
|
+
});
|
|
200
|
+
return env;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The caller's schemas, each run through `parse` before t3 sees it. t3 would let a throwing validator's
|
|
205
|
+
* raw error escape and report an async one with its own error (leaving a rejection unhandled). Here both
|
|
206
|
+
* fail as `UnexpectedFailure` (canonical validator defect with its private cause, or async mismatch), and bad values still reach t3 as issues.
|
|
207
|
+
*/
|
|
208
|
+
function syncSchemas(schemas: Record<string, StandardSchemaV1> | undefined): Record<string, StandardSchemaV1> {
|
|
209
|
+
const wrapped: Record<string, StandardSchemaV1> = {};
|
|
210
|
+
for (const [key, schema] of Object.entries(schemas ?? {})) wrapped[key] = syncSchema(schema);
|
|
211
|
+
return wrapped;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Same `version`, `vendor` and `types` as `schema`; only `validate` changes. */
|
|
215
|
+
function syncSchema<Input, Output>(schema: StandardSchemaV1<Input, Output>): StandardSchemaV1<Input, Output> {
|
|
216
|
+
return {
|
|
217
|
+
'~standard': {
|
|
218
|
+
...schema['~standard'],
|
|
219
|
+
validate: value => {
|
|
220
|
+
const result = parse(schema, value);
|
|
221
|
+
if (InvalidInput.is(result)) return { issues: result.data.issues };
|
|
222
|
+
// env-core expects validator defects to throw; bridge that foreign contract explicitly.
|
|
223
|
+
return { value: orThrow(result) };
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
146
227
|
}
|
|
147
228
|
|
|
148
229
|
/** The built env, and whether its reads may be copied (it is private and returns stored values). */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `describeEnv(env)`: the keys an env built by `defineEnv` declares, which of them are `secret()`,
|
|
3
|
+
* and where they live. It reads the declaration only, never a value, so tools (`dot env check`) can
|
|
4
|
+
* use it on a bare checkout.
|
|
5
|
+
*/
|
|
6
|
+
import type { StandardSchemaV1 } from '@arki/schema';
|
|
7
|
+
|
|
8
|
+
import { isSecretSchema } from './secret.js';
|
|
9
|
+
|
|
10
|
+
/** One declared key. */
|
|
11
|
+
export type EnvKeyDescription = {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
/** Declared with `secret()`: must never be plaintext in a tracked file. */
|
|
14
|
+
readonly secret: boolean;
|
|
15
|
+
readonly scope: 'server' | 'client' | 'shared';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What an env declares, its `extends` included. */
|
|
19
|
+
export type EnvDescription = { readonly name?: string; readonly keys: readonly EnvKeyDescription[] };
|
|
20
|
+
|
|
21
|
+
const descriptions = new WeakMap<object, EnvDescription>();
|
|
22
|
+
|
|
23
|
+
/** The description of an env built by `defineEnv`, or `undefined` for any other object. */
|
|
24
|
+
export function describeEnv(env: object): EnvDescription | undefined {
|
|
25
|
+
return descriptions.get(env);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Called by `defineEnv` once per env. Later scopes and the env's own keys win over `extends`. */
|
|
29
|
+
export function registerEnvDescription(
|
|
30
|
+
env: object,
|
|
31
|
+
declaration: {
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
readonly server: Record<string, StandardSchemaV1>;
|
|
34
|
+
readonly client?: Record<string, StandardSchemaV1>;
|
|
35
|
+
readonly shared?: Record<string, StandardSchemaV1>;
|
|
36
|
+
readonly extends?: readonly object[];
|
|
37
|
+
},
|
|
38
|
+
): void {
|
|
39
|
+
const keys = new Map<string, EnvKeyDescription>();
|
|
40
|
+
for (const base of declaration.extends ?? []) {
|
|
41
|
+
for (const key of describeEnv(base)?.keys ?? []) keys.set(key.key, key);
|
|
42
|
+
}
|
|
43
|
+
const scopes = [
|
|
44
|
+
['shared', declaration.shared],
|
|
45
|
+
['client', declaration.client],
|
|
46
|
+
['server', declaration.server],
|
|
47
|
+
] as const;
|
|
48
|
+
for (const [scope, schemas] of scopes) {
|
|
49
|
+
for (const [key, schema] of Object.entries(schemas ?? {})) keys.set(key, { key, secret: isSecretSchema(schema), scope });
|
|
50
|
+
}
|
|
51
|
+
descriptions.set(env, declaration.name === undefined ? { keys: [...keys.values()] } : { name: declaration.name, keys: [...keys.values()] });
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { defineFailure } from '@arki/failure';
|
|
4
|
+
|
|
5
|
+
/** The process environment does not match the schema, so the app must not boot.
|
|
6
|
+
* @fix Set or correct the variables listed in the report, then start the app again.
|
|
7
|
+
* @verify bun run test -- src/core/define-env.test.ts
|
|
8
|
+
*/
|
|
9
|
+
export const InvalidEnvironment = /* @__PURE__ */ defineFailure('env.invalid_environment', {
|
|
10
|
+
status: 500,
|
|
11
|
+
data: z.object({ report: z.string() }),
|
|
12
|
+
message: data => data.report,
|
|
13
|
+
remediation: 'Set or correct the variables listed in the report, then start the app again.',
|
|
14
|
+
});
|
package/src/core/index.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
export {
|
|
1
|
+
/** `defineEnv()` plus `secret()`, `reveal()` and `Redacted` for typed, lazily validated environments. */
|
|
2
|
+
export { defineEnv, formatIssues } from './define-env.js';
|
|
3
|
+
export { InvalidEnvironment } from './errors.js';
|
|
4
|
+
export { isSecretSchema, Redacted, reveal, secret, SECRET_SCHEMA } from './secret.js';
|
|
5
|
+
export { describeEnv } from './describe.js';
|
|
6
|
+
export type { EnvDescription, EnvKeyDescription } from './describe.js';
|
|
7
|
+
export { presets } from '../presets/index.js';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets that cannot leak by accident.
|
|
3
|
+
*
|
|
4
|
+
* `secret(schema)` parses a variable as usual and wraps the result in a
|
|
5
|
+
* {@link Redacted}. Printing it, logging it, serialising it or putting it in a
|
|
6
|
+
* template string all show `[redacted]`. Reading the value takes a deliberate,
|
|
7
|
+
* greppable `reveal(env.KEY)` call at the one place that needs it.
|
|
8
|
+
*
|
|
9
|
+
* Redaction guards against accidents (a logged config object, an error
|
|
10
|
+
* message with the env in it). It is not encryption: code that calls
|
|
11
|
+
* `reveal` gets the plain value.
|
|
12
|
+
*/
|
|
13
|
+
import type { SchemaInput, SchemaOutput, StandardSchemaV1 } from '@arki/schema';
|
|
14
|
+
|
|
15
|
+
/** What a redacted value prints as, everywhere. */
|
|
16
|
+
const REDACTED_TEXT = '[redacted]';
|
|
17
|
+
|
|
18
|
+
/** Node's `util.inspect` hook, so `console.log(env)` shows `[redacted]` too. */
|
|
19
|
+
const inspect: unique symbol = Symbol.for('nodejs.util.inspect.custom');
|
|
20
|
+
|
|
21
|
+
/** Reads the private value. Assigned in the class's static block, so only this module can read it. */
|
|
22
|
+
let read: <Value>(redacted: Redacted<Value>) => Value;
|
|
23
|
+
|
|
24
|
+
/** A value that prints as `[redacted]`. Read it with {@link reveal}. */
|
|
25
|
+
export class Redacted<Value = string> {
|
|
26
|
+
// A private field: invisible to JSON, `Object.keys`, spreading and `util.inspect`.
|
|
27
|
+
readonly #value: Value;
|
|
28
|
+
|
|
29
|
+
static {
|
|
30
|
+
read = redacted => redacted.#value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
constructor(value: Value) {
|
|
34
|
+
this.#value = value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toString(): string {
|
|
38
|
+
return REDACTED_TEXT;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
toJSON(): string {
|
|
42
|
+
return REDACTED_TEXT;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[inspect](): string {
|
|
46
|
+
return REDACTED_TEXT;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What {@link secret} returns: a Standard Schema whose output is redacted, with an `.optional()` variant. */
|
|
51
|
+
export type SecretSchema<Schema extends StandardSchemaV1> = StandardSchemaV1<
|
|
52
|
+
SchemaInput<Schema>,
|
|
53
|
+
Redacted<SchemaOutput<Schema>>
|
|
54
|
+
> & {
|
|
55
|
+
readonly [SECRET_SCHEMA]: true;
|
|
56
|
+
/** Accepts a missing variable. `undefined` passes through as `undefined`, never redacted. */
|
|
57
|
+
optional(): StandardSchemaV1<SchemaInput<Schema> | undefined, Redacted<SchemaOutput<Schema>> | undefined> & {
|
|
58
|
+
readonly [SECRET_SCHEMA]: true;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Marks a schema built by {@link secret}, so `describeEnv` can tell secrets apart. */
|
|
63
|
+
export const SECRET_SCHEMA: unique symbol = Symbol.for('arki.env.secret');
|
|
64
|
+
|
|
65
|
+
/** True for a schema built by {@link secret} (or its `.optional()`). */
|
|
66
|
+
export function isSecretSchema(schema: StandardSchemaV1): boolean {
|
|
67
|
+
return SECRET_SCHEMA in schema;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What every issue of a rejected secret says. Vendors such as valibot put the received value in
|
|
72
|
+
* their messages (`Received "sk_live_…"`), and `defineEnv` logs and throws issues, so a secret's
|
|
73
|
+
* issues keep only their path.
|
|
74
|
+
*/
|
|
75
|
+
const HIDDEN_ISSUE_MESSAGE = 'Invalid secret (details hidden so the value cannot leak)';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate with `schema` (any Standard Schema), then wrap the value in a {@link Redacted}. For an
|
|
79
|
+
* optional secret, call `.optional()`: `secret(z.string()).optional()`. The inner result passes
|
|
80
|
+
* straight through: an async inner schema yields a promise, so `defineEnv`'s synchronous check
|
|
81
|
+
* fails with the same `UnexpectedFailure` it would give without `secret`, and a throwing validator
|
|
82
|
+
* still throws.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const env = defineEnv({ server: { STRIPE_KEY: secret(z.string().min(1)) } });
|
|
86
|
+
* console.log(env.STRIPE_KEY); // [redacted]
|
|
87
|
+
* stripe(reveal(env.STRIPE_KEY)); // the key
|
|
88
|
+
*/
|
|
89
|
+
export function secret<Schema extends StandardSchemaV1>(schema: Schema): SecretSchema<Schema> {
|
|
90
|
+
const { version, vendor } = schema['~standard'];
|
|
91
|
+
type Sealed = StandardSchemaV1.Result<Redacted<SchemaOutput<Schema>>>;
|
|
92
|
+
const seal = (result: StandardSchemaV1.Result<SchemaOutput<Schema>>): Sealed =>
|
|
93
|
+
result.issues === undefined
|
|
94
|
+
? { value: new Redacted(result.value) }
|
|
95
|
+
: {
|
|
96
|
+
issues: result.issues.map(issue =>
|
|
97
|
+
issue.path === undefined
|
|
98
|
+
? { message: HIDDEN_ISSUE_MESSAGE }
|
|
99
|
+
: { message: HIDDEN_ISSUE_MESSAGE, path: issue.path },
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
const redact = (value: unknown): Sealed | Promise<Sealed> => {
|
|
103
|
+
const result = schema['~standard'].validate(value);
|
|
104
|
+
return result instanceof Promise ? result.then(seal) : seal(result);
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
[SECRET_SCHEMA]: true,
|
|
108
|
+
'~standard': { version, vendor, validate: redact },
|
|
109
|
+
optional: () => ({
|
|
110
|
+
[SECRET_SCHEMA]: true,
|
|
111
|
+
'~standard': {
|
|
112
|
+
version,
|
|
113
|
+
vendor,
|
|
114
|
+
validate: value => (value === undefined ? { value: undefined } : redact(value)),
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The plain value of a secret. Also takes the raw value, because when
|
|
122
|
+
* validation is skipped (`skipValidation`, or `CI` builds) schemas do not run
|
|
123
|
+
* and the variable is still a plain string.
|
|
124
|
+
*/
|
|
125
|
+
export function reveal<Value>(value: Redacted<Value> | Value): Value {
|
|
126
|
+
return value instanceof Redacted ? read(value) : value;
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
/** The `presets` namespace of ready-made variable shapes, for `defineEnv()`. */
|
|
2
|
+
// Re-export presets for convenience
|
|
3
|
+
export { presets } from './presets/index.js';
|