@nage-api/config
v1.0.0-beta.4
Published
One typed configuration for a @nage-api application — defineConfig, zod env validation, secret providers
Readme
@nage-api/config
One typed configuration for a @nage-api application, validated once at boot
(PLAN.md §11).
Replaces the legacy pair of an env.ts singleton and @nestjs/config — two
systems, no validation, env.get<T>() as T — with a single source: a committed
defineConfig() literal, a zod schema for the environment, and a
SecretProviderPort for everything that must not be committed.
Usage
// apps/api/src/config/env.schema.ts
import { baseEnvSchema, secretSchema } from '@nage-api/config';
import { z } from 'zod';
export const EnvSchema = baseEnvSchema.extend({
DATABASE_URL: z.url(),
JWT_PRIVATE_KEY: secretSchema,
});
export type Env = z.infer<typeof EnvSchema>;// apps/api/src/config/nage.config.ts
import { defineConfig, defineConfigFragment } from '@nage-api/config';
import type { Env } from './env.schema.js';
// Normally imported from `@app/config`, shared by every app in the workspace.
const workspaceDefaults = defineConfigFragment({
logging: { json: true },
database: { driver: 'postgres', ssl: 'verify-full' },
});
export const buildConfig = (env: Env) =>
defineConfig(
{
app: { name: 'my-api', environment: env.NODE_ENV, port: env.PORT },
// Omitting `cors` disables it; an allow-list is what switches it on.
http: {
...(env.CORS_ORIGINS === undefined ? {} : { cors: { origins: env.CORS_ORIGINS } }),
},
database: { enabled: true, driver: 'postgres', url: env.DATABASE_URL },
secrets: { provider: 'aws', prefix: 'prod/my-api/' },
},
{ extends: [workspaceDefaults] },
);// apps/api/src/app.module.ts
import { NageConfigModule } from '@nage-api/config';
import { NageCoreModule } from '@nage-api/core';
import { Module } from '@nestjs/common';
import { EnvSchema } from './config/env.schema.js';
import { buildConfig } from './config/nage.config.js';
const config = buildConfig(EnvSchema.parse(process.env));
@Module({
imports: [
// Core first: both modules publish NAGE_CONFIG, and the later import wins.
NageCoreModule.forRoot(config),
NageConfigModule.forRoot({ config, envSchema: EnvSchema }),
],
})
export class AppModule {}// apps/api/src/main.ts
import { loadEnvOrExit } from '@nage-api/config';
import { bootstrap } from '@nage-api/core';
import { AppModule } from './app.module.js';
import { EnvSchema } from './config/env.schema.js';
import { buildConfig } from './config/nage.config.js';
const env = loadEnvOrExit(EnvSchema); // invalid env → stderr report, exit 1
void bootstrap({ module: AppModule, config: buildConfig(env) });import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nage-api/config';
import type { Env } from './env.schema.js';
@Injectable()
export class ReportService {
constructor(@Inject(ConfigService) private readonly config: ConfigService<Env>) {}
async run(): Promise<void> {
this.config.env('DATABASE_URL'); // string, typed by the schema
this.config.isEnabled('database'); // feature toggles (§11.1 item 6)
await this.config.requireSecret('JWT_PRIVATE_KEY');
}
}The layers (§11.1)
| # | Layer | Where |
| --- | ------------------ | ---------------------------------------------------------------- |
| 1 | Framework defaults | withDefaults() — applied when the config is published |
| 2 | Workspace defaults | defineConfigFragment() in @app/config, passed via extends |
| 3 | App config | the app's own defineConfig() literal |
| 4 | Environment | env.schema.ts, validated by loadEnv/loadEnvOrExit |
| 5 | Secrets | SecretProviderPort — env, AWS Secrets Manager, or your own |
| 6 | Feature flags | enabled per block; a disabled feature contributes no providers |
Later layers win. Arrays replace rather than concatenate: a CORS allow-list is a complete statement of policy, and appending to an inherited one is how an override silently widens it.
Behaviour worth knowing
Failures are complete and early. loadEnv reports every problem at once,
so a misconfigured deployment is fixed in one pass instead of one restart per
variable. NageConfigModule.forRoot() validates while the module is being
built — before a port is bound.
Reports never publish what they were validating. For variables whose name
contains SECRET, PASSWORD, TOKEN, KEY, CREDENTIAL, DSN, URL or
URI, the message is reduced to "is required but not set" / "is set but has the
wrong shape (value hidden)". Env reports land in CI logs, and
expected url, received postgres://user:pw@… would publish the credential it
was rejecting.
The env is only validated if you hand over the schema. forRoot({ config })
without envSchema publishes an empty environment, so config.env('X') is
typed by your schema and undefined at run time. Passing envSchema is what
makes ConfigService<Env> true rather than a claim.
Errors separate the two audiences. Error.message names the variable, the
section or the secret for the operator and the log; the client-facing payload
stays { code: 'CONFIGURATION_INVALID', message: 'Internal server error' }.
Secrets are a port. EnvSecretProvider is the default; AwsSecretProvider
loads the SDK lazily and treats it as an optional peer, so an app using env
secrets carries no AWS client. CachingSecretProvider wraps either with a TTL,
because a signing key should not be a per-request network call.
Defaults are the safe ones. verify-full TLS, a bounded maxQueryLimit,
RS256 with 15m access tokens, rotating refresh tokens with reuse detection,
argon2id passwords — applied whenever the relevant block is present, and
overridable in the open where nage doctor (Phase 4) can see it.
Where the types live
The config types (NageConfig and every block) are declared in
@nage-api/contracts, not here. Feature packages may not import each other, so a
package reading its own config block must find that type at the bottom of the
graph. This package owns the runtime that produces and validates a value.
Not yet implemented
- The
vaultsecret provider.secrets.provider: 'vault'throws aConfigurationErrornaming the two that work, rather than falling back to a provider you did not ask for. - Runtime configuration — the
Settingsmodule of §11.1 item 7, for values that change without a redeploy. Everything here is settled at boot. - Reading a
.envfile. Nothing in this package touches one:loadEnvreadsprocess.env, and populating it is the job of the process manager,docker compose, ornode --env-file=.env.nage doctorreads the workspace's.envfor its own audit, which is a different thing.
The deeper guide is docs/packages/config.md.
