Skip to content

Configuration Schema

Plugin configuration in Authrim uses Zod schemas for validation and automatic Admin UI generation. This guide covers best practices for defining configuration schemas.

Every plugin must define a configSchema using Zod:

import { z } from 'zod';
const configSchema = z.object({
apiKey: z.string().min(1),
timeout: z.number().int().min(1000).max(30000).default(10000),
retries: z.number().int().min(0).max(5).default(3),
enabled: z.boolean().default(true),
});
type MyConfig = z.infer<typeof configSchema>;

Use .describe() to provide labels and help text for the Admin UI:

const configSchema = z.object({
apiKey: z
.string()
.min(1)
.describe('API key from your provider dashboard'),
endpoint: z
.string()
.url()
.default('https://api.example.com')
.describe('API endpoint URL. Change only if using a custom server.'),
timeout: z
.number()
.int()
.min(1000)
.max(30000)
.default(10000)
.describe('Request timeout in milliseconds (1000-30000)'),
logLevel: z
.enum(['debug', 'info', 'warn', 'error'])
.default('info')
.describe('Logging verbosity level'),
});

The Admin UI automatically generates forms based on these descriptions:

Field TypeUI Component
z.string()Text input
z.string().url()URL input
z.number()Number input
z.boolean()Toggle switch
z.enum([...])Dropdown select
z.string() with secret namePassword input

Authrim automatically detects fields matching these patterns and encrypts them when PLUGIN_ENCRYPTION_KEY or KEY_MANAGER_SECRET is configured:

  • apiKey, apiSecret
  • secretKey, clientSecret
  • password, token
  • authToken, accessToken, refreshToken
  • privateKey, credential

If no encryption key is available, Admin API writes continue and the value is stored unencrypted with a warning log. Configure an encryption key before storing production secrets.

const configSchema = z.object({
// Automatically detected and encrypted
apiKey: z.string().describe('Your API key'),
clientSecret: z.string().describe('OAuth client secret'),
authToken: z.string().describe('Authentication token'),
// NOT encrypted (doesn't match patterns)
endpoint: z.string().url(),
timeout: z.number(),
});

For custom secret field names, specify them in the API request:

Terminal window
curl -X PUT "/api/admin/plugins/my-plugin/config" \
-H "Content-Type: application/json" \
-d '{
"config": {
"customCredential": "super-secret-value",
"endpoint": "https://api.example.com"
},
"secret_fields": ["customCredential"]
}'

Secret values are masked in all API responses:

{
"config": {
"apiKey": "sk_l****XYZ1",
"clientSecret": "cs_a****bcde",
"endpoint": "https://api.example.com"
}
}

Masking format:

  • First 4 and last 4 characters shown (e.g., sk_l****XYZ1)
  • Short values completely masked as ****

Plugin schemas are automatically converted to JSON Schema for the Admin UI:

// Zod schema
const configSchema = z.object({
apiKey: z.string().min(1).describe('Your API key'),
timeout: z.number().min(1000).max(30000).default(10000),
algorithm: z.enum(['sha1', 'sha256', 'sha512']).default('sha256'),
});
// Converted to JSON Schema
{
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"minLength": 1,
"description": "Your API key"
},
"timeout": {
"type": "number",
"minimum": 1000,
"maximum": 30000,
"default": 10000
},
"algorithm": {
"type": "string",
"enum": ["sha1", "sha256", "sha512"],
"default": "sha256"
}
},
"required": ["apiKey"]
}

The Plugin Management Admin API resolves configuration in this order:

┌─────────────────────────────────────────┐
│ 1. Environment Variables + built-in env │
├─────────────────────────────────────────┤
│ 2. KV Storage (global config) │
├─────────────────────────────────────────┤
│ 3. KV Storage (per-tenant override) │
├─────────────────────────────────────────┤
│ 4. Plugin schema defaults during parse │
└─────────────────────────────────────────┘

Runtime plugin loaders may cache resolved plugin registry and configuration values. Admin API writes to config, enable, and disable endpoints invalidate the plugin runtime cache for the affected plugin and tenant scope.

Terminal window
# Format: PLUGIN_{PLUGIN_ID}_CONFIG=<JSON>
PLUGIN_NOTIFIER_RESEND_CONFIG='{"apiKey":"re_xxx","defaultFrom":"[email protected]"}'
# Plugin IDs with hyphens become underscores
PLUGIN_NOTIFIER_CLOUDFLARE_CONFIG='{"defaultFrom":"[email protected]","fromName":"Authrim"}'

Built-in notifier plugins can also bootstrap from deployment env values. For example, notifier-resend reads RESEND_API_KEY and EMAIL_FROM, and notifier-cloudflare reads EMAIL_FROM and EMAIL_FROM_NAME before KV overrides are applied.

Key PatternDescription
plugins:config:{pluginId}Global configuration
plugins:config:{pluginId}:tenant:{tenantId}Tenant-specific override
plugins:enabled:{pluginId}Global enable/disable
plugins:enabled:{pluginId}:tenant:{tenantId}Tenant enable/disable
// Global config
const globalConfig = {
apiKey: 'default-api-key',
defaultFrom: '[email protected]',
};
// Tenant A overrides just the from address
const tenantAConfig = {
defaultFrom: '[email protected]',
};
// Resolved config for Tenant A (merged)
{
apiKey: 'default-api-key', // From global
defaultFrom: '[email protected]' // From tenant
}
Terminal window
# Get tenant-specific config from a platform settings screen
curl "/api/admin/platform/plugins/notifier-resend/config?tenant_id=tenant_123"
# Set tenant-specific config from a platform settings screen
curl -X PUT "/api/admin/platform/plugins/notifier-resend/config" \
-d '{"config": {"defaultFrom": "[email protected]"}, "tenant_id": "tenant_123"}'

Tenant-facing /api/admin/plugins/... routes use the current tenant context. Use /api/admin/platform/plugins/... when you need to pass tenant_id explicitly.

Keep configuration structures flat or shallow:

// ✅ Good: Flat or shallow nesting
const configSchema = z.object({
apiKey: z.string(),
smtp: z.object({
host: z.string(),
port: z.number(),
secure: z.boolean(),
}),
retries: z.object({
max: z.number().default(3),
delay: z.number().default(1000),
}),
});
// ❌ Bad: Excessively deep nesting
const badConfigSchema = z.object({
level1: z.object({
level2: z.object({
level3: z.object({
// ... 20+ levels deep - secrets won't be masked!
}),
}),
}),
});
const configSchema = z.object({
// Required (no default)
apiKey: z.string().min(1),
// Optional with default
timeout: z.number().default(10000),
// Optional without default
webhook: z.string().url().optional(),
// Optional with nullable
fallbackUrl: z.string().url().nullable().default(null),
});
const configSchema = z.object({
// Different authentication methods
auth: z.union([
z.object({
type: z.literal('api_key'),
apiKey: z.string(),
}),
z.object({
type: z.literal('oauth'),
clientId: z.string(),
clientSecret: z.string(),
}),
]),
});
const configSchema = z.object({
useTLS: z.boolean().default(true),
tlsCert: z.string().optional(),
tlsKey: z.string().optional(),
}).refine(
(data) => !data.useTLS || (data.tlsCert && data.tlsKey),
{ message: 'TLS certificate and key are required when TLS is enabled' }
);
const configSchema = z.object({
endpoints: z
.array(z.string().url())
.min(1)
.max(5)
.describe('List of API endpoints (1-5)'),
allowedDomains: z
.array(z.string())
.default([])
.describe('Domains allowed for callbacks'),
});
const configSchema = z.object({
// Secure defaults
timeout: z.number().default(10000), // 10 seconds
retries: z.number().default(3),
validateCerts: z.boolean().default(true), // Security: always validate
// Production-safe defaults
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
rateLimit: z.number().default(60),
});
const configSchema = z.object({
timeout: z
.number()
.int()
.min(1000)
.max(30000)
.default(10000)
.describe('Request timeout in ms (1000-30000). Increase for slow networks.'),
batchSize: z
.number()
.int()
.min(1)
.max(100)
.default(10)
.describe('Items per batch (1-100). Higher values use more memory.'),
});
const configSchema = z.object({
// Connection settings
host: z.string().default('smtp.example.com'),
port: z.number().default(587),
secure: z.boolean().default(true),
// Authentication
username: z.string().optional(),
password: z.string().optional(),
// Message defaults
defaultFrom: z.string().email(),
defaultReplyTo: z.string().email().optional(),
});