@galaxus/chabis-application-config
v0.1.2
Published
Type-safe, layered application configuration implementing the Chabis application-config contract: YAML/TOML files, Google Parameter Manager and Azure Key Vault secrets, schema-driven env overrides, and Standard Schema validation.
Downloads
327
Keywords
Readme
@galaxus/chabis-application-config
Type-safe application configuration for Node and TypeScript. Describe your config shape with a zod or valibot schema and get back one fully typed object, merged from layered YAML/TOML files and cloud secrets and validated against that schema
It implements the Chabis application-config contract: the same file layering,
merge semantics, and secret resolution as the Python chabis-application-config
library, so a service keeps one config layout across stacks
What you get:
- Layered
settings,settings.{env}, and.secretsfiles in YAML or TOML, deep-merged in a fixed priority order - Secrets from Google Cloud Parameter Manager or Azure Key Vault, filling placeholders left in those files
- GPM versions resolve to the latest automatically, no version pin to bump
- Environment-variable overrides derived from your schema, no magic prefix
- A single typed object validated against your schema, or a thrown error
Install
pnpm add @galaxus/chabis-application-configBring a schema library as a peer dependency, zod or valibot:
pnpm add zod
# or
pnpm add valibot @valibot/to-json-schemaGoogle Cloud Parameter Manager is included out of the box. Azure Key Vault is
optional, install its SDKs only if you use an AzureKeyVault provider instead:
pnpm add @azure/identity @azure/keyvault-secretsUsage
import { z } from "zod";
import { loadConfig } from "@galaxus/chabis-application-config";
const Schema = z.object({
LogLevel:
z.string().default("INFO"),
Database:
z.object({ Url: z.string() }),
Auth:
z.object({ ClientId: z.string(), TenantId: z.string(), Secret: z.string() }),
});
export const config = await loadConfig(Schema, {
// directory holding the settings.*.{yaml,toml} files, defaults to process.cwd()
settingsDir: "./configs",
// selects settings.{env}.*, defaults to process.env.ENV ?? "dev"
env: process.env.ENV ?? "dev",
});
// config is fully typed: config.Auth.Secret, config.Database.UrlTypesafe:
loadConfig is async (secret fetches are async) and memoized by
(schema, settingsDir, env). Pass { fresh: true } to bypass the cache, or call
resetConfigCache()
Options
loadConfig(schema, opts) takes an optional second argument:
| Field | Type | Default | Purpose |
| --- | --- | --- | --- |
| settingsDir | string | process.cwd() | Directory holding the settings.*.{yaml,toml} files |
| env | string | process.env.ENV ?? "dev" | Selects the settings.{env}.* layer |
| overrides | Record<string, unknown> | {} | Highest-priority layer, deep-merged on top of everything |
| envNestingDelimiter | string | "__" | Separator between schema leaf segments in env-var names |
| fresh | boolean | false | Bypass the memoized result and force a fresh load |
overrides and environment variables are two different layers (tiers 1 and 2)
with two different shapes:
overridesis a nested object passed in code,{ Database: { Url: "…" } }. It is theprocess.env-free way to force a value, e.g. in tests- Env vars are flat,
__-delimited strings,Database__Url=…. See Environment-variable overrides
Files
loadConfig reads, for a given settingsDir and env, any of these that exist:
| Base name | Purpose |
| --- | --- |
| settings.{toml,yaml} | Base configuration (lowest priority) |
| settings.{env}.{toml,yaml} | Environment-specific overrides |
| .secrets.{toml,yaml} | Local secrets, highest-priority file layer |
How it resolves a value (priority, highest to lowest)
The order is 1:1 with the Python settings_customise_sources:
1. opts.overrides
2. env vars (schema leaf) Database__Url -> { Database: { Url } }
3. Azure Key Vault secrets (when configured)
4. Google Parameter Manager secrets
5. .secrets.{toml,yaml}
6. settings.{env}.{toml,yaml}
7. settings.{toml,yaml}Within the file tiers every TOML file outranks every YAML file
(*toml_sources before *yaml_sources), and base-name order is
.secrets > settings.{env} > settings, so the full file priority, high to low:
.secrets.toml > settings.{env}.toml > settings.toml >
.secrets.yaml > settings.{env}.yaml > settings.yamlThe secret tiers (3 to 4) outrank every settings file (5 to 7), so a
"<Configured-in-GoogleParameterManager>" placeholder written in
settings.dev.yaml is replaced by the real secret payload
Merge semantics
Applied lowest to highest (pydantic-settings deep_update):
- Plain objects deep-merge recursively
- Every other value, including arrays, is replaced wholesale by the higher-priority layer, no array concatenation
Environment-variable overrides
Selection is schema-driven: the loader walks your schema's leaf paths and looks up
the env var named by joining each path with the nesting delimiter (__). A
Database.Url leaf is overridden by Database__Url, mapped back to
{ Database: { Url } }. Matching is case-sensitive against your verbatim keys, so
there is no prefix and ambient vars like PATH never leak in
Database__Url=postgres://… node app.jsValues stay raw strings, coercion to number or bool is the schema's job
(z.coerce.number()). Change the delimiter per call:
await loadConfig(Schema, { envNestingDelimiter: "." }); // Database.Url=…Only leaves declared in the schema are overridable, and only zod and valibot schemas can be introspected. For any other vendor, env overrides quietly do not apply rather than failing the load
ENV selects the environment and is not itself an override. To force a value
from code without touching process.env, pass a nested object to
overrides instead (tier 1, outranks env vars)
The Secrets block
Any settings file may carry a top-level Secrets array. It is metadata, stripped
before validation, and drives the providers. If more than one file declares it the
last in execution order wins (TOML before YAML); in practice exactly one file does
Secrets:
- Provider: GoogleParameterManager
GCPProject: my-project-dev
GCPParameterName: my-app-config
# GCPParameterVersion: v5 # OPTIONAL, omit for auto-latest- Auth via Application Default Credentials
- Without
GCPParameterVersionthe provider lists all versions and picks the newestcreateTime - The payload is parsed JSON-first, YAML-fallback, deep-merged whole at tier 4
- Any failure (no versions, list/fetch error, unparseable payload) throws
Azure:
Secrets:
- Provider: AzureKeyVault
AzureKeyVault: my-vault
Mode: per-leaf # default, one GET per schema leaf, Auth.Secret -> Auth--Secret
# Mode: single-blob # one secret holding a JSON/YAML blob
# SecretName: config # single-blob secret name (default "config")per-leaf(default): introspect the schema's leaf paths, GET one secret per leaf with.→--, reassemble nested. Missing secrets are skipped (fall through to a lower layer)single-blob: GET one secret whose payload is a JSON/YAML blob (any vendor)- Auth via
DefaultAzureCredential. SDKs are loaded only when Azure is configured
SecretFiles is reserved, declaring it throws NotImplementedProviderError
Validation
Validation runs through the Standard Schema
interface, so zod, valibot, and arktype all work. On issues a
ConfigValidationError lists each path: message, on success the typed
result.value is returned
Unknown-key handling is your schema's call: zod strips unknown keys unless you use
z.looseObject({...}) or .loose(), and any key you want kept must be declared or
it is stripped
Azure per-leaf mode reads your schema's leaf paths to derive secret names, which
works on zod and valibot only (via their JSON Schema emitters). Any other vendor
must use Mode: single-blob
Errors
Everything surfaces fast as a thrown subclass of ChabisConfigError. The library
logs nothing
| Error | When |
| --- | --- |
| SecretsConfigError | Malformed Secrets block |
| ProviderError | GPM/Azure auth/list/fetch/parse failure (wraps the cause) |
| IntrospectionError | Azure per-leaf with a vendor that has no JSON-Schema emitter |
| ConfigValidationError | Standard Schema validation issues |
| NotImplementedProviderError | SecretFiles provider requested |
| PlaceholderError | assertNoPlaceholders found an unresolved placeholder |
assertNoPlaceholders(config) is an opt-in helper that throws if any leaf string
still starts with "<Configured-in-", guarding against a provider key that never
matched a field
More
See examples/zod and examples/valibot for
runnable setups
