@nage-api/config 1.0.0-beta.2
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/LICENSE +202 -0
- package/README.md +172 -0
- package/dist/config.module.d.ts +28 -0
- package/dist/config.module.js +84 -0
- package/dist/config.service.d.ts +47 -0
- package/dist/config.service.js +106 -0
- package/dist/define-config.d.ts +69 -0
- package/dist/define-config.js +131 -0
- package/dist/env/base-env.schema.d.ts +55 -0
- package/dist/env/base-env.schema.js +42 -0
- package/dist/env/fail-fast.d.ts +25 -0
- package/dist/env/fail-fast.js +39 -0
- package/dist/env/load-env.d.ts +51 -0
- package/dist/env/load-env.js +94 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +48 -0
- package/dist/secrets/aws.secret-provider.d.ts +49 -0
- package/dist/secrets/aws.secret-provider.js +92 -0
- package/dist/secrets/caching.secret-provider.d.ts +24 -0
- package/dist/secrets/caching.secret-provider.js +55 -0
- package/dist/secrets/env.secret-provider.d.ts +21 -0
- package/dist/secrets/env.secret-provider.js +38 -0
- package/dist/tokens.d.ts +15 -0
- package/dist/tokens.js +16 -0
- package/package.json +68 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineConfig()` — the typed replacement for the legacy functional config blob
|
|
3
|
+
* (PLAN.md §11.2).
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately not just an identity function. It:
|
|
6
|
+
* - types the literal against `NageConfig`, so a typo is a compile error
|
|
7
|
+
* rather than an `undefined` at runtime,
|
|
8
|
+
* - merges workspace defaults with app overrides (§11.1 layers 2 and 3),
|
|
9
|
+
* - and applies framework defaults (layer 1) at read time via `withDefaults`.
|
|
10
|
+
*/
|
|
11
|
+
import type { NageConfig } from '@nage-api/contracts';
|
|
12
|
+
/** A config fragment: any subset of the full shape, for shared defaults. */
|
|
13
|
+
export type ConfigFragment = DeepPartialConfig<NageConfig>;
|
|
14
|
+
type DeepPartialConfig<T> = {
|
|
15
|
+
[K in keyof T]?: T[K] extends readonly (infer U)[] ? readonly U[] : T[K] extends object | undefined ? DeepPartialConfig<NonNullable<T[K]>> : T[K];
|
|
16
|
+
};
|
|
17
|
+
export interface DefineConfigOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Shared workspace defaults this app extends (§11.1 layer 2). Later entries
|
|
20
|
+
* win, and the app's own literal wins over all of them.
|
|
21
|
+
*/
|
|
22
|
+
readonly extends?: readonly ConfigFragment[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Deep-merge for config layers. Arrays replace rather than concatenate: a CORS
|
|
26
|
+
* allow-list or a MIME list is a complete statement of policy, and appending to
|
|
27
|
+
* an inherited one is how an override accidentally widens it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function mergeConfig<TTarget extends object>(base: TTarget, override: unknown): TTarget;
|
|
30
|
+
/**
|
|
31
|
+
* Declare an application's configuration.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* export default defineConfig({
|
|
35
|
+
* app: { name: 'my-api', environment: env.NODE_ENV, port: env.PORT },
|
|
36
|
+
* http: { cors: { origins: env.CORS_ORIGINS } },
|
|
37
|
+
* database: { enabled: true, driver: 'postgres', url: env.DATABASE_URL, ssl: 'verify-full' },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function defineConfig(config: NageConfig, options?: DefineConfigOptions): NageConfig;
|
|
42
|
+
/** Reusable fragment for workspace-level defaults (`@app/config`). */
|
|
43
|
+
export declare function defineConfigFragment(fragment: ConfigFragment): ConfigFragment;
|
|
44
|
+
/** Framework defaults — §11.1 layer 1. Applied at read time, never persisted. */
|
|
45
|
+
export declare const FRAMEWORK_DEFAULTS: {
|
|
46
|
+
readonly port: 3000;
|
|
47
|
+
readonly host: "0.0.0.0";
|
|
48
|
+
readonly apiVersion: 1;
|
|
49
|
+
readonly logLevel: "info";
|
|
50
|
+
readonly databaseSsl: "verify-full";
|
|
51
|
+
readonly maxQueryLimit: 100;
|
|
52
|
+
readonly jwtAlgorithm: "RS256";
|
|
53
|
+
readonly accessTtl: "15m";
|
|
54
|
+
readonly refreshTtl: "30d";
|
|
55
|
+
readonly passwordAlgorithm: "argon2id";
|
|
56
|
+
readonly passwordMinLength: 12;
|
|
57
|
+
readonly otpLength: 6;
|
|
58
|
+
readonly otpTtl: "10m";
|
|
59
|
+
readonly lockoutMaxAttempts: 5;
|
|
60
|
+
readonly lockoutWindow: "15m";
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Fill in framework defaults for the values `@nage-api/core` and the feature
|
|
64
|
+
* packages read most often, so every consumer sees the same answer instead of
|
|
65
|
+
* repeating `?? 3000` at each call site.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withDefaults(config: NageConfig): NageConfig;
|
|
68
|
+
export {};
|
|
69
|
+
//# sourceMappingURL=define-config.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `defineConfig()` — the typed replacement for the legacy functional config blob
|
|
4
|
+
* (PLAN.md §11.2).
|
|
5
|
+
*
|
|
6
|
+
* It is deliberately not just an identity function. It:
|
|
7
|
+
* - types the literal against `NageConfig`, so a typo is a compile error
|
|
8
|
+
* rather than an `undefined` at runtime,
|
|
9
|
+
* - merges workspace defaults with app overrides (§11.1 layers 2 and 3),
|
|
10
|
+
* - and applies framework defaults (layer 1) at read time via `withDefaults`.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.FRAMEWORK_DEFAULTS = void 0;
|
|
14
|
+
exports.mergeConfig = mergeConfig;
|
|
15
|
+
exports.defineConfig = defineConfig;
|
|
16
|
+
exports.defineConfigFragment = defineConfigFragment;
|
|
17
|
+
exports.withDefaults = withDefaults;
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Deep-merge for config layers. Arrays replace rather than concatenate: a CORS
|
|
23
|
+
* allow-list or a MIME list is a complete statement of policy, and appending to
|
|
24
|
+
* an inherited one is how an override accidentally widens it.
|
|
25
|
+
*/
|
|
26
|
+
function mergeConfig(base, override) {
|
|
27
|
+
if (!isPlainObject(override))
|
|
28
|
+
return base;
|
|
29
|
+
const result = { ...base };
|
|
30
|
+
for (const [key, value] of Object.entries(override)) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
const existing = result[key];
|
|
34
|
+
result[key] =
|
|
35
|
+
isPlainObject(existing) && isPlainObject(value) ? mergeConfig(existing, value) : value;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Declare an application's configuration.
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* export default defineConfig({
|
|
44
|
+
* app: { name: 'my-api', environment: env.NODE_ENV, port: env.PORT },
|
|
45
|
+
* http: { cors: { origins: env.CORS_ORIGINS } },
|
|
46
|
+
* database: { enabled: true, driver: 'postgres', url: env.DATABASE_URL, ssl: 'verify-full' },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
function defineConfig(config, options = {}) {
|
|
51
|
+
const merged = (options.extends ?? []).reduce((accumulated, fragment) => mergeConfig(accumulated, fragment), {});
|
|
52
|
+
return mergeConfig(merged, config);
|
|
53
|
+
}
|
|
54
|
+
/** Reusable fragment for workspace-level defaults (`@app/config`). */
|
|
55
|
+
function defineConfigFragment(fragment) {
|
|
56
|
+
return fragment;
|
|
57
|
+
}
|
|
58
|
+
/** Framework defaults — §11.1 layer 1. Applied at read time, never persisted. */
|
|
59
|
+
exports.FRAMEWORK_DEFAULTS = {
|
|
60
|
+
port: 3000,
|
|
61
|
+
host: '0.0.0.0',
|
|
62
|
+
apiVersion: 1,
|
|
63
|
+
logLevel: 'info',
|
|
64
|
+
databaseSsl: 'verify-full',
|
|
65
|
+
maxQueryLimit: 100,
|
|
66
|
+
jwtAlgorithm: 'RS256',
|
|
67
|
+
accessTtl: '15m',
|
|
68
|
+
refreshTtl: '30d',
|
|
69
|
+
passwordAlgorithm: 'argon2id',
|
|
70
|
+
passwordMinLength: 12,
|
|
71
|
+
otpLength: 6,
|
|
72
|
+
otpTtl: '10m',
|
|
73
|
+
lockoutMaxAttempts: 5,
|
|
74
|
+
lockoutWindow: '15m',
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Fill in framework defaults for the values `@nage-api/core` and the feature
|
|
78
|
+
* packages read most often, so every consumer sees the same answer instead of
|
|
79
|
+
* repeating `?? 3000` at each call site.
|
|
80
|
+
*/
|
|
81
|
+
function withDefaults(config) {
|
|
82
|
+
const environment = config.app.environment;
|
|
83
|
+
return mergeConfig({
|
|
84
|
+
app: {
|
|
85
|
+
port: exports.FRAMEWORK_DEFAULTS.port,
|
|
86
|
+
host: exports.FRAMEWORK_DEFAULTS.host,
|
|
87
|
+
version: exports.FRAMEWORK_DEFAULTS.apiVersion,
|
|
88
|
+
},
|
|
89
|
+
versioning: { enabled: true, defaultVersion: exports.FRAMEWORK_DEFAULTS.apiVersion },
|
|
90
|
+
validation: { enabled: true, whitelist: true, forbidNonWhitelisted: true, transform: true },
|
|
91
|
+
shutdown: { enabled: true },
|
|
92
|
+
logging: {
|
|
93
|
+
level: exports.FRAMEWORK_DEFAULTS.logLevel,
|
|
94
|
+
json: environment !== 'development',
|
|
95
|
+
},
|
|
96
|
+
...(config.database === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: {
|
|
99
|
+
database: {
|
|
100
|
+
ssl: exports.FRAMEWORK_DEFAULTS.databaseSsl,
|
|
101
|
+
maxQueryLimit: exports.FRAMEWORK_DEFAULTS.maxQueryLimit,
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
...(config.auth === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: {
|
|
107
|
+
auth: {
|
|
108
|
+
strategy: 'jwt',
|
|
109
|
+
jwt: {
|
|
110
|
+
algorithm: exports.FRAMEWORK_DEFAULTS.jwtAlgorithm,
|
|
111
|
+
accessTtl: exports.FRAMEWORK_DEFAULTS.accessTtl,
|
|
112
|
+
},
|
|
113
|
+
refresh: {
|
|
114
|
+
ttl: exports.FRAMEWORK_DEFAULTS.refreshTtl,
|
|
115
|
+
rotate: true,
|
|
116
|
+
reuseDetection: true,
|
|
117
|
+
},
|
|
118
|
+
password: {
|
|
119
|
+
algorithm: exports.FRAMEWORK_DEFAULTS.passwordAlgorithm,
|
|
120
|
+
minLength: exports.FRAMEWORK_DEFAULTS.passwordMinLength,
|
|
121
|
+
},
|
|
122
|
+
otp: { length: exports.FRAMEWORK_DEFAULTS.otpLength, ttl: exports.FRAMEWORK_DEFAULTS.otpTtl },
|
|
123
|
+
lockout: {
|
|
124
|
+
maxAttempts: exports.FRAMEWORK_DEFAULTS.lockoutMaxAttempts,
|
|
125
|
+
window: exports.FRAMEWORK_DEFAULTS.lockoutWindow,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
}, config);
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=define-config.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env fragments every application needs (PLAN.md §11.3).
|
|
3
|
+
*
|
|
4
|
+
* Applications compose these with their own variables rather than restating
|
|
5
|
+
* them, so `NODE_ENV`, `PORT` and the CORS allow-list are parsed the same way
|
|
6
|
+
* everywhere — including by `nage doctor` (Phase 4/6).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
/** `production` is not the default: a deployment must say what it is. */
|
|
10
|
+
export declare const nodeEnvSchema: z.ZodEnum<{
|
|
11
|
+
development: "development";
|
|
12
|
+
test: "test";
|
|
13
|
+
staging: "staging";
|
|
14
|
+
production: "production";
|
|
15
|
+
}>;
|
|
16
|
+
/** Ports are numeric and bounded; `parseInt(...) || 3000` hid typos. */
|
|
17
|
+
export declare const portSchema: z.ZodCoercedNumber<unknown>;
|
|
18
|
+
export declare const logLevelSchema: z.ZodEnum<{
|
|
19
|
+
info: "info";
|
|
20
|
+
trace: "trace";
|
|
21
|
+
debug: "debug";
|
|
22
|
+
warn: "warn";
|
|
23
|
+
error: "error";
|
|
24
|
+
fatal: "fatal";
|
|
25
|
+
}>;
|
|
26
|
+
/** Comma-separated origin allow-list → array. Empty entries are dropped. */
|
|
27
|
+
export declare const originListSchema: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>, z.ZodArray<z.ZodURL>>;
|
|
28
|
+
/**
|
|
29
|
+
* A secret that must actually be set to something. No default, ever — a default
|
|
30
|
+
* secret is the vulnerability the legacy `admin@admin.com/123456` seed was (§12).
|
|
31
|
+
*/
|
|
32
|
+
export declare const secretSchema: z.ZodString;
|
|
33
|
+
export declare const durationSchema: z.ZodString;
|
|
34
|
+
/** The variables `@nage-api/core` itself reads. Extend it per app. */
|
|
35
|
+
export declare const baseEnvSchema: z.ZodObject<{
|
|
36
|
+
NODE_ENV: z.ZodEnum<{
|
|
37
|
+
development: "development";
|
|
38
|
+
test: "test";
|
|
39
|
+
staging: "staging";
|
|
40
|
+
production: "production";
|
|
41
|
+
}>;
|
|
42
|
+
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
43
|
+
HOST: z.ZodDefault<z.ZodString>;
|
|
44
|
+
LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
|
|
45
|
+
info: "info";
|
|
46
|
+
trace: "trace";
|
|
47
|
+
debug: "debug";
|
|
48
|
+
warn: "warn";
|
|
49
|
+
error: "error";
|
|
50
|
+
fatal: "fatal";
|
|
51
|
+
}>>;
|
|
52
|
+
CORS_ORIGINS: z.ZodOptional<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>, z.ZodArray<z.ZodURL>>>;
|
|
53
|
+
}, z.core.$strip>;
|
|
54
|
+
export type BaseEnv = z.infer<typeof baseEnvSchema>;
|
|
55
|
+
//# sourceMappingURL=base-env.schema.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Env fragments every application needs (PLAN.md §11.3).
|
|
4
|
+
*
|
|
5
|
+
* Applications compose these with their own variables rather than restating
|
|
6
|
+
* them, so `NODE_ENV`, `PORT` and the CORS allow-list are parsed the same way
|
|
7
|
+
* everywhere — including by `nage doctor` (Phase 4/6).
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.baseEnvSchema = exports.durationSchema = exports.secretSchema = exports.originListSchema = exports.logLevelSchema = exports.portSchema = exports.nodeEnvSchema = void 0;
|
|
11
|
+
const zod_1 = require("zod");
|
|
12
|
+
/** `production` is not the default: a deployment must say what it is. */
|
|
13
|
+
exports.nodeEnvSchema = zod_1.z.enum(['development', 'test', 'staging', 'production']);
|
|
14
|
+
/** Ports are numeric and bounded; `parseInt(...) || 3000` hid typos. */
|
|
15
|
+
exports.portSchema = zod_1.z.coerce.number().int().min(1).max(65_535);
|
|
16
|
+
exports.logLevelSchema = zod_1.z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']);
|
|
17
|
+
/** Comma-separated origin allow-list → array. Empty entries are dropped. */
|
|
18
|
+
exports.originListSchema = zod_1.z
|
|
19
|
+
.string()
|
|
20
|
+
.transform((value) => value
|
|
21
|
+
.split(',')
|
|
22
|
+
.map((origin) => origin.trim())
|
|
23
|
+
.filter((origin) => origin.length > 0))
|
|
24
|
+
.pipe(zod_1.z.array(zod_1.z.url()).min(1));
|
|
25
|
+
/**
|
|
26
|
+
* A secret that must actually be set to something. No default, ever — a default
|
|
27
|
+
* secret is the vulnerability the legacy `admin@admin.com/123456` seed was (§12).
|
|
28
|
+
*/
|
|
29
|
+
exports.secretSchema = zod_1.z.string().min(16, 'must be at least 16 characters');
|
|
30
|
+
exports.durationSchema = zod_1.z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^\d+(ms|s|m|h|d)$/, 'must be a duration such as 15m, 24h or 30d');
|
|
33
|
+
/** The variables `@nage-api/core` itself reads. Extend it per app. */
|
|
34
|
+
exports.baseEnvSchema = zod_1.z.object({
|
|
35
|
+
NODE_ENV: exports.nodeEnvSchema,
|
|
36
|
+
PORT: exports.portSchema.default(3000),
|
|
37
|
+
HOST: zod_1.z.string().default('0.0.0.0'),
|
|
38
|
+
LOG_LEVEL: exports.logLevelSchema.default('info'),
|
|
39
|
+
/** Omit to disable CORS entirely — the safe default (§12). */
|
|
40
|
+
CORS_ORIGINS: exports.originListSchema.optional(),
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=base-env.schema.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-fast boot (PLAN.md §11.3, §21).
|
|
3
|
+
*
|
|
4
|
+
* A process that cannot validate its own configuration must not start. It exits
|
|
5
|
+
* non-zero so an orchestrator restarts or rolls back, instead of serving traffic
|
|
6
|
+
* with half its settings undefined.
|
|
7
|
+
*/
|
|
8
|
+
import { type EnvSchema, type LoadEnvOptions } from './load-env.js';
|
|
9
|
+
export interface FailFastOptions extends LoadEnvOptions {
|
|
10
|
+
/** Where the report goes. Injectable so tests can read it. */
|
|
11
|
+
readonly write?: (message: string) => void;
|
|
12
|
+
/** How the process ends. Injectable so tests do not kill the runner. */
|
|
13
|
+
readonly exit?: (code: number) => never;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate the environment or terminate the process.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* // apps/api/src/main.ts
|
|
20
|
+
* const env = loadEnvOrExit(EnvSchema);
|
|
21
|
+
* void bootstrap({ module: AppModule, config: buildConfig(env) });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function loadEnvOrExit<TEnv>(schema: EnvSchema<TEnv>, options?: FailFastOptions): TEnv;
|
|
25
|
+
//# sourceMappingURL=fail-fast.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fail-fast boot (PLAN.md §11.3, §21).
|
|
4
|
+
*
|
|
5
|
+
* A process that cannot validate its own configuration must not start. It exits
|
|
6
|
+
* non-zero so an orchestrator restarts or rolls back, instead of serving traffic
|
|
7
|
+
* with half its settings undefined.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.loadEnvOrExit = loadEnvOrExit;
|
|
11
|
+
const load_env_js_1 = require("./load-env.js");
|
|
12
|
+
const defaultWrite = (message) => {
|
|
13
|
+
process.stderr.write(message);
|
|
14
|
+
};
|
|
15
|
+
const defaultExit = (code) => process.exit(code);
|
|
16
|
+
/**
|
|
17
|
+
* Validate the environment or terminate the process.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* // apps/api/src/main.ts
|
|
21
|
+
* const env = loadEnvOrExit(EnvSchema);
|
|
22
|
+
* void bootstrap({ module: AppModule, config: buildConfig(env) });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
function loadEnvOrExit(schema, options = {}) {
|
|
26
|
+
const write = options.write ?? defaultWrite;
|
|
27
|
+
const exit = options.exit ?? defaultExit;
|
|
28
|
+
try {
|
|
29
|
+
return (0, load_env_js_1.loadEnv)(schema, options);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof load_env_js_1.EnvValidationError) {
|
|
33
|
+
write(`\n${error.report}\n`);
|
|
34
|
+
return exit(1);
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=fail-fast.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment validation (PLAN.md §11.3).
|
|
3
|
+
*
|
|
4
|
+
* The legacy framework had no env schema: a missing `DATABASE_URL` surfaced as a
|
|
5
|
+
* connection error minutes into runtime, and `parseInt(process.env.PORT) || 3000`
|
|
6
|
+
* silently swallowed typos. Here every variable an app reads is declared in a
|
|
7
|
+
* zod schema, validated once at boot, and reported in full — all failures at
|
|
8
|
+
* once, not one per restart.
|
|
9
|
+
*/
|
|
10
|
+
import { ConfigurationError } from '@nage-api/core';
|
|
11
|
+
import type { ZodError, ZodType } from 'zod';
|
|
12
|
+
/** A schema over the environment. Output is what the application then reads. */
|
|
13
|
+
export type EnvSchema<TEnv> = ZodType<TEnv>;
|
|
14
|
+
/** Raw environment source; `process.env` by default. */
|
|
15
|
+
export type EnvSource = Record<string, string | undefined>;
|
|
16
|
+
export interface EnvIssue {
|
|
17
|
+
readonly variable: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
/** True when the variable is absent rather than malformed. */
|
|
20
|
+
readonly missing: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface LoadEnvOptions {
|
|
23
|
+
readonly source?: EnvSource;
|
|
24
|
+
/** Prefix stripped from variable names before validation, e.g. `APP_`. */
|
|
25
|
+
readonly prefix?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Turn a `ZodError` into per-variable issues.
|
|
29
|
+
*
|
|
30
|
+
* Messages are scrubbed for secret-shaped variables: an env validation failure
|
|
31
|
+
* is often printed to a CI log, and "expected url, received postgres://user:pw@…"
|
|
32
|
+
* would publish the credential it was validating.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toEnvIssues(error: ZodError): readonly EnvIssue[];
|
|
35
|
+
/** Human-readable, copy-pasteable report — what an operator actually needs. */
|
|
36
|
+
export declare function formatEnvIssues(issues: readonly EnvIssue[]): string;
|
|
37
|
+
/** Thrown when the environment does not satisfy the schema. */
|
|
38
|
+
export declare class EnvValidationError extends ConfigurationError {
|
|
39
|
+
readonly issues: readonly EnvIssue[];
|
|
40
|
+
constructor(issues: readonly EnvIssue[]);
|
|
41
|
+
/** The report an operator sees on stderr. */
|
|
42
|
+
get report(): string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate `source` against `schema`.
|
|
46
|
+
*
|
|
47
|
+
* @throws EnvValidationError listing **every** problem, so a misconfigured
|
|
48
|
+
* deployment is fixed in one pass rather than one restart per variable.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadEnv<TEnv>(schema: EnvSchema<TEnv>, options?: LoadEnvOptions): TEnv;
|
|
51
|
+
//# sourceMappingURL=load-env.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Environment validation (PLAN.md §11.3).
|
|
4
|
+
*
|
|
5
|
+
* The legacy framework had no env schema: a missing `DATABASE_URL` surfaced as a
|
|
6
|
+
* connection error minutes into runtime, and `parseInt(process.env.PORT) || 3000`
|
|
7
|
+
* silently swallowed typos. Here every variable an app reads is declared in a
|
|
8
|
+
* zod schema, validated once at boot, and reported in full — all failures at
|
|
9
|
+
* once, not one per restart.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.EnvValidationError = void 0;
|
|
13
|
+
exports.toEnvIssues = toEnvIssues;
|
|
14
|
+
exports.formatEnvIssues = formatEnvIssues;
|
|
15
|
+
exports.loadEnv = loadEnv;
|
|
16
|
+
const core_1 = require("@nage-api/core");
|
|
17
|
+
/** Names that look like a secret, and so must never appear in an error message. */
|
|
18
|
+
const SECRET_NAME_PATTERN = /(SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL|DSN|URL|URI)/i;
|
|
19
|
+
function variableOf(issue) {
|
|
20
|
+
return issue.path.map(String).join('.') || '(root)';
|
|
21
|
+
}
|
|
22
|
+
function isMissing(issue) {
|
|
23
|
+
// zod 4 reports an absent key as an invalid_type whose input is undefined.
|
|
24
|
+
return issue.code === 'invalid_type' && /received undefined|expected/i.test(issue.message);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Turn a `ZodError` into per-variable issues.
|
|
28
|
+
*
|
|
29
|
+
* Messages are scrubbed for secret-shaped variables: an env validation failure
|
|
30
|
+
* is often printed to a CI log, and "expected url, received postgres://user:pw@…"
|
|
31
|
+
* would publish the credential it was validating.
|
|
32
|
+
*/
|
|
33
|
+
function toEnvIssues(error) {
|
|
34
|
+
return error.issues.map((issue) => {
|
|
35
|
+
const variable = variableOf(issue);
|
|
36
|
+
const missing = isMissing(issue);
|
|
37
|
+
const message = SECRET_NAME_PATTERN.test(variable)
|
|
38
|
+
? missing
|
|
39
|
+
? 'is required but not set'
|
|
40
|
+
: 'is set but has the wrong shape (value hidden)'
|
|
41
|
+
: issue.message;
|
|
42
|
+
return { variable, message, missing };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Human-readable, copy-pasteable report — what an operator actually needs. */
|
|
46
|
+
function formatEnvIssues(issues) {
|
|
47
|
+
const lines = [
|
|
48
|
+
`Environment validation failed (${issues.length} ${issues.length === 1 ? 'problem' : 'problems'}):`,
|
|
49
|
+
'',
|
|
50
|
+
...issues.map((issue) => ` ✖ ${issue.variable} — ${issue.message}`),
|
|
51
|
+
'',
|
|
52
|
+
];
|
|
53
|
+
const missing = issues.filter((issue) => issue.missing).map((issue) => issue.variable);
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
lines.push(`Set the missing variables in your environment or .env file: ${missing.join(', ')}`, '');
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
/** Thrown when the environment does not satisfy the schema. */
|
|
60
|
+
class EnvValidationError extends core_1.ConfigurationError {
|
|
61
|
+
issues;
|
|
62
|
+
constructor(issues) {
|
|
63
|
+
super({
|
|
64
|
+
detail: 'Invalid environment configuration',
|
|
65
|
+
meta: { issues: issues.map((issue) => `${issue.variable}: ${issue.message}`) },
|
|
66
|
+
});
|
|
67
|
+
this.issues = issues;
|
|
68
|
+
}
|
|
69
|
+
/** The report an operator sees on stderr. */
|
|
70
|
+
get report() {
|
|
71
|
+
return formatEnvIssues(this.issues);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.EnvValidationError = EnvValidationError;
|
|
75
|
+
/**
|
|
76
|
+
* Validate `source` against `schema`.
|
|
77
|
+
*
|
|
78
|
+
* @throws EnvValidationError listing **every** problem, so a misconfigured
|
|
79
|
+
* deployment is fixed in one pass rather than one restart per variable.
|
|
80
|
+
*/
|
|
81
|
+
function loadEnv(schema, options = {}) {
|
|
82
|
+
const source = options.source ?? process.env;
|
|
83
|
+
const { prefix } = options;
|
|
84
|
+
const input = prefix === undefined
|
|
85
|
+
? source
|
|
86
|
+
: Object.fromEntries(Object.entries(source)
|
|
87
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
88
|
+
.map(([key, value]) => [key.slice(prefix.length), value]));
|
|
89
|
+
const result = schema.safeParse(input);
|
|
90
|
+
if (!result.success)
|
|
91
|
+
throw new EnvValidationError(toEnvIssues(result.error));
|
|
92
|
+
return result.data;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=load-env.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nage-api/config` — one typed configuration, validated once at boot
|
|
3
|
+
* (PLAN.md §11).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the legacy pair of an `env.ts` singleton and `@nestjs/config` with a
|
|
6
|
+
* single source: `defineConfig()` for committed settings, a zod schema for the
|
|
7
|
+
* environment, and a `SecretProviderPort` for everything that must not be
|
|
8
|
+
* committed.
|
|
9
|
+
*/
|
|
10
|
+
export { defineConfig, defineConfigFragment, mergeConfig, withDefaults, FRAMEWORK_DEFAULTS, type ConfigFragment, type DefineConfigOptions, } from './define-config.js';
|
|
11
|
+
export { loadEnv, formatEnvIssues, toEnvIssues, EnvValidationError, type EnvIssue, type EnvSchema, type EnvSource, type LoadEnvOptions, } from './env/load-env.js';
|
|
12
|
+
export { loadEnvOrExit, type FailFastOptions } from './env/fail-fast.js';
|
|
13
|
+
export { baseEnvSchema, durationSchema, logLevelSchema, nodeEnvSchema, originListSchema, portSchema, secretSchema, type BaseEnv, } from './env/base-env.schema.js';
|
|
14
|
+
export { EnvSecretProvider, type EnvSecretProviderOptions } from './secrets/env.secret-provider.js';
|
|
15
|
+
export { AwsSecretProvider, type AwsSecretProviderOptions, type GetSecretValueCommandFactory, type SecretsManagerLike, } from './secrets/aws.secret-provider.js';
|
|
16
|
+
export { CachingSecretProvider, type CachingSecretProviderOptions, } from './secrets/caching.secret-provider.js';
|
|
17
|
+
export { ConfigService, type EnvRecord } from './config.service.js';
|
|
18
|
+
export { NAGE_ENV, NAGE_SECRET_PROVIDER } from './tokens.js';
|
|
19
|
+
export { NageConfigModule, createSecretProvider, type NageConfigModuleOptions, } from './config.module.js';
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@nage-api/config` — one typed configuration, validated once at boot
|
|
4
|
+
* (PLAN.md §11).
|
|
5
|
+
*
|
|
6
|
+
* Replaces the legacy pair of an `env.ts` singleton and `@nestjs/config` with a
|
|
7
|
+
* single source: `defineConfig()` for committed settings, a zod schema for the
|
|
8
|
+
* environment, and a `SecretProviderPort` for everything that must not be
|
|
9
|
+
* committed.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.createSecretProvider = exports.NageConfigModule = exports.NAGE_SECRET_PROVIDER = exports.NAGE_ENV = exports.ConfigService = exports.CachingSecretProvider = exports.AwsSecretProvider = exports.EnvSecretProvider = exports.secretSchema = exports.portSchema = exports.originListSchema = exports.nodeEnvSchema = exports.logLevelSchema = exports.durationSchema = exports.baseEnvSchema = exports.loadEnvOrExit = exports.EnvValidationError = exports.toEnvIssues = exports.formatEnvIssues = exports.loadEnv = exports.FRAMEWORK_DEFAULTS = exports.withDefaults = exports.mergeConfig = exports.defineConfigFragment = exports.defineConfig = void 0;
|
|
13
|
+
var define_config_js_1 = require("./define-config.js");
|
|
14
|
+
Object.defineProperty(exports, "defineConfig", { enumerable: true, get: function () { return define_config_js_1.defineConfig; } });
|
|
15
|
+
Object.defineProperty(exports, "defineConfigFragment", { enumerable: true, get: function () { return define_config_js_1.defineConfigFragment; } });
|
|
16
|
+
Object.defineProperty(exports, "mergeConfig", { enumerable: true, get: function () { return define_config_js_1.mergeConfig; } });
|
|
17
|
+
Object.defineProperty(exports, "withDefaults", { enumerable: true, get: function () { return define_config_js_1.withDefaults; } });
|
|
18
|
+
Object.defineProperty(exports, "FRAMEWORK_DEFAULTS", { enumerable: true, get: function () { return define_config_js_1.FRAMEWORK_DEFAULTS; } });
|
|
19
|
+
var load_env_js_1 = require("./env/load-env.js");
|
|
20
|
+
Object.defineProperty(exports, "loadEnv", { enumerable: true, get: function () { return load_env_js_1.loadEnv; } });
|
|
21
|
+
Object.defineProperty(exports, "formatEnvIssues", { enumerable: true, get: function () { return load_env_js_1.formatEnvIssues; } });
|
|
22
|
+
Object.defineProperty(exports, "toEnvIssues", { enumerable: true, get: function () { return load_env_js_1.toEnvIssues; } });
|
|
23
|
+
Object.defineProperty(exports, "EnvValidationError", { enumerable: true, get: function () { return load_env_js_1.EnvValidationError; } });
|
|
24
|
+
var fail_fast_js_1 = require("./env/fail-fast.js");
|
|
25
|
+
Object.defineProperty(exports, "loadEnvOrExit", { enumerable: true, get: function () { return fail_fast_js_1.loadEnvOrExit; } });
|
|
26
|
+
var base_env_schema_js_1 = require("./env/base-env.schema.js");
|
|
27
|
+
Object.defineProperty(exports, "baseEnvSchema", { enumerable: true, get: function () { return base_env_schema_js_1.baseEnvSchema; } });
|
|
28
|
+
Object.defineProperty(exports, "durationSchema", { enumerable: true, get: function () { return base_env_schema_js_1.durationSchema; } });
|
|
29
|
+
Object.defineProperty(exports, "logLevelSchema", { enumerable: true, get: function () { return base_env_schema_js_1.logLevelSchema; } });
|
|
30
|
+
Object.defineProperty(exports, "nodeEnvSchema", { enumerable: true, get: function () { return base_env_schema_js_1.nodeEnvSchema; } });
|
|
31
|
+
Object.defineProperty(exports, "originListSchema", { enumerable: true, get: function () { return base_env_schema_js_1.originListSchema; } });
|
|
32
|
+
Object.defineProperty(exports, "portSchema", { enumerable: true, get: function () { return base_env_schema_js_1.portSchema; } });
|
|
33
|
+
Object.defineProperty(exports, "secretSchema", { enumerable: true, get: function () { return base_env_schema_js_1.secretSchema; } });
|
|
34
|
+
var env_secret_provider_js_1 = require("./secrets/env.secret-provider.js");
|
|
35
|
+
Object.defineProperty(exports, "EnvSecretProvider", { enumerable: true, get: function () { return env_secret_provider_js_1.EnvSecretProvider; } });
|
|
36
|
+
var aws_secret_provider_js_1 = require("./secrets/aws.secret-provider.js");
|
|
37
|
+
Object.defineProperty(exports, "AwsSecretProvider", { enumerable: true, get: function () { return aws_secret_provider_js_1.AwsSecretProvider; } });
|
|
38
|
+
var caching_secret_provider_js_1 = require("./secrets/caching.secret-provider.js");
|
|
39
|
+
Object.defineProperty(exports, "CachingSecretProvider", { enumerable: true, get: function () { return caching_secret_provider_js_1.CachingSecretProvider; } });
|
|
40
|
+
var config_service_js_1 = require("./config.service.js");
|
|
41
|
+
Object.defineProperty(exports, "ConfigService", { enumerable: true, get: function () { return config_service_js_1.ConfigService; } });
|
|
42
|
+
var tokens_js_1 = require("./tokens.js");
|
|
43
|
+
Object.defineProperty(exports, "NAGE_ENV", { enumerable: true, get: function () { return tokens_js_1.NAGE_ENV; } });
|
|
44
|
+
Object.defineProperty(exports, "NAGE_SECRET_PROVIDER", { enumerable: true, get: function () { return tokens_js_1.NAGE_SECRET_PROVIDER; } });
|
|
45
|
+
var config_module_js_1 = require("./config.module.js");
|
|
46
|
+
Object.defineProperty(exports, "NageConfigModule", { enumerable: true, get: function () { return config_module_js_1.NageConfigModule; } });
|
|
47
|
+
Object.defineProperty(exports, "createSecretProvider", { enumerable: true, get: function () { return config_module_js_1.createSecretProvider; } });
|
|
48
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Secrets Manager provider (PLAN.md §11.1 layer 5).
|
|
3
|
+
*
|
|
4
|
+
* The SDK is an **optional peer** loaded lazily: an application using env-based
|
|
5
|
+
* secrets should not carry an AWS client in its image. The client is also
|
|
6
|
+
* injectable, which is what makes this testable without the SDK installed and
|
|
7
|
+
* without a network — the legacy AWS-only singleton was neither.
|
|
8
|
+
*/
|
|
9
|
+
import type { SecretProviderPort } from '@nage-api/contracts';
|
|
10
|
+
/** The slice of the SDK client this provider uses. */
|
|
11
|
+
export interface SecretsManagerLike {
|
|
12
|
+
send(command: unknown): Promise<{
|
|
13
|
+
SecretString?: string | undefined;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** Builds the `GetSecretValue` command; supplied with the client. */
|
|
17
|
+
export type GetSecretValueCommandFactory = (input: {
|
|
18
|
+
SecretId: string;
|
|
19
|
+
}) => unknown;
|
|
20
|
+
/** The two SDK exports this provider needs. */
|
|
21
|
+
export interface SecretsManagerSdk {
|
|
22
|
+
readonly SecretsManagerClient: new (config: {
|
|
23
|
+
region?: string;
|
|
24
|
+
}) => SecretsManagerLike;
|
|
25
|
+
readonly GetSecretValueCommand: new (input: {
|
|
26
|
+
SecretId: string;
|
|
27
|
+
}) => unknown;
|
|
28
|
+
}
|
|
29
|
+
export interface AwsSecretProviderOptions {
|
|
30
|
+
readonly region?: string;
|
|
31
|
+
/** Prepended to every secret name, e.g. `prod/my-app/`. */
|
|
32
|
+
readonly prefix?: string;
|
|
33
|
+
/** Pre-built client. When omitted, the SDK is imported on first use. */
|
|
34
|
+
readonly client?: SecretsManagerLike;
|
|
35
|
+
readonly commandFactory?: GetSecretValueCommandFactory;
|
|
36
|
+
/**
|
|
37
|
+
* How the SDK is loaded. Injectable so both paths — present and absent — are
|
|
38
|
+
* testable, rather than depending on what happens to be installed.
|
|
39
|
+
*/
|
|
40
|
+
readonly importSdk?: () => Promise<SecretsManagerSdk>;
|
|
41
|
+
}
|
|
42
|
+
export declare class AwsSecretProvider implements SecretProviderPort {
|
|
43
|
+
#private;
|
|
44
|
+
readonly provider = "aws-secrets-manager";
|
|
45
|
+
constructor(options?: AwsSecretProviderOptions);
|
|
46
|
+
get(name: string): Promise<string | null>;
|
|
47
|
+
require(name: string): Promise<string>;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=aws.secret-provider.d.ts.map
|