Extension Points and Strategies
Overview
Section titled “Overview”Extension points enable plugins to provide pluggable implementations for core functionality. They follow the Strategy Pattern, allowing different implementations to be swapped at runtime.
Core Concepts
Section titled “Core Concepts”Extension Point
Section titled “Extension Point”A contract that defines what implementations must provide:
interface ExtensionPoint<T> { id: string; T: T; // Phantom type for type safety}Strategy
Section titled “Strategy”An implementation of an extension point:
interface Strategy { id: string; displayName: string; // ... strategy-specific methods}Backend Extension Points
Section titled “Backend Extension Points”HealthCheckStrategy
Section titled “HealthCheckStrategy”Implements custom health check methods.
Interface
Section titled “Interface”interface HealthCheckStrategy<Config = unknown> { /** Unique identifier for this strategy */ id: string;
/** Human-readable name */ displayName: string;
/** Optional description */ description?: string;
/** Current version of the configuration schema */ configVersion: number;
/** Validation schema for the strategy-specific config */ configSchema: z.ZodType<Config>;
/** Optional migrations for backward compatibility */ migrations?: MigrationChain<Config>;
/** Execute the health check */ execute(config: Config): Promise<HealthCheckResult>;}
interface HealthCheckResult { status: "healthy" | "unhealthy" | "degraded"; latency?: number; // ms message?: string; metadata?: Record<string, unknown>;}Example: HTTP Health Check
Section titled “Example: HTTP Health Check”import { z } from "zod";import { HealthCheckStrategy } from "@checkstack/backend-api";
const httpCheckConfig = z.object({ url: z.string().url().describe("URL to check"), method: z.enum(["GET", "POST", "HEAD"]).default("GET"), timeout: z.number().min(100).max(30000).default(5000), expectedStatus: z.number().min(100).max(599).default(200), headers: z.record(z.string()).optional(),});
type HttpCheckConfig = z.infer<typeof httpCheckConfig>;
export const httpHealthCheckStrategy: HealthCheckStrategy<HttpCheckConfig> = { id: "http-check", displayName: "HTTP Health Check", description: "Check if an HTTP endpoint is responding", configVersion: 1, configSchema: httpCheckConfig,
async execute(config: HttpCheckConfig): Promise<HealthCheckResult> { const startTime = Date.now();
try { const response = await fetch(config.url, { method: config.method, headers: config.headers, signal: AbortSignal.timeout(config.timeout), });
const latency = Date.now() - startTime;
if (response.status === config.expectedStatus) { return { status: "healthy", latency, message: `HTTP ${response.status}`, }; } else { return { status: "unhealthy", latency, message: `Expected ${config.expectedStatus}, got ${response.status}`, }; } } catch (error) { return { status: "unhealthy", latency: Date.now() - startTime, message: error instanceof Error ? error.message : "Unknown error", }; } },};Registering a Health Check Strategy
Section titled “Registering a Health Check Strategy”import { healthCheckExtensionPoint } from "@checkstack/backend-api";
export default createBackendPlugin({ metadata: pluginMetadata, register(env) { // Get the health check registry const registry = env.getExtensionPoint(healthCheckExtensionPoint);
// Register the strategy registry.register(httpHealthCheckStrategy); },});ExporterStrategy
Section titled “ExporterStrategy”Exports metrics and data in various formats.
Interface
Section titled “Interface”interface ExporterStrategy<Config = unknown> { id: string; displayName: string; description?: string; configVersion: number; configSchema: z.ZodType<Config>; migrations?: MigrationChain<Config>;
/** Export type: endpoint or file */ type: "endpoint" | "file";
/** For endpoint exporters: register routes */ registerRoutes?(router: Hono, config: Config): void;
/** For file exporters: generate file */ generateFile?(config: Config): Promise<{ filename: string; content: string | Buffer; mimeType: string; }>;}Example: Prometheus Exporter
Section titled “Example: Prometheus Exporter”const prometheusConfig = z.object({ path: z.string().default("/metrics"), includeTimestamps: z.boolean().default(false),});
type PrometheusConfig = z.infer<typeof prometheusConfig>;
export const prometheusExporter: ExporterStrategy<PrometheusConfig> = { id: "prometheus", displayName: "Prometheus Metrics", description: "Export metrics in Prometheus format", configVersion: 1, configSchema: prometheusConfig, type: "endpoint",
registerRoutes(router, config) { router.get(config.path, async (c) => { const metrics = await collectMetrics(); const output = formatPrometheus(metrics, config.includeTimestamps); return c.text(output, 200, { "Content-Type": "text/plain; version=0.0.4", }); }); },};Example: CSV Exporter
Section titled “Example: CSV Exporter”const csvConfig = z.object({ includeHeaders: z.boolean().default(true), delimiter: z.string().default(","),});
type CsvConfig = z.infer<typeof csvConfig>;
export const csvExporter: ExporterStrategy<CsvConfig> = { id: "csv", displayName: "CSV Export", description: "Export data as CSV file", configVersion: 1, configSchema: csvConfig, type: "file",
async generateFile(config) { const data = await fetchData(); const csv = formatCsv(data, config);
return { filename: `export-${Date.now()}.csv`, content: csv, mimeType: "text/csv", }; },};NotificationStrategy
Section titled “NotificationStrategy”Send notifications via different channels.
Interface
Section titled “Interface”interface NotificationStrategy<Config = unknown> { id: string; displayName: string; description?: string; configVersion: number; configSchema: z.ZodType<Config>; migrations?: MigrationChain<Config>;
/** Send a notification */ send(config: Config, notification: Notification): Promise<void>;}
interface Notification { title: string; message: string; severity: "info" | "warning" | "error" | "critical"; metadata?: Record<string, unknown>;}Example: Slack Notification
Section titled “Example: Slack Notification”const slackConfig = z.object({ webhookUrl: z.string().url(), channel: z.string().optional(), username: z.string().default("Checkstack"), iconEmoji: z.string().default(":robot_face:"),});
type SlackConfig = z.infer<typeof slackConfig>;
export const slackNotificationStrategy: NotificationStrategy<SlackConfig> = { id: "slack", displayName: "Slack", description: "Send notifications to Slack", configVersion: 1, configSchema: slackConfig,
async send(config, notification) { const color = { info: "#36a64f", warning: "#ff9900", error: "#ff0000", critical: "#990000", }[notification.severity];
await fetch(config.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel: config.channel, username: config.username, icon_emoji: config.iconEmoji, attachments: [ { color, title: notification.title, text: notification.message, fields: Object.entries(notification.metadata || {}).map( ([key, value]) => ({ title: key, value: String(value), short: true, }) ), }, ], }), }); },};Example: Email Notification
Section titled “Example: Email Notification”const emailConfig = z.object({ smtpHost: z.string(), smtpPort: z.number().default(587), username: z.string(), password: z.string(), from: z.string().email(), to: z.array(z.string().email()),});
type EmailConfig = z.infer<typeof emailConfig>;
export const emailNotificationStrategy: NotificationStrategy<EmailConfig> = { id: "email", displayName: "Email", description: "Send notifications via email", configVersion: 1, configSchema: emailConfig,
async send(config, notification) { const transporter = createTransport({ host: config.smtpHost, port: config.smtpPort, auth: { user: config.username, pass: config.password, }, });
await transporter.sendMail({ from: config.from, to: config.to.join(", "), subject: notification.title, text: notification.message, html: formatEmailHtml(notification), }); },};AuthenticationStrategy
Section titled “AuthenticationStrategy”Integrate authentication providers using Better Auth.
Interface
Section titled “Interface”interface AuthenticationStrategy<Config = unknown> { id: string; displayName: string; description?: string; configVersion: number; configSchema: z.ZodType<Config>; migrations?: MigrationChain<Config>;
/** Configure Better Auth with this strategy */ configure(config: Config): BetterAuthConfig;}Example: OAuth Provider
Section titled “Example: OAuth Provider”const oauthConfig = z.object({ clientId: z.string(), clientSecret: z.string(), authorizationUrl: z.string().url(), tokenUrl: z.string().url(), userInfoUrl: z.string().url(),});
type OAuthConfig = z.infer<typeof oauthConfig>;
export const oauthStrategy: AuthenticationStrategy<OAuthConfig> = { id: "oauth", displayName: "OAuth 2.0", description: "Authenticate using OAuth 2.0", configVersion: 1, configSchema: oauthConfig,
configure(config) { return { socialProviders: { custom: { clientId: config.clientId, clientSecret: config.clientSecret, authorizationUrl: config.authorizationUrl, tokenUrl: config.tokenUrl, userInfoUrl: config.userInfoUrl, }, }, }; },};[!WARNING] Registration Check Requirement
If your custom authentication strategy creates new user accounts automatically (e.g., LDAP, SSO, or custom OAuth implementations), you must check the platform’s registration settings before creating users.
Use the typed RPC client to call
auth-backend.getRegistrationStatus()and verify thatallowRegistrationistruebefore creating any new users. If registration is disabled, throw an appropriate error.Example:
import { coreServices } from "@checkstack/backend-api";import { AuthApi } from "@checkstack/auth-common";env.registerInit({deps: {rpcClient: coreServices.rpcClient,logger: coreServices.logger,},init: async ({ rpcClient, logger }) => {// In your user sync/creation logic:try {const authClient = rpcClient.forPlugin(AuthApi);const { allowRegistration } = await authClient.getRegistrationStatus();if (!allowRegistration) {throw new Error("Registration is disabled. Please contact an administrator.");}// Proceed with user creation} catch (error) {logger.warn("Failed to check registration status:", error);throw error;}},});This ensures administrators have full control over user registration across all authentication methods. See Backend Service Communication for more details on using the RPC client.
Frontend Extension Points
Section titled “Frontend Extension Points”Slots allow plugins to inject UI components into predefined locations. Plugins can either:
- Register extensions to core slots defined in
@checkstack/frontend-api - Register extensions to plugin-defined slots exported from plugin common packages
Core Slots (from @checkstack/frontend-api)
Section titled “Core Slots (from @checkstack/frontend-api)”Core slots are defined using the createSlot utility and exported as SlotDefinition objects:
import { DashboardSlot, NavbarRightSlot, NavbarLeftSlot, UserMenuItemsSlot, UserMenuItemsBottomSlot,} from "@checkstack/frontend-api";Plugin-Defined Slots
Section titled “Plugin-Defined Slots”Plugins can expose their own slots using the createSlot utility from @checkstack/frontend-api. This allows other plugins to extend specific areas of your plugin’s UI.
Example: Catalog plugin exposing slots (from @checkstack/catalog-common)
import { createSlot } from "@checkstack/frontend-api";import type { System } from "./types";
// Slot for extending the System Details pageexport const SystemDetailsSlot = createSlot<{ system: System }>( "plugin.catalog.system-details");
// Slot for adding actions to the system configuration pageexport const CatalogSystemActionsSlot = createSlot<{ systemId: string; systemName: string;}>("plugin.catalog.system-actions");Registering Extensions to Slots
Section titled “Registering Extensions to Slots”Extensions use the slot: property with a SlotDefinition object:
To a core slot:
import { UserMenuItemsSlot } from "@checkstack/frontend-api";
export const myPlugin = createFrontendPlugin({ name: "myplugin-frontend", extensions: [ { id: "myplugin.user-menu.items", slot: UserMenuItemsSlot, component: MyUserMenuItems, }, ],});To a plugin-defined slot:
import { SystemDetailsSlot } from "@checkstack/catalog-common";
export const myPlugin = createFrontendPlugin({ name: "myplugin-frontend", extensions: [ { id: "myplugin.system-details", slot: SystemDetailsSlot, component: MySystemDetailsExtension, // Receives { system: System } }, ],});Type-Safe Extension Registration (Recommended)
Section titled “Type-Safe Extension Registration (Recommended)”For strict typing that infers component props directly from the slot definition, use the createSlotExtension helper and SlotContext type.
Using createSlotExtension for registration:
import { createFrontendPlugin, createSlotExtension } from "@checkstack/frontend-api";import { SystemDetailsSlot, CatalogSystemActionsSlot } from "@checkstack/catalog-common";
export default createFrontendPlugin({ name: "myplugin-frontend", extensions: [ // Type-safe: component props are inferred from SystemDetailsSlot createSlotExtension(SystemDetailsSlot, { id: "myplugin.system-details", component: MySystemDetailsPanel, // Must accept { system: System } }), createSlotExtension(CatalogSystemActionsSlot, { id: "myplugin.system-actions", component: MySystemAction, // Must accept { systemId: string; systemName: string } }), ],});Using SlotContext for component typing:
import type { SlotContext } from "@checkstack/frontend-api";import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
// Props inferred directly from the slot definition - no manual interface needed!type Props = SlotContext<typeof CatalogSystemActionsSlot>;// Equivalent to: { systemId: string; systemName: string }
export const MySystemAction: React.FC<Props> = ({ systemId, systemName }) => { // Full type safety - no casting, no unknown! return <Button onClick={() => doSomething(systemId)}>Action for {systemName}</Button>;};Typed Metadata on Extensions
Section titled “Typed Metadata on Extensions”Some slots need each extension to declare a static descriptor at registration
time — for example, the Infrastructure Settings tab bar needs a label, icon,
and access rules to render its nav before the tab body is mounted. Pass a
second type argument to createSlot to express that contract:
import { createSlot } from "@checkstack/frontend-api";import type { AccessRule } from "@checkstack/common";
export interface InfrastructureTabContext { canUpdate: boolean;}
export interface InfrastructureTabMetadata { label: string; icon: React.ComponentType<{ className?: string }>; readAccess: AccessRule; manageAccess: AccessRule; order?: number;}
export const InfrastructureTabsSlot = createSlot< InfrastructureTabContext, InfrastructureTabMetadata>("infrastructure.tabs");Extensions for a slot whose metadata type is non-undefined must supply a
metadata field; createSlotExtension will type-check it:
createSlotExtension(InfrastructureTabsSlot, { id: "queue.infrastructure.tab", component: QueueInfrastructureTab, metadata: { label: "Queue", icon: Gauge, readAccess: queueAccess.settings.read, manageAccess: queueAccess.settings.manage, order: 10, },});Consumers read metadata via useSlotExtensions, which subscribes to plugin
register/unregister events:
import { useSlotExtensions } from "@checkstack/frontend-api";
const tabs = useSlotExtensions(InfrastructureTabsSlot);// tabs[i].metadata is typed as InfrastructureTabMetadata<ExtensionSlot slot={…} context={…} /> remains the right tool when the
consumer just needs to render every extension inline. Reach for
useSlotExtensions only when you need metadata, ordering, or per-extension
gating logic.
Example: User Menu Extension
Section titled “Example: User Menu Extension”User menu slots (UserMenuItemsSlot, UserMenuItemsBottomSlot) receive a UserMenuItemsContext with pre-fetched user data for synchronous rendering:
interface UserMenuItemsContext { accessRules: string[]; // Pre-fetched user access rules hasCredentialAccount: boolean; // Whether user has credential auth}Access-gated menu item:
import type { UserMenuItemsContext } from "@checkstack/frontend-api";import { qualifyAccessRuleId, resolveRoute } from "@checkstack/common";import { access, pluginMetadata, myRoutes } from "@checkstack/myplugin-common";import { DropdownMenuItem } from "@checkstack/ui";import { Link } from "react-router-dom";import { Settings } from "lucide-react";
export const MyPluginMenuItems = ({ accessRules: userPerms,}: UserMenuItemsContext) => { const qualifiedId = qualifyAccessRuleId(pluginMetadata, access.myAccess); const canAccess = userPerms.includes("*") || userPerms.includes(qualifiedId);
if (!canAccess) return null;
return ( <Link to={resolveRoute(myRoutes.routes.settings)}> <DropdownMenuItem icon={<Settings className="h-4 w-4" />}> My Settings </DropdownMenuItem> </Link> );};Registration with createSlotExtension:
import { createSlotExtension, UserMenuItemsSlot } from "@checkstack/frontend-api";
export default createFrontendPlugin({ metadata: pluginMetadata, extensions: [ createSlotExtension(UserMenuItemsSlot, { id: "myplugin.user-menu.items", component: MyPluginMenuItems, group: "Configuration", // Optional: bucket the item into a UserMenu section }), ],});Grouping menu items
The user menu groups UserMenuItemsSlot extensions by their optional
group field and renders each group under a labeled header. Canonical
groups (rendered in this order) are:
"Workspace"— domain-level browsing (catalogs, dashboards, dependency graphs)"Reliability"— operational signals (health checks, incidents, maintenances, SLOs)"Configuration"— admin/setup pages (auth, infrastructure, integrations, plugins)"Documentation"— reference material (API docs, kind registry)"Account"— personal items (profile)
Extensions without a group are rendered last (no header), and any custom
group strings introduced by plugins are rendered after the canonical groups
in alphabetical order. UserMenuItemsBottomSlot always renders below the
top section, separated by a divider — no group is honored there.
Example: Dashboard Widget
Section titled “Example: Dashboard Widget”export const MyDashboardWidget = () => { return ( <Card> <CardHeader> <CardTitle>My Widget</CardTitle> </CardHeader> <CardContent> <p>Widget content here</p> </CardContent> </Card> );};Creating Custom Extension Points
Section titled “Creating Custom Extension Points”Backend Extension Point
Section titled “Backend Extension Point”// 1. Define the interfaceexport interface CustomStrategy<Config = unknown> { id: string; displayName: string; configSchema: z.ZodType<Config>; execute(config: Config): Promise<Result>;}
// 2. Create the extension pointimport { createExtensionPoint } from "@checkstack/backend-api";
export const customExtensionPoint = createExtensionPoint<CustomStrategy[]>( "custom-extension");
// 3. Create a registryexport class CustomRegistry { private strategies = new Map<string, CustomStrategy>();
register(strategy: CustomStrategy) { this.strategies.set(strategy.id, strategy); }
getStrategy(id: string): CustomStrategy | undefined { return this.strategies.get(id); }
getStrategies(): CustomStrategy[] { return Array.from(this.strategies.values()); }}
// 4. Register in coreconst registry = new CustomRegistry();env.registerExtensionPoint(customExtensionPoint, registry);
// 5. Plugins can now register implementationsconst myStrategy: CustomStrategy = { id: "my-impl", displayName: "My Implementation", configSchema: z.object({ /* ... */ }), async execute(config) { // Implementation },};
const registry = env.getExtensionPoint(customExtensionPoint);registry.register(myStrategy);Frontend Extension Point (Slot)
Section titled “Frontend Extension Point (Slot)”To expose a slot from your plugin that other plugins can extend:
// 1. Define the slot in your plugin's -common package// e.g., in @checkstack/myplugin-common/src/slots.tsimport { createSlot } from "@checkstack/frontend-api";
// Define with typed context that extensions will receiveexport const MyPluginCustomSlot = createSlot<{ itemId: string }>( "myplugin.custom.slot");
// 2. Export from your common package indexexport * from "./slots";
// 3. Use the slot in your plugin's frontend componentimport { ExtensionSlot } from "@checkstack/frontend-api";import { MyPluginCustomSlot } from "@checkstack/myplugin-common";
export const MyComponent = ({ itemId }: { itemId: string }) => { return ( <div> {/* Your plugin's content */} <h1>My Component</h1>
{/* Extension point for other plugins */} <ExtensionSlot slot={MyPluginCustomSlot} context={{ itemId }} /> </div> );};
// 4. Other plugins can now register extensions// e.g., in @checkstack/other-plugin-frontendimport { MyPluginCustomSlot } from "@checkstack/myplugin-common";
export default createFrontendPlugin({ name: "other-plugin-frontend", extensions: [ { id: "other-plugin.myplugin-extension", slot: MyPluginCustomSlot, component: ({ itemId }) => <MyExtension itemId={itemId} />, }, ],});Best Practices
Section titled “Best Practices”1. Use Descriptive IDs
Section titled “1. Use Descriptive IDs”// ✅ Goodid: "http-health-check"id: "slack-notification"
// ❌ Badid: "check1"id: "notif"2. Provide Clear Descriptions
Section titled “2. Provide Clear Descriptions”displayName: "HTTP Health Check",description: "Checks if an HTTP endpoint is responding with the expected status code"3. Use Zod Descriptions
Section titled “3. Use Zod Descriptions”const config = z.object({ url: z.string().url().describe("The URL to check"), timeout: z.number().describe("Request timeout in milliseconds"),});These descriptions are used to generate UI forms automatically.
4. Handle Errors Gracefully
Section titled “4. Handle Errors Gracefully”async execute(config) { try { // Implementation } catch (error) { return { status: "unhealthy", message: error instanceof Error ? error.message : "Unknown error", }; }}5. Test Strategies
Section titled “5. Test Strategies”import { describe, expect, test } from "bun:test";
describe("HTTP Health Check Strategy", () => { test("returns healthy for 200 response", async () => { const result = await httpHealthCheckStrategy.execute({ url: "https://example.com", method: "GET", timeout: 5000, expectedStatus: 200, });
expect(result.status).toBe("healthy"); });});