@arki/env 0.0.11 → 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.
Files changed (130) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/README.md +231 -62
  3. package/dist/api.json +597 -0
  4. package/dist/core/define-env.d.ts +29 -4
  5. package/dist/core/define-env.d.ts.map +1 -1
  6. package/dist/core/define-env.js +85 -15
  7. package/dist/core/define-env.js.map +1 -1
  8. package/dist/core/describe.d.ts +29 -0
  9. package/dist/core/describe.d.ts.map +1 -0
  10. package/dist/core/describe.js +25 -0
  11. package/dist/core/describe.js.map +1 -0
  12. package/dist/core/errors.d.ts +10 -0
  13. package/dist/core/errors.d.ts.map +1 -0
  14. package/dist/core/errors.js +13 -0
  15. package/dist/core/errors.js.map +1 -0
  16. package/dist/core/index.d.ts +7 -2
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +6 -2
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/secret.d.ts +56 -0
  21. package/dist/core/secret.d.ts.map +1 -0
  22. package/dist/core/secret.js +85 -0
  23. package/dist/core/secret.js.map +1 -0
  24. package/dist/errors.json +128 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/node/codec.d.ts +63 -0
  30. package/dist/node/codec.d.ts.map +1 -0
  31. package/dist/node/codec.js +182 -0
  32. package/dist/node/codec.js.map +1 -0
  33. package/dist/node/edit.d.ts +112 -0
  34. package/dist/node/edit.d.ts.map +1 -0
  35. package/dist/node/edit.js +268 -0
  36. package/dist/node/edit.js.map +1 -0
  37. package/dist/node/errors.d.ts +119 -0
  38. package/dist/node/errors.d.ts.map +1 -0
  39. package/dist/node/errors.js +144 -0
  40. package/dist/node/errors.js.map +1 -0
  41. package/dist/node/files.d.ts +9 -0
  42. package/dist/node/files.d.ts.map +1 -0
  43. package/dist/node/files.js +35 -0
  44. package/dist/node/files.js.map +1 -0
  45. package/dist/node/index.d.ts +20 -0
  46. package/dist/node/index.d.ts.map +1 -0
  47. package/dist/node/index.js +13 -0
  48. package/dist/node/index.js.map +1 -0
  49. package/dist/node/key-schemas.d.ts +25 -0
  50. package/dist/node/key-schemas.d.ts.map +1 -0
  51. package/dist/node/key-schemas.js +38 -0
  52. package/dist/node/key-schemas.js.map +1 -0
  53. package/dist/node/keys.d.ts +54 -0
  54. package/dist/node/keys.d.ts.map +1 -0
  55. package/dist/node/keys.js +76 -0
  56. package/dist/node/keys.js.map +1 -0
  57. package/dist/node/load.d.ts +103 -0
  58. package/dist/node/load.d.ts.map +1 -0
  59. package/dist/node/load.js +306 -0
  60. package/dist/node/load.js.map +1 -0
  61. package/dist/node/operation-telemetry.d.ts +3 -0
  62. package/dist/node/operation-telemetry.d.ts.map +1 -0
  63. package/dist/node/operation-telemetry.js +53 -0
  64. package/dist/node/operation-telemetry.js.map +1 -0
  65. package/dist/node/references.d.ts +16 -0
  66. package/dist/node/references.d.ts.map +1 -0
  67. package/dist/node/references.js +72 -0
  68. package/dist/node/references.js.map +1 -0
  69. package/dist/node/register.d.ts +2 -0
  70. package/dist/node/register.d.ts.map +1 -0
  71. package/dist/node/register.js +19 -0
  72. package/dist/node/register.js.map +1 -0
  73. package/dist/{contracts → presets}/common.d.ts +1 -1
  74. package/dist/presets/common.d.ts.map +1 -0
  75. package/dist/{contracts → presets}/common.js +4 -8
  76. package/dist/presets/common.js.map +1 -0
  77. package/dist/{contracts → presets}/index.d.ts +5 -5
  78. package/dist/presets/index.d.ts.map +1 -0
  79. package/dist/presets/index.js +15 -0
  80. package/dist/presets/index.js.map +1 -0
  81. package/dist/{contracts → presets}/parsing.d.ts +2 -2
  82. package/dist/presets/parsing.d.ts.map +1 -0
  83. package/dist/{contracts → presets}/parsing.js +13 -5
  84. package/dist/presets/parsing.js.map +1 -0
  85. package/dist/{contracts → presets}/urls.d.ts +1 -1
  86. package/dist/presets/urls.d.ts.map +1 -0
  87. package/dist/{contracts → presets}/urls.js +1 -1
  88. package/dist/presets/urls.js.map +1 -0
  89. package/docs/agent-guide.md +121 -0
  90. package/docs/upgrades/0.0.11-to-0.1.0.md +62 -0
  91. package/docs/upgrades/0.1.0-to-0.2.0.md +73 -0
  92. package/docs/upgrades/typed-node-outcomes.md +13 -0
  93. package/llms.txt +30 -0
  94. package/package.json +27 -20
  95. package/src/core/define-env.ts +101 -20
  96. package/src/core/describe.ts +52 -0
  97. package/src/core/errors.ts +14 -0
  98. package/src/core/index.ts +7 -2
  99. package/src/core/secret.ts +127 -0
  100. package/src/index.ts +3 -2
  101. package/src/node/codec.ts +220 -0
  102. package/src/node/edit.ts +279 -0
  103. package/src/node/errors.ts +147 -0
  104. package/src/node/files.ts +41 -0
  105. package/src/node/index.ts +48 -0
  106. package/src/node/key-schemas.ts +44 -0
  107. package/src/node/keys.ts +93 -0
  108. package/src/node/load.ts +386 -0
  109. package/src/node/operation-telemetry.ts +56 -0
  110. package/src/node/references.ts +90 -0
  111. package/src/node/register.ts +18 -0
  112. package/src/{contracts → presets}/common.ts +4 -8
  113. package/src/presets/index.ts +18 -0
  114. package/src/{contracts → presets}/parsing.ts +12 -5
  115. package/src/{contracts → presets}/urls.ts +1 -1
  116. package/dist/contracts/common.d.ts.map +0 -1
  117. package/dist/contracts/common.js.map +0 -1
  118. package/dist/contracts/index.d.ts.map +0 -1
  119. package/dist/contracts/index.js +0 -14
  120. package/dist/contracts/index.js.map +0 -1
  121. package/dist/contracts/parsing.d.ts.map +0 -1
  122. package/dist/contracts/parsing.js.map +0 -1
  123. package/dist/contracts/urls.d.ts.map +0 -1
  124. package/dist/contracts/urls.js.map +0 -1
  125. package/dist/dot.d.ts +0 -67
  126. package/dist/dot.d.ts.map +0 -1
  127. package/dist/dot.js +0 -65
  128. package/dist/dot.js.map +0 -1
  129. package/src/contracts/index.ts +0 -17
  130. 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.11",
4
- "description": "Type-safe environment variable parsing for ARKI — defineEnv() built on @t3-oss/env-core with Zod contracts for common variable shapes.",
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": false,
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
- "./contracts": {
37
- "types": "./dist/contracts/index.d.ts",
38
- "import": "./dist/contracts/index.js",
39
- "default": "./dist/contracts/index.js"
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
- "./dot": {
42
- "types": "./dist/dot.d.ts",
43
- "import": "./dist/dot.js",
44
- "default": "./dist/dot.js"
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/ts": "0.0.3",
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.6.1"
67
- },
68
- "peerDependenciesMeta": {
69
- "@arki/dot": {
70
- "optional": true
71
- }
72
- },
73
80
  "publishConfig": {
74
81
  "access": "public"
75
82
  }
@@ -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.path ?? [])
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`, prod-safe `skipValidation`, a legible labelled error
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, ZodType>,
36
- TClient extends Record<string, ZodType> = NonNullable<unknown>,
37
- TShared extends Record<string, ZodType> = NonNullable<unknown>,
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
- const skipValidation =
88
- config.options?.skipValidation ?? (!!process.env.CI || process.env.NODE_ENV !== 'development');
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] = process.env[key];
135
+ runtimeEnv[key] = source?.[key];
100
136
  }
101
137
  for (const key of Object.keys(config.client ?? {})) {
102
- runtimeEnv[key] = process.env[key];
138
+ runtimeEnv[key] = source?.[key];
103
139
  }
104
140
  for (const key of Object.keys(config.shared ?? {})) {
105
- runtimeEnv[key] = process.env[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
- throw new Error(message);
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
- return lazyEnv(build);
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
- export { defineEnv } from './define-env.js';
2
- export { contracts } from '../contracts/index.js';
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
- // Re-export contracts for convenience
2
- export { contracts } from './contracts/index.js';
1
+ /** The `presets` namespace of ready-made variable shapes, for `defineEnv()`. */
2
+ // Re-export presets for convenience
3
+ export { presets } from './presets/index.js';