Sending Configuration Schemas to Frontend
This guide documents the required pattern for exposing plugin configuration schemas to the frontend to enable dynamic form rendering with proper secret field handling.
Overview
Section titled “Overview”When building admin UIs for plugins, configuration schemas must be converted to JSON Schema format and sent to the frontend. The critical requirement is to use the custom toJsonSchema() utility from @checkstack/backend-api instead of Zod’s native toJSONSchema() method.
The Problem
Section titled “The Problem”The DynamicForm component in @checkstack/ui automatically renders password input fields (with show/hide toggles) for fields marked as secrets. However, it relies on the x-secret metadata in the JSON Schema to identify these fields.
Zod’s native method does NOT add this metadata:
// ❌ WRONG: Missing x-secret metadataimport { z } from "zod";const jsonSchema = mySchema.toJSONSchema();// Result: Secret fields render as regular text inputsThe Solution
Section titled “The Solution”Use the custom toJsonSchema() function from @checkstack/backend-api:
// ✅ CORRECT: Adds x-secret metadataimport { toJsonSchema } from "@checkstack/backend-api";const jsonSchema = toJsonSchema(mySchema);// Result: Secret fields render as password inputs with show/hide toggleComplete Implementation Pattern
Section titled “Complete Implementation Pattern”1. Backend Router
Section titled “1. Backend Router”When exposing plugin/strategy metadata to the frontend:
import { implement } from "@orpc/server";import { autoAuthMiddleware, type RpcContext, toJsonSchema } from "@checkstack/backend-api";import { myPluginContract } from "@checkstack/myplugin-common";
// Contract-based implementation with auto auth enforcementconst os = implement(myPluginContract) .$context<RpcContext>() .use(autoAuthMiddleware);
export const createMyPluginRouter = () => { return os.router({ // Auth and access rules auto-enforced from contract meta getPlugins: os.getPlugins.handler(async ({ context }) => { const plugins = context.myPluginRegistry.getPlugins().map((p) => ({ id: p.id, displayName: p.displayName, description: p.description, configVersion: p.configVersion, configSchema: toJsonSchema(p.configSchema), // ✅ Use custom function })); return plugins; }), });};2. Plugin/Strategy Schema Definition
Section titled “2. Plugin/Strategy Schema Definition”Use factory functions for fields that need specialized handling:
import { configString, configNumber, configBoolean } from "@checkstack/backend-api";import { z } from "zod";
const configSchema = z.object({ host: configString({}).default("localhost").describe("API host"), port: configNumber({}).default(443).describe("API port"), apiKey: configString({ "x-secret": true }).describe("API authentication key"), // Marked as secret username: configString({}).optional().describe("Username"), password: configString({ "x-secret": true }).optional().describe("Password"), // Marked as secret});3. Frontend Consumption
Section titled “3. Frontend Consumption”The frontend automatically handles the password fields:
import { PluginConfigForm } from "@checkstack/ui";
// The configSchema from the backend already has x-secret metadata<PluginConfigForm plugins={plugins} // Contains schemas with x-secret metadata selectedPluginId={selectedPluginId} config={config} onConfigChange={setConfig}/>How It Works
Section titled “How It Works”Backend: Schema Conversion Process
Section titled “Backend: Schema Conversion Process”The toJsonSchema() function in schema-utils.ts:
- Calls Zod’s native
toJSONSchema()to get the base JSON Schema - Traverses the Zod schema to identify fields created with branded types (
secret(),color()) - Adds
x-secret: trueorx-color: truemetadata to those fields - Returns the enhanced JSON Schema
// Simplified implementationfunction toJsonSchema(zodSchema: z.ZodTypeAny): Record<string, unknown> { const jsonSchema = zodSchema.toJSONSchema(); addSchemaMetadata(zodSchema, jsonSchema); // Adds x-secret, x-color return jsonSchema;}Frontend: Specialized Field Rendering
Section titled “Frontend: Specialized Field Rendering”The DynamicForm component in DynamicForm.tsx detects branded fields:
// Detect secret fields from x-secret metadataconst isSecret = propSchema["x-secret"];if (isSecret) { // Render password input with show/hide toggle return <Input type={showPassword ? "text" : "password"} ... />;}
// Detect color fields from x-color metadataconst isColor = propSchema["x-color"];if (isColor) { // Render color picker with swatch and text input return <ColorPicker value={value} onChange={onChange} />;}Factory Functions Reference
Section titled “Factory Functions Reference”The platform provides factory functions for creating Zod schemas with specialized metadata:
configString({ "x-secret": true }) - Sensitive Data
Section titled “configString({ "x-secret": true }) - Sensitive Data”Use for passwords, API keys, tokens, and other sensitive data:
import { configString } from "@checkstack/backend-api";
const schema = z.object({ apiKey: configString({ "x-secret": true }).describe("API authentication key"), password: configString({ "x-secret": true }).optional().describe("Optional password"),});Features:
- Renders as password input with show/hide toggle
- Values are encrypted at rest via
ConfigService - Redacted when returning config to frontend
configString({ "x-color": true }) - Hex Colors
Section titled “configString({ "x-color": true }) - Hex Colors”Use for hex color values (e.g., brand colors, theme colors):
import { configString } from "@checkstack/backend-api";
const schema = z.object({ // With default value primaryColor: configString({ "x-color": true }).default("#3b82f6").describe("Primary brand color"), // Optional without default accentColor: configString({ "x-color": true }).optional().describe("Accent color"),});Features:
- Renders as color picker with swatch + text input
- Validates hex format (
#RGBor#RRGGBB) - Supports optional default values
configString({ "x-options-resolver": ... }) - Dynamic Dropdowns
Section titled “configString({ "x-options-resolver": ... }) - Dynamic Dropdowns”Use for fields that need to fetch options dynamically from the backend:
import { configString } from "@checkstack/backend-api";
const schema = z.object({ // Basic options resolver projectKey: configString({ "x-options-resolver": "projectOptions", }).describe("Jira project"),
// With dependencies (refetches when dependent fields change) issueTypeId: configString({ "x-options-resolver": "issueTypeOptions", "x-depends-on": ["projectKey"], }).describe("Issue type"),
// With searchable dropdown for many options fieldKey: configString({ "x-options-resolver": "fieldOptions", "x-depends-on": ["projectKey", "issueTypeId"], "x-searchable": true, }).describe("Jira field"),});Features:
- Renders as a dropdown that fetches options from backend
x-options-resolver: Name of the resolver function to callx-depends-on: Array of field names that trigger refetch when changedx-searchable: When true, renders a searchable dropdown with filter input inside
Implementation requirements:
The provider must implement getConnectionOptions() to handle resolver calls. See Integration Providers for details.
configString({ "x-hidden": true }) - Auto-populated Fields
Section titled “configString({ "x-hidden": true }) - Auto-populated Fields”Use for fields that are auto-populated and should not be shown in the form:
import { configString } from "@checkstack/backend-api";
const schema = z.object({ // Hidden field (auto-populated) connectionId: configString({ "x-hidden": true }).describe("Connection ID (auto-populated)"),
// Normal visible fields name: configString({}).describe("Subscription name"),});Features:
- Field is hidden from the form UI
- Value is typically set programmatically
- Useful for connection IDs or other auto-populated values
configString({ "x-editor-types": [...] }) - Multi-Type Editor
Section titled “configString({ "x-editor-types": [...] }) - Multi-Type Editor”Use for string fields that support multiple input formats with dynamic editing modes:
import { configString } from "@checkstack/backend-api";
const schema = z.object({ // HTTP request body with multiple format options body: configString({ "x-editor-types": ["none", "raw", "json", "yaml", "xml", "formdata"], }).optional().describe("Request body"),
// Template field with structured data support template: configString({ "x-editor-types": ["json", "yaml", "xml"], }).describe("Template content"),
// Simple template with just raw text bodyTemplate: configString({ "x-editor-types": ["raw"], }).optional().describe("Custom body template"),
// Documentation with markdown support notes: configString({ "x-editor-types": ["raw", "markdown"], }).optional().describe("Notes or documentation"),});Available Editor Types:
"none": Disabled input (value is cleared/undefined)"raw": Plain text textarea"json": JSON code editor with syntax highlighting and auto-indentation"yaml": YAML code editor with syntax highlighting and auto-indentation"xml": XML/HTML code editor with tag highlighting and smart tag splitting"markdown": Markdown editor with syntax highlighting"formdata": Key-value pair editor (URL-encoded format)
CodeEditor Features:
- Syntax Highlighting: Language-specific colors for keys, values, tags, and templates
- Smart Indentation: Custom Enter key behavior with proper indentation for each language
- Bracket/Tag Splitting: Pressing Enter between
{},[], or<tag></tag>properly splits them - Template Support: Mustache-style
{{variable}}syntax with autocomplete - Line Numbers: Visible with proper gutter styling
- Full Click Area: Entire editor area is clickable (per official CodeMirror best practices)
Features:
- Dropdown selector when multiple types are available
- Auto-detects initial editor type from existing value
- Automatic format conversion when switching between types
- Template autocomplete ({% raw %}
{{{% endraw %} syntax) whentemplatePropertiesare provided toDynamicForm - All formats serialize to a single string for storage
Template Autocomplete:
When the parent DynamicForm receives templateProperties, all applicable editor types (raw, json, yaml, xml, markdown) will show autocomplete suggestions when typing {% raw %}{{{% endraw %}:
<DynamicForm schema={configSchema} value={formValue} onChange={setFormValue} templateProperties={[ { path: "payload.incident.title", type: "string" }, { path: "payload.incident.severity", type: "string" }, ]}/>Secret Handling Best Practices
Section titled “Secret Handling Best Practices”1. Marking Fields as Secrets
Section titled “1. Marking Fields as Secrets”Use configString({ "x-secret": true }) for any sensitive data:
- Passwords
- API keys
- Authentication tokens
- Private keys
- Database connection strings with credentials
import { configString, configNumber } from "@checkstack/backend-api";
const schema = z.object({ // Regular field timeout: configNumber({}).default(5000),
// Secret field accessToken: configString({ "x-secret": true }).describe("OAuth access token"),});2. Optional vs Required Secrets
Section titled “2. Optional vs Required Secrets”Secrets can be optional or required (via defaults):
const schema = z.object({ // Optional secret (can be empty) password: configString({ "x-secret": true }).optional().describe("Password (optional)"),
// Required secret (has default, but user should change it) apiKey: configString({ "x-secret": true }).default("").describe("API Key"),});3. Configuration Retrieval Security
Section titled “3. Configuration Retrieval Security”When returning current configuration to the frontend for editing:
// ✅ CORRECT: Use getRedacted() to remove secretsgetConfiguration: os.getConfiguration.handler(async ({ context }) => { const config = await context.configService.getRedacted( pluginId, plugin.configSchema, plugin.configVersion );
return { pluginId, config }; // Secrets are empty/undefined}),
// ❌ WRONG: Exposes unredacted secrets to frontendgetConfiguration: os.getConfiguration.handler(async ({ context }) => { const config = await context.configService.get(...); return { pluginId, config }; // Security vulnerability!}),Testing
Section titled “Testing”Verify schema conversion includes secret metadata:
import { describe, test, expect } from "bun:test";import { toJsonSchema } from "@checkstack/backend-api";import { myPluginConfigSchema } from "./schema";
describe("Plugin Config Schema", () => { test("should mark password field as secret", () => { const jsonSchema = toJsonSchema(myPluginConfigSchema);
expect(jsonSchema.properties.password["x-secret"]).toBe(true); });});Common Mistakes
Section titled “Common Mistakes”❌ Using Native Zod Method
Section titled “❌ Using Native Zod Method”// WRONG: No x-secret metadataconfigSchema: zod.toJSONSchema(p.configSchema)❌ Forgetting to Import
Section titled “❌ Forgetting to Import”// WRONG: Using wrong functionimport { zod } from "@checkstack/backend-api";configSchema: zod.toJSONSchema(p.configSchema)❌ Not Using secret() Helper
Section titled “❌ Not Using secret() Helper”// WRONG: Regular string field for sensitive datapassword: z.string().describe("Password")✅ Correct Pattern
Section titled “✅ Correct Pattern”import { toJsonSchema, configString } from "@checkstack/backend-api";
// In schemapassword: configString({ "x-secret": true }).describe("Password")
// In routerconfigSchema: toJsonSchema(p.configSchema)Reference Implementations
Section titled “Reference Implementations”Good examples to follow:
auth-backend/router.ts- UsestoJsonSchemaandgetRedactedqueue-backend/router.ts- UsestoJsonSchemaauth-ldap-backend/strategy.ts- Usessecret()helper
Summary
Section titled “Summary”Always follow these rules when exposing config schemas to the frontend:
- ✅ Use
toJsonSchema()from@checkstack/backend-api, not Zod’s native method - ✅ Mark sensitive fields with
configString({ "x-secret": true })in your schemas - ✅ Use
ConfigService.getRedacted()when returning current config to frontend - ✅ Test that secret fields have
x-secret: truemetadata
This ensures:
- Password fields render correctly with show/hide toggles
- Secrets never leak to the frontend
- Consistent security behavior across all plugins