| title | Configuration Resolution |
|---|---|
| description | Hierarchical config merging, precedence rules, environment overrides, and tenant isolation |
import { Settings, Layers, Lock, Users, FileCode, Shield } from 'lucide-react';
ObjectOS uses a hierarchical configuration system that merges settings from multiple sources with clear precedence rules. This enables environment-specific overrides, tenant isolation, and user preferences—all from a single unified API.
Traditional applications struggle with configuration management:
// Where does apiKey come from? 🤷
const apiKey =
process.env.API_KEY || // Environment variable?
config.stripe.apiKey || // Config file?
tenantSettings.apiKey || // Database?
userPrefs.apiKey || // User override?
'fallback-key'; // Hardcoded default?
// Which value wins if multiple sources define it?
// How do you handle tenant-specific overrides?
// How do you validate that the value is correct?Result: Configuration chaos. Developers spend hours debugging "works on my machine" issues caused by conflicting config sources.
ObjectOS defines six configuration sources with strict precedence:
┌─────────────────────────────────────────────────────────────┐
│ 1. RUNTIME │
│ Programmatic overrides (context.config.set()) │
│ Highest priority, temporary │
└─────────────────────────────────────────────────────────────┘
↓ overrides
┌─────────────────────────────────────────────────────────────┐
│ 2. USER PREFERENCES │
│ Per-user settings (language, theme, etc.) │
│ Stored in database, user-specific │
└─────────────────────────────────────────────────────────────┘
↓ overrides
┌─────────────────────────────────────────────────────────────┐
│ 3. TENANT │
│ Multi-tenant overrides (per-customer config) │
│ Stored in database, tenant-scoped │
└─────────────────────────────────────────────────────────────┘
↓ overrides
┌─────────────────────────────────────────────────────────────┐
│ 4. ENVIRONMENT │
│ Environment variables (OS_*, NODE_ENV, etc.) │
│ Set by deployment platform (Kubernetes, Docker) │
└─────────────────────────────────────────────────────────────┘
↓ overrides
┌─────────────────────────────────────────────────────────────┐
│ 5. FILE │
│ Configuration files (objectstack.config.yml) │
│ Checked into Git, environment-specific │
└─────────────────────────────────────────────────────────────┘
↓ overrides
┌─────────────────────────────────────────────────────────────┐
│ 6. PLUGIN DEFAULTS │
│ Default values from plugin manifests │
│ Lowest priority, fallback values │
└─────────────────────────────────────────────────────────────┘
Precedence Rule: Higher source always wins when same key is defined.
// Access configuration via context
const config = context.config;
// Get single value
const apiKey = config.get('stripe.apiKey');
// Returns: Value from highest-priority source that defines 'stripe.apiKey'
// Get with default
const timeout = config.get('http.timeout', 30_000);
// Returns: 30000 if 'http.timeout' not defined in any source
// Get typed value (with Zod schema)
const stripeConfig = config.get('stripe', stripeConfigSchema);
// Returns: Validated and typed value
// Throws: ZodError if value doesn't match schema
// Get required value (throw if missing)
const requiredKey = config.require('stripe.apiKey');
// Throws: ConfigError if 'stripe.apiKey' not defined in any source
// Get all config for namespace
const allStripeConfig = config.getNamespace('stripe');
// Returns: { apiKey: '...', webhookSecret: '...', ... }
// Check if key exists
if (config.has('stripe.apiKey')) {
// Key is defined in at least one source
}// Set runtime value (highest priority, temporary)
config.set('feature.newUI', true);
// Set user preference (persisted to database)
await config.setUserPreference('theme', 'dark');
// Set tenant config (multi-tenant SaaS)
await config.setTenant('stripe.apiKey', 'sk_test_tenant123');
// Batch set
config.merge({
'stripe.apiKey': 'sk_test_...',
'stripe.webhookSecret': 'whsec_...',
});Use Case: Temporary overrides for testing, feature flags toggled at runtime.
// Example: Enable feature for A/B test
context.config.set('feature.newCheckout', true);
// Config is ephemeral (not persisted)
// Lost on server restartStorage: In-memory Map (per-request context)
Use Case: Per-user settings (language, timezone, UI theme).
// User changes language preference
await context.config.setUserPreference('locale', 'de');
// Next request from this user
const locale = context.config.get('locale');
// Returns: 'de' (from user preferences)Storage: Database table
CREATE TABLE objectstack_user_preferences (
user_id UUID NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, key)
);Use Case: Customer-specific configuration in multi-tenant SaaS.
// Tenant "acme-corp" has custom Stripe key
await context.config.setTenant('stripe.apiKey', 'sk_live_acme...', {
tenantId: 'acme-corp',
});
// Request from Acme Corp user
const apiKey = context.config.get('stripe.apiKey');
// Returns: 'sk_live_acme...' (tenant-specific)
// Request from different tenant
// Returns: Default Stripe key (from lower-priority source)Storage: Database table
CREATE TABLE objectstack_tenant_config (
tenant_id UUID NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (tenant_id, key)
);Isolation: Tenant config is automatically scoped to current tenant in request context.
Use Case: Deployment-specific config (API keys, database URLs, feature flags).
# .env.production
OS_STRIPE_API_KEY=sk_live_...
OS_DATABASE_URL=postgresql://prod-db:5432/objectstack
OS_FEATURE_NEW_UI=true
NODE_ENV=productionNaming Convention:
- Prefix with
OS_for ObjectOS config - Use
__for nested keys:OS_STRIPE__API_KEY→stripe.apiKey - Use
_for snake_case:OS_DATABASE_URL→databaseUrl
// Environment variables are auto-loaded at boot
const apiKey = config.get('stripe.apiKey');
// Reads from: process.env.OS_STRIPE_API_KEY
const dbUrl = config.get('databaseUrl');
// Reads from: process.env.OS_DATABASE_URLType Coercion:
OS_HTTP_TIMEOUT=30000 # → number: 30000
OS_FEATURE_NEW_UI=true # → boolean: true
OS_ALLOWED_ORIGINS=a,b,c # → array: ['a', 'b', 'c']Use Case: Environment-specific defaults checked into Git.
ObjectOS loads config files in this order:
1. objectstack.config.ts (TypeScript, recommended)
2. objectstack.config.js (JavaScript)
3. objectstack.config.yml (YAML)
4. objectstack.config.json (JSON)
5. objectstack.config.{env}.ts (Environment-specific)
- objectstack.config.production.ts
- objectstack.config.staging.ts
- objectstack.config.development.ts
// objectstack.config.ts
import { defineConfig } from '@objectstack/core';
export default defineConfig({
// Database
database: {
url: 'postgresql://localhost:5432/objectstack',
pool: {
min: 2,
max: 10,
},
},
// Plugins
plugins: {
enabled: [
'@objectstack/core',
'@mycompany/crm',
],
},
// HTTP server
http: {
port: 3000,
cors: {
origins: ['http://localhost:3000'],
},
},
// Feature flags
features: {
newUI: false,
aiAssistant: true,
},
// Plugin-specific config
stripe: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
});// objectstack.config.production.ts
import { defineConfig } from '@objectstack/core';
export default defineConfig({
database: {
url: process.env.DATABASE_URL,
pool: {
min: 10,
max: 50,
},
},
http: {
port: process.env.PORT || 8080,
cors: {
origins: ['https://app.mycompany.com'],
},
},
features: {
newUI: true, // Enabled in production
},
});File Selection: Based on NODE_ENV:
NODE_ENV=production → loads objectstack.config.production.ts
NODE_ENV=staging → loads objectstack.config.staging.ts
NODE_ENV=development → loads objectstack.config.development.ts# objectstack.config.yml
database:
url: postgresql://localhost:5432/objectstack
pool:
min: 2
max: 10
plugins:
enabled:
- '@objectstack/core'
- '@mycompany/crm'
http:
port: 3000
cors:
origins:
- http://localhost:3000
features:
newUI: false
aiAssistant: trueUse Case: Default values defined by plugin authors.
// @mycompany/crm plugin manifest
export default definePlugin({
name: '@mycompany/crm',
config: {
defaults: {
maxAccountsPerUser: 1000,
enableScoring: true,
syncInterval: 'daily',
},
},
});
// In application code (no config set)
const maxAccounts = config.get('crm.maxAccountsPerUser');
// Returns: 1000 (from plugin defaults)Storage: In-memory (loaded from plugin manifest at boot)
When multiple sources define the same key, ObjectOS uses deep merge for objects and replace for primitives.
// Plugin defaults
{ stripe: { apiKey: 'sk_test_default' } }
// File config
{ stripe: { apiKey: 'sk_test_file' } }
// Environment variable
OS_STRIPE__API_KEY=sk_test_env
// Result
config.get('stripe.apiKey')
// Returns: 'sk_test_env' (highest priority source)// Plugin defaults
{
http: {
port: 3000,
timeout: 30000,
cors: {
origins: ['*'],
credentials: false,
},
},
}
// File config
{
http: {
port: 8080,
cors: {
origins: ['https://app.example.com'],
},
},
}
// Result (deep merge)
config.get('http')
// Returns:
{
port: 8080, // From file (overrides default)
timeout: 30000, // From defaults (not overridden)
cors: {
origins: ['https://app.example.com'], // From file
credentials: false, // From defaults (not overridden)
},
}Arrays are replaced, not concatenated:
// Defaults
{ plugins: { enabled: ['@objectstack/core', '@mycompany/base'] } }
// File config
{ plugins: { enabled: ['@objectstack/core', '@mycompany/crm'] } }
// Result
config.get('plugins.enabled')
// Returns: ['@objectstack/core', '@mycompany/crm']
// (File config replaces defaults, does NOT concat)In multi-tenant SaaS, each tenant can have isolated configuration.
// Request from Tenant A
context.tenantId = 'tenant-a';
const apiKey = context.config.get('stripe.apiKey');
// Checks: tenant-a config → env → file → defaults
// Request from Tenant B
context.tenantId = 'tenant-b';
const apiKey = context.config.get('stripe.apiKey');
// Checks: tenant-b config → env → file → defaults// Admin API: Set tenant-specific config
await admin.setTenantConfig('tenant-a', {
'stripe.apiKey': 'sk_live_tenantA...',
'features.newUI': true,
});
await admin.setTenantConfig('tenant-b', {
'stripe.apiKey': 'sk_live_tenantB...',
'features.newUI': false,
});// ObjectUI admin panel for tenant config
export default defineView({
name: 'tenant_config',
type: 'form',
fields: [
{
name: 'stripe.apiKey',
label: 'Stripe API Key',
type: 'text',
secret: true,
},
{
name: 'features.newUI',
label: 'Enable New UI',
type: 'boolean',
},
],
onSave: async ({ values, context }) => {
await context.config.setTenant(values, {
tenantId: context.tenantId,
});
},
});Sensitive configuration (API keys, passwords) requires special handling.
// Plugin config schema
export const configSchema = z.object({
apiKey: z.string()
.describe('Stripe API Key')
.meta({ secret: true }), // ← Mark as secret
webhookSecret: z.string()
.describe('Webhook Secret')
.meta({ secret: true }),
});Secrets are encrypted at rest using AES-256-GCM:
CREATE TABLE objectstack_secrets (
key TEXT PRIMARY KEY,
encrypted_value BYTEA NOT NULL,
encryption_key_id UUID NOT NULL,
created_at TIMESTAMP NOT NULL
);// Secrets are automatically decrypted when accessed
const apiKey = config.get('stripe.apiKey');
// ObjectOS decrypts value transparently
// Secrets are redacted in logs
logger.info('Config loaded', { config: config.getAll() });
// Output: { stripe: { apiKey: '[REDACTED]', ... } }ObjectOS integrates with external secret managers:
// objectstack.config.ts
export default defineConfig({
secrets: {
provider: 'aws-secrets-manager',
// Map config keys to secret ARNs
mappings: {
'stripe.apiKey': 'arn:aws:secretsmanager:us-east-1:123:secret:stripe-key',
'database.password': 'arn:aws:secretsmanager:us-east-1:123:secret:db-pass',
},
},
});
// Access works the same
const apiKey = config.get('stripe.apiKey');
// ObjectOS fetches from AWS Secrets Manager transparentlySupported Providers:
- AWS Secrets Manager
- Google Cloud Secret Manager
- Azure Key Vault
- HashiCorp Vault
- Environment variables (fallback)
ObjectOS validates configuration against Zod schemas at boot.
// Plugin defines config schema
export const configSchema = z.object({
maxAccountsPerUser: z.number()
.min(1)
.max(10000)
.default(1000),
enableScoring: z.boolean()
.default(true),
syncInterval: z.enum(['hourly', 'daily', 'weekly'])
.default('daily'),
apiKey: z.string()
.min(1)
.describe('API Key is required')
.meta({ secret: true }),
});// ObjectOS validates config during boot
export async function onBoot({ context }) {
// Get and validate config
const config = context.config.get('crm', configSchema);
// If config is invalid, ObjectOS throws ZodError with details
}ConfigValidationError: Invalid configuration for plugin @mycompany/crm
Errors:
- crm.apiKey: Required
- crm.maxAccountsPerUser: Expected number, received string
- crm.syncInterval: Invalid enum value. Expected 'hourly' | 'daily' | 'weekly', received 'monthly'
Fix these errors in:
1. Environment variable: OS_CRM__API_KEY
2. Config file: objectstack.config.ts
3. Plugin defaults: @mycompany/crm/plugin.manifest.ts
# Show all configuration (merged result)
objectstack config show
# Show config for specific namespace
objectstack config show stripe
# Show which source provides each value
objectstack config sources
# Validate configuration
objectstack config validate
# Export configuration (for backup)
objectstack config export > backup.json
# Import configuration
objectstack config import backup.json// Get config with source attribution
const configWithSources = config.inspect('stripe.apiKey');
// Returns:
{
value: 'sk_live_...',
source: 'environment', // Which source provided the value
sources: {
runtime: undefined,
user: undefined,
tenant: undefined,
environment: 'sk_live_...',
file: 'sk_test_...',
defaults: 'sk_test_default',
},
}
// List all config keys
const allKeys = config.keys();
// Returns: ['stripe.apiKey', 'stripe.webhookSecret', ...]
// Get config schema
const schema = config.getSchema('stripe');
// Returns: Zod schema for 'stripe' namespace// Plugin defaults (features disabled by default)
{
features: {
newUI: false,
aiAssistant: false,
},
}
// File config (enable in staging)
// objectstack.config.staging.ts
{
features: {
newUI: true, // Test in staging
},
}
// Tenant override (enable for specific customer)
await admin.setTenantConfig('beta-customer', {
'features.aiAssistant': true,
});
// User preference (user opts into beta)
await context.config.setUserPreference('features.newUI', true);
// In application code
if (config.get('features.newUI')) {
return <NewUIComponent />;
}// Base config (shared)
// objectstack.config.ts
{
database: {
pool: { min: 2, max: 10 },
},
}
// US region
// objectstack.config.us.ts
{
database: {
url: process.env.DATABASE_URL_US,
},
stripe: {
apiKey: process.env.STRIPE_KEY_US,
},
}
// EU region
// objectstack.config.eu.ts
{
database: {
url: process.env.DATABASE_URL_EU,
},
stripe: {
apiKey: process.env.STRIPE_KEY_EU,
},
}
// Deploy with region-specific config
NODE_ENV=us node server.js # Loads objectstack.config.us.ts
NODE_ENV=eu node server.js # Loads objectstack.config.eu.ts// Production config
{
stripe: {
apiKey: process.env.STRIPE_API_KEY, // Live key
},
email: {
provider: 'sendgrid',
fromAddress: 'noreply@company.com',
},
}
// Development override
// objectstack.config.development.ts
{
stripe: {
apiKey: 'sk_test_...', // Test key
},
email: {
provider: 'console', // Log emails instead of sending
},
}
// Developer can further override with .env.local
OS_EMAIL__PROVIDER=mailhog # Use local Mailhog for testing// ✗ BAD: Hardcoded API key
const apiKey = 'sk_live_abc123';
// ✓ GOOD: Load from config
const apiKey = config.require('stripe.apiKey');// ✓ GOOD: Separate configs for each environment
objectstack.config.production.ts # Production settings
objectstack.config.staging.ts # Staging settings
objectstack.config.development.ts # Development settings// ✓ GOOD: Validate during boot, not at runtime
export async function onBoot({ context }) {
const config = context.config.get('myPlugin', myConfigSchema);
// Throws clear error if invalid
}
// ✗ BAD: Validate on first use (fails in production)
export async function someHandler({ context }) {
const config = context.config.get('myPlugin', myConfigSchema);
// Error only occurs when handler is called!
}// ✓ GOOD: Plugin works out-of-box with defaults
export default definePlugin({
config: {
defaults: {
maxRetries: 3,
timeout: 30000,
},
},
});// ✓ GOOD: Use Zod descriptions
export const configSchema = z.object({
apiKey: z.string()
.describe('API key from Stripe Dashboard > Developers > API Keys'),
webhookSecret: z.string()
.describe('Webhook signing secret for verifying webhook payloads'),
});ObjectOS configuration resolution:
- Six sources with clear precedence (runtime > user > tenant > environment > file > defaults)
- Deep merge for objects, replace for primitives
- Tenant isolation for multi-tenant SaaS
- Secret encryption with external secret store integration
- Boot-time validation using Zod schemas
- Inspection tools for debugging config issues
Next: Learn about internationalization in i18n Standard.