@checkstack/ai-backend 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +7 -7
- package/src/generated/docs-index.ts +3 -3
- package/src/projection.test.ts +3 -1
- package/src/registry-wiring.test.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @checkstack/ai-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 56e7c75: Hide navigation, actions and links that the current user cannot use, so anonymous
|
|
8
|
+
and read-only users no longer see entries that lead to "Access Denied" or to
|
|
9
|
+
actions the server would reject.
|
|
10
|
+
|
|
11
|
+
- **Sidebar**: a nav entry can now declare a dynamic `nav.isVisible({ accessRules, isAuthenticated })` predicate (in addition to the static `accessRule`). A group whose every entry is filtered out is no longer rendered. The filtering/grouping logic is extracted to a pure, unit-tested helper.
|
|
12
|
+
- **Infrastructure**: its sidebar entry is shown only when the user can READ at least one contributed tab (queue, cache, …), instead of always (it previously had no static rule because tabs are contributed at runtime).
|
|
13
|
+
- **Notification Settings**: hidden from anonymous users - notifications are per-user, so an anonymous visitor can't have any.
|
|
14
|
+
- **Anomaly Mute / Suppress**: the "Mute" / "Mute all" controls (a per-user preference) are hidden from anonymous visitors; the "Suppress" control is gated on `anomalyAccess.feed.manage`. Both were previously always visible.
|
|
15
|
+
- **Dashboard**: the "Open Catalog" actions (which open the manage-only Catalog config page) are hidden from users without `catalogAccess.system.manage`, and the "View catalog" link is gated on `catalogAccess.system.read`.
|
|
16
|
+
- **Dashboard status signals**: the per-system status rows contributed by plugins (`SystemSignalsSlot`) now render as a LINK only when the user can open the target, and as plain text otherwise. `SystemSignal` gains an optional `accessRule`; the healthcheck, anomaly, and dependency fillers set it for their gated targets (check-history / assignments / dependency-map). Signals pointing at ungated pages (incident / maintenance / SLO detail) stay links.
|
|
17
|
+
- **Plugin Manager**: the "Install plugin" button (which opens the install-gated page) is hidden from users with only `plugin` view access.
|
|
18
|
+
- **Satellites**: the page is entirely manage-gated, but its route/sidebar entry was gated on `read`, so read-only users saw the nav item and hit "Access Denied" on click. The route and nav entry now require `satellite.manage`.
|
|
19
|
+
|
|
20
|
+
The `@checkstack/ai-backend` bump is only the regenerated bundled docs index
|
|
21
|
+
(the frontend routing guide gained the `nav.isVisible` section); no code change.
|
|
22
|
+
|
|
23
|
+
**BREAKING (`@checkstack/frontend-api`):** the `AccessApi` interface gains a
|
|
24
|
+
required `useIsAuthenticated()` method. Custom `AccessApi` implementations must
|
|
25
|
+
add it (it returns `{ loading, isAuthenticated }`). The built-in auth
|
|
26
|
+
implementation and the no-auth fallback already do. `NavEntry` also gains an
|
|
27
|
+
optional `isVisible` predicate (purely additive).
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [0626782]
|
|
30
|
+
- Updated dependencies [56e7c75]
|
|
31
|
+
- @checkstack/backend-api@0.21.5
|
|
32
|
+
- @checkstack/common@0.15.0
|
|
33
|
+
- @checkstack/ai-common@0.1.3
|
|
34
|
+
- @checkstack/integration-backend@0.4.5
|
|
35
|
+
- @checkstack/sdk@0.100.1
|
|
36
|
+
|
|
3
37
|
## 0.1.4
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/ai-backend",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@ai-sdk/openai-compatible": "^2.0.48",
|
|
19
|
-
"@checkstack/ai-common": "0.1.
|
|
20
|
-
"@checkstack/backend-api": "0.21.
|
|
21
|
-
"@checkstack/common": "0.
|
|
19
|
+
"@checkstack/ai-common": "0.1.3",
|
|
20
|
+
"@checkstack/backend-api": "0.21.5",
|
|
21
|
+
"@checkstack/common": "0.15.0",
|
|
22
22
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
23
|
-
"@checkstack/integration-backend": "0.4.
|
|
24
|
-
"@checkstack/sdk": "0.
|
|
23
|
+
"@checkstack/integration-backend": "0.4.5",
|
|
24
|
+
"@checkstack/sdk": "0.100.1",
|
|
25
25
|
"@orpc/client": "^1.14.4",
|
|
26
26
|
"@orpc/contract": "^1.14.4",
|
|
27
27
|
"@orpc/server": "^1.14.4",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"zod": "^4.2.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@checkstack/scripts": "0.6.
|
|
34
|
+
"@checkstack/scripts": "0.6.1",
|
|
35
35
|
"@checkstack/tsconfig": "0.0.7",
|
|
36
36
|
"@types/node": "^20.0.0",
|
|
37
37
|
"@types/pg": "^8.20.0",
|
|
@@ -1496,7 +1496,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
1496
1496
|
"5. Test Strategies",
|
|
1497
1497
|
"Next Steps"
|
|
1498
1498
|
],
|
|
1499
|
-
"content": "## Overview\n\nExtension points enable plugins to provide **pluggable implementations** for core functionality. They follow the **Strategy Pattern**, allowing different implementations to be swapped at runtime.\n\n## Core Concepts\n\n### Extension Point\n\nA **contract** that defines what implementations must provide:\n\n```typescript\ninterface ExtensionPoint<T> {\n id: string;\n T: T; // Phantom type for type safety\n}\n```\n\n### Strategy\n\nAn **implementation** of an extension point:\n\n```typescript\ninterface Strategy {\n id: string;\n displayName: string;\n // ... strategy-specific methods\n}\n```\n\n## Backend Extension Points\n\n### HealthCheckStrategy\n\nImplements custom health check methods.\n\n#### Interface\n\n```typescript\ninterface HealthCheckStrategy<Config = unknown> {\n /** Unique identifier for this strategy */\n id: string;\n\n /** Human-readable name */\n displayName: string;\n\n /** Optional description */\n description?: string;\n\n /** Current version of the configuration schema */\n configVersion: number;\n\n /** Validation schema for the strategy-specific config */\n configSchema: z.ZodType<Config>;\n\n /** Optional migrations for backward compatibility */\n migrations?: MigrationChain<Config>;\n\n /** Execute the health check */\n execute(config: Config): Promise<HealthCheckResult>;\n}\n\ninterface HealthCheckResult {\n status: \"healthy\" | \"unhealthy\" | \"degraded\";\n latency?: number; // ms\n message?: string;\n metadata?: Record<string, unknown>;\n}\n```\n\n#### Example: HTTP Health Check\n\n```typescript\nimport { z } from \"zod\";\nimport { HealthCheckStrategy } from \"@checkstack/backend-api\";\n\nconst httpCheckConfig = z.object({\n url: z.string().url().describe(\"URL to check\"),\n method: z.enum([\"GET\", \"POST\", \"HEAD\"]).default(\"GET\"),\n timeout: z.number().min(100).max(30000).default(5000),\n expectedStatus: z.number().min(100).max(599).default(200),\n headers: z.record(z.string()).optional(),\n});\n\ntype HttpCheckConfig = z.infer<typeof httpCheckConfig>;\n\nexport const httpHealthCheckStrategy: HealthCheckStrategy<HttpCheckConfig> = {\n id: \"http-check\",\n displayName: \"HTTP Health Check\",\n description: \"Check if an HTTP endpoint is responding\",\n configVersion: 1,\n configSchema: httpCheckConfig,\n\n async execute(config: HttpCheckConfig): Promise<HealthCheckResult> {\n const startTime = Date.now();\n\n try {\n const response = await fetch(config.url, {\n method: config.method,\n headers: config.headers,\n signal: AbortSignal.timeout(config.timeout),\n });\n\n const latency = Date.now() - startTime;\n\n if (response.status === config.expectedStatus) {\n return {\n status: \"healthy\",\n latency,\n message: `HTTP ${response.status}`,\n };\n } else {\n return {\n status: \"unhealthy\",\n latency,\n message: `Expected ${config.expectedStatus}, got ${response.status}`,\n };\n }\n } catch (error) {\n return {\n status: \"unhealthy\",\n latency: Date.now() - startTime,\n message: error instanceof Error ? error.message : \"Unknown error\",\n };\n }\n },\n};\n```\n\n#### Registering a Health Check Strategy\n\n```typescript\nimport { healthCheckExtensionPoint } from \"@checkstack/backend-api\";\n\nexport default createBackendPlugin({\n metadata: pluginMetadata,\n register(env) {\n // Get the health check registry\n const registry = env.getExtensionPoint(healthCheckExtensionPoint);\n\n // Register the strategy\n registry.register(httpHealthCheckStrategy);\n },\n});\n```\n\n### ExporterStrategy\n\nExports metrics and data in various formats.\n\n#### Interface\n\n```typescript\ninterface ExporterStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Export type: endpoint or file */\n type: \"endpoint\" | \"file\";\n\n /** For endpoint exporters: register routes */\n registerRoutes?(router: Hono, config: Config): void;\n\n /** For file exporters: generate file */\n generateFile?(config: Config): Promise<{\n filename: string;\n content: string | Buffer;\n mimeType: string;\n }>;\n}\n```\n\n#### Example: Prometheus Exporter\n\n```typescript\nconst prometheusConfig = z.object({\n path: z.string().default(\"/metrics\"),\n includeTimestamps: z.boolean().default(false),\n});\n\ntype PrometheusConfig = z.infer<typeof prometheusConfig>;\n\nexport const prometheusExporter: ExporterStrategy<PrometheusConfig> = {\n id: \"prometheus\",\n displayName: \"Prometheus Metrics\",\n description: \"Export metrics in Prometheus format\",\n configVersion: 1,\n configSchema: prometheusConfig,\n type: \"endpoint\",\n\n registerRoutes(router, config) {\n router.get(config.path, async (c) => {\n const metrics = await collectMetrics();\n const output = formatPrometheus(metrics, config.includeTimestamps);\n return c.text(output, 200, {\n \"Content-Type\": \"text/plain; version=0.0.4\",\n });\n });\n },\n};\n```\n\n#### Example: CSV Exporter\n\n```typescript\nconst csvConfig = z.object({\n includeHeaders: z.boolean().default(true),\n delimiter: z.string().default(\",\"),\n});\n\ntype CsvConfig = z.infer<typeof csvConfig>;\n\nexport const csvExporter: ExporterStrategy<CsvConfig> = {\n id: \"csv\",\n displayName: \"CSV Export\",\n description: \"Export data as CSV file\",\n configVersion: 1,\n configSchema: csvConfig,\n type: \"file\",\n\n async generateFile(config) {\n const data = await fetchData();\n const csv = formatCsv(data, config);\n\n return {\n filename: `export-${Date.now()}.csv`,\n content: csv,\n mimeType: \"text/csv\",\n };\n },\n};\n```\n\n### NotificationStrategy\n\nSend notifications via different channels.\n\n#### Interface\n\n```typescript\ninterface NotificationStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Send a notification */\n send(config: Config, notification: Notification): Promise<void>;\n}\n\ninterface Notification {\n title: string;\n message: string;\n severity: \"info\" | \"warning\" | \"error\" | \"critical\";\n metadata?: Record<string, unknown>;\n}\n```\n\n#### Example: Slack Notification\n\n```typescript\nconst slackConfig = z.object({\n webhookUrl: z.string().url(),\n channel: z.string().optional(),\n username: z.string().default(\"Checkstack\"),\n iconEmoji: z.string().default(\":robot_face:\"),\n});\n\ntype SlackConfig = z.infer<typeof slackConfig>;\n\nexport const slackNotificationStrategy: NotificationStrategy<SlackConfig> = {\n id: \"slack\",\n displayName: \"Slack\",\n description: \"Send notifications to Slack\",\n configVersion: 1,\n configSchema: slackConfig,\n\n async send(config, notification) {\n const color = {\n info: \"#36a64f\",\n warning: \"#ff9900\",\n error: \"#ff0000\",\n critical: \"#990000\",\n }[notification.severity];\n\n await fetch(config.webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n channel: config.channel,\n username: config.username,\n icon_emoji: config.iconEmoji,\n attachments: [\n {\n color,\n title: notification.title,\n text: notification.message,\n fields: Object.entries(notification.metadata || {}).map(\n ([key, value]) => ({\n title: key,\n value: String(value),\n short: true,\n })\n ),\n },\n ],\n }),\n });\n },\n};\n```\n\n#### Example: Email Notification\n\n```typescript\nconst emailConfig = z.object({\n smtpHost: z.string(),\n smtpPort: z.number().default(587),\n username: z.string(),\n password: z.string(),\n from: z.string().email(),\n to: z.array(z.string().email()),\n});\n\ntype EmailConfig = z.infer<typeof emailConfig>;\n\nexport const emailNotificationStrategy: NotificationStrategy<EmailConfig> = {\n id: \"email\",\n displayName: \"Email\",\n description: \"Send notifications via email\",\n configVersion: 1,\n configSchema: emailConfig,\n\n async send(config, notification) {\n const transporter = createTransport({\n host: config.smtpHost,\n port: config.smtpPort,\n auth: {\n user: config.username,\n pass: config.password,\n },\n });\n\n await transporter.sendMail({\n from: config.from,\n to: config.to.join(\", \"),\n subject: notification.title,\n text: notification.message,\n html: formatEmailHtml(notification),\n });\n },\n};\n```\n\n### AuthenticationStrategy\n\nIntegrate authentication providers using Better Auth.\n\n#### Interface\n\n```typescript\ninterface AuthenticationStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Configure Better Auth with this strategy */\n configure(config: Config): BetterAuthConfig;\n}\n```\n\n#### Example: OAuth Provider\n\n```typescript\nconst oauthConfig = z.object({\n clientId: z.string(),\n clientSecret: z.string(),\n authorizationUrl: z.string().url(),\n tokenUrl: z.string().url(),\n userInfoUrl: z.string().url(),\n});\n\ntype OAuthConfig = z.infer<typeof oauthConfig>;\n\nexport const oauthStrategy: AuthenticationStrategy<OAuthConfig> = {\n id: \"oauth\",\n displayName: \"OAuth 2.0\",\n description: \"Authenticate using OAuth 2.0\",\n configVersion: 1,\n configSchema: oauthConfig,\n\n configure(config) {\n return {\n socialProviders: {\n custom: {\n clientId: config.clientId,\n clientSecret: config.clientSecret,\n authorizationUrl: config.authorizationUrl,\n tokenUrl: config.tokenUrl,\n userInfoUrl: config.userInfoUrl,\n },\n },\n };\n },\n};\n```\n\n> [!WARNING] Registration Check Requirement\n>\n> 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.\n>\n> Use the typed RPC client to call `auth-backend.getRegistrationStatus()` and verify that `allowRegistration` is `true` before creating any new users. If registration is disabled, throw an appropriate error.\n>\n> **Example:**\n> ```typescript\n> import { coreServices } from \"@checkstack/backend-api\";\n> import { AuthApi } from \"@checkstack/auth-common\";\n>\n> env.registerInit({\n> deps: {\n> rpcClient: coreServices.rpcClient,\n> logger: coreServices.logger,\n> },\n> init: async ({ rpcClient, logger }) => {\n> // In your user sync/creation logic:\n> try {\n> const authClient = rpcClient.forPlugin(AuthApi);\n> const { allowRegistration } = await authClient.getRegistrationStatus();\n> \n> if (!allowRegistration) {\n> throw new Error(\n> \"Registration is disabled. Please contact an administrator.\"\n> );\n> }\n> \n> // Proceed with user creation\n> } catch (error) {\n> logger.warn(\"Failed to check registration status:\", error);\n> throw error;\n> }\n> },\n> });\n> ```\n>\n> This ensures administrators have full control over user registration across all authentication methods. See [Backend Service Communication](/checkstack/developer-guide/backend/services/) for more details on using the RPC client.\n\n## Frontend Extension Points\n\n### Slots\n\nSlots allow plugins to inject UI components into predefined locations. Plugins can either:\n1. Register extensions to **core slots** defined in `@checkstack/frontend-api`\n2. Register extensions to **plugin-defined slots** exported from plugin common packages\n\n#### Core Slots (from `@checkstack/frontend-api`)\n\nCore slots are defined using the `createSlot` utility and exported as `SlotDefinition` objects:\n\n```typescript\nimport {\n DashboardSlot,\n NavbarRightSlot,\n NavbarLeftSlot,\n UserMenuItemsSlot,\n UserMenuItemsBottomSlot,\n} from \"@checkstack/frontend-api\";\n```\n\n#### Plugin-Defined Slots\n\nPlugins 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.\n\n**Example: Catalog plugin exposing slots (from `@checkstack/catalog-common`)**\n\n```typescript\nimport { createSlot } from \"@checkstack/frontend-api\";\nimport type { System } from \"./types\";\n\n// Slot for extending the System Details page\nexport const SystemDetailsSlot = createSlot<{ system: System }>(\n \"plugin.catalog.system-details\"\n);\n\n// Slot for adding actions to the system configuration page\nexport const CatalogSystemActionsSlot = createSlot<{\n systemId: string;\n systemName: string;\n}>(\"plugin.catalog.system-actions\");\n```\n\n##### `CatalogBrowseHealthSlot` (bulk health rollup)\n\nThe catalog browse view surfaces a group-level health rollup without depending on any health provider. It does this through the optional `CatalogBrowseHealthSlot` contract: catalog only **consumes** the slot, and a health provider plugin **fills** it.\n\nThe slot context passes the visible system ids and a callback the filler reports statuses to:\n\n```typescript\nexport type CatalogHealthStatus = \"healthy\" | \"degraded\" | \"unhealthy\";\nexport type CatalogHealthStatuses = Record<string, CatalogHealthStatus>;\n\nexport interface CatalogBrowseHealthSlotContext {\n systemIds: string[];\n onStatuses: (statuses: CatalogHealthStatuses) => void;\n}\n\nexport const CatalogBrowseHealthSlot =\n createSlot<CatalogBrowseHealthSlotContext>(\"plugin.catalog.browse-health\");\n```\n\nContract rules:\n\n- The filler renders nothing visible. It is a headless data boundary that bulk-fetches health for `systemIds` and reports the resolved statuses via `onStatuses`.\n- `CatalogHealthStatus` is catalog's own vocabulary. A filler maps its own status enum into these three values so catalog stays decoupled from the provider's types.\n- A system **absent** from the reported map is treated as `\"unknown\"` by the catalog rollup, never as healthy. This matters because healthy systems emit no per-system badge, so \"all healthy\" can only be derived from the reported data, not from rendered output.\n- When the slot is unfilled (no health provider installed), group headers show member counts only and the health filter is disabled. Catalog remains fully functional.\n\nPer-system badges continue to come from `SystemStateBadgesSlot`; this slot exists only to feed the group-level rollup and the health filter from the underlying status data.\n\nExample filler (the health-provider side owns all cross-plugin coupling):\n\n```tsx\nimport { createSlotExtension } from \"@checkstack/frontend-api\";\nimport { CatalogBrowseHealthSlot } from \"@checkstack/catalog-common\";\n\ncreateSlotExtension(CatalogBrowseHealthSlot, {\n id: \"my-plugin.catalog.browse-health\",\n load: () =>\n import(\"./CatalogBrowseHealthFiller\").then((m) => ({\n default: m.CatalogBrowseHealthFiller,\n })),\n});\n```\n\n##### `SystemSignalsSlot` (dashboard \"needs attention\" overview)\n\nThe dashboard overview lists only the systems that need attention and hides\nhealthy ones. It builds that list entirely from signals reported through the\n`SystemSignalsSlot` contract, so it is **agnostic to which plugins contribute**:\nthe dashboard only consumes the slot, and any plugin (including third-party\nplugins) fills it to add a new kind of per-system state to the overview. Adding\na new signal source requires no dashboard change.\n\nA signal carries everything the overview needs to surface, sort, count, and\ndeep-link the issue:\n\n```typescript\nexport type SystemSignalTone = \"error\" | \"warn\" | \"info\";\n\nexport interface SystemSignal {\n source: string; // stable source id, e.g. \"incident\" - dedupes re-reports\n tone: SystemSignalTone; // drives colour, sort order, and the header counts\n label: string; // short label, e.g. \"Critical incident\"\n detail?: string; // optional context, e.g. the incident title\n href?: string; // deep link to where the issue originates\n since?: string; // ISO start time - shown as \"since\" and used as a tie-break\n iconName?: IconName; // lucide icon name, rendered via DynamicIcon\n}\n\nexport type SystemSignalsMap = Record<string, SystemSignal[]>; // keyed by systemId\n\nexport interface SystemSignalsSlotContext {\n systemIds: string[];\n onSignals: (sourceId: string, signals: SystemSignalsMap) => void;\n}\n\nexport const SystemSignalsSlot = createSlot<SystemSignalsSlotContext>(\n \"plugin.catalog.system-signals\",\n);\n```\n\nContract rules:\n\n- The filler renders nothing visible. It is a headless data boundary that\n bulk-fetches its state for `systemIds` (no N+1) and reports a per-system-id\n signal map via `onSignals`, tagged with its own stable `sourceId`.\n- Re-reporting with the same `sourceId` **replaces** that source's previous\n contribution, so a source that reports an empty map clears its signals.\n- A system **absent** from every source's map has no signals and is hidden from\n the overview (it is healthy). The dashboard derives the \"all healthy\" state,\n the severity counts, and the sort order purely from the reported DATA, never\n from rendered output.\n- Sort order is worst tone first (`error` -> `warn` -> `info`), matching the\n icon-only `StatusBadge` ordering used elsewhere.\n\nEach core reliability plugin (healthcheck, incident, SLO, maintenance, anomaly,\ndependency) ships a filler for this slot. A third-party plugin adds a new signal\ntype the same way:\n\n```tsx\nimport { createSlotExtension } from \"@checkstack/frontend-api\";\nimport { SystemSignalsSlot } from \"@checkstack/catalog-common\";\n\ncreateSlotExtension(SystemSignalsSlot, {\n id: \"my-plugin.dashboard.signals\",\n load: () =>\n import(\"./MySignalsFiller\").then((m) => ({\n default: m.MySignalsFiller,\n })),\n});\n```\n\n#### Registering Extensions to Slots\n\nExtensions use the `slot:` property with a `SlotDefinition` object:\n\n**To a core slot:**\n```typescript\nimport { UserMenuItemsBottomSlot } from \"@checkstack/frontend-api\";\n\nexport const myPlugin = createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n {\n id: \"myplugin.user-menu.account-item\",\n slot: UserMenuItemsBottomSlot,\n component: MyAccountMenuItem,\n },\n ],\n});\n```\n\n> [!NOTE]\n> Primary **navigation** is NOT a user-menu extension. The user menu is\n> account-only (profile, theme, logout); to add a page to the left sidebar, give\n> its route `nav` metadata - see\n> [Frontend Routing](/checkstack/developer-guide/frontend/routing/#sidebar-navigation).\n\n**To a plugin-defined slot:**\n```typescript\nimport { SystemDetailsSlot } from \"@checkstack/catalog-common\";\n\nexport const myPlugin = createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n {\n id: \"myplugin.system-details\",\n slot: SystemDetailsSlot,\n component: MySystemDetailsExtension, // Receives { system: System }\n },\n ],\n});\n```\n\n#### Type-Safe Extension Registration (Recommended)\n\nFor strict typing that infers component props directly from the slot definition, use the `createSlotExtension` helper and `SlotContext` type.\n\n**Using `createSlotExtension` for registration:**\n```typescript\nimport { createFrontendPlugin, createSlotExtension } from \"@checkstack/frontend-api\";\nimport { SystemDetailsSlot, CatalogSystemActionsSlot } from \"@checkstack/catalog-common\";\n\nexport default createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n // Type-safe: component props are inferred from SystemDetailsSlot\n createSlotExtension(SystemDetailsSlot, {\n id: \"myplugin.system-details\",\n component: MySystemDetailsPanel, // Must accept { system: System }\n }),\n createSlotExtension(CatalogSystemActionsSlot, {\n id: \"myplugin.system-actions\",\n component: MySystemAction, // Must accept { systemId: string; systemName: string }\n }),\n ],\n});\n```\n\n**Using `SlotContext` for component typing:**\n```typescript\nimport type { SlotContext } from \"@checkstack/frontend-api\";\nimport { CatalogSystemActionsSlot } from \"@checkstack/catalog-common\";\n\n// Props inferred directly from the slot definition - no manual interface needed!\ntype Props = SlotContext<typeof CatalogSystemActionsSlot>;\n// Equivalent to: { systemId: string; systemName: string }\n\nexport const MySystemAction: React.FC<Props> = ({ systemId, systemName }) => {\n // Full type safety - no casting, no unknown!\n return <Button onClick={() => doSomething(systemId)}>Action for {systemName}</Button>;\n};\n```\n\n> [!TIP]\n> Using `SlotContext` and `createSlotExtension` ensures compile-time type checking. If the slot definition changes, TypeScript will immediately flag any component prop mismatches.\n\n#### Eager `component` vs lazy `load`\n\nEvery extension provides exactly one of `component` or `load`:\n\n- `component` (eager) — bundled with the plugin and registered at load. Use for\n LIGHT, always-rendered contributions (navbar items, user-menu links, status\n badges) where code-splitting would only add a load flash.\n- `load` (lazy) — a `() => import(...).then((m) => ({ default: m.X }))` thunk.\n The framework renders it through `React.lazy` inside a Suspense boundary and a\n per-plugin error boundary, so its chunk is fetched on demand and a failed load\n is contained to that one contribution. Use for HEAVY or page-scoped\n contributions (dashboards, editors, chart panels).\n\n```typescript\ncreateSlotExtension(SystemEditorSlot, {\n id: \"myplugin.system-editor\",\n // Heavy editor → lazy; only loads when the editor slot renders.\n load: () =>\n import(\"./components/MyEditor\").then((m) => ({ default: m.MyEditor })),\n});\n```\n\nIf you read extensions yourself via `useSlotExtensions` (e.g. to build a tab\nbar) instead of `<ExtensionSlot>`, render each one with the `<ExtensionComponent\nextension={ext} context={...} />` helper so both eager and lazy contributions\nare handled uniformly.\n\n#### Typed Metadata on Extensions\n\nSome slots need each extension to declare a static descriptor at registration\ntime — for example, the Infrastructure Settings tab bar needs a label, icon,\nand access rules to render its nav before the tab body is mounted. Pass a\nsecond type argument to `createSlot` to express that contract:\n\n```typescript\nimport { createSlot } from \"@checkstack/frontend-api\";\nimport type { AccessRule } from \"@checkstack/common\";\n\nexport interface InfrastructureTabContext {\n canUpdate: boolean;\n}\n\nexport interface InfrastructureTabMetadata {\n label: string;\n icon: React.ComponentType<{ className?: string }>;\n readAccess: AccessRule;\n manageAccess: AccessRule;\n order?: number;\n}\n\nexport const InfrastructureTabsSlot = createSlot<\n InfrastructureTabContext,\n InfrastructureTabMetadata\n>(\"infrastructure.tabs\");\n```\n\nExtensions for a slot whose metadata type is non-`undefined` must supply a\n`metadata` field; `createSlotExtension` will type-check it:\n\n```typescript\ncreateSlotExtension(InfrastructureTabsSlot, {\n id: \"queue.infrastructure.tab\",\n component: QueueInfrastructureTab,\n metadata: {\n label: \"Queue\",\n icon: Gauge,\n readAccess: queueAccess.settings.read,\n manageAccess: queueAccess.settings.manage,\n order: 10,\n },\n});\n```\n\nConsumers read metadata via `useSlotExtensions`, which subscribes to plugin\nregister/unregister events:\n\n```typescript\nimport { useSlotExtensions } from \"@checkstack/frontend-api\";\n\nconst tabs = useSlotExtensions(InfrastructureTabsSlot);\n// tabs[i].metadata is typed as InfrastructureTabMetadata\n```\n\n`<ExtensionSlot slot={…} context={…} />` remains the right tool when the\nconsumer just needs to render every extension inline. Reach for\n`useSlotExtensions` only when you need metadata, ordering, or per-extension\ngating logic.\n\n#### Example: User Menu Extension\n\nUser menu slots (`UserMenuItemsSlot`, `UserMenuItemsBottomSlot`) receive a `UserMenuItemsContext` with pre-fetched user data for synchronous rendering:\n\n```typescript\ninterface UserMenuItemsContext {\n accessRules: string[]; // Pre-fetched user access rules\n hasCredentialAccount: boolean; // Whether user has credential auth\n}\n```\n\n**Access-gated menu item:**\n```typescript\nimport type { UserMenuItemsContext } from \"@checkstack/frontend-api\";\nimport { qualifyAccessRuleId, resolveRoute } from \"@checkstack/common\";\nimport { access, pluginMetadata, myRoutes } from \"@checkstack/myplugin-common\";\nimport { DropdownMenuItem } from \"@checkstack/ui\";\nimport { Link } from \"react-router-dom\";\nimport { Settings } from \"lucide-react\";\n\nexport const MyPluginMenuItems = ({\n accessRules: userPerms,\n}: UserMenuItemsContext) => {\n const qualifiedId = qualifyAccessRuleId(pluginMetadata, access.myAccess);\n const canAccess ",
|
|
1499
|
+
"content": "## Overview\n\nExtension points enable plugins to provide **pluggable implementations** for core functionality. They follow the **Strategy Pattern**, allowing different implementations to be swapped at runtime.\n\n## Core Concepts\n\n### Extension Point\n\nA **contract** that defines what implementations must provide:\n\n```typescript\ninterface ExtensionPoint<T> {\n id: string;\n T: T; // Phantom type for type safety\n}\n```\n\n### Strategy\n\nAn **implementation** of an extension point:\n\n```typescript\ninterface Strategy {\n id: string;\n displayName: string;\n // ... strategy-specific methods\n}\n```\n\n## Backend Extension Points\n\n### HealthCheckStrategy\n\nImplements custom health check methods.\n\n#### Interface\n\n```typescript\ninterface HealthCheckStrategy<Config = unknown> {\n /** Unique identifier for this strategy */\n id: string;\n\n /** Human-readable name */\n displayName: string;\n\n /** Optional description */\n description?: string;\n\n /** Current version of the configuration schema */\n configVersion: number;\n\n /** Validation schema for the strategy-specific config */\n configSchema: z.ZodType<Config>;\n\n /** Optional migrations for backward compatibility */\n migrations?: MigrationChain<Config>;\n\n /** Execute the health check */\n execute(config: Config): Promise<HealthCheckResult>;\n}\n\ninterface HealthCheckResult {\n status: \"healthy\" | \"unhealthy\" | \"degraded\";\n latency?: number; // ms\n message?: string;\n metadata?: Record<string, unknown>;\n}\n```\n\n#### Example: HTTP Health Check\n\n```typescript\nimport { z } from \"zod\";\nimport { HealthCheckStrategy } from \"@checkstack/backend-api\";\n\nconst httpCheckConfig = z.object({\n url: z.string().url().describe(\"URL to check\"),\n method: z.enum([\"GET\", \"POST\", \"HEAD\"]).default(\"GET\"),\n timeout: z.number().min(100).max(30000).default(5000),\n expectedStatus: z.number().min(100).max(599).default(200),\n headers: z.record(z.string()).optional(),\n});\n\ntype HttpCheckConfig = z.infer<typeof httpCheckConfig>;\n\nexport const httpHealthCheckStrategy: HealthCheckStrategy<HttpCheckConfig> = {\n id: \"http-check\",\n displayName: \"HTTP Health Check\",\n description: \"Check if an HTTP endpoint is responding\",\n configVersion: 1,\n configSchema: httpCheckConfig,\n\n async execute(config: HttpCheckConfig): Promise<HealthCheckResult> {\n const startTime = Date.now();\n\n try {\n const response = await fetch(config.url, {\n method: config.method,\n headers: config.headers,\n signal: AbortSignal.timeout(config.timeout),\n });\n\n const latency = Date.now() - startTime;\n\n if (response.status === config.expectedStatus) {\n return {\n status: \"healthy\",\n latency,\n message: `HTTP ${response.status}`,\n };\n } else {\n return {\n status: \"unhealthy\",\n latency,\n message: `Expected ${config.expectedStatus}, got ${response.status}`,\n };\n }\n } catch (error) {\n return {\n status: \"unhealthy\",\n latency: Date.now() - startTime,\n message: error instanceof Error ? error.message : \"Unknown error\",\n };\n }\n },\n};\n```\n\n#### Registering a Health Check Strategy\n\n```typescript\nimport { healthCheckExtensionPoint } from \"@checkstack/backend-api\";\n\nexport default createBackendPlugin({\n metadata: pluginMetadata,\n register(env) {\n // Get the health check registry\n const registry = env.getExtensionPoint(healthCheckExtensionPoint);\n\n // Register the strategy\n registry.register(httpHealthCheckStrategy);\n },\n});\n```\n\n### ExporterStrategy\n\nExports metrics and data in various formats.\n\n#### Interface\n\n```typescript\ninterface ExporterStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Export type: endpoint or file */\n type: \"endpoint\" | \"file\";\n\n /** For endpoint exporters: register routes */\n registerRoutes?(router: Hono, config: Config): void;\n\n /** For file exporters: generate file */\n generateFile?(config: Config): Promise<{\n filename: string;\n content: string | Buffer;\n mimeType: string;\n }>;\n}\n```\n\n#### Example: Prometheus Exporter\n\n```typescript\nconst prometheusConfig = z.object({\n path: z.string().default(\"/metrics\"),\n includeTimestamps: z.boolean().default(false),\n});\n\ntype PrometheusConfig = z.infer<typeof prometheusConfig>;\n\nexport const prometheusExporter: ExporterStrategy<PrometheusConfig> = {\n id: \"prometheus\",\n displayName: \"Prometheus Metrics\",\n description: \"Export metrics in Prometheus format\",\n configVersion: 1,\n configSchema: prometheusConfig,\n type: \"endpoint\",\n\n registerRoutes(router, config) {\n router.get(config.path, async (c) => {\n const metrics = await collectMetrics();\n const output = formatPrometheus(metrics, config.includeTimestamps);\n return c.text(output, 200, {\n \"Content-Type\": \"text/plain; version=0.0.4\",\n });\n });\n },\n};\n```\n\n#### Example: CSV Exporter\n\n```typescript\nconst csvConfig = z.object({\n includeHeaders: z.boolean().default(true),\n delimiter: z.string().default(\",\"),\n});\n\ntype CsvConfig = z.infer<typeof csvConfig>;\n\nexport const csvExporter: ExporterStrategy<CsvConfig> = {\n id: \"csv\",\n displayName: \"CSV Export\",\n description: \"Export data as CSV file\",\n configVersion: 1,\n configSchema: csvConfig,\n type: \"file\",\n\n async generateFile(config) {\n const data = await fetchData();\n const csv = formatCsv(data, config);\n\n return {\n filename: `export-${Date.now()}.csv`,\n content: csv,\n mimeType: \"text/csv\",\n };\n },\n};\n```\n\n### NotificationStrategy\n\nSend notifications via different channels.\n\n#### Interface\n\n```typescript\ninterface NotificationStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Send a notification */\n send(config: Config, notification: Notification): Promise<void>;\n}\n\ninterface Notification {\n title: string;\n message: string;\n severity: \"info\" | \"warning\" | \"error\" | \"critical\";\n metadata?: Record<string, unknown>;\n}\n```\n\n#### Example: Slack Notification\n\n```typescript\nconst slackConfig = z.object({\n webhookUrl: z.string().url(),\n channel: z.string().optional(),\n username: z.string().default(\"Checkstack\"),\n iconEmoji: z.string().default(\":robot_face:\"),\n});\n\ntype SlackConfig = z.infer<typeof slackConfig>;\n\nexport const slackNotificationStrategy: NotificationStrategy<SlackConfig> = {\n id: \"slack\",\n displayName: \"Slack\",\n description: \"Send notifications to Slack\",\n configVersion: 1,\n configSchema: slackConfig,\n\n async send(config, notification) {\n const color = {\n info: \"#36a64f\",\n warning: \"#ff9900\",\n error: \"#ff0000\",\n critical: \"#990000\",\n }[notification.severity];\n\n await fetch(config.webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n channel: config.channel,\n username: config.username,\n icon_emoji: config.iconEmoji,\n attachments: [\n {\n color,\n title: notification.title,\n text: notification.message,\n fields: Object.entries(notification.metadata || {}).map(\n ([key, value]) => ({\n title: key,\n value: String(value),\n short: true,\n })\n ),\n },\n ],\n }),\n });\n },\n};\n```\n\n#### Example: Email Notification\n\n```typescript\nconst emailConfig = z.object({\n smtpHost: z.string(),\n smtpPort: z.number().default(587),\n username: z.string(),\n password: z.string(),\n from: z.string().email(),\n to: z.array(z.string().email()),\n});\n\ntype EmailConfig = z.infer<typeof emailConfig>;\n\nexport const emailNotificationStrategy: NotificationStrategy<EmailConfig> = {\n id: \"email\",\n displayName: \"Email\",\n description: \"Send notifications via email\",\n configVersion: 1,\n configSchema: emailConfig,\n\n async send(config, notification) {\n const transporter = createTransport({\n host: config.smtpHost,\n port: config.smtpPort,\n auth: {\n user: config.username,\n pass: config.password,\n },\n });\n\n await transporter.sendMail({\n from: config.from,\n to: config.to.join(\", \"),\n subject: notification.title,\n text: notification.message,\n html: formatEmailHtml(notification),\n });\n },\n};\n```\n\n### AuthenticationStrategy\n\nIntegrate authentication providers using Better Auth.\n\n#### Interface\n\n```typescript\ninterface AuthenticationStrategy<Config = unknown> {\n id: string;\n displayName: string;\n description?: string;\n configVersion: number;\n configSchema: z.ZodType<Config>;\n migrations?: MigrationChain<Config>;\n\n /** Configure Better Auth with this strategy */\n configure(config: Config): BetterAuthConfig;\n}\n```\n\n#### Example: OAuth Provider\n\n```typescript\nconst oauthConfig = z.object({\n clientId: z.string(),\n clientSecret: z.string(),\n authorizationUrl: z.string().url(),\n tokenUrl: z.string().url(),\n userInfoUrl: z.string().url(),\n});\n\ntype OAuthConfig = z.infer<typeof oauthConfig>;\n\nexport const oauthStrategy: AuthenticationStrategy<OAuthConfig> = {\n id: \"oauth\",\n displayName: \"OAuth 2.0\",\n description: \"Authenticate using OAuth 2.0\",\n configVersion: 1,\n configSchema: oauthConfig,\n\n configure(config) {\n return {\n socialProviders: {\n custom: {\n clientId: config.clientId,\n clientSecret: config.clientSecret,\n authorizationUrl: config.authorizationUrl,\n tokenUrl: config.tokenUrl,\n userInfoUrl: config.userInfoUrl,\n },\n },\n };\n },\n};\n```\n\n> [!WARNING] Registration Check Requirement\n>\n> 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.\n>\n> Use the typed RPC client to call `auth-backend.getRegistrationStatus()` and verify that `allowRegistration` is `true` before creating any new users. If registration is disabled, throw an appropriate error.\n>\n> **Example:**\n> ```typescript\n> import { coreServices } from \"@checkstack/backend-api\";\n> import { AuthApi } from \"@checkstack/auth-common\";\n>\n> env.registerInit({\n> deps: {\n> rpcClient: coreServices.rpcClient,\n> logger: coreServices.logger,\n> },\n> init: async ({ rpcClient, logger }) => {\n> // In your user sync/creation logic:\n> try {\n> const authClient = rpcClient.forPlugin(AuthApi);\n> const { allowRegistration } = await authClient.getRegistrationStatus();\n> \n> if (!allowRegistration) {\n> throw new Error(\n> \"Registration is disabled. Please contact an administrator.\"\n> );\n> }\n> \n> // Proceed with user creation\n> } catch (error) {\n> logger.warn(\"Failed to check registration status:\", error);\n> throw error;\n> }\n> },\n> });\n> ```\n>\n> This ensures administrators have full control over user registration across all authentication methods. See [Backend Service Communication](/checkstack/developer-guide/backend/services/) for more details on using the RPC client.\n\n## Frontend Extension Points\n\n### Slots\n\nSlots allow plugins to inject UI components into predefined locations. Plugins can either:\n1. Register extensions to **core slots** defined in `@checkstack/frontend-api`\n2. Register extensions to **plugin-defined slots** exported from plugin common packages\n\n#### Core Slots (from `@checkstack/frontend-api`)\n\nCore slots are defined using the `createSlot` utility and exported as `SlotDefinition` objects:\n\n```typescript\nimport {\n DashboardSlot,\n NavbarRightSlot,\n NavbarLeftSlot,\n UserMenuItemsSlot,\n UserMenuItemsBottomSlot,\n} from \"@checkstack/frontend-api\";\n```\n\n#### Plugin-Defined Slots\n\nPlugins 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.\n\n**Example: Catalog plugin exposing slots (from `@checkstack/catalog-common`)**\n\n```typescript\nimport { createSlot } from \"@checkstack/frontend-api\";\nimport type { System } from \"./types\";\n\n// Slot for extending the System Details page\nexport const SystemDetailsSlot = createSlot<{ system: System }>(\n \"plugin.catalog.system-details\"\n);\n\n// Slot for adding actions to the system configuration page\nexport const CatalogSystemActionsSlot = createSlot<{\n systemId: string;\n systemName: string;\n}>(\"plugin.catalog.system-actions\");\n```\n\n##### `CatalogBrowseHealthSlot` (bulk health rollup)\n\nThe catalog browse view surfaces a group-level health rollup without depending on any health provider. It does this through the optional `CatalogBrowseHealthSlot` contract: catalog only **consumes** the slot, and a health provider plugin **fills** it.\n\nThe slot context passes the visible system ids and a callback the filler reports statuses to:\n\n```typescript\nexport type CatalogHealthStatus = \"healthy\" | \"degraded\" | \"unhealthy\";\nexport type CatalogHealthStatuses = Record<string, CatalogHealthStatus>;\n\nexport interface CatalogBrowseHealthSlotContext {\n systemIds: string[];\n onStatuses: (statuses: CatalogHealthStatuses) => void;\n}\n\nexport const CatalogBrowseHealthSlot =\n createSlot<CatalogBrowseHealthSlotContext>(\"plugin.catalog.browse-health\");\n```\n\nContract rules:\n\n- The filler renders nothing visible. It is a headless data boundary that bulk-fetches health for `systemIds` and reports the resolved statuses via `onStatuses`.\n- `CatalogHealthStatus` is catalog's own vocabulary. A filler maps its own status enum into these three values so catalog stays decoupled from the provider's types.\n- A system **absent** from the reported map is treated as `\"unknown\"` by the catalog rollup, never as healthy. This matters because healthy systems emit no per-system badge, so \"all healthy\" can only be derived from the reported data, not from rendered output.\n- When the slot is unfilled (no health provider installed), group headers show member counts only and the health filter is disabled. Catalog remains fully functional.\n\nPer-system badges continue to come from `SystemStateBadgesSlot`; this slot exists only to feed the group-level rollup and the health filter from the underlying status data.\n\nExample filler (the health-provider side owns all cross-plugin coupling):\n\n```tsx\nimport { createSlotExtension } from \"@checkstack/frontend-api\";\nimport { CatalogBrowseHealthSlot } from \"@checkstack/catalog-common\";\n\ncreateSlotExtension(CatalogBrowseHealthSlot, {\n id: \"my-plugin.catalog.browse-health\",\n load: () =>\n import(\"./CatalogBrowseHealthFiller\").then((m) => ({\n default: m.CatalogBrowseHealthFiller,\n })),\n});\n```\n\n##### `SystemSignalsSlot` (dashboard \"needs attention\" overview)\n\nThe dashboard overview lists only the systems that need attention and hides\nhealthy ones. It builds that list entirely from signals reported through the\n`SystemSignalsSlot` contract, so it is **agnostic to which plugins contribute**:\nthe dashboard only consumes the slot, and any plugin (including third-party\nplugins) fills it to add a new kind of per-system state to the overview. Adding\na new signal source requires no dashboard change.\n\nA signal carries everything the overview needs to surface, sort, count, and\ndeep-link the issue:\n\n```typescript\nexport type SystemSignalTone = \"error\" | \"warn\" | \"info\";\n\nexport interface SystemSignal {\n source: string; // stable source id, e.g. \"incident\" - dedupes re-reports\n tone: SystemSignalTone; // drives colour, sort order, and the header counts\n label: string; // short label, e.g. \"Critical incident\"\n detail?: string; // optional context, e.g. the incident title\n href?: string; // deep link to where the issue originates\n accessRule?: AccessRule; // rule required to open href; see contract rules below\n since?: string; // ISO start time - shown as \"since\" and used as a tie-break\n iconName?: IconName; // lucide icon name, rendered via DynamicIcon\n}\n\nexport type SystemSignalsMap = Record<string, SystemSignal[]>; // keyed by systemId\n\nexport interface SystemSignalsSlotContext {\n systemIds: string[];\n onSignals: (sourceId: string, signals: SystemSignalsMap) => void;\n}\n\nexport const SystemSignalsSlot = createSlot<SystemSignalsSlotContext>(\n \"plugin.catalog.system-signals\",\n);\n```\n\nContract rules:\n\n- The filler renders nothing visible. It is a headless data boundary that\n bulk-fetches its state for `systemIds` (no N+1) and reports a per-system-id\n signal map via `onSignals`, tagged with its own stable `sourceId`.\n- Re-reporting with the same `sourceId` **replaces** that source's previous\n contribution, so a source that reports an empty map clears its signals.\n- A system **absent** from every source's map has no signals and is hidden from\n the overview (it is healthy). The dashboard derives the \"all healthy\" state,\n the severity counts, and the sort order purely from the reported DATA, never\n from rendered output.\n- Sort order is worst tone first (`error` -> `warn` -> `info`), matching the\n icon-only `StatusBadge` ordering used elsewhere.\n- Set `accessRule` whenever `href` points at a permission-gated page. The\n dashboard renders the signal as a LINK only if the current user satisfies that\n rule, and as plain TEXT otherwise - so a user is never offered a deep link that\n would immediately hit \"Access Denied\". Omit `accessRule` only when the target\n needs no specific permission (the link is then always rendered).\n\nEach core reliability plugin (healthcheck, incident, SLO, maintenance, anomaly,\ndependency) ships a filler for this slot. A third-party plugin adds a new signal\ntype the same way:\n\n```tsx\nimport { createSlotExtension } from \"@checkstack/frontend-api\";\nimport { SystemSignalsSlot } from \"@checkstack/catalog-common\";\n\ncreateSlotExtension(SystemSignalsSlot, {\n id: \"my-plugin.dashboard.signals\",\n load: () =>\n import(\"./MySignalsFiller\").then((m) => ({\n default: m.MySignalsFiller,\n })),\n});\n```\n\n#### Registering Extensions to Slots\n\nExtensions use the `slot:` property with a `SlotDefinition` object:\n\n**To a core slot:**\n```typescript\nimport { UserMenuItemsBottomSlot } from \"@checkstack/frontend-api\";\n\nexport const myPlugin = createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n {\n id: \"myplugin.user-menu.account-item\",\n slot: UserMenuItemsBottomSlot,\n component: MyAccountMenuItem,\n },\n ],\n});\n```\n\n> [!NOTE]\n> Primary **navigation** is NOT a user-menu extension. The user menu is\n> account-only (profile, theme, logout); to add a page to the left sidebar, give\n> its route `nav` metadata - see\n> [Frontend Routing](/checkstack/developer-guide/frontend/routing/#sidebar-navigation).\n\n**To a plugin-defined slot:**\n```typescript\nimport { SystemDetailsSlot } from \"@checkstack/catalog-common\";\n\nexport const myPlugin = createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n {\n id: \"myplugin.system-details\",\n slot: SystemDetailsSlot,\n component: MySystemDetailsExtension, // Receives { system: System }\n },\n ],\n});\n```\n\n#### Type-Safe Extension Registration (Recommended)\n\nFor strict typing that infers component props directly from the slot definition, use the `createSlotExtension` helper and `SlotContext` type.\n\n**Using `createSlotExtension` for registration:**\n```typescript\nimport { createFrontendPlugin, createSlotExtension } from \"@checkstack/frontend-api\";\nimport { SystemDetailsSlot, CatalogSystemActionsSlot } from \"@checkstack/catalog-common\";\n\nexport default createFrontendPlugin({\n name: \"myplugin-frontend\",\n extensions: [\n // Type-safe: component props are inferred from SystemDetailsSlot\n createSlotExtension(SystemDetailsSlot, {\n id: \"myplugin.system-details\",\n component: MySystemDetailsPanel, // Must accept { system: System }\n }),\n createSlotExtension(CatalogSystemActionsSlot, {\n id: \"myplugin.system-actions\",\n component: MySystemAction, // Must accept { systemId: string; systemName: string }\n }),\n ],\n});\n```\n\n**Using `SlotContext` for component typing:**\n```typescript\nimport type { SlotContext } from \"@checkstack/frontend-api\";\nimport { CatalogSystemActionsSlot } from \"@checkstack/catalog-common\";\n\n// Props inferred directly from the slot definition - no manual interface needed!\ntype Props = SlotContext<typeof CatalogSystemActionsSlot>;\n// Equivalent to: { systemId: string; systemName: string }\n\nexport const MySystemAction: React.FC<Props> = ({ systemId, systemName }) => {\n // Full type safety - no casting, no unknown!\n return <Button onClick={() => doSomething(systemId)}>Action for {systemName}</Button>;\n};\n```\n\n> [!TIP]\n> Using `SlotContext` and `createSlotExtension` ensures compile-time type checking. If the slot definition changes, TypeScript will immediately flag any component prop mismatches.\n\n#### Eager `component` vs lazy `load`\n\nEvery extension provides exactly one of `component` or `load`:\n\n- `component` (eager) — bundled with the plugin and registered at load. Use for\n LIGHT, always-rendered contributions (navbar items, user-menu links, status\n badges) where code-splitting would only add a load flash.\n- `load` (lazy) — a `() => import(...).then((m) => ({ default: m.X }))` thunk.\n The framework renders it through `React.lazy` inside a Suspense boundary and a\n per-plugin error boundary, so its chunk is fetched on demand and a failed load\n is contained to that one contribution. Use for HEAVY or page-scoped\n contributions (dashboards, editors, chart panels).\n\n```typescript\ncreateSlotExtension(SystemEditorSlot, {\n id: \"myplugin.system-editor\",\n // Heavy editor → lazy; only loads when the editor slot renders.\n load: () =>\n import(\"./components/MyEditor\").then((m) => ({ default: m.MyEditor })),\n});\n```\n\nIf you read extensions yourself via `useSlotExtensions` (e.g. to build a tab\nbar) instead of `<ExtensionSlot>`, render each one with the `<ExtensionComponent\nextension={ext} context={...} />` helper so both eager and lazy contributions\nare handled uniformly.\n\n#### Typed Metadata on Extensions\n\nSome slots need each extension to declare a static descriptor at registration\ntime — for example, the Infrastructure Settings tab bar needs a label, icon,\nand access rules to render its nav before the tab body is mounted. Pass a\nsecond type argument to `createSlot` to express that contract:\n\n```typescript\nimport { createSlot } from \"@checkstack/frontend-api\";\nimport type { AccessRule } from \"@checkstack/common\";\n\nexport interface InfrastructureTabContext {\n canUpdate: boolean;\n}\n\nexport interface InfrastructureTabMetadata {\n label: string;\n icon: React.ComponentType<{ className?: string }>;\n readAccess: AccessRule;\n manageAccess: AccessRule;\n order?: number;\n}\n\nexport const InfrastructureTabsSlot = createSlot<\n InfrastructureTabContext,\n InfrastructureTabMetadata\n>(\"infrastructure.tabs\");\n```\n\nExtensions for a slot whose metadata type is non-`undefined` must supply a\n`metadata` field; `createSlotExtension` will type-check it:\n\n```typescript\ncreateSlotExtension(InfrastructureTabsSlot, {\n id: \"queue.infrastructure.tab\",\n component: QueueInfrastructureTab,\n metadata: {\n label: \"Queue\",\n icon: Gauge,\n readAccess: queueAccess.settings.read,\n manageAccess: queueAccess.settings.manage,\n order: 10,\n },\n});\n```\n\nConsumers read metadata via `useSlotExtensions`, which subscribes to plugin\nregister/unregister events:\n\n```typescript\nimport { useSlotExtensions } from \"@checkstack/frontend-api\";\n\nconst tabs = useSlotExtensions(InfrastructureTabsSlot);\n// tabs[i].metadata is typed as InfrastructureTabMetadata\n```\n\n`<ExtensionSlot slot={…} context={…} />` remains the right tool when the\nconsumer just needs to render every extension inline. Reach for\n`useSlotExtensions` only when you need metadata, ordering, or per-extension\ngating logic.\n\n#### Example: User Menu Extension\n\nUser menu slots (`UserMenuItemsSlot`, `UserMenuItemsBottomSlot`) receive a `UserMenuItemsContext` with pre-fetched user data for synchronous rendering:\n\n```typescript\ninterface UserMenuItemsContext {\n accessRules: string[]; // Pre-fetched user access rules\n hasCredentialAccount: boolean; // Whether user has credential auth\n}\n```\n\n**Access-gated menu item:**\n```typescript\nimport type { UserMenuItemsContext } from \"@checkstack/frontend-api\";\nimpor",
|
|
1500
1500
|
"truncated": true
|
|
1501
1501
|
},
|
|
1502
1502
|
{
|
|
@@ -1653,7 +1653,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
1653
1653
|
"Auto-Prefixing",
|
|
1654
1654
|
"Best Practices"
|
|
1655
1655
|
],
|
|
1656
|
-
"content": "This guide covers the routing system for frontend plugins in Checkstack.\n\n## Route Definition Pattern\n\nRoutes are defined in **common packages** using `createRoutes`, which establishes a contract between the common package (which defines the routes) and the frontend plugin (which provides the components).\n\n### Defining Routes (Common Package)\n\n```typescript\n// In your-plugin-common/src/routes.ts\nimport { createRoutes } from \"@checkstack/common\";\n\nexport const yourPluginRoutes = createRoutes(\"your-plugin\", {\n home: \"/\",\n config: \"/config\",\n detail: \"/detail/:id\", // Path parameters are supported\n});\n```\n\nExport from your index:\n```typescript\n// In your-plugin-common/src/index.ts\nexport { yourPluginRoutes } from \"./routes\";\n```\n\n### Using Routes (Frontend Plugin)\n\nEach route declares a `load` thunk that imports its page module. The framework\ncode-splits the page and wraps it in a Suspense boundary plus a per-plugin error\nboundary, so the page's JavaScript is fetched on navigation (never in the\ninitial app load) and a page that fails to load degrades gracefully instead of\ncrashing the shell. Plugins do NOT call `React.lazy` themselves.\n\n```tsx\n// In your-plugin-frontend/src/index.tsx\nimport { createFrontendPlugin } from \"@checkstack/frontend-api\";\nimport { yourPluginRoutes, pluginMetadata, yourPluginAccess } from \"@checkstack/your-plugin-common\";\n\nexport default createFrontendPlugin({\n metadata: pluginMetadata,\n routes: [\n {\n route: yourPluginRoutes.routes.home,\n // `load` returns the page module. For a named export, map it to `default`:\n load: () => import(\"./pages/HomePage\").then((m) => ({ default: m.HomePage })),\n },\n {\n route: yourPluginRoutes.routes.config,\n load: () => import(\"./pages/ConfigPage\").then((m) => ({ default: m.ConfigPage })),\n accessRule: yourPluginAccess.manage,\n },\n {\n route: yourPluginRoutes.routes.detail,\n load: () => import(\"./pages/DetailPage\").then((m) => ({ default: m.DetailPage })),\n },\n ],\n});\n```\n\n> [!NOTE]\n> A route may instead provide an eager `element: <Page />` (mutually exclusive\n> with `load`). Reserve this for the rare page that must paint without a chunk\n> fetch - e.g. the login page on the unauthenticated critical path. Everything\n> else should use `load`.\n\n## Sidebar navigation\n\nThe left sidebar is the app's primary navigation. A route opts into it by adding\n`nav` metadata - there is no separate nav registry, and the user menu is\naccount-only (profile, theme, logout). Routes without `nav` are still reachable\n(deep links, detail pages) but are not listed in the sidebar.\n\n```tsx\nimport { Activity } from \"lucide-react\";\n\n{\n route: yourPluginRoutes.routes.config,\n load: () => import(\"./pages/ConfigPage\").then((m) => ({ default: m.ConfigPage })),\n title: \"Health Checks\",\n accessRule: yourPluginAccess.configuration.manage,\n nav: {\n group: \"Reliability\", // section heading (see canonical groups below)\n icon: Activity, // any lucide-react icon (or ComponentType<{className?}>)\n // label defaults to the route `title`; set to override.\n // order defaults to 0 (lower sorts first within the group).\n // accessRule defaults to the route's accessRule; override to show the entry\n // on a BROADER rule than the page needs (e.g. nav on `read`, page on `manage`).\n accessRule: yourPluginAccess.configuration.read,\n },\n},\n```\n\nThe sidebar filters entries by the user's access rules (via the same check as\npage guards, so nav visibility matches page accessibility), groups them, and\nhighlights the active route. Canonical group order: **Workspace
|
|
1656
|
+
"content": "This guide covers the routing system for frontend plugins in Checkstack.\n\n## Route Definition Pattern\n\nRoutes are defined in **common packages** using `createRoutes`, which establishes a contract between the common package (which defines the routes) and the frontend plugin (which provides the components).\n\n### Defining Routes (Common Package)\n\n```typescript\n// In your-plugin-common/src/routes.ts\nimport { createRoutes } from \"@checkstack/common\";\n\nexport const yourPluginRoutes = createRoutes(\"your-plugin\", {\n home: \"/\",\n config: \"/config\",\n detail: \"/detail/:id\", // Path parameters are supported\n});\n```\n\nExport from your index:\n```typescript\n// In your-plugin-common/src/index.ts\nexport { yourPluginRoutes } from \"./routes\";\n```\n\n### Using Routes (Frontend Plugin)\n\nEach route declares a `load` thunk that imports its page module. The framework\ncode-splits the page and wraps it in a Suspense boundary plus a per-plugin error\nboundary, so the page's JavaScript is fetched on navigation (never in the\ninitial app load) and a page that fails to load degrades gracefully instead of\ncrashing the shell. Plugins do NOT call `React.lazy` themselves.\n\n```tsx\n// In your-plugin-frontend/src/index.tsx\nimport { createFrontendPlugin } from \"@checkstack/frontend-api\";\nimport { yourPluginRoutes, pluginMetadata, yourPluginAccess } from \"@checkstack/your-plugin-common\";\n\nexport default createFrontendPlugin({\n metadata: pluginMetadata,\n routes: [\n {\n route: yourPluginRoutes.routes.home,\n // `load` returns the page module. For a named export, map it to `default`:\n load: () => import(\"./pages/HomePage\").then((m) => ({ default: m.HomePage })),\n },\n {\n route: yourPluginRoutes.routes.config,\n load: () => import(\"./pages/ConfigPage\").then((m) => ({ default: m.ConfigPage })),\n accessRule: yourPluginAccess.manage,\n },\n {\n route: yourPluginRoutes.routes.detail,\n load: () => import(\"./pages/DetailPage\").then((m) => ({ default: m.DetailPage })),\n },\n ],\n});\n```\n\n> [!NOTE]\n> A route may instead provide an eager `element: <Page />` (mutually exclusive\n> with `load`). Reserve this for the rare page that must paint without a chunk\n> fetch - e.g. the login page on the unauthenticated critical path. Everything\n> else should use `load`.\n\n## Sidebar navigation\n\nThe left sidebar is the app's primary navigation. A route opts into it by adding\n`nav` metadata - there is no separate nav registry, and the user menu is\naccount-only (profile, theme, logout). Routes without `nav` are still reachable\n(deep links, detail pages) but are not listed in the sidebar.\n\n```tsx\nimport { Activity } from \"lucide-react\";\n\n{\n route: yourPluginRoutes.routes.config,\n load: () => import(\"./pages/ConfigPage\").then((m) => ({ default: m.ConfigPage })),\n title: \"Health Checks\",\n accessRule: yourPluginAccess.configuration.manage,\n nav: {\n group: \"Reliability\", // section heading (see canonical groups below)\n icon: Activity, // any lucide-react icon (or ComponentType<{className?}>)\n // label defaults to the route `title`; set to override.\n // order defaults to 0 (lower sorts first within the group).\n // accessRule defaults to the route's accessRule; override to show the entry\n // on a BROADER rule than the page needs (e.g. nav on `read`, page on `manage`).\n accessRule: yourPluginAccess.configuration.read,\n },\n},\n```\n\nThe sidebar filters entries by the user's access rules (via the same check as\npage guards, so nav visibility matches page accessibility), groups them, and\nhighlights the active route. A group whose every entry is filtered out is not\nrendered. Canonical group order: **Workspace**, **Reliability**, **Automation**,\n**Configuration**, **Documentation**; unknown groups are appended alphabetically.\n\nFor entries whose visibility cannot be expressed as one static `accessRule`, add\na dynamic `nav.isVisible` predicate. It receives the user's `accessRules` (rule\nids) and `isAuthenticated`, and is evaluated IN ADDITION to `accessRule` (both\nmust pass). Use it when visibility depends on runtime contributions or on auth\nstate rather than a single rule:\n\n```ts\nnav: {\n group: \"Configuration\",\n icon: Server,\n // Show only when the user can read at least one tab contributed by other\n // plugins (the Infrastructure page aggregates them via a slot):\n isVisible: ({ accessRules }) =>\n pluginRegistry\n .getExtensions(InfrastructureTabsSlot.id)\n .some((ext) => isAccessRuleSatisfied(accessRules, ext.metadata.readAccess)),\n // Or, for a per-user page that needs a login but no specific rule:\n // isVisible: ({ isAuthenticated }) => isAuthenticated,\n},\n```\n\nFor gating buttons/links INSIDE a page on auth state (not a specific rule), use\n`accessApi.useIsAuthenticated()` (alongside `accessApi.useAccess(rule)`).\n\n> [!NOTE]\n> `nav.icon` is a component (`React.ComponentType<{ className?: string }>`), so\n> lucide-react icons work directly. Keep it imported in the plugin's\n> `index.tsx`, alongside the route registration.\n\n## Route Resolution\n\nRoutes can be resolved using `resolveRoute` from `@checkstack/common`:\n\n### In Components\n```tsx\nimport { resolveRoute } from \"@checkstack/common\";\nimport { catalogRoutes } from \"@checkstack/catalog-common\";\n\n// Simple route\nconst configPath = resolveRoute(catalogRoutes.routes.config);\n// Returns: \"/catalog/config\"\n\n// With parameters\nconst detailPath = resolveRoute(catalogRoutes.routes.systemDetail, { systemId: \"abc-123\" });\n// Returns: \"/catalog/system/abc-123\"\n```\n\n### Using the Hook\n```tsx\nimport { usePluginRoute } from \"@checkstack/frontend-api\";\nimport { maintenanceRoutes } from \"@checkstack/maintenance-common\";\n\nfunction MyComponent() {\n const getRoute = usePluginRoute();\n \n return (\n <Link to={getRoute(maintenanceRoutes.routes.config)}>\n Maintenances\n </Link>\n );\n}\n```\n\n## Runtime Validation\n\nThe plugin registry automatically validates that route `pluginId` matches the frontend plugin name. For example, if a plugin named `maintenance-frontend` registers a route with `pluginId: \"maintenence\"` (typo), an error is thrown:\n\n```\n❌ Route pluginId mismatch: route \"maintenence.config\" has pluginId \"maintenence\" \nbut plugin is \"maintenance-frontend\" (base: \"maintenance\")\n```\n\nThis ensures consistency between common package definitions and frontend plugins.\n\n## Auto-Prefixing\n\nAll routes are automatically prefixed with `/{pluginId}`:\n\n- Route path `/config` in plugin `maintenance` → `/maintenance/config`\n- Route path `/` in plugin `catalog` → `/catalog/`\n- Route path `/system/:systemId` in plugin `catalog` → `/catalog/system/:systemId`\n\n## Best Practices\n\n1. **Define routes in common packages** - This allows both frontend and backend to share route definitions.\n\n2. **Use `resolveRoute` for links** - Instead of hardcoding paths, use `resolveRoute` to get the full path.\n\n3. **Use path parameters** - Define dynamic segments with `:paramName` syntax for type-safe parameter substitution.\n\n4. **Export routes from common index** - Make routes easily importable.",
|
|
1657
1657
|
"truncated": false
|
|
1658
1658
|
},
|
|
1659
1659
|
{
|
|
@@ -3019,4 +3019,4 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
3019
3019
|
];
|
|
3020
3020
|
|
|
3021
3021
|
/** A content hash of the source tree, so a CI check can detect drift. */
|
|
3022
|
-
export const DOCS_INDEX_HASH = "
|
|
3022
|
+
export const DOCS_INDEX_HASH = "a37429cd2e6f71d57ce52a8084d890e51b47d6c5e662a6ab576e4a05875528d8";
|
package/src/projection.test.ts
CHANGED
|
@@ -5,7 +5,9 @@ import type { AnyContractProcedure } from "@orpc/contract";
|
|
|
5
5
|
import { buildProjectedTool } from "./projection";
|
|
6
6
|
|
|
7
7
|
const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
|
|
8
|
-
const incidentRead = access("incident", "read", "View incidents"
|
|
8
|
+
const incidentRead = access("incident", "read", "View incidents", {
|
|
9
|
+
pluginId: sourcePluginMetadata.pluginId,
|
|
10
|
+
});
|
|
9
11
|
|
|
10
12
|
// A realistic contract procedure with access metadata + an input schema.
|
|
11
13
|
const listIncidents = proc({
|
|
@@ -8,7 +8,9 @@ import { createRegistryExtensionPoints } from "./registry-wiring";
|
|
|
8
8
|
import type { RegisteredAiTool } from "./tool-registry";
|
|
9
9
|
|
|
10
10
|
const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
|
|
11
|
-
const incidentRead = access("incident", "read", "View incidents"
|
|
11
|
+
const incidentRead = access("incident", "read", "View incidents", {
|
|
12
|
+
pluginId: sourcePluginMetadata.pluginId,
|
|
13
|
+
});
|
|
12
14
|
const listIncidents = proc({
|
|
13
15
|
operationType: "query",
|
|
14
16
|
userType: "authenticated",
|