Extension points enable plugins to provide pluggable implementations for core functionality. They follow the Strategy Pattern, allowing different implementations to be swapped at runtime.
A contract that defines what implementations must provide:
interface ExtensionPoint<T> {
id: string;
T: T; // Phantom type for type safety
}
An implementation of an extension point:
interface Strategy {
id: string;
displayName: string;
// ... strategy-specific methods
}
Implements custom health check methods.
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>;
}
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",
};
}
},
};
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);
},
});
Exports metrics and data in various formats.
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;
}>;
}
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",
});
});
},
};
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",
};
},
};
Send notifications via different channels.
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>;
}
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,
})
),
},
],
}),
});
},
};
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),
});
},
};
Integrate authentication providers using Better Auth.
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;
}
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.
Slots allow plugins to inject UI components into predefined locations. Plugins can either:
@checkstack/frontend-api@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";
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 page
export const SystemDetailsSlot = createSlot<{ system: System }>(
"plugin.catalog.system-details"
);
// Slot for adding actions to the system configuration page
export const CatalogSystemActionsSlot = createSlot<{
systemId: string;
systemName: string;
}>("plugin.catalog.system-actions");
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 }
},
],
});
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>;
};
[!TIP] Using
SlotContextandcreateSlotExtensionensures compile-time type checking. If the slot definition changes, TypeScript will immediately flag any component prop mismatches.
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,
}),
],
});
export const MyDashboardWidget = () => {
return (
<Card>
<CardHeader>
<CardTitle>My Widget</CardTitle>
</CardHeader>
<CardContent>
<p>Widget content here</p>
</CardContent>
</Card>
);
};
// 1. Define the interface
export interface CustomStrategy<Config = unknown> {
id: string;
displayName: string;
configSchema: z.ZodType<Config>;
execute(config: Config): Promise<Result>;
}
// 2. Create the extension point
import { createExtensionPoint } from "@checkstack/backend-api";
export const customExtensionPoint = createExtensionPoint<CustomStrategy[]>(
"custom-extension"
);
// 3. Create a registry
export 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 core
const registry = new CustomRegistry();
env.registerExtensionPoint(customExtensionPoint, registry);
// 5. Plugins can now register implementations
const myStrategy: CustomStrategy = {
id: "my-impl",
displayName: "My Implementation",
configSchema: z.object({ /* ... */ }),
async execute(config) {
// Implementation
},
};
const registry = env.getExtensionPoint(customExtensionPoint);
registry.register(myStrategy);
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.ts
import { createSlot } from "@checkstack/frontend-api";
// Define with typed context that extensions will receive
export const MyPluginCustomSlot = createSlot<{ itemId: string }>(
"myplugin.custom.slot"
);
// 2. Export from your common package index
export * from "./slots";
// 3. Use the slot in your plugin's frontend component
import { 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=
/>
</div>
);
};
// 4. Other plugins can now register extensions
// e.g., in @checkstack/other-plugin-frontend
import { 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} />,
},
],
});
// ✅ Good
id: "http-health-check"
id: "slack-notification"
// ❌ Bad
id: "check1"
id: "notif"
displayName: "HTTP Health Check",
description: "Checks if an HTTP endpoint is responding with the expected status code"
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.
async execute(config) {
try {
// Implementation
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}
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");
});
});