@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21

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 (94) hide show
  1. package/README.md +343 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-TsUIjp_x.d.ts +157 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +62 -43
  53. package/dist/ops/index.js +638 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +6 -3
  57. package/dist/route/index.js +97 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/route/types.d.ts +7 -1
  60. package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
  61. package/dist/router-4YoIy5fN.d.ts +172 -0
  62. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  63. package/dist/server/index.d.ts +185 -14
  64. package/dist/server/index.js +883 -78
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/surface/index.d.ts +115 -0
  67. package/dist/surface/index.js +1003 -0
  68. package/dist/surface/index.js.map +1 -0
  69. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  70. package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  71. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  72. package/dist/types-CxhPH4s_.d.ts +375 -0
  73. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  74. package/docs/file-upload.md +195 -333
  75. package/package.json +20 -9
  76. package/src/cache/README.md +330 -0
  77. package/src/codegen/README.md +634 -0
  78. package/src/config/README.md +333 -0
  79. package/src/contract/README.md +440 -0
  80. package/src/db/README.md +589 -0
  81. package/src/db/manager/README.md +500 -0
  82. package/src/db/schema/README.md +354 -0
  83. package/src/db/transaction/README.md +822 -0
  84. package/src/env/README.md +787 -0
  85. package/src/errors/README.md +429 -0
  86. package/src/event/README.md +789 -0
  87. package/src/job/README.md +613 -0
  88. package/src/logger/README.md +321 -0
  89. package/src/middleware/README.md +634 -0
  90. package/src/nextjs/README.md +688 -0
  91. package/src/route/README.md +763 -0
  92. package/src/security/README.md +100 -0
  93. package/src/server/README.md +704 -0
  94. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,787 @@
1
+ # @spfn/core/env — Environment Variable Management
2
+
3
+ Type-safe, schema-based environment variable validation, parsing, and security-focused
4
+ file separation for Next.js + SPFN server.
5
+
6
+ ## Import paths
7
+
8
+ There are **two** entry points. Picking the wrong one breaks the build.
9
+
10
+ ```typescript
11
+ // Schema, registry, parsers, type guards — isomorphic (no node:fs)
12
+ import { defineEnvSchema, createEnvRegistry, envString /* ... */ } from '@spfn/core/env';
13
+
14
+ // File loader — SERVER ONLY (uses node:fs). Never import in client/edge code.
15
+ import { loadEnv, loadEnvOnce } from '@spfn/core/env/loader';
16
+ ```
17
+
18
+ `loadEnv` is **not** re-exported from `@spfn/core/env`. Import it from `@spfn/core/env/loader`.
19
+
20
+ ---
21
+
22
+ ## Public API (complete)
23
+
24
+ From `@spfn/core/env`:
25
+
26
+ - Schema helpers: `defineEnvSchema`, `envString`, `envNumber`, `envBoolean`, `envUrl`,
27
+ `envEnum`, `envJson`
28
+ - Registry: `createEnvRegistry`, `EnvRegistry` (class), `validateAllEnv`
29
+ - Type guards: `isClientAccessible`, `isServerOnly`, `isNextjsAccessible`, `isSpfnServerOnly`
30
+ - Parsers: `parseString`, `createStringParser`, `parseBoolean`, `parseNumber`,
31
+ `createNumberParser`, `parseInteger`, `parseDecimal`, `parseUrl`, `createUrlParser`,
32
+ `parsePostgresUrl`, `parseRedisUrl`, `parseEnum`, `createEnumParser`, `parseJson`,
33
+ `createJsonParser`, `parseArray`, `createArrayParser`, `createSecureSecretParser`,
34
+ `createPasswordParser`
35
+ - Parser composition: `chain`, `withFallback`, `optional`
36
+ - Types: `Parser<T>`, `EnvVarSchema`, `EnvSchemaCollection`, `InferEnvType`,
37
+ `EnvValidationResult`, `NodeEnv`, `LogLevel`
38
+
39
+ From `@spfn/core/env/loader`:
40
+
41
+ - `loadEnv`, `loadEnvOnce`, `resetEnvLoadState`
42
+ - Types: `LoadEnvOptions`, `LoadEnvResult`
43
+
44
+ > There is **no** `getEnvVar` / `requireEnvVar` / `hasEnvVar` / `getEnvVars` /
45
+ > `loadEnvironment` function, and **no** `env.get()` / `env.require()` /
46
+ > `getByCategory()` / `generateMarkdownDocs()` etc. Those belong to a removed API — do
47
+ > not use them. The current model is: define a schema, build a registry, call
48
+ > `.validate()`, read properties off the returned proxy.
49
+
50
+ ---
51
+
52
+ ## Quick Start
53
+
54
+ ```typescript
55
+ // src/config/env.ts
56
+ import {
57
+ defineEnvSchema,
58
+ envString,
59
+ envNumber,
60
+ envBoolean,
61
+ envEnum,
62
+ createEnvRegistry,
63
+ parsePostgresUrl,
64
+ } from '@spfn/core/env';
65
+
66
+ const schema = defineEnvSchema({
67
+ DATABASE_URL: envString({
68
+ description: 'PostgreSQL connection URL',
69
+ required: true,
70
+ sensitive: true,
71
+ validator: parsePostgresUrl,
72
+ }),
73
+ PORT: envNumber({
74
+ description: 'Server port',
75
+ default: 3000,
76
+ }),
77
+ DEBUG: envBoolean({
78
+ description: 'Enable debug mode',
79
+ default: false,
80
+ }),
81
+ LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
82
+ description: 'Logging level',
83
+ default: 'info',
84
+ }),
85
+ });
86
+
87
+ const registry = createEnvRegistry(schema);
88
+ export const env = registry.validate();
89
+ export type Env = typeof env;
90
+
91
+ // Full type safety:
92
+ env.DATABASE_URL; // string (required)
93
+ env.PORT; // number (default: 3000)
94
+ env.DEBUG; // boolean (default: false)
95
+ env.LOG_LEVEL; // 'debug' | 'info' | 'warn' | 'error'
96
+ ```
97
+
98
+ `defineEnvSchema` auto-fills the `key` field from each object key. You can write the
99
+ schema object inline without it, but then you must set `key` manually on every entry —
100
+ prefer `defineEnvSchema`.
101
+
102
+ ---
103
+
104
+ ## Schema Definition
105
+
106
+ ### Type helpers
107
+
108
+ | Helper | Result type | Default validator |
109
+ |--------|-------------|-------------------|
110
+ | `envString(options)` | `string` | identity (no-op) |
111
+ | `envNumber(options)` | `number` | `parseNumber` |
112
+ | `envBoolean(options)` | `boolean` | `parseBoolean` |
113
+ | `envUrl(options)` | `string` | none (set `validator` yourself, e.g. `parsePostgresUrl`) |
114
+ | `envEnum(allowed, options)` | union of `allowed` | built-in membership check |
115
+ | `envJson<T>(options)` | `T` | `parseJson` |
116
+
117
+ ```typescript
118
+ API_KEY: envString({
119
+ description: 'API authentication key',
120
+ required: true,
121
+ sensitive: true,
122
+ minLength: 32,
123
+ }),
124
+
125
+ PORT: envNumber({
126
+ description: 'Server port',
127
+ default: 3000,
128
+ validator: createNumberParser({ min: 1, max: 65535, integer: true }),
129
+ }),
130
+
131
+ // envUrl has NO default validator — add one for protocol enforcement:
132
+ API_URL: envUrl({
133
+ description: 'API endpoint URL',
134
+ required: true,
135
+ validator: createUrlParser('https'),
136
+ }),
137
+
138
+ LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
139
+ description: 'Logging level',
140
+ default: 'info',
141
+ }),
142
+
143
+ CONFIG: envJson<{ host: string; port: number }>({
144
+ description: 'JSON configuration',
145
+ required: true,
146
+ }),
147
+ ```
148
+
149
+ ### `EnvVarSchema` options
150
+
151
+ | Option | Type | Description |
152
+ |--------|------|-------------|
153
+ | `description` | `string` | Purpose / usage (used in CLI validate output) |
154
+ | `type` | `'string' \| 'number' \| 'boolean' \| 'url' \| 'enum' \| 'json'` | Set by the helper; don't pass manually |
155
+ | `required` | `boolean` | Throw at access time if unset and no default |
156
+ | `default` | `T` | Value returned when the variable is unset |
157
+ | `validator` | `(value: string) => T` | Parse/validate. Throwing fails validation |
158
+ | `fallbackKeys` | `string[]` | Legacy keys read (in order) when the primary key is unset |
159
+ | `minLength` | `number` | Minimum raw string length (checked before `validator`) |
160
+ | `sensitive` | `boolean` | Marks a secret; triggers a warning if `NEXT_PUBLIC_*` |
161
+ | `examples` | `T[]` | Example values (metadata only) |
162
+ | `nextjs` | `boolean` | File-separation hint (see below). Defaults to `true` for `NEXT_PUBLIC_*`, else `false` |
163
+ | `layer` | `'environment' \| 'instance'` | Which deployment layer must supply the value (see below). Unset: may come from any layer |
164
+ | `readBy` | `string[]` | Files that read the name under a computed key, relative to the project root — for `spfn env audit` (see below) |
165
+
166
+ > There is **no** `category` option. Docs that show `category: '...'` are stale — passing
167
+ > it is harmless (excess property) but it does nothing.
168
+
169
+ ### Type inference (`InferEnvType`)
170
+
171
+ ```typescript
172
+ import type { InferEnvType } from '@spfn/core/env';
173
+
174
+ const schema = defineEnvSchema({
175
+ DATABASE_URL: envString({ description: 'DB URL', required: true }),
176
+ PORT: envNumber({ description: 'Port', default: 3000 }),
177
+ DEBUG: envBoolean({ description: 'Debug' }),
178
+ });
179
+
180
+ type Env = InferEnvType<typeof schema>;
181
+ // {
182
+ // DATABASE_URL: string; // required: true → required
183
+ // PORT: number; // has default → required
184
+ // DEBUG?: boolean | undefined; // neither → optional
185
+ // }
186
+ ```
187
+
188
+ Rule: `required: true` **or** a `default` present ⇒ required field. Otherwise optional
189
+ (`| undefined`). `registry.validate()` returns `InferEnvType<typeof schema>`.
190
+
191
+ ---
192
+
193
+ ## Registry
194
+
195
+ ```typescript
196
+ import { createEnvRegistry } from '@spfn/core/env';
197
+
198
+ const registry = createEnvRegistry(schema); // or: new EnvRegistry(schema)
199
+ const env = registry.validate();
200
+ ```
201
+
202
+ ### Lazy validation (timing matters)
203
+
204
+ `registry.validate()` performs **only schema-level checks** (e.g. sensitive + client-
205
+ accessible warnings) and returns a **Proxy**. Each individual variable is read from
206
+ `process.env`, defaulted, and run through its `validator` **at the moment you access the
207
+ property** — not when `validate()` is called.
208
+
209
+ ```typescript
210
+ const env = registry.validate(); // does NOT read process.env values yet
211
+ loadEnv(); // dotenv files populate process.env
212
+ env.DATABASE_URL; // value is read + validated HERE
213
+ ```
214
+
215
+ Consequences:
216
+
217
+ - You can call `validate()` before `loadEnv()` — values are picked up lazily. This is the
218
+ intended order for the SPFN server entry point.
219
+ - A missing `required` variable does **not** throw at `validate()` time; it throws on
220
+ first access (`Error('Environment validation failed')`, with the offending key logged).
221
+ - Accessing an unknown key (not in the schema) returns `undefined`.
222
+
223
+ ### Skipping validation
224
+
225
+ Set `SKIP_ENV_VALIDATION=true` (or `=1`) to make the proxy skip the `required` check —
226
+ missing required vars return their `default` (or `undefined`) instead of throwing. Useful
227
+ for build steps that don't have secrets. `validator`/`minLength` checks still apply when a
228
+ value is present. **`EnvRegistry.validateAll()` ignores this flag** and always reports
229
+ missing requireds.
230
+
231
+ ### `validateAll()` / `validateAllEnv()` (eager, for CLI)
232
+
233
+ For an explicit up-front check of *all* variables (used by `spfn env validate`):
234
+
235
+ ```typescript
236
+ import { validateAllEnv } from '@spfn/core/env';
237
+
238
+ const result = validateAllEnv([coreRegistry, authRegistry]); // EnvValidationResult
239
+ if (!result.valid) {
240
+ for (const e of result.errors) {
241
+ console.error(`${e.key}: ${e.message}`);
242
+ }
243
+ process.exit(1);
244
+ }
245
+ for (const w of result.warnings) {
246
+ console.warn(`${w.key}: ${w.message}`);
247
+ }
248
+ ```
249
+
250
+ `EnvValidationResult` is `{ valid: boolean; errors: {key,message}[]; warnings: {key,message}[] }`.
251
+ A single registry exposes the same via `registry.validateAll()` returning just
252
+ `{ errors, warnings }`.
253
+
254
+ Messages carry the key and the rule, never the value. For a `sensitive` key the validator's
255
+ own message is replaced by `(value hidden — sensitive key)`, because a validator may echo its
256
+ input; core's parsers describe the expected shape instead of quoting the input. An app
257
+ validator on a non-sensitive key should do the same.
258
+
259
+ ### Boot-time check (the server)
260
+
261
+ `startServer()` runs `validateAllEnv` right after loading the env files and the app's
262
+ `server.config`, before it initializes infrastructure or listens. The list is `@spfn/core`'s
263
+ registry plus the registries the app hands over in `server.config`:
264
+
265
+ ```typescript
266
+ // src/server/config/env.config.ts
267
+ export const envSchema = defineEnvSchema({ /* ... */ });
268
+ export const envRegistry = createEnvRegistry(envSchema);
269
+ export const env = envRegistry.validate();
270
+
271
+ // src/server/server.config.ts
272
+ import { envRegistry } from '@/server/config/env.config';
273
+
274
+ export default defineServerConfig()
275
+ .env({ registries: [envRegistry] })
276
+ .build();
277
+ ```
278
+
279
+ On failure it logs each key with its reason (never a value) and exits with code 1. A
280
+ package's variables are checked only when the app passes a registry for them —
281
+ `createEnvRegistry(envSchema)` over the schema the package exports from `./config`.
282
+
283
+ - `SKIP_ENV_VALIDATION` does not apply: it relaxes the lazy proxy for build steps, and a
284
+ server about to serve is not a build step.
285
+ - `createServerlessApp()` runs the same check and rejects its promise instead of exiting.
286
+ - `createServer()` (the app factory both of them use) and `provisionInfrastructure()` do not
287
+ run it.
288
+ - `@spfn/core` itself requires `SPFN_API_URL`, so the server process needs it set.
289
+ `NEXT_PUBLIC_SPFN_API_URL` is optional there: Next.js inlines it at build time and the
290
+ server falls back to `SPFN_API_URL`.
291
+
292
+ ### The app's schema in the CLI (`spfn.config.js` `env.schemas`)
293
+
294
+ `spfn env` and `spfn secret` find a package's schema at `<package>/config`; an app's own
295
+ schema can live anywhere, so `spfn.config.js` names it:
296
+
297
+ ```js
298
+ // spfn.config.js
299
+ export default {
300
+ env: {
301
+ schemas: ['src/server/config/env.config.ts'], // modules exporting `envSchema`
302
+ },
303
+ };
304
+ ```
305
+
306
+ Paths are relative to the project root; TypeScript and tsconfig path aliases are supported.
307
+ Without `-p <package>`, the CLI reads these modules plus every installed `@spfn/*` package
308
+ whose `./config` exports `envSchema`, and groups its output by source. A key two sources
309
+ declare identically is listed once; two declarations that differ in type, `required` or
310
+ `sensitive` are an error naming the key and both sources — one variable has one owner, and
311
+ other packages read it from the owner's `env` instead of declaring it again.
312
+
313
+ ### Auditing the code against the list (`env.audit`, `readBy`)
314
+
315
+ `spfn env audit` keeps the list and the code in step, and exits 1 on any finding. It prints
316
+ `file:line NAME reason` — names only, never values — for:
317
+
318
+ - **Direct reads.** `process.env.X`, `process.env['X']`, `process.env[expr]` and
319
+ `const { X } = process.env` (also through `process?.env` and `globalThis.process.env`).
320
+ Allowed in the modules named in `env.schemas`, in root-level `*.config.{js,cjs,mjs,ts}`
321
+ files and in files matching `env.audit.ignore`. `NODE_ENV` is allowed everywhere.
322
+ - **Unused declarations.** A name in the app's own schemas that no scanned file outside the
323
+ schema modules reads — as a property (`env.X`), a destructured key (`const { X } = env`)
324
+ or a string literal `'X'`. Names an installed `@spfn/*` package also declares are read
325
+ inside that package and are not checked. Without `env.schemas` this check is skipped
326
+ with a notice.
327
+
328
+ ```js
329
+ // spfn.config.js
330
+ export default {
331
+ env: {
332
+ schemas: ['src/server/config/env.config.ts'],
333
+ audit: {
334
+ include: ['src'], // default; directories or globs from the project root
335
+ ignore: ['src/**/*.test.ts'], // may read process.env; the names they read do not count
336
+ },
337
+ },
338
+ };
339
+ ```
340
+
341
+ `node_modules`, build output (`dist`, `build`, `out`, `.next`, …), `*.d.ts` files and
342
+ symbolic links are never scanned. Sources are parsed with TypeScript, so a mention in a
343
+ comment or a string is not a read.
344
+
345
+ A name the code only reaches under a computed key — ``env[`${provider}_API_KEY`]`` — is
346
+ invisible to the scan. List the files that read it in `readBy`:
347
+
348
+ ```typescript
349
+ const schema = defineEnvSchema({
350
+ PAYMENT_API_KEY: envSecret({ description: 'Payment provider key', readBy: ['src/server/providers.ts'] }),
351
+ });
352
+ ```
353
+
354
+ The name passes when every listed file exists and is scanned; a listed file that does not
355
+ exist, or that the audit does not scan, is a finding. The runtime ignores `readBy`.
356
+
357
+ A name that appears only in a log message string counts as read — the string-literal rule
358
+ cannot tell a message from a lookup key.
359
+
360
+ ### Deployment layers (`layer`)
361
+
362
+ A deployed environment can run more than one instance — `staging` as `blue` and `green`,
363
+ each with its own port, URLs and database. `layer` pins where a variable's value must come
364
+ from, so `spfn secret export` can assemble one instance's env file and refuse a value that
365
+ arrives from the wrong place. Unset, the value may come from any layer — a package schema
366
+ leaves it unset, so an app can keep a value such as a session secret shared or give each
367
+ instance its own:
368
+
369
+ | Layer | Holds | Source |
370
+ |-------|-------|--------|
371
+ | `environment` | Values every instance of the environment shares — third-party API keys | `secrets/<env>.enc.json` |
372
+ | `instance` | Values of one instance: its own secrets, or plain values the deploy script computes (port, URLs, database name) | `secrets/<env>.<instance>.enc.json`, or a `--with <file>` dotenv file |
373
+
374
+ ```typescript
375
+ const schema = defineEnvSchema({
376
+ PAYMENT_API_KEY: envSecret({ description: 'Payment provider key', required: true, layer: 'environment' }),
377
+ DB_NAME: envString({ description: 'Database of this instance', required: true, layer: 'instance' }),
378
+ });
379
+ ```
380
+
381
+ A name declared `environment` found in an instance layer, a name declared `instance` found
382
+ in the environment file, or any name found in two layers fails the export. The runtime ignores
383
+ `layer`; it only steers the CLI.
384
+
385
+ ### Fallback keys
386
+
387
+ ```typescript
388
+ DATABASE_URL: envString({
389
+ description: 'Database URL',
390
+ required: true,
391
+ fallbackKeys: ['DB_URL', 'POSTGRES_URL'], // tried in order if DATABASE_URL is unset
392
+ }),
393
+ ```
394
+
395
+ ### Other `EnvRegistry` methods
396
+
397
+ `register(schema)`, `registerMultiple(schemas)` — add schemas after construction.
398
+ `reset()` — clears the internal "already validated" flag (test helper).
399
+
400
+ ---
401
+
402
+ ## Environment File Loading
403
+
404
+ `loadEnv` is server-only (`@spfn/core/env/loader`). It parses `.env*` files, merges them,
405
+ and writes into `process.env`.
406
+
407
+ ```typescript
408
+ import { loadEnv } from '@spfn/core/env/loader';
409
+ import { createEnvRegistry } from '@spfn/core/env';
410
+ import { envSchema } from './env.schema';
411
+
412
+ loadEnv(); // server entry point
413
+ const env = createEnvRegistry(envSchema).validate();
414
+ ```
415
+
416
+ ### Loading priority (5 tiers, low → high; later overrides earlier)
417
+
418
+ `NODE_ENV` (default `'local'`) selects which files apply:
419
+
420
+ 1. `.env` — common defaults (committed)
421
+ 2. `.env.{NODE_ENV}` — per-environment override (committed)
422
+ 3. `.env.local` — local override (gitignored; **skipped when `NODE_ENV=test`**)
423
+ 4. `.env.{NODE_ENV}.local` — per-environment secrets (gitignored)
424
+ 5. `.env.server` — SPFN-server-only secrets (gitignored; **never read by Next.js**)
425
+
426
+ Within `loadEnv`, **keys already present in `process.env` are not overwritten** (so
427
+ platform-injected vars win) unless `override: true`.
428
+
429
+ ### `LoadEnvOptions`
430
+
431
+ ```typescript
432
+ loadEnv({
433
+ cwd: '/path/to/project', // project root (default: process.cwd())
434
+ nodeEnv: 'production', // selects .env.{nodeEnv} (default: process.env.NODE_ENV || 'local')
435
+ server: true, // include .env.server (default: true; set false for Next.js client builds)
436
+ debug: true, // log loaded files (default: false)
437
+ override: false, // overwrite existing process.env keys (default: false)
438
+ });
439
+ ```
440
+
441
+ `loadEnv` returns `LoadEnvResult` = `{ loadedFiles: string[]; loadedKeys: string[] }`.
442
+
443
+ ### `loadEnvOnce()` / `resetEnvLoadState()`
444
+
445
+ `loadEnvOnce(options?)` runs `loadEnv` once per process; subsequent calls return an empty
446
+ result. `resetEnvLoadState()` clears that latch (test helper).
447
+
448
+ ### Next.js does its own loading
449
+
450
+ Next.js loads `.env` / `.env.local` / `.env.{NODE_ENV}` itself. In Next.js code (server
451
+ components, route handlers) **do not call `loadEnv()`** — just build the registry and read
452
+ values. `.env.server` is intentionally invisible to Next.js (that is the whole point of
453
+ the separation below).
454
+
455
+ ---
456
+
457
+ ## Security Separation (Next.js + SPFN)
458
+
459
+ SPFN runs as a **separate process** from Next.js. Next.js server components can read every
460
+ variable in `.env` / `.env.local`; a server-side data-exfiltration bug (e.g. a
461
+ react2shell-style vulnerability) would expose all of them. Keep server-only secrets out of
462
+ files Next.js reads.
463
+
464
+ ### File layout
465
+
466
+ ```
467
+ project/
468
+ ├── .env # common defaults (committed)
469
+ ├── .env.production # production non-secret overrides (committed)
470
+ ├── .env.local # Next.js local overrides (gitignored)
471
+ ├── .env.production.local # production secrets (gitignored)
472
+ └── .env.server # SPFN-server-only secrets (gitignored)
473
+ ```
474
+
475
+ Commit a `.env.server.example` template (placeholder values only) so teammates know which
476
+ keys to fill in.
477
+
478
+ ### Which file for what?
479
+
480
+ | Variable | File | Why |
481
+ |----------|------|-----|
482
+ | `NEXT_PUBLIC_*` | `.env.local` | Client-exposed; browser-safe |
483
+ | `SPFN_API_URL` (per-env) | `.env` / `.env.production` | Used by Next.js too; non-secret |
484
+ | `SPFN_APP_URL` | `.env.local` | Next.js local setting |
485
+ | `DB_POOL_MAX` | `.env.server` | Server-only, non-secret |
486
+ | `DATABASE_URL` | `.env.server` | Server-only, **secret** |
487
+ | `SESSION_SECRET` | `.env.server` | Server-only, **secret** |
488
+
489
+ Rule of thumb: anything Next.js must read goes in `.env` / `.env.local`; everything
490
+ server-only (especially secrets) goes in `.env.server`.
491
+
492
+ ### Schema `nextjs` flag
493
+
494
+ `nextjs` documents which process a variable belongs to. Defaults: `true` for
495
+ `NEXT_PUBLIC_*`, `false` otherwise. The type guards below consume it.
496
+
497
+ ```typescript
498
+ DATABASE_URL: envString({
499
+ description: 'PostgreSQL connection URL',
500
+ required: true,
501
+ sensitive: true,
502
+ nextjs: false, // SPFN-server-only → belongs in .env.server
503
+ }),
504
+
505
+ SPFN_API_URL: envString({
506
+ description: 'Backend API URL',
507
+ required: true,
508
+ nextjs: true, // Next.js also reads it → .env / .env.local
509
+ }),
510
+
511
+ NEXT_PUBLIC_WS_URL: envString({
512
+ description: 'WebSocket URL',
513
+ // nextjs defaults to true (NEXT_PUBLIC_ prefix)
514
+ }),
515
+ ```
516
+
517
+ ### Type guards
518
+
519
+ ```typescript
520
+ import {
521
+ isClientAccessible, isServerOnly,
522
+ isNextjsAccessible, isSpfnServerOnly,
523
+ } from '@spfn/core/env';
524
+
525
+ isClientAccessible('NEXT_PUBLIC_API_URL'); // true — string key, NEXT_PUBLIC_ prefix
526
+ isClientAccessible('DATABASE_URL'); // false
527
+ isServerOnly('DATABASE_URL'); // true — inverse of isClientAccessible
528
+
529
+ // These take a SCHEMA object (not a string) and honor the `nextjs` flag:
530
+ isNextjsAccessible(schema.SPFN_API_URL); // true
531
+ isSpfnServerOnly(schema.DATABASE_URL); // true — inverse of isNextjsAccessible
532
+ ```
533
+
534
+ > `isClientAccessible` / `isServerOnly` take a **string key**.
535
+ > `isNextjsAccessible` / `isSpfnServerOnly` take an **`EnvVarSchema` object** and respect
536
+ > its `nextjs` flag. Don't mix them up.
537
+
538
+ ### Sensitive-on-client warning
539
+
540
+ `registry.validate()` (and `validateAll()`) emit a warning if a `sensitive` variable is
541
+ client-accessible — fix it by dropping the `NEXT_PUBLIC_` prefix, not by unmarking it:
542
+
543
+ ```typescript
544
+ // Wrong — secret exposed to the browser, triggers a warning:
545
+ NEXT_PUBLIC_API_SECRET: envString({ description: 'secret', sensitive: true }),
546
+
547
+ // Right:
548
+ API_SECRET: envString({ description: 'secret', sensitive: true }),
549
+ ```
550
+
551
+ ---
552
+
553
+ ## Parsers
554
+
555
+ All parsers are `(value: string) => T` and **throw** on invalid input. Use them as a
556
+ schema `validator`, or standalone.
557
+
558
+ ### String
559
+
560
+ ```typescript
561
+ import { parseString, createStringParser } from '@spfn/core/env';
562
+
563
+ parseString(' hello '); // 'hello' (trims; throws if empty)
564
+
565
+ createStringParser({ minLength: 32, maxLength: 128, pattern: /^[A-Za-z0-9_-]+$/, trim: true });
566
+ ```
567
+
568
+ ### Boolean
569
+
570
+ ```typescript
571
+ import { parseBoolean } from '@spfn/core/env';
572
+
573
+ parseBoolean('true' /* | '1' | 'yes' */); // true (case-insensitive)
574
+ parseBoolean('false' /* | '0' | 'no' */); // false
575
+ // anything else → throws
576
+ ```
577
+
578
+ ### Number
579
+
580
+ ```typescript
581
+ import {
582
+ parseNumber, createNumberParser, parseInteger, parseDecimal,
583
+ } from '@spfn/core/env';
584
+
585
+ parseNumber('42'); // 42
586
+ parseNumber('42', { min: 1, max: 100, integer: true });
587
+ createNumberParser({ min: 1, max: 65535, integer: true });
588
+ parseInteger('42', { min: 1, max: 100 }); // integer-constrained
589
+ parseDecimal('0.75', { min: 0, max: 1 }); // float-constrained
590
+ ```
591
+
592
+ > The float helper is named **`parseDecimal`**. There is no `parseFloat` export.
593
+
594
+ ### URL
595
+
596
+ ```typescript
597
+ import {
598
+ parseUrl, createUrlParser, parsePostgresUrl, parseRedisUrl,
599
+ } from '@spfn/core/env';
600
+
601
+ parseUrl('https://api.example.com'); // any protocol
602
+ parseUrl('https://x', { protocol: 'https' }); // enforce protocol
603
+ createUrlParser('https'); // reusable
604
+ parsePostgresUrl('postgres://u:p@host:5432/db'); // postgres:// or postgresql://
605
+ parseRedisUrl('redis://localhost:6379'); // redis:// or rediss://
606
+ ```
607
+
608
+ ### Enum
609
+
610
+ ```typescript
611
+ import { parseEnum, createEnumParser } from '@spfn/core/env';
612
+
613
+ parseEnum('info', ['debug', 'info', 'warn', 'error']);
614
+ createEnumParser(['debug', 'info', 'warn', 'error'], true /* caseInsensitive */);
615
+ ```
616
+
617
+ > For schema fields prefer the `envEnum(allowed, options)` helper — it produces a precise
618
+ > string-literal union type. `parseEnum`/`createEnumParser` return a plain `string`.
619
+
620
+ ### JSON
621
+
622
+ ```typescript
623
+ import { parseJson, createJsonParser } from '@spfn/core/env';
624
+
625
+ parseJson('{"host":"localhost","port":3000}');
626
+ createJsonParser<{ host: string; port: number }>();
627
+ ```
628
+
629
+ ### Array
630
+
631
+ ```typescript
632
+ import { parseArray, createArrayParser, createNumberParser } from '@spfn/core/env';
633
+
634
+ parseArray('a,b,c'); // ['a','b','c']
635
+ parseArray('a|b|c', { separator: '|' }); // ['a','b','c'] (empty string → [])
636
+
637
+ createArrayParser(createNumberParser({ min: 1, max: 65535, integer: true }))('3000,4000');
638
+ // → [3000, 4000]
639
+ ```
640
+
641
+ ### Secrets & passwords
642
+
643
+ ```typescript
644
+ import { createSecureSecretParser, createPasswordParser } from '@spfn/core/env';
645
+
646
+ // Entropy-based secret check (Shannon entropy in bits/char)
647
+ createSecureSecretParser({ minLength: 32, minUniqueChars: 16, minEntropy: 3.5 });
648
+ // reference: random lowercase ~4.7 · alphanumeric ~5.2 · printable ASCII ~6.6 · "aaaa" ~0
649
+
650
+ createPasswordParser({
651
+ minLength: 12,
652
+ requireUppercase: true,
653
+ requireLowercase: true,
654
+ requireNumber: true,
655
+ requireSpecial: true,
656
+ });
657
+ ```
658
+
659
+ ### Composition
660
+
661
+ ```typescript
662
+ import { chain, withFallback, optional, parseString, createStringParser, parseRedisUrl, parseJson } from '@spfn/core/env';
663
+
664
+ chain(parseString, createStringParser({ minLength: 32 })); // run sequentially
665
+ withFallback(parseJson, { host: 'localhost' }); // return fallback if parser throws
666
+ optional(parseRedisUrl); // '' → undefined, else parse
667
+ ```
668
+
669
+ ---
670
+
671
+ ## Pitfalls & anti-patterns
672
+
673
+ - **Don't import `loadEnv` from `@spfn/core/env`.** It lives in `@spfn/core/env/loader`
674
+ (server-only, uses `node:fs`). Importing the loader into client/edge code breaks bundling.
675
+ - **`.env.server.local` does not exist.** The loader's 5 tiers are `.env`,
676
+ `.env.{NODE_ENV}`, `.env.local`, `.env.{NODE_ENV}.local`, `.env.server`. Do not invent a
677
+ `.env.server.local` file — it is never loaded.
678
+ - **`.env.server` is gitignored and Next.js-invisible by design.** Commit
679
+ `.env.server.example` as the template; never put secrets in `.env`/`.env.local`/committed
680
+ files.
681
+ - **Don't call `loadEnv()` inside Next.js.** Next.js already loads `.env*` (minus
682
+ `.env.server`). Calling `loadEnv` there is redundant and, with `server: true`, would pull
683
+ server secrets into the Next.js process — defeating the separation.
684
+ - **`validate()` is lazy.** It does not throw for missing required vars; the throw happens
685
+ on first property access. For an eager all-at-once check (CI/startup gate) use
686
+ `validateAllEnv([...])` / `registry.validateAll()`.
687
+ - **`envUrl` has no built-in validator.** Without `validator`, it only enforces presence —
688
+ add `parsePostgresUrl` / `createUrlParser('https')` etc. for actual URL validation.
689
+ - **`sensitive: true` + `NEXT_PUBLIC_` is wrong.** It only emits a warning, not an error —
690
+ the secret is still exposed. Remove the prefix.
691
+ - **`SKIP_ENV_VALIDATION` only skips the `required` check** (and only in the lazy proxy);
692
+ present values are still validated, and `validateAll()` — and with it the server's boot
693
+ check — ignores the flag entirely.
694
+ - **A validator message must not contain the value.** Validation errors are logged. Throw
695
+ `'Must be a valid number'`, not `` `got: ${value}` ``.
696
+ - **Removed API.** `getEnvVar`, `requireEnvVar`, `hasEnvVar`, `getEnvVars`,
697
+ `loadEnvironment`, `namespace`/`useFolderStructure`/`customPaths`/`useCache` loader
698
+ options, `env.get()`/`env.require()`, `getByCategory()`/`getAllSchemas()`,
699
+ `generateMarkdownDocs`/`generateEnvExample`/`generateJsonDocs`, and the `category` schema
700
+ option **do not exist**. Some validator JSDoc still shows `getEnvVar(...)` in examples —
701
+ that is stale; use the schema-`validator` pattern instead.
702
+
703
+ ---
704
+
705
+ ## Complete example
706
+
707
+ ```typescript
708
+ // src/config/env.ts
709
+ import {
710
+ defineEnvSchema,
711
+ envString, envNumber, envBoolean, envEnum, envUrl,
712
+ createEnvRegistry,
713
+ parsePostgresUrl, createNumberParser, createSecureSecretParser, createUrlParser,
714
+ } from '@spfn/core/env';
715
+
716
+ const schema = defineEnvSchema({
717
+ DATABASE_URL: envString({
718
+ description: 'PostgreSQL connection URL',
719
+ required: true,
720
+ sensitive: true,
721
+ nextjs: false,
722
+ validator: parsePostgresUrl,
723
+ }),
724
+ PORT: envNumber({
725
+ description: 'Server port',
726
+ default: 3000,
727
+ validator: createNumberParser({ min: 1, max: 65535, integer: true }),
728
+ }),
729
+ HOST: envString({ description: 'Server host', default: '0.0.0.0' }),
730
+ SESSION_SECRET: envString({
731
+ description: 'Session encryption secret',
732
+ required: true,
733
+ sensitive: true,
734
+ nextjs: false,
735
+ validator: createSecureSecretParser({ minLength: 32 }),
736
+ }),
737
+ NODE_ENV: envEnum(['local', 'development', 'staging', 'production', 'test'] as const, {
738
+ description: 'Node environment',
739
+ default: 'local',
740
+ }),
741
+ LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error', 'fatal'] as const, {
742
+ description: 'Log level',
743
+ default: 'info',
744
+ }),
745
+ REDIS_URL: envUrl({
746
+ description: 'Redis connection URL (optional)',
747
+ required: false,
748
+ validator: createUrlParser('any'),
749
+ }),
750
+ DEBUG: envBoolean({ description: 'Enable debug mode', default: false }),
751
+ });
752
+
753
+ const registry = createEnvRegistry(schema);
754
+ export const env = registry.validate();
755
+ export type Env = typeof env;
756
+ ```
757
+
758
+ ```typescript
759
+ // SPFN server entry point
760
+ import { loadEnv } from '@spfn/core/env/loader';
761
+ import { env } from '@/config/env';
762
+
763
+ loadEnv(); // populate process.env from .env* (server reads .env.server too)
764
+ console.log(env.PORT); // validated lazily on access
765
+ ```
766
+
767
+ ```typescript
768
+ // Next.js — no loadEnv()
769
+ import { env } from '@/config/env';
770
+ const dbUrl = env.DATABASE_URL; // (only resolves if DATABASE_URL is in the Next.js process)
771
+ ```
772
+
773
+ ---
774
+
775
+ ## Types reference
776
+
777
+ ```typescript
778
+ type NodeEnv = 'local' | 'development' | 'staging' | 'production' | 'test';
779
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; // re-exported from logger
780
+ type Parser<T> = (value: string) => T;
781
+ ```
782
+
783
+ ## Related
784
+
785
+ - [@spfn/core/config](../config/README.md) — application configuration
786
+ - [@spfn/core/logger](../logger/README.md) — logging infrastructure
787
+ - [@spfn/core](../../README.md) — main package documentation