@ordinatio/settings
v1.0.3
Published
System settings, AI provider configuration, and per-user preferences for the Ordinatio platform.
Readme
@ordinatio/settings
System settings, AI provider configuration, and per-user preferences for the Ordinatio platform.
Package: @ordinatio/settings | Version: 1.0.0 | License: MIT
Tests: 115 | Error codes: 25 (SETTINGS_100-401) | Zero runtime dependencies
Table of Contents
- Architecture Overview
- Installation
- Module Structure
- Core Settings
- AI Settings
- User Preferences
- Database Interface
- Prisma Schema
- Validation
- Error Registry
- Callbacks
- Domus Integration
- App-Layer Bridge (System 1701)
- Testing
- API Reference
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ Your Application (Next.js, Express, etc.) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ App-Layer Bridge (optional) │ │
│ │ Wires callbacks for activity logging, │ │
│ │ security events, etc. │ │
│ └─────────────────────┬──────────────────────────┘ │
│ │ imports │
│ ┌─────────────────────▼──────────────────────────┐ │
│ │ @ordinatio/settings │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │
│ │ │ settings │ │ai-settings│ │user-preferences│ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ getSetting│ │getLLMProv.│ │getPreferences │ │ │
│ │ │ setSetting│ │getApiKey │ │updatePrefs │ │ │
│ │ │ getAll.. │ │getAISet.. │ │getReplyLayout │ │ │
│ │ └──────────┘ └──────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ types │ │ errors │ │validation│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────┬──────────────────────────┘ │
│ │ uses │
│ ┌─────────────────────▼──────────────────────────┐ │
│ │ Database (via SettingsDb / UserPreferenceDb) │ │
│ │ Any Prisma client or compatible object │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘Key design decisions:
- No Prisma dependency. The package defines its own minimal DB interfaces (
SettingsDb,UserPreferenceDb). Any object matching the interface works — Prisma, Drizzle, a test mock, anything. - No env var reads in core settings. All configuration is passed as function arguments. The one exception is
ai-settings.ts, which falls back toprocess.envfor API keys (standard pattern for secret management). - Callback injection. Mutation functions accept an optional
SettingsCallbacksparameter for side effects (activity logging, event emission). The package never imports your app's logging framework. - Defaults built in. Every setting key has a default value.
getSetting()returns the default when no DB row exists — the system works with an empty database.
Installation
With @ordinatio/domus (recommended)
npm install @ordinatio/domus
# The setup wizard will ask which modules to installStandalone
npm install @ordinatio/settingsThe package has zero runtime dependencies. zod is a peer dependency used only for validation schemas.
Module Structure
packages/ordinatio-settings/
├── src/
│ ├── index.ts # Barrel export
│ ├── types.ts # DB interfaces, type definitions
│ ├── settings.ts # Core settings CRUD (10 keys)
│ ├── ai-settings.ts # LLM provider configuration (6 providers)
│ ├── user-preferences.ts # Per-user preferences (reply layout)
│ ├── errors.ts # 25 error codes (SETTINGS_100-401)
│ ├── validation.ts # Zod schemas
│ └── __tests__/
│ ├── settings.test.ts # 27 tests
│ └── user-preferences.test.ts # 13 tests
├── settings.prisma # Schema fragment (2 models, 1 enum)
├── package.json
├── vitest.config.ts
└── README.mdCore Settings
The core settings module provides a key-value store backed by a SystemSettings database table.
Setting Keys
There are 10 built-in setting keys, defined in the SETTINGS_KEYS constant:
| Key | Default | Purpose |
|-----|---------|---------|
| admin_feed_enabled | 'true' | Show/hide admin activity feed |
| llm_provider | 'claude' | Global LLM provider selection |
| llm_provider_bookkeeper | '' | Override LLM for Bookkeeper agent role |
| llm_provider_coo | '' | Override LLM for COO agent role |
| anthropic_api_key | '' | Anthropic Claude API key |
| openai_api_key | '' | OpenAI API key |
| gemini_api_key | '' | Google Gemini API key |
| deepseek_api_key | '' | DeepSeek API key |
| mistral_api_key | '' | Mistral API key |
| xai_api_key | '' | xAI Grok API key |
Functions
import { getSetting, setBooleanSetting, getAllSettings, SETTINGS_KEYS } from '@ordinatio/settings';
// Read a single setting (returns default if not in DB)
const provider = await getSetting(db, SETTINGS_KEYS.LLM_PROVIDER);
// => 'claude'
// Read a boolean setting
const feedEnabled = await getBooleanSetting(db, SETTINGS_KEYS.ADMIN_FEED_ENABLED);
// => true
// Write a setting (upsert — creates if missing, updates if exists)
await setSetting(db, SETTINGS_KEYS.LLM_PROVIDER, 'openai', 'Active LLM provider');
// Write a boolean setting (stored as 'true'/'false' string)
await setBooleanSetting(db, SETTINGS_KEYS.ADMIN_FEED_ENABLED, false);
// Get all settings as a flat object (defaults merged with DB values)
const all = await getAllSettings(db);
// => { admin_feed_enabled: 'true', llm_provider: 'claude', ... }How defaults work
getSetting() always returns a string — never null or undefined:
- Check DB for a row with the given key
- If found, return
row.value - If not found, return the built-in default from
DEFAULTS - If no default exists, return
''
This means the system is functional with an empty SystemSettings table.
AI Settings
The AI settings module manages LLM provider configuration. It builds on top of core settings — the same SystemSettings table stores API keys and provider selections.
6 Supported Providers
| Provider ID | Display Name | Setting Key | Env Var Fallback |
|-------------|-------------|-------------|------------------|
| claude | Anthropic Claude | anthropic_api_key | ANTHROPIC_API_KEY |
| openai | OpenAI GPT | openai_api_key | OPENAI_API_KEY |
| gemini | Google Gemini | gemini_api_key | GEMINI_API_KEY |
| deepseek | DeepSeek | deepseek_api_key | DEEPSEEK_API_KEY |
| mistral | Mistral AI | mistral_api_key | MISTRAL_API_KEY |
| grok | xAI Grok | xai_api_key | XAI_API_KEY |
API Key Resolution Priority
When resolving an API key, the system checks:
- Database —
SystemSettingsrow with the provider'ssettingKey - Environment variable —
process.env[envVar] - Empty string — provider is unconfigured
Per-Role Provider Overrides
Each agent role can use a different LLM provider. If no override is set for a role, it falls back to the global llm_provider setting.
import { getLLMProvider, getRoleProvider, setRoleProvider } from '@ordinatio/settings';
// Global provider
const global = await getLLMProvider(db); // => 'claude'
// Role-specific override (null = no override, use global)
const coo = await getRoleProvider(db, 'coo'); // => 'openai' or null
// Set a role override
await setRoleProvider(db, 'bookkeeper', 'gemini');
// Clear a role override (revert to global)
await setRoleProvider(db, 'bookkeeper', '');Key Masking
API keys are masked for safe display in the UI:
import { maskApiKey } from '@ordinatio/settings';
maskApiKey('sk-ant-api03-abcdef...wxyz5678');
// => 'sk-ant...5678'
maskApiKey('short-key'); // <= 12 chars
// => '****-key'
maskApiKey('');
// => ''AI Settings Bundle
getAISettings() returns everything the Settings UI page needs in one call:
import { getAISettings } from '@ordinatio/settings';
const settings = await getAISettings(db);
// => {
// provider: 'claude', // Active global provider
// providers: [ // All 6 providers with status
// { id: 'claude', name: 'Anthropic Claude', maskedKey: 'sk-ant...5678', configured: true, placeholder: 'sk-ant-api03-...' },
// { id: 'openai', name: 'OpenAI GPT', maskedKey: '', configured: false, placeholder: 'sk-proj-...' },
// ...
// ],
// roleOverrides: { // Per-role overrides (only set ones)
// coo: 'openai',
// },
// }User Preferences
Per-user preferences stored in the UserPreference table.
Reply Layout
The only preference currently stored is replyLayout, which controls how the email reply composer opens:
| Value | Behavior |
|-------|----------|
| MODAL (default) | Traditional modal dialog over the email |
| SPLIT_HORIZONTAL | Email on top, reply editor on bottom |
| SPLIT_VERTICAL | Email on left, reply editor on right |
| POPOUT | Opens reply in a new browser window |
Functions
import { getPreferences, updatePreferences, getReplyLayout } from '@ordinatio/settings';
// Get preferences (auto-creates with defaults if first access)
const prefs = await getPreferences(db, userId);
// => { id: 'clx...', userId: 'usr_123', replyLayout: 'MODAL', createdAt: ..., updatedAt: ... }
// Update preferences
await updatePreferences(db, userId, { replyLayout: 'SPLIT_HORIZONTAL' });
// Shortcut: get just the reply layout
const layout = await getReplyLayout(db, userId);
// => 'SPLIT_HORIZONTAL'Auto-creation: getPreferences() creates a default UserPreference record the first time a user's preferences are requested. No seed data needed.
Database Interface
The package defines two minimal interfaces instead of depending on Prisma:
SettingsDb
Used by settings.ts and ai-settings.ts:
interface SettingsDb {
systemSettings: {
findUnique(args: {
where: { key: string }
}): Promise<{ key: string; value: string; description?: string | null } | null>;
upsert(args: {
where: { key: string };
create: { key: string; value: string; description?: string };
update: { value: string; description?: string };
}): Promise<{ key: string; value: string }>;
findMany(args?: {
take?: number
}): Promise<Array<{ key: string; value: string }>>;
};
}UserPreferenceDb
Used by user-preferences.ts:
interface UserPreferenceDb {
userPreference: {
findUnique(args: { where: { userId: string } }): Promise<UserPreference | null>;
create(args: { data: { userId: string; replyLayout: ReplyLayout } }): Promise<UserPreference>;
upsert(args: {
where: { userId: string };
update: { replyLayout?: ReplyLayout };
create: { userId: string; replyLayout: ReplyLayout };
}): Promise<UserPreference>;
};
}Any Prisma client with systemSettings and userPreference models will satisfy these interfaces automatically. You can also pass a plain object for testing:
const mockDb: SettingsDb = {
systemSettings: {
findUnique: async () => ({ key: 'llm_provider', value: 'claude' }),
upsert: async (args) => ({ key: args.where.key, value: args.create.value }),
findMany: async () => [],
},
};Prisma Schema
The settings.prisma fragment defines the database models:
model SystemSettings {
key String @id // Setting key (unique identifier)
value String @db.Text // Setting value (stored as text)
description String? // Human-readable description
updatedAt DateTime @updatedAt // Last modified timestamp
}
model UserPreference {
id String @id @default(cuid())
userId String @unique // One preference record per user
replyLayout ReplyLayout @default(MODAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ReplyLayout {
MODAL
SPLIT_HORIZONTAL
SPLIT_VERTICAL
POPOUT
}Note: SystemSettings.key is the @id — there is no separate id column. The key itself is the primary key.
When used with @ordinatio/domus, this schema fragment is automatically merged with the main schema during prisma db push.
Validation
Zod schemas for validating API request bodies:
import { SettingKeySchema, UpdateSettingSchema } from '@ordinatio/settings';
// Validate a setting key
SettingKeySchema.parse('llm_provider'); // ok
SettingKeySchema.parse('invalid_key'); // throws ZodError
// Validate an update request
UpdateSettingSchema.parse({ key: 'llm_provider', value: 'openai' }); // ok
UpdateSettingSchema.parse({ key: 'llm_provider', value: true }); // ok (coerced to 'true')
UpdateSettingSchema.parse({ key: 'llm_provider', value: 42 }); // ok (coerced to '42')The UpdateSettingSchema accepts string | boolean | number for the value field and auto-coerces to string via .transform(String).
Error Registry
25 error codes across 4 categories, following the System 1701 Error Reference System (Rule 8):
| Code | HTTP | Severity | Description | |------|------|----------|-------------| | SETTINGS_100 | 401 | warn | Unauthenticated request to settings endpoint | | SETTINGS_101 | 500 | error | Failed to retrieve system settings from database | | SETTINGS_102 | 500 | error | Failed to update a system setting | | SETTINGS_103 | 429 | warn | Rate limit exceeded for settings update | | SETTINGS_104 | 500 | error | Failed to read individual setting by key | | SETTINGS_200 | 400 | warn | Settings update request body validation failed | | SETTINGS_201 | 400 | warn | Setting key is not in the allowed enum list | | SETTINGS_202 | 400 | warn | AI settings update validation failed | | SETTINGS_203 | 400 | warn | Invalid LLM provider ID supplied | | SETTINGS_204 | 400 | warn | Invalid boolean value for boolean setting | | SETTINGS_300 | 401 | warn | Unauthenticated request to AI settings endpoint | | SETTINGS_301 | 500 | error | Failed to retrieve AI settings | | SETTINGS_302 | 500 | error | Failed to save AI setting or clear provider cache | | SETTINGS_400 | 500 | error | Failed to retrieve user preferences | | SETTINGS_401 | 500 | error | Failed to update user preferences |
Each error entry includes file, function, httpStatus, severity, recoverable, description, and diagnosis[] (troubleshooting steps).
Usage
import { settingsError, SETTINGS_ERRORS } from '@ordinatio/settings';
try {
const value = await getSetting(db, key);
} catch (err) {
const { code, ref } = settingsError('SETTINGS_104');
// code: 'SETTINGS_104'
// ref: 'SETTINGS_104-20260307T143000' (timestamped for log correlation)
const meta = SETTINGS_ERRORS.SETTINGS_104;
// meta.description: 'Failed to read individual setting by key.'
// meta.diagnosis: ['Database error in findUnique()...', ...]
console.error(`[${ref}] ${meta.description}`, err);
return Response.json({ error: meta.description, ref }, { status: meta.httpStatus });
}Callbacks
Mutation functions accept an optional SettingsCallbacks parameter. This is how the consuming application wires in side effects (activity logging, security events, analytics) without the package knowing about those systems.
interface SettingsCallbacks {
onSettingChanged?: (key: string, value: string, userId?: string) => Promise<void>;
onPreferenceChanged?: (userId: string, changes: Record<string, unknown>) => Promise<void>;
}Example: Activity logging
import { setSetting, SETTINGS_KEYS } from '@ordinatio/settings';
await setSetting(db, SETTINGS_KEYS.LLM_PROVIDER, 'openai', 'Active provider', {
onSettingChanged: async (key, value) => {
await createActivity(db, {
action: 'SETTING_CHANGED',
severity: 'info',
metadata: { key, value },
userId: session.user.id,
});
},
});When callbacks are called
| Function | Callback | When |
|----------|----------|------|
| setSetting() | onSettingChanged | After successful DB upsert |
| setBooleanSetting() | onSettingChanged | After successful DB upsert (value is 'true' or 'false') |
| setAISetting() | onSettingChanged | After successful DB upsert (via setSetting) |
| setRoleProvider() | onSettingChanged | After successful DB upsert (via setAISetting) |
| updatePreferences() | onPreferenceChanged | After successful DB upsert |
Read-only functions (getSetting, getBooleanSetting, getAllSettings, getAISettings, getPreferences, getReplyLayout) never invoke callbacks.
Domus Integration
When used with @ordinatio/domus, the settings module is automatically registered and wired:
import { createDomus } from '@ordinatio/domus';
const domus = await createDomus({
databaseUrl: process.env.DATABASE_URL,
modules: ['settings'], // or include alongside other modules
});
// Use the pre-wired API (db is already injected)
const provider = await domus.settings.getSetting('llm_provider');
await domus.settings.setSetting('llm_provider', 'openai');
const aiSettings = await domus.settings.getAISettings();
const prefs = await domus.settings.getPreferences(userId);DomusSettingsApi
The Domus factory exposes this interface:
interface DomusSettingsApi {
getSetting: (key: string) => Promise<string>;
getBooleanSetting: (key: string) => Promise<boolean>;
setSetting: (key: string, value: string, description?: string) => Promise<void>;
setBooleanSetting: (key: string, value: boolean, description?: string) => Promise<void>;
getAllSettings: () => Promise<Record<string, string>>;
getAISettings: () => Promise<unknown>;
getPreferences: (userId: string) => Promise<unknown>;
updatePreferences: (userId: string, data: Record<string, unknown>) => Promise<unknown>;
raw: unknown; // Access the raw module for advanced usage
}Seed Data
When the Domus setup wizard runs, the settings module seeds two default rows:
| Key | Value | Description |
|-----|-------|-------------|
| admin_feed_enabled | true | Enable admin activity feed |
| llm_provider | claude | Active LLM provider |
The seed function is idempotent — it checks systemSettings.count() and skips if rows already exist.
App-Layer Bridge (System 1701)
In System 1701, the settings module is consumed through a thin bridge at apps/web/src/services/settings/index.ts. The bridge is pure re-exports — no wrapping or callback injection needed for most functions:
// apps/web/src/services/settings/index.ts
export {
getSetting, getBooleanSetting, setSetting, setBooleanSetting, getAllSettings, SETTINGS_KEYS,
getLLMProvider, getRoleProvider, setRoleProvider, getApiKey, setAISetting, getAISettings,
maskApiKey, ALL_PROVIDER_IDS, PROVIDER_CONFIG,
getPreferences, updatePreferences, getReplyLayout,
settingsError, SETTINGS_ERRORS,
SettingKeySchema, UpdateSettingSchema,
} from '@ordinatio/settings';Backward Compatibility
The old service files still exist as thin re-exports so existing imports continue to work:
| Old Import Path | Now Re-exports From |
|----------------|---------------------|
| @/services/settings.service | @/services/settings (bridge) |
| @/services/ai-settings.service | @/services/settings (bridge) |
| @/services/user-preferences.service | @/services/settings (bridge) |
| @/lib/settings/errors | @ordinatio/settings (direct) |
| @/lib/validation/settings.schema | @ordinatio/settings (direct) |
No consumer files needed import path changes.
Testing
# Run all 115 tests
pnpm --filter @ordinatio/settings test:run
# Watch mode
pnpm --filter @ordinatio/settings testTest Coverage
| File | Tests | What's Covered |
|------|-------|----------------|
| settings.test.ts | 27 | getSetting defaults, setSetting upsert, boolean settings, getAllSettings merge, AI settings (maskApiKey, PROVIDER_CONFIG, getLLMProvider, getRoleProvider, getApiKey, getAISettings bundle), error registry (all 25 codes, timestamped refs), validation schemas |
| user-preferences.test.ts | 13 | getPreferences auto-create, updatePreferences upsert, getReplyLayout, callback invocation |
Tests use mock SettingsDb / UserPreferenceDb objects — no database required.
System 1701 Integration Tests
In System 1701, additional tests verify the bridge and API routes:
# Service tests (14 settings + 12 preferences)
pnpm --filter web vitest run src/services/settings.service.test.ts
pnpm --filter web vitest run src/services/user-preferences.service.test.ts
# Mob tests (30 adversarial tests)
pnpm --filter web vitest run src/test/mob/settings/
# AI settings page component tests (8)
pnpm --filter web vitest run src/app/dashboard/settings/ai/page.test.tsxAPI Reference
Core Settings
| Function | Signature | Returns |
|----------|-----------|---------|
| getSetting | (db: SettingsDb, key: SettingKey) => Promise<string> | Setting value or default |
| getBooleanSetting | (db: SettingsDb, key: SettingKey) => Promise<boolean> | true if value is 'true' |
| setSetting | (db: SettingsDb, key: SettingKey, value: string, description?: string, callbacks?: SettingsCallbacks) => Promise<void> | — |
| setBooleanSetting | (db: SettingsDb, key: SettingKey, value: boolean, description?: string, callbacks?: SettingsCallbacks) => Promise<void> | — |
| getAllSettings | (db: SettingsDb) => Promise<Record<string, string>> | All settings (defaults + DB) |
AI Settings
| Function | Signature | Returns |
|----------|-----------|---------|
| getLLMProvider | (db: SettingsDb) => Promise<string> | Provider ID (e.g., 'claude') |
| getRoleProvider | (db: SettingsDb, roleId: string) => Promise<string \| null> | Override or null |
| setRoleProvider | (db: SettingsDb, roleId: string, providerId: string, callbacks?: SettingsCallbacks) => Promise<void> | — |
| getApiKey | (db: SettingsDb, provider: ProviderId) => Promise<string> | Raw API key or '' |
| setAISetting | (db: SettingsDb, key: string, value: string, callbacks?: SettingsCallbacks) => Promise<void> | — |
| getAISettings | (db: SettingsDb) => Promise<AISettings> | Full AI config for UI |
| maskApiKey | (key: string) => string | Masked key (pure function) |
User Preferences
| Function | Signature | Returns |
|----------|-----------|---------|
| getPreferences | (db: UserPreferenceDb, userId: string) => Promise<UserPreference> | Prefs (auto-created if missing) |
| updatePreferences | (db: UserPreferenceDb, userId: string, data: { replyLayout?: ReplyLayout }, callbacks?: SettingsCallbacks) => Promise<UserPreference> | Updated prefs |
| getReplyLayout | (db: UserPreferenceDb, userId: string) => Promise<ReplyLayout> | Layout enum value |
Constants
| Export | Type | Description |
|--------|------|-------------|
| SETTINGS_KEYS | Record<string, SettingKey> | All 10 setting key constants |
| ALL_PROVIDER_IDS | ProviderId[] | ['claude', 'openai', 'gemini', 'deepseek', 'mistral', 'grok'] |
| PROVIDER_CONFIG | Record<ProviderId, ProviderConfig> | Static config per provider |
| SETTINGS_ERRORS | Record<string, ErrorEntry> | 25 error definitions |
Types
| Type | Description |
|------|-------------|
| SettingsDb | Minimal DB interface for settings operations |
| UserPreferenceDb | Minimal DB interface for user preferences |
| SettingKey | Union of all valid setting key strings |
| ReplyLayout | 'MODAL' \| 'SPLIT_HORIZONTAL' \| 'SPLIT_VERTICAL' \| 'POPOUT' |
| ProviderId | 'claude' \| 'openai' \| 'gemini' \| 'deepseek' \| 'mistral' \| 'grok' |
| ProviderConfig | { settingKey, envVar, name, placeholder } |
| ProviderInfo | { id, name, maskedKey, configured, placeholder } |
| AISettings | { provider, providers: ProviderInfo[], roleOverrides } |
| UserPreference | { id, userId, replyLayout, createdAt, updatedAt } |
| SettingsCallbacks | { onSettingChanged?, onPreferenceChanged? } |
| SettingKeyValue | Zod inferred type from SettingKeySchema |
| UpdateSettingInput | Zod inferred type from UpdateSettingSchema |
Pugil Integration
This package includes a Pugil reporter that generates Council-consumable trial_report artifacts from test results.
# Normal test run (no Pugil overhead)
pnpm --filter @ordinatio/settings test:run
# With Pugil trial report generation
PUGIL_ENABLED=true pnpm --filter @ordinatio/settings test:run
# With Council cycle integration
PUGIL_ENABLED=true PUGIL_CYCLE_ID=cycle-settings-v1 pnpm --filter @ordinatio/settings test:run- Config:
src/pugil.config.ts— maps test files to categories (unit, integration, adversarial, chaos, concurrency) - Reporter:
src/pugil-reporter.ts— Vitest custom reporter, writes topugil-reports/ - Types:
PugilTestResult,PugilTestCategoryfrom@ordinatio/core
License
MIT
