npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@yedoma-labs/turar-config

v0.3.0

Published

Type-safe configuration management with file loading, environment cascading, and secrets integration - extends bylyt-env-guard

Readme

@yedoma-labs/turar-config

CI npm version npm downloads Node.js TypeScript License Bundle Size

Type-safe configuration management with file loading, environment cascading, and secrets integration. Built on @yedoma-labs/bylyt-env-guard for type-safe validation.

Features

  • 📁 Multi-format config - JSON, YAML, TOML support with auto-detection
  • 🌍 Environment cascading - Merge default{NODE_ENV} → env vars
  • 🔒 Secrets integration - HashiCorp Vault, AWS Secrets Manager, .env files
  • 🔐 Type-safe - Full TypeScript inference from schema
  • Validation - Uses bylyt-env-guard's zero-dependency validation
  • 🔗 Interpolation - Reference env vars with ${VAR} syntax in config files
  • 🔥 Hot reload - File watching with debouncing for development
  • 🎯 Loose coupling - Optional dependencies for cloud integrations (AWS SDK, Vault)

Installation

npm install @yedoma-labs/bylyt-env-guard @yedoma-labs/turar-config
# or
pnpm add @yedoma-labs/bylyt-env-guard @yedoma-labs/turar-config

Quick Start

1. Create config files

// config/default.json
{
  "database": {
    "host": "localhost",
    "port": 5432,
    "pool": {
      "min": 2,
      "max": 10
    }
  },
  "server": {
    "port": 3000
  }
}
// config/production.json
{
  "database": {
    "host": "${DB_HOST}",
    "pool": {
      "max": 100
    }
  },
  "server": {
    "port": 8080
  }
}

2. Define schema and load config

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";

const config = createConfigSync({
  schema: {
    database_host: eg.string().required(),
    database_port: eg.integer().default(5432),
    database_pool_min: eg.integer().default(2),
    database_pool_max: eg.integer().default(10),
    server_port: eg.port().default(3000),
  },
  configDir: "./config",
  envFile: true, // Load .env file
  prefix: "APP_", // Use APP_ prefix for env vars
});

console.log(config.database_host); // Type-safe access
console.log(config.server_port);   // Type: number

Configuration Cascading

Configuration values are merged with the following priority (highest to lowest):

  1. Environment variables - process.env.APP_database_host
  2. .env file - Values from .env (if envFile: true)
  3. Secrets - HashiCorp Vault or other secrets providers (if configured)
  4. Environment config - config/{NODE_ENV}.json
  5. Base config - config/default.json
  6. Schema defaults - .default() values in schema

Example Cascade

// config/default.json
{ "server": { "port": 3000, "host": "localhost" } }

// config/production.json
{ "server": { "port": 8080 } }

// process.env.NODE_ENV = "production"
// process.env.APP_server_port = "9000"

// Result:
// {
//   server_port: 9000,     // from env var
//   server_host: "localhost" // from default.json
// }

Variable Interpolation

Reference environment variables in config files using ${VAR} syntax:

{
  "database": {
    "url": "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/myapp"
  },
  "api": {
    "key": "${API_KEY}"
  }
}

To escape interpolation, use \${VAR}:

{
  "example": "This is a literal \\${NOT_INTERPOLATED}"
}

API Reference

createConfig(options)

Async version supporting HashiCorp Vault secrets provider.

const config = await createConfig({
  schema: { /* ... */ },
  configDir: "./config",
  envFile: true,
  secrets: { provider: "env" },
  prefix: "APP_",
  strict: false,
});

createConfigSync(options)

Synchronous version for simple use cases.

const config = createConfigSync({
  schema: { /* ... */ },
  configDir: "./config",
  envFile: true,
  prefix: "APP_",
  strict: false,
});

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | schema | SchemaDefinition | required | Bylyt schema defining config structure | | configDir | string | "./config" | Directory containing config files | | envFile | boolean \| string | false | Load .env file (or custom path) | | secrets | SecretsProviderConfig | undefined | Secrets provider config | | prefix | string | undefined | Prefix for environment variables (e.g., "APP_") | | strict | boolean | false | Throw on unknown prefixed env vars |

File Structure

your-project/
├── config/
│   ├── default.json      # Base config (always loaded)
│   ├── development.json  # Loaded when NODE_ENV=development
│   ├── test.json         # Loaded when NODE_ENV=test
│   └── production.json   # Loaded when NODE_ENV=production
├── .env                  # Optional environment file
└── src/
    └── config.ts         # Your config setup

Security Considerations

Safe:

  • JSON files are parsed safely (no eval)
  • Environment variables are never logged
  • Sensitive values marked with .sensitive() are hidden
  • Path traversal is prevented (resolved paths)

⚠️ Important:

  • Never commit .env files to version control
  • Use .sensitive() for secrets in schema
  • Interpolation only resolves existing env vars (no code execution)

Examples

Basic Web Server

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";

const config = createConfigSync({
  schema: {
    port: eg.port().default(3000),
    host: eg.string().default("0.0.0.0"),
    database_url: eg.url().required(),
    log_level: eg.enum(["debug", "info", "warn", "error"] as const).default("info"),
  },
  configDir: "./config",
  envFile: true,
});

// Start server with type-safe config
startServer(config.host, config.port);

With Prefix

const config = createConfigSync({
  schema: {
    database_host: eg.string(),
    database_port: eg.port(),
  },
  prefix: "MYAPP_",
  envFile: true,
});

// Reads MYAPP_database_host and MYAPP_database_port from env

Deep Nesting

// config/default.json
{
  "services": {
    "redis": {
      "cluster": {
        "nodes": ["localhost:6379"]
      }
    }
  }
}

const config = createConfigSync({
  schema: {
    services_redis_cluster_nodes: eg.array().separator(",").default(["localhost:6379"]),
  },
  configDir: "./config",
});

Error Handling

import { eg, EnvValidationError } from "@yedoma-labs/bylyt-env-guard";
import {
  createConfigSync,
  ConfigFileError,
  ConfigInterpolationError,
} from "@yedoma-labs/turar-config";

try {
  const config = createConfigSync({
    schema: {
      required_field: eg.string().required(),
    },
    configDir: "./config",
  });
} catch (error) {
  if (error instanceof ConfigFileError) {
    console.error("Failed to load config file:", error.path);
  }
  if (error instanceof ConfigInterpolationError) {
    console.error("Undefined variable:", error.variable);
  }
  if (error instanceof EnvValidationError) {
    console.error("Validation failed:", error.failures);
  }
}

Integration with bylyt-env-guard

turar-config is an orchestration layer built on top of bylyt-env-guard. Here's how they work together:

Architecture

┌─────────────────────────────────────────┐
│         Your Application                │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│     turar-config (this package)         │
│  • Load JSON files                      │
│  • Merge configs                        │
│  • Interpolate ${VAR}                   │
│  • Flatten objects                      │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│     bylyt-env-guard (peer dep)          │
│  • Validate types                       │
│  • Type coercion                        │
│  • Type inference                       │
│  • Freeze result                        │
└─────────────────────────────────────────┘

Responsibilities

| Feature | Handled By | Description | |---------|-----------|-------------| | File Loading | turar-config | Load config/*.json files | | Cascading | turar-config | Merge configs by environment | | Interpolation | turar-config | Resolve ${VAR} in JSON | | Flattening | turar-config | {db: {host}}{db_host} | | Validation | bylyt-env-guard | Type checking, constraints | | Type Safety | bylyt-env-guard | TypeScript inference | | Freezing | bylyt-env-guard | Immutable config object |

When to Use What?

Use bylyt-env-guard alone if:

  • ✅ You only need environment variables
  • ✅ Simple .env file is sufficient
  • ✅ No multi-environment configs

Use turar-config if:

  • ✅ You have config files (JSON)
  • ✅ Different configs per environment (dev/staging/prod)
  • ✅ Need ${VAR} interpolation in configs
  • ✅ Want to centralize config management

Advanced Examples

Multi-Environment Setup

# File structure
config/
  default.json        # Base config (always loaded)
  development.json    # Local development
  staging.json        # Staging environment  
  production.json     # Production
// Automatically loads config/{NODE_ENV}.json
process.env.NODE_ENV = "production";

const config = createConfigSync({
  schema: {
    api_url: eg.url().required(),
    database_pool_max: eg.integer().default(10),
  },
  configDir: "./config",
});

// In production: loads default.json + production.json
// Values from production.json override default.json

Express.js Integration

import express from "express";
import cors from "cors";
import session from "express-session";
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";

const config = createConfigSync({
  schema: {
    port: eg.port().default(3000),
    cors_origins: eg.array().of("string"),
    session_secret: eg.string().sensitive().required(),
    database_url: eg.url().required(),
  },
  configDir: "./config",
  envFile: true,
});

const app = express();

app.use(cors({ origin: config.cors_origins }));
app.use(session({ secret: config.session_secret }));

app.listen(config.port, () => {
  console.log(`Server running on port ${config.port}`);
});

NestJS Integration

// config.service.ts
import { Injectable } from "@nestjs/common";
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";

@Injectable()
export class ConfigService {
  private readonly config = createConfigSync({
    schema: {
      database_host: eg.string().required(),
      database_port: eg.port().default(5432),
      redis_url: eg.url().required(),
      jwt_secret: eg.string().sensitive().required(),
    },
    configDir: "./config",
    prefix: "APP_",
  });

  get database() {
    return {
      host: this.config.database_host,
      port: this.config.database_port,
    };
  }

  get redis() {
    return this.config.redis_url;
  }
}

Nested Object Configs

// config/default.json
{
  "services": {
    "redis": {
      "host": "localhost",
      "port": 6379,
      "options": {
        "maxRetriesPerRequest": 3,
        "enableReadyCheck": true
      }
    },
    "database": {
      "primary": { "host": "db1.local", "port": 5432 },
      "replica": { "host": "db2.local", "port": 5432 }
    }
  }
}
const config = createConfigSync({
  schema: {
    services_redis_host: eg.string(),
    services_redis_port: eg.port(),
    services_redis_options_maxRetriesPerRequest: eg.integer(),
    services_database_primary_host: eg.string(),
    services_database_primary_port: eg.port(),
    services_database_replica_host: eg.string(),
    services_database_replica_port: eg.port(),
  },
  configDir: "./config",
});

// Access flattened keys
const redisHost = config.services_redis_host;
const primaryDbHost = config.services_database_primary_host;

Best Practices

File Structure

project/
├── config/
│   ├── default.json       # Required: base config
│   ├── development.json   # Optional: overrides for dev
│   ├── test.json          # Optional: test environment
│   ├── staging.json       # Optional: staging env
│   └── production.json    # Optional: production env
├── .env                   # Optional: local overrides (gitignored)
├── .env.example           # Commit this: documents required env vars
└── src/
    └── config.ts          # Config setup

Security Guidelines

✅ DO:

  • Commit config/*.json files (non-sensitive defaults)
  • Commit .env.example (template)
  • Use .sensitive() for secrets in schema
  • Use ${VAR} interpolation for secrets in production.json
  • Set real secrets via environment variables

❌ DON'T:

  • Commit .env files (add to .gitignore)
  • Put secrets directly in JSON files
  • Commit production credentials
  • Log config values marked .sensitive()

Performance Tips

  1. Use createConfigSync when possible - Faster startup
  2. Keep config files small - Load time is linear with file size
  3. Minimize interpolations - Each ${VAR} is a lookup
  4. Use flattened schemas - Avoid deep nesting (5+ levels)

Naming Conventions

// Consistent naming: lowercase with underscores
const schema = {
  database_url: eg.url(),           // ✅ Good
  database_connection_timeout: eg.integer(),  // ✅ Good
  
  DatabaseURL: eg.url(),            // ❌ Avoid
  "database-url": eg.url(),         // ❌ Avoid (kebab-case)
};

// Environment variable mapping
// With prefix="APP_":
// APP_database_url → config.database_url
// APP_database_connection_timeout → config.database_connection_timeout

Troubleshooting

Common Errors

"Invalid environment name"

// ❌ Error: path traversal attempt
loadConfigFiles("./config", "../etc/passwd");

// ✅ Fix: use alphanumeric names only
loadConfigFiles("./config", "production");

"Undefined environment variable"

// config/production.json
{ "api_key": "${API_KEY}" }
# ❌ Error: API_KEY not set
NODE_ENV=production node app.js

# ✅ Fix: set env var first
API_KEY=secret123 NODE_ENV=production node app.js

"Config file must contain a JSON object"

// ❌ Invalid: array at root
["value1", "value2"]

// ✅ Valid: object at root
{ "values": ["value1", "value2"] }

Debugging Config Loading

import { createConfigSync } from "@yedoma-labs/turar-config";

try {
  const config = createConfigSync({
    schema: { /* ... */ },
    configDir: "./config",
  });
  
  // Log loaded config (excluding sensitive values)
  console.log("Config loaded:", JSON.stringify(config, null, 2));
  
} catch (error) {
  if (error instanceof ConfigFileError) {
    console.error("Failed to load:", error.path);
    console.error("Reason:", error.message);
  } else if (error instanceof ConfigInterpolationError) {
    console.error("Undefined variable:", error.variable);
  } else if (error instanceof EnvValidationError) {
    console.error("Validation failures:");
    for (const failure of error.failures) {
      console.error(`  ${failure.field}: ${failure.message}`);
    }
  }
}

Environment Variable Priority

If a value isn't what you expect, check the priority order:

# Priority (highest to lowest):
1. process.env.APP_database_host      # Direct env var
2. .env file: APP_database_host=...   # .env file (if envFile: true)
3. config/production.json             # Environment config
4. config/default.json                # Base config
5. schema: eg.string().default(...)   # Schema default

Migration Guides

See docs/migration.md for detailed guides:

YAML and TOML Support

turar-config automatically detects and loads configuration files in multiple formats:

Supported Formats

  • JSON (.json) - Traditional format
  • YAML (.yaml, .yml) - Human-friendly, great for complex configs ✨ NEW
  • TOML (.toml) - Minimal, clear syntax ✨ NEW

Format Priority

When multiple formats exist, files are loaded in this order:

  1. .yaml / .yml
  2. .json
  3. .toml

Example:

config/
  default.yaml      # ← Loaded (YAML has priority)
  default.json      # Ignored (YAML exists)
  production.toml   # ← Loaded (no YAML/JSON for production)

YAML Example

# config/default.yaml
server:
  host: localhost
  port: 3000

database:
  host: localhost
  port: 5432
  pool:
    min: 2
    max: 10

features:
  enableMetrics: false
  enableDebug: true
# config/production.yaml
server:
  host: 0.0.0.0
  port: 8080

database:
  host: ${DB_HOST}
  name: ${DB_NAME}
  pool:
    max: 100

features:
  enableMetrics: true
  enableDebug: false

TOML Example

# config/default.toml
[server]
host = "localhost"
port = 3000

[database]
host = "localhost"
port = 5432
name = "myapp_dev"

[database.pool]
min = 2
max = 10

[features]
enableMetrics = false
enableDebug = true
# config/production.toml
[server]
host = "0.0.0.0"
port = 8080

[database]
host = "${DB_HOST}"
name = "${DB_NAME}"

[database.pool]
max = 100

[features]
enableMetrics = true
enableDebug = false

Usage

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";

// Automatically detects YAML/TOML/JSON
const config = createConfigSync({
  schema: {
    server_host: eg.string(),
    server_port: eg.port(),
    database_host: eg.string(),
    features_enableMetrics: eg.boolean(),
  },
  configDir: "./config", // Looks for .yaml, .yml, .toml, .json
});

Why YAML/TOML?

YAML Benefits:

  • ✅ No quotes needed for strings
  • ✅ Comments with #
  • ✅ Multi-line strings with | or >
  • ✅ More readable for complex nested configs
  • ✅ Industry standard (Kubernetes, Docker Compose, GitHub Actions)

TOML Benefits:

  • ✅ Clear, minimal syntax
  • ✅ Tables for nested structures
  • ✅ Explicit types (no ambiguity like YAML's Norway problem)
  • ✅ Good for flat configs
  • ✅ Popular in Rust ecosystem (Cargo.toml)

JSON Benefits:

  • ✅ Universal support
  • ✅ Strict syntax (no surprises)
  • ✅ Easy to generate programmatically
  • ✅ Good for machine-generated configs

Config File Watching (Hot Reload)

Watch config files for changes and automatically reload configuration in development. Perfect for updating feature flags, connection strings, or other settings without restarting your app.

Basic Usage

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { watchConfig } from "@yedoma-labs/turar-config";

const handle = await watchConfig({
  schema: {
    server_port: eg.port().default(3000),
    database_host: eg.string().required(),
    features_enableDebug: eg.boolean().default(false),
  },
  configDir: "./config",
  onChange: (newConfig, change, oldConfig) => {
    console.log(`Config changed: ${change.type} ${change.path}`);
    console.log(`Debug mode: ${oldConfig.features_enableDebug} → ${newConfig.features_enableDebug}`);
    
    // Update your app with new config
    updateFeatureFlags(newConfig);
  },
  debounce: 500, // Wait 500ms after last change before reloading
});

// Get current config at any time
const currentConfig = handle.getConfig();

// Stop watching when done
await handle.stop();

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | onChange | function | undefined | Callback when config changes | | debounce | number | 500 | Milliseconds to wait after last change | | ignoreInitial | boolean | true | Don't trigger onChange on startup | | All config options | | | Same as createConfig() |

onChange Callback

type OnChange = (
  newConfig: ConfigResult,
  change: ConfigChange,
  oldConfig: ConfigResult
) => void;

interface ConfigChange {
  type: "added" | "changed" | "removed";
  path: string;       // Full path to changed file
  timestamp: Date;    // When change was detected
}

Watch Handle

interface WatchHandle {
  stop(): Promise<void>;          // Stop watching
  getConfig(): ConfigResult;      // Get current config
}

Use Cases

Feature Flags

const handle = await watchConfig({
  schema: {
    features_newUI: eg.boolean().default(false),
    features_betaFeatures: eg.boolean().default(false),
  },
  configDir: "./config",
  onChange: (newConfig) => {
    // Update feature flags without restart
    featureFlags.update(newConfig);
  },
});

Database Connection Pool

const handle = await watchConfig({
  schema: {
    database_pool_min: eg.integer().default(2),
    database_pool_max: eg.integer().default(10),
  },
  configDir: "./config",
  onChange: (newConfig, change, oldConfig) => {
    if (
      newConfig.database_pool_max !== oldConfig.database_pool_max ||
      newConfig.database_pool_min !== oldConfig.database_pool_min
    ) {
      // Reconfigure pool without restart
      databasePool.reconfigure({
        min: newConfig.database_pool_min,
        max: newConfig.database_pool_max,
      });
    }
  },
});

Development Mode Only

let handle: WatchHandle | null = null;

if (process.env.NODE_ENV === "development") {
  handle = await watchConfig({
    schema: mySchema,
    configDir: "./config",
    onChange: (newConfig) => {
      console.log("🔄 Config reloaded:", newConfig);
    },
  });
}

// Cleanup on shutdown
process.on("SIGTERM", async () => {
  if (handle) await handle.stop();
});

What Files are Watched?

  • All .json, .yaml, .yml, and .toml files in configDir
  • Recursive (watches subdirectories too)
  • Changes, additions, and deletions are detected
  • Uses efficient file system events (not polling)

Performance Notes

  • Debouncing prevents excessive reloads during rapid changes
  • Minimal overhead - uses native file system events
  • Safe - validates config before calling onChange
  • Graceful errors - logs errors without crashing your app

Best Practices

Use in development only - Production apps should restart on config changes
Set appropriate debounce - 500ms works well for most cases
Handle partial updates - Check what changed before applying
Test error scenarios - Ensure app handles invalid config gracefully
Stop watching on shutdown - Call handle.stop() in cleanup hooks

HashiCorp Vault Integration

Load secrets from HashiCorp Vault at runtime. Supports both token and AppRole authentication.

Quick Start

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig } from "@yedoma-labs/turar-config";

const config = await createConfig({
  schema: {
    database_host: eg.string().required(),
    database_password: eg.string().required(),
    api_key: eg.string().required(),
  },
  configDir: "./config",
  secrets: {
    provider: "vault",
    vault: {
      url: "https://vault.example.com",
      auth: {
        type: "token",
        token: process.env.VAULT_TOKEN!,
      },
      path: "myapp/config",
    },
  },
});

console.log(config.database_password); // Secret from Vault

Token Authentication

Simplest method - use a Vault token directly:

const config = await createConfig({
  schema: { /* ... */ },
  secrets: {
    provider: "vault",
    vault: {
      url: "https://vault.example.com",
      auth: {
        type: "token",
        token: process.env.VAULT_TOKEN!, // Read from environment
      },
      path: "myapp/production/config",
      mountPath: "secret", // Optional, defaults to "secret"
      namespace: "my-namespace", // Optional, for Vault Enterprise
    },
  },
});

AppRole Authentication

Recommended for production - use AppRole for better security:

const config = await createConfig({
  schema: { /* ... */ },
  secrets: {
    provider: "vault",
    vault: {
      url: "https://vault.example.com",
      auth: {
        type: "appRole",
        roleId: process.env.VAULT_ROLE_ID!,
        secretId: process.env.VAULT_SECRET_ID!,
      },
      path: "myapp/production/config",
    },
  },
});

Vault Configuration Options

| Option | Type | Required | Description | |--------|------|----------|-------------| | url | string | Yes | Vault server URL | | auth | VaultAuth | Yes | Authentication config | | path | string | Yes | Path to secrets in KV store | | mountPath | string | No | KV mount path (default: "secret") | | namespace | string | No | Vault namespace (Enterprise only) |

Authentication Types

Token Auth:

auth: {
  type: "token",
  token: string,
}

AppRole Auth:

auth: {
  type: "appRole",
  roleId: string,
  secretId: string,
}

Nested Secrets

Vault secrets are automatically flattened using underscore separators:

Vault KV Store:

// vault kv get secret/myapp/config
{
  "database": {
    "host": "db.example.com",
    "credentials": {
      "username": "admin",
      "password": "secret123"
    }
  },
  "api_key": "key-xyz"
}

Flattened Config:

const config = await createConfig({
  schema: {
    database_host: eg.string().required(),
    database_credentials_username: eg.string().required(),
    database_credentials_password: eg.string().required(),
    api_key: eg.string().required(),
  },
  secrets: { /* vault config */ },
});

console.log(config.database_host); // "db.example.com"
console.log(config.database_credentials_username); // "admin"
console.log(config.api_key); // "key-xyz"

Priority Order

Secrets from Vault have higher priority than config files but lower priority than environment variables:

Environment Variables (highest priority)
       ↓
  Vault Secrets
       ↓
   .env file
       ↓
Config Files (lowest priority)

Example:

// config/default.json
{ "api_key": "dev-key" }

// Vault secret
{ "api_key": "vault-key" }

// Environment variable
export API_KEY="prod-key"

// Result:
config.api_key === "prod-key" // Environment wins

Legacy Configuration (Deprecated)

For backward compatibility, you can use the legacy flat configuration:

secrets: {
  provider: "vault",
  vaultUrl: "https://vault.example.com",
  vaultToken: process.env.VAULT_TOKEN,
  vaultPath: "myapp/config",
}

⚠️ Legacy config only supports token auth. Use the new structured config for AppRole support.

Setting Up Vault

1. Enable KV v2 Secrets Engine:

vault secrets enable -path=secret kv-v2

2. Write Secrets:

vault kv put secret/myapp/config \
  database_password="secret123" \
  api_key="key-xyz"

3. Create Token (Dev/Testing):

vault token create -policy=myapp-read

4. Configure AppRole (Production):

# Enable AppRole
vault auth enable approle

# Create role
vault write auth/approle/role/myapp \
  token_policies="myapp-read" \
  token_ttl=1h \
  token_max_ttl=4h

# Get role_id
vault read auth/approle/role/myapp/role-id

# Generate secret_id
vault write -f auth/approle/role/myapp/secret-id

Error Handling

Vault errors are wrapped in ConfigSecretError:

import { ConfigSecretError } from "@yedoma-labs/turar-config";

try {
  const config = await createConfig({
    schema: { api_key: eg.string().required() },
    secrets: { provider: "vault", /* ... */ },
  });
} catch (error) {
  if (error instanceof ConfigSecretError) {
    console.error("Vault error:", error.message);
    // Handle: connection failure, auth error, missing secrets, etc.
  }
}

Security Best Practices

Never commit tokens - Use environment variables
Use AppRole in production - More secure than long-lived tokens
Rotate secrets regularly - Vault supports automatic rotation
Use short TTLs - Limit token lifetime
Enable audit logging - Track all secret access
Use namespaces - Isolate environments (Vault Enterprise)

Troubleshooting

Connection Refused:

# Check Vault is running
curl -s https://vault.example.com/v1/sys/health

# Check network connectivity
telnet vault.example.com 8200

Permission Denied:

# Verify token has read permissions
vault token lookup

# Check policy allows reading the path
vault policy read myapp-read

Secrets Not Found:

# Verify secrets exist
vault kv get secret/myapp/config

# Check mount path is correct
vault secrets list

Complete Example

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig, ConfigSecretError } from "@yedoma-labs/turar-config";

async function loadConfig() {
  try {
    const config = await createConfig({
      schema: {
        // From config files
        server_port: eg.port().default(3000),
        server_host: eg.string().default("0.0.0.0"),
        
        // From Vault
        database_host: eg.string().required(),
        database_port: eg.port().required(),
        database_username: eg.string().required(),
        database_password: eg.string().required(),
        api_key: eg.string().required(),
      },
      configDir: "./config",
      secrets: {
        provider: "vault",
        vault: {
          url: process.env.VAULT_ADDR || "http://localhost:8200",
          auth: {
            type: "appRole",
            roleId: process.env.VAULT_ROLE_ID!,
            secretId: process.env.VAULT_SECRET_ID!,
          },
          path: `myapp/${process.env.NODE_ENV}/config`,
          namespace: process.env.VAULT_NAMESPACE,
        },
      },
    });

    console.log("✅ Configuration loaded successfully");
    return config;
  } catch (error) {
    if (error instanceof ConfigSecretError) {
      console.error("❌ Vault error:", error.message);
      process.exit(1);
    }
    throw error;
  }
}

const config = await loadConfig();

// Use config
const app = express();
app.listen(config.server_port);

AWS Secrets Manager Integration

Load secrets from AWS Secrets Manager at runtime. Supports IAM roles, explicit credentials, and custom endpoints.

Installation

AWS Secrets Manager integration requires the AWS SDK:

npm install @aws-sdk/client-secrets-manager
# or
pnpm add @aws-sdk/client-secrets-manager

Note: This is an optional dependency. Only install if you use AWS Secrets Manager.

Quick Start

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig } from "@yedoma-labs/turar-config";

const config = await createConfig({
  schema: {
    database_host: eg.string().required(),
    database_password: eg.string().required(),
    api_key: eg.string().required(),
  },
  configDir: "./config",
  secrets: {
    provider: "aws-secrets-manager",
    aws: {
      region: "us-east-1",
      secretName: "myapp/production/config",
    },
  },
});

console.log(config.database_password); // Secret from AWS

IAM Role Authentication (Recommended)

In production, use IAM roles instead of explicit credentials:

const config = await createConfig({
  schema: { /* ... */ },
  secrets: {
    provider: "aws-secrets-manager",
    aws: {
      region: "us-east-1",
      secretName: "myapp/production/config",
      // No credentials needed - uses IAM role
    },
  },
});

IAM Policy Example:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "secretsmanager:GetSecretValue"
    ],
    "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/*"
  }]
}

Explicit Credentials

For development or CI/CD:

const config = await createConfig({
  schema: { /* ... */ },
  secrets: {
    provider: "aws-secrets-manager",
    aws: {
      region: "us-east-1",
      secretName: "myapp/dev/config",
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
  },
});

Temporary Credentials

For assumed roles or session tokens:

aws: {
  region: "us-east-1",
  secretName: "myapp/config",
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  sessionToken: process.env.AWS_SESSION_TOKEN,
}

LocalStack Testing

Test with LocalStack locally:

const config = await createConfig({
  schema: { /* ... */ },
  secrets: {
    provider: "aws-secrets-manager",
    aws: {
      region: "us-east-1",
      secretName: "test/config",
      endpoint: "http://localhost:4566", // LocalStack endpoint
      accessKeyId: "test",
      secretAccessKey: "test",
    },
  },
});

Docker Compose for LocalStack:

services:
  localstack:
    image: localstack/localstack:latest
    environment:
      - SERVICES=secretsmanager
    ports:
      - "4566:4566"

Configuration Options

| Option | Type | Required | Description | |--------|------|----------|-------------| | region | string | Yes | AWS region (e.g., us-east-1, eu-west-2) | | secretName | string | Yes | Secret name or ARN | | endpoint | string | No | Custom endpoint (for LocalStack/testing) | | accessKeyId | string | No | AWS access key (use IAM roles instead) | | secretAccessKey | string | No | AWS secret key (use IAM roles instead) | | sessionToken | string | No | Session token for temporary credentials |

Secret Format

AWS secrets must be JSON objects:

{
  "database_host": "prod-db.example.com",
  "database_password": "secret123",
  "api_key": "key-xyz"
}

Nested secrets are automatically flattened:

{
  "database": {
    "host": "prod-db.example.com",
    "credentials": {
      "username": "admin",
      "password": "secret123"
    }
  }
}

Becomes:

config.database_host              // "prod-db.example.com"
config.database_credentials_username  // "admin"
config.database_credentials_password  // "secret123"

Priority Order

AWS secrets have the same priority as Vault:

Environment Variables (highest priority)
       ↓
  .env file
       ↓
  AWS Secrets
       ↓
Config Files (lowest priority)

Security Best Practices

Use IAM roles - Don't hardcode credentials
Enable secret rotation - Automatic password rotation
Use KMS encryption - Encrypt secrets at rest
Least privilege - Only grant GetSecretValue permission
Use resource tags - Organize and control access
Enable CloudTrail - Audit all secret access
Short-lived credentials - Use temporary session tokens

Error Handling

import { ConfigSecretError } from "@yedoma-labs/turar-config";

try {
  const config = await createConfig({
    schema: { api_key: eg.string().required() },
    secrets: {
      provider: "aws-secrets-manager",
      aws: { region: "us-east-1", secretName: "app/config" },
    },
  });
} catch (error) {
  if (error instanceof ConfigSecretError) {
    console.error("AWS error:", error.message);
    // Handle: permission denied, secret not found, etc.
  }
}

Common Errors

AccessDeniedException:

Failed to load secrets from AWS Secrets Manager: 
User is not authorized to perform secretsmanager:GetSecretValue

Fix: Attach IAM policy with GetSecretValue permission.

ResourceNotFoundException:

Failed to load secrets from AWS Secrets Manager:
Secrets Manager can't find the specified secret

Fix: Verify secret name and region.

InvalidRequestException:

AWS Secrets Manager secret 'myapp/config' is not valid JSON

Fix: Ensure secret contains valid JSON object.

Creating Secrets

CLI:

aws secretsmanager create-secret \
  --name myapp/production/config \
  --secret-string '{"database_password":"secret123","api_key":"key-xyz"}'

Console:

  1. Go to AWS Secrets Manager
  2. Click "Store a new secret"
  3. Select "Other type of secret"
  4. Add key-value pairs
  5. Name: myapp/production/config
  6. Enable automatic rotation (optional)

Comparison: AWS vs Vault

| Feature | AWS Secrets Manager | HashiCorp Vault | |---------|---------------------|------------------| | Cloud native | ✅ AWS only | ✅ Any cloud/on-prem | | Managed service | ✅ Fully managed | ❌ Self-hosted | | Auto rotation | ✅ Built-in | ✅ Supported | | Cost | $0.40/secret/month + API calls | Infrastructure + maintenance | | Setup | Easy (IAM) | Complex (install, configure) | | Access control | IAM policies | ACL policies | | Best for | AWS-native apps | Multi-cloud, on-prem |

Complete Example

import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig, ConfigSecretError } from "@yedoma-labs/turar-config";

async function loadConfig() {
  try {
    const config = await createConfig({
      schema: {
        // From config files
        server_port: eg.port().default(3000),
        server_host: eg.string().default("0.0.0.0"),
        
        // From AWS Secrets Manager
        database_host: eg.string().required(),
        database_port: eg.port().required(),
        database_username: eg.string().required(),
        database_password: eg.string().required(),
        api_key: eg.string().required(),
      },
      configDir: "./config",
      secrets: {
        provider: "aws-secrets-manager",
        aws: {
          region: process.env.AWS_REGION || "us-east-1",
          secretName: `myapp/${process.env.NODE_ENV}/config`,
        },
      },
    });

    console.log("✅ Configuration loaded successfully");
    return config;
  } catch (error) {
    if (error instanceof ConfigSecretError) {
      console.error("❌ AWS Secrets Manager error:", error.message);
      process.exit(1);
    }
    throw error;
  }
}

const config = await loadConfig();

// Use config
const app = express();
app.listen(config.server_port);

Roadmap

  • [x] HashiCorp Vault integration
  • [x] AWS Secrets Manager support
  • [x] YAML config file support
  • [x] TOML config file support
  • [x] Config file watching / hot reload
  • [ ] Config migration helpers

License

MIT

Related Projects