Configuration Resolution
Hierarchical config merging, precedence rules, environment overrides, and tenant isolation
Configuration Resolution
Protocol spec, partial implementation. This page describes ObjectStack's target
configuration model: hierarchical sources, tenant isolation, secret stores.
The merge / precedence logic is implemented; the richer nested shapes shown in
code samples below (e.g. database, http, secrets.provider) are
illustrative of intent β today they live as env-vars, service-level options,
and the datasources / plugins keys on defineStack. Treat the snippets
as design intent, not paste-ready code.
ObjectStack 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.
The Configuration Problem
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.
Configuration Sources
ObjectStack defines six configuration sources with strict precedence:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. RUNTIME β
β Programmatic overrides (context.config.set()) β
β Highest priority, temporary β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β overrides
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. ENVIRONMENT β
β Environment variables (OS_*, NODE_ENV, etc.) β
β Set by deployment platform (Kubernetes, Docker) β
β Pins the value (locked) when present β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β overrides
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. TENANT β
β Multi-tenant overrides (per-customer config) β
β Stored in database, tenant-scoped β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β overrides
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. USER PREFERENCES β
β Per-user settings (language, theme, etc.) β
β Stored in database, user-specific β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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.
Configuration API
Reading Configuration
// 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
}Writing Configuration
// 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_...',
});Source Details
1. Runtime (Programmatic)
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)
2. Environment Variables
Use Case: Deployment-specific config (API keys, database URLs, feature flags).
When an OS_* env var is present, it pins (locks) the value β no
database scope (tenant or user) can override it.
# .env.production
OS_STRIPE_APIKEY=sk_live_...
OS_DATABASE_URL=postgresql://prod-db:5432/objectstack
OS_FEATURE_NEWUI=true
NODE_ENV=productionNaming Convention (see envKeyOf in service-settings):
- Prefix with
OS_ - Form the key from
{namespace}_{key}, then uppercase it - Dots and hyphens become underscores:
stripe.apiKeyβOS_STRIPE_APIKEY
// Environment variables are auto-loaded at boot
const apiKey = config.get('stripe.apiKey');
// Reads from: process.env.OS_STRIPE_APIKEY
const dbUrl = config.get('database.url');
// Reads from: process.env.OS_DATABASE_URL3. Tenant (Multi-Tenant)
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.
4. User Preferences
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)
);Type Coercion is driven by the type of the setting's default value
(coerceEnvValue in service-settings):
OS_HTTP_TIMEOUT=30000 # default is number β 30000
OS_FEATURE_NEWUI=true # default is boolean β true ('1'/'yes' also truthy)
OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object β JSON.parse β ['a','b','c']If the raw value can't be parsed for the expected type, it falls back to the raw string.
5. Configuration Files
Use Case: Environment-specific defaults checked into Git.
File Locations
ObjectStack 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.tsTypeScript Config (Recommended)
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';
export default defineStack({
// 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,
},
});Environment-Specific Config
// objectstack.config.production.ts
import { defineStack } from '@objectstack/spec';
export default defineStack({
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.tsYAML Config (Alternative)
# 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: true6. Plugin Defaults
Use 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)
Merge Strategies
When multiple sources define the same key, ObjectStack uses deep merge for objects and replace for primitives.
Replace (Primitives)
// Plugin defaults
{ stripe: { apiKey: 'sk_test_default' } }
// File config
{ stripe: { apiKey: 'sk_test_file' } }
// Environment variable
OS_STRIPE_APIKEY=sk_test_env
// Result
config.get('stripe.apiKey')
// Returns: 'sk_test_env' (highest priority source)Deep Merge (Objects)
// 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)
},
}Array Merge (Replace, Not Concat)
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)Tenant Isolation
In multi-tenant SaaS, each tenant can have isolated configuration.
Automatic Scoping
// 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 β defaultsSetting Tenant Config
// 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,
});Tenant Config UI
// 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,
});
},
});Secrets Management
Sensitive configuration (API keys, passwords) requires special handling.
Marking Secrets
// 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 }),
});Secret Storage
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
);Secret Access
// Secrets are automatically decrypted when accessed
const apiKey = config.get('stripe.apiKey');
// ObjectStack decrypts value transparently
// Secrets are redacted in logs
logger.info('Config loaded', { config: config.getAll() });
// Output: { stripe: { apiKey: '[REDACTED]', ... } }External Secret Stores
ObjectStack integrates with external secret managers:
// objectstack.config.ts
export default defineStack({
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');
// ObjectStack fetches from AWS Secrets Manager transparentlySupported Providers:
- AWS Secrets Manager
- Google Cloud Secret Manager
- Azure Key Vault
- HashiCorp Vault
- Environment variables (fallback)
Configuration Validation
ObjectStack validates configuration against Zod schemas at boot.
Schema Definition
// 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 }),
});Boot-Time Validation
// ObjectStack validates config during boot
export async function onBoot({ context }) {
// Get and validate config
const config = context.config.get('crm', configSchema);
// If config is invalid, ObjectStack throws ZodError with details
}Validation Error Example
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_APIKEY
2. Config file: objectstack.config.ts
3. Plugin defaults: @mycompany/crm/plugin.manifest.tsConfiguration Inspection
CLI Commands
A dedicated config topic on the os CLI is not yet implemented β the
commands below are illustrative of the intended surface. Today, configuration
is validated as part of os validate and inspected via os info / os doctor.
# (planned) Show all configuration (merged result)
os config show
# (planned) Show config for specific namespace
os config show stripe
# (planned) Show which source provides each value
os config sources
# (planned) Validate configuration
os config validate
# (planned) Export configuration (for backup)
os config export > backup.json
# (planned) Import configuration
os config import backup.jsonProgrammatic Inspection
// 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' namespaceReal-World Examples
Example 1: Feature Flags
// 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 />;
}Example 2: Multi-Region Deployment
// 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.tsExample 3: Development Overrides
// 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 testingBest Practices
1. Never Hardcode Secrets
// β BAD: Hardcoded API key
const apiKey = 'sk_live_abc123';
// β GOOD: Load from config
const apiKey = config.require('stripe.apiKey');2. Use Environment-Specific Files
// β GOOD: Separate configs for each environment
objectstack.config.production.ts # Production settings
objectstack.config.staging.ts # Staging settings
objectstack.config.development.ts # Development settings3. Validate Early
// β 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!
}4. Provide Sensible Defaults
// β GOOD: Plugin works out-of-box with defaults
export default definePlugin({
config: {
defaults: {
maxRetries: 3,
timeout: 30000,
},
},
});5. Document Configuration
// β 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'),
});Summary
ObjectStack configuration resolution:
- Six sources with clear precedence (runtime > environment > tenant > user > 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.