@pawells/config-provider-json
Description
@pawells/config-provider-json is an async configuration provider for @pawells/config that reads values from a JSON file and writes configuration snapshots or templates back to disk.
The file must contain a JSON object at the top level. Nested objects are flattened one level deep using _ as the separator, so a key registered as KEYCLOAK_HOST is found at { "KEYCLOAK": { "HOST": "..." } }. Top-level non-object values are kept at their own key unchanged. Native JSON types (numbers, booleans, arrays, null) are passed directly to Zod schema validation without string pre-processing.
The provider enforces a 10 MB file-size limit and rejects symlinks and path-traversal sequences (..) for security.
See the workspace README for an end-to-end quick start. See CHANGELOG.md for version history.
Requirements
- Node.js
>=22.0.0 @pawells/config(declared as a direct dependency viaworkspace:^)
Installation
yarn add @pawells/config-provider-json @pawells/config
All packages are ESM-only ("type": "module").
Quick Start
import { ConfigManager, RegisterConfigSchema, Secret } from '@pawells/config';
import { ConfigJSONProvider } from '@pawells/config-provider-json';
import { z } from 'zod';
// Register providers BEFORE importing any schema modules.
const jsonProvider = await ConfigJSONProvider.Register({
name: 'json',
path: './config.json',
required: false // OK if the file does not exist yet
});
// config.json: { "APP": { "HOST": "localhost", "PORT": 3000 } }
// Flattens to: APP_HOST='localhost', APP_PORT=3000
const AppConfig = RegisterConfigSchema('App', z.object({
HOST: z.string().min(1).default('localhost'),
PORT: z.coerce.number().int().positive().default(3000),
SECRET_KEY: Secret(z.string().min(32)).default(''),
}));
const host = AppConfig.Get('HOST'); // string
const port = AppConfig.Get('PORT'); // number
// Generate config.example.json — defaults; secrets written as null.
await ConfigManager.Save(jsonProvider, { path: './config.example.json' });
// Snapshot current runtime values.
await ConfigManager.Save(jsonProvider, {
path: './config.snapshot.json',
useCurrentValues: true
});
JSON file format
Nested objects are flattened one level deep:
{
"APP": {
"HOST": "localhost",
"PORT": 3000,
"DEBUG": false
},
"KEYCLOAK": {
"HOST": "keycloak.example.com",
"PORT": 8080
}
}
Produces the configuration keys: APP_HOST, APP_PORT, APP_DEBUG, KEYCLOAK_HOST, KEYCLOAK_PORT.
Only one level of nesting is flattened. Deeper objects are skipped. Top-level non-object values (strings, numbers, booleans, arrays, null) are kept at their own key:
{ "TOP_LEVEL_KEY": "value" }
Produces TOP_LEVEL_KEY='value'.
API Reference
ConfigJSONProvider
An async configuration provider that extends ConfigProvider from @pawells/config.
static Register(options)
Convenience factory: creates a ConfigJSONProvider instance, registers it with ConfigManager, and returns it.
static async Register(
options: Partial<TConfigJSONProviderOptions> & Pick<TConfigJSONProviderOptions, 'name'>
): Promise<ConfigJSONProvider>
Unspecified options (other than name) use schema defaults (path: <cwd>/config.json, required: false). name has no schema default, so it is required both at compile time and at runtime.
// Register with a specific path
const provider = await ConfigJSONProvider.Register({
name: 'json',
path: './config.json',
required: false
});
// Register an optional local override file
const localProvider = await ConfigJSONProvider.Register({
name: 'json-local',
path: './config.local.json',
required: false
});
Constructor
new ConfigJSONProvider(options: TConfigJSONProviderOptions)
After constructing, pass the instance to ConfigManager.RegisterProvider manually if you need the instance before registration:
import { ConfigManager } from '@pawells/config';
import { ConfigJSONProvider } from '@pawells/config-provider-json';
const provider = new ConfigJSONProvider({
name: 'json',
path: './config.json',
required: true
});
await ConfigManager.RegisterProvider(provider);
Throws ZodError if options fail schema validation (e.g. the path contains ..). Note this is a raw, unwrapped ZodError, not a ConfigError — unlike Save(), the constructor does not catch and re-wrap the validation error.
Load()
async Load(): Promise<Record<string, unknown>>
Reads and flattens the JSON configuration file:
- The parent directory of
options.pathis canonicalized viafs.realpathto detect symlinked ancestor directories. - The final path component is checked via
lstatbefore opening — a cross-platform check that works reliably on all platforms including Windows — to detect and reject a symlinked final path component;O_NOFOLLOWis retained as additional defense-in-depth on POSIX systems. - Content is read and the size is checked against the 10 MB limit.
- The JSON is parsed; malformed JSON throws
ConfigError. - Top-level properties are flattened one level deep (see format above).
- Prototype-pollution keys (
__proto__,constructor,prototype) are silently skipped.
If the file is absent (ENOENT) and required is false, returns {}. If required is true, throws ConfigError.
// config.json: { "APP": { "HOST": "localhost", "PORT": 3000, "DEBUG": false } }
const values = await provider.Load();
// → { APP_HOST: 'localhost', APP_PORT: 3000, APP_DEBUG: false }
Throws:
ConfigError— The file is a symlink (final component or parent directory).ConfigError— The file cannot be read andrequiredistrue.ConfigError— The file exceeds 10 MB.ConfigError— The file content is not valid JSON.
Save(entries, options?)
async Save(
entries: readonly ConfigSaveEntry[],
options?: TConfigJSONProviderSaveOptions
): Promise<void>
Writes configuration entries to a nested JSON file. Call via ConfigManager.Save rather than directly.
Template mode (useCurrentValues: false, the default):
- Each entry is written using its registered default value.
- Entries where
entry.isSecretistrueare written asnull, indicating that a value is required but intentionally absent. - This is suitable for generating
config.example.jsonfiles safe to commit.
Current-values mode (useCurrentValues: true):
- All entries including secrets are written with their live resolved values.
The output structure mirrors the input structure that Load() flattens: entries with a section (registered via a namespace) are nested under their section key. Entries without a section are written at the top level.
Output is pretty-printed with tab indentation. The resolved output path is symlink-checked the same way Load() checks its input path (final component and parent directories); a symlinked path throws ConfigError.
options may be omitted entirely — Save(entries) is equivalent to Save(entries, {}).
Options:
| Field | Type | Default | Description |
|---|---|---|---|
path |
string (optional) |
this.options.path |
Output file path; overrides the constructor path if provided. .. sequences are rejected. |
useCurrentValues |
boolean (optional) |
false |
false = registered defaults, secrets as null; true = live resolved values. |
Options types
TConfigJSONProviderOptions
type TConfigJSONProviderOptions = {
name: string // Required; unique identifier for diagnostics
path: string // Default: <cwd>/config.json; '..' sequences are rejected
required: boolean // Default: false; when true, missing file throws ConfigError
}
Validated by CONFIG_JSON_PROVIDER_OPTIONS_SCHEMA.
TConfigJSONProviderSaveOptions
type TConfigJSONProviderSaveOptions = {
path?: string // Overrides constructor path if provided; '..' sequences are rejected
useCurrentValues?: boolean // Default: false
}
Validated by CONFIG_JSON_PROVIDER_SAVE_OPTIONS_SCHEMA.
Assertion and validation utilities
| Export | Description |
|---|---|
CONFIG_JSON_PROVIDER_OPTIONS_SCHEMA |
Zod schema for TConfigJSONProviderOptions |
AssertConfigJSONProviderOptions(options) |
Asserts conformance; throws ZodError otherwise |
ValidateConfigJSONProviderOptions(options) |
Returns true if valid; false otherwise |
CONFIG_JSON_PROVIDER_SAVE_OPTIONS_SCHEMA |
Zod schema for TConfigJSONProviderSaveOptions |
AssertConfigJSONProviderSaveOptions(options) |
Asserts conformance; throws ZodError otherwise |
ValidateConfigJSONProviderSaveOptions(options) |
Returns true if valid; false otherwise |
CONFIG_JSON_PROVIDER_PATH_SCHEMA |
Zod schema for an individual path value |
Error handling
import { ZodError } from 'zod/v4';
import { ConfigError, ConfigManager } from '@pawells/config';
import { ConfigJSONProvider } from '@pawells/config-provider-json';
try {
const provider = new ConfigJSONProvider({
name: 'json',
path: './config.json',
required: true
});
await ConfigManager.RegisterProvider(provider);
} catch (error) {
if (error instanceof ZodError) {
// Constructor options failed schema validation (e.g. path contains "..")
console.error('Invalid provider options:', error.message);
} else if (error instanceof ConfigError) {
// Load()-time failure: symlink detected, JSON parse failure,
// file not found (required), or size limit exceeded
console.error('Configuration error:', error.message);
} else {
// File system permission error or other I/O error
console.error('I/O error:', error);
}
}
Security
- Symlink rejection — Applies to both
Load()(read path) andSave()(write path). The final path component is checked vialstatbefore opening — a cross-platform check that works reliably on all platforms including Windows — withO_NOFOLLOWretained as additional defense-in-depth on POSIX systems. Parent directories are canonicalized viafs.realpath. Either form of symlink throwsConfigError. - Path-traversal protection — Any path containing
..is rejected by the options schema at construction time and at save time. - Prototype-pollution protection — Keys
__proto__,constructor, andprototypeare silently skipped in bothLoad()andSave(). - Size limit — Files larger than 10 MB (byte-accurate) throw
ConfigErrorbefore parsing. - No string coercion — Native JSON types are passed directly to Zod; no implicit string-to-number conversion occurs.
License
MIT — See LICENSE for details.