Skip to content

Plugin Architecture Overview

Checkstack is built on a pluggable architecture that enables extensibility, modularity, and flexible deployment options. Everything beyond the core framework is implemented as a plugin, allowing the system to scale from monolithic deployments to distributed microservices.

Plugins MUST be registerable at runtime. This design enables:

  • Loading plugins from remote sources without code changes
  • Hot-swapping plugins during development
  • Dynamic feature enablement based on deployment needs

The platform supports four install sources, all going through a discriminated PluginSource union and a per-source PluginInstaller:

SourceUse case
npmPublic or private npm registry (configurable)
tarballUploaded .tgz (filesystem analogue)
githubGitHub release asset (.tgz packed by our CLI)
catalogCurated marketplace (stub - coming soon)

Plugin tarballs (single package or --bundle-mode multi-package) are persisted in plugin_artifacts (Postgres bytea). A freshly spun replica recovers every runtime-installed plugin from this table at boot - no re-fetch from the original source is needed for replicas to come up.

For plugin authors: see Plugin Distribution & Packing for the developer-facing guide on packing, bundles, npm/GitHub/tarball distribution, and the bunx @checkstack/scripts@latest plugin-pack CLI.

Plugins register themselves with the core application through well-defined interfaces:

  • Backend plugins register via BackendPluginRegistry
  • Frontend plugins register via FrontendPlugin interface

The core calls plugin registration functions, not the other way around.

3. Secure Service-to-Service Communication

Section titled “3. Secure Service-to-Service Communication”

All plugin-to-plugin communication happens via:

  • HTTPS for transport security
  • Signed JWTs for authentication
  • Configured secrets for token signing

This ensures security even in distributed deployments.

Each plugin is a standalone npm package that can:

  • Run independently
  • Be deployed as part of a monolith
  • Be deployed as a separate microservice
  • Share code through common packages
checkstack/
├── core/
│ ├── backend/ # Core backend framework
│ ├── frontend/ # Core frontend framework
│ ├── backend-api/ # Backend plugin API
│ ├── frontend-api/ # Frontend plugin API
│ ├── common/ # Shared core types
│ ├── ui/ # Shared UI components
│ │
│ ├── auth-*/ # Authentication (essential)
│ ├── catalog-*/ # Entity management (essential)
│ ├── notification-*/ # Notifications (essential)
│ ├── healthcheck-*/ # Health monitoring (essential)
│ ├── satellite-*/ # Remote satellite agents (essential)
│ ├── queue-*/ # Queue abstraction (essential)
│ └── theme-*/ # UI theming (essential)
└── plugins/ # Replaceable providers only
├── auth-github-backend/ # GitHub OAuth provider
├── auth-credential-backend/ # Username/password auth
├── auth-ldap-backend/ # LDAP auth provider
├── queue-bullmq-*/ # BullMQ implementation
├── queue-memory-*/ # In-memory implementation
└── healthcheck-http-backend/ # HTTP health strategy

Note: See Packages vs Plugins Architecture for decision criteria on when to create a package vs a plugin.

Checkstack uses a strict package type system to maintain clean architecture:

Package TypeSuffix/PatternPurposeCan Depend On
Backend-backendREST APIs, business logic, databaseBackend packages, common packages
Frontend-frontendUI components, pages, routingFrontend packages, common packages
Common-commonShared types, access rules, constantsCommon packages only
Node-nodeBackend-only shared codeBackend packages, common packages
React-reactFrontend-only shared componentsFrontend packages, common packages

These rules are automatically enforced by the dependency linter:

  • Common → Common only
  • Frontend → Frontend or Common
  • Backend → Backend or Common
  • Common → Backend or Frontend (FORBIDDEN)
  • Frontend → Backend (FORBIDDEN)

See dependency-linter.md for details.

The package-type rule above (backend / frontend / common) is one axis. The other axis is how general a package is, and dependencies flow one way only: from the specific to the general. A more-general package must never import a more-specific one. There are three generality layers, most general at the bottom:

  1. Foundation (most general): @checkstack/common, backend-api, frontend-api, @checkstack/ui, and the shared *-api / *-utils helpers.
  2. Platform / host plugins: the cross-cutting mechanisms other plugins plug into - auth-*, automation-backend, status-page-backend, integration-backend, and so on. These own the extension points.
  3. Domain / capability plugins (most specific): the concrete features - catalog-*, healthcheck-*, incident-*, slo-*, and everything under plugins/.

A package may depend on its own layer and any layer below it, never a layer above it. The thin host loads every plugin at runtime; the arrows show what each layer is allowed to depend on.

flowchart TD
    Host(["Thin host · core<br/>loads every plugin at runtime"])

    subgraph domain ["Domain / capability plugins (most specific)"]
      Dom["catalog-* · healthcheck-* · incident-* · slo-* ..."]
    end
    subgraph platform ["Platform / host plugins"]
      Plat["auth-* · automation-backend · status-page-backend<br/>(own the extension points)"]
    end
    subgraph foundation ["Foundation (most general)"]
      Found["common · backend-api · frontend-api · ui"]
    end

    Host -. loads .-> domain
    Host -. loads .-> platform

    Dom -->|"depends on"| Plat
    Plat -->|"depends on"| Found
    Dom -->|"depends on"| Found

    Plat -->|"1 · DEFINES extension point"| EP[["e.g. statusWidgetTypeExtensionPoint"]]
    Dom -->|"2 · CONTRIBUTES implementation"| EP

So when a platform package needs domain data or behaviour, it must not reach up into the domain plugin. Instead the platform DEFINES an extension point and the owning domain plugin CONTRIBUTES the implementation by registering against it. The domain plugin imports the platform; the platform stays ignorant of the domain. Registration is buffered behind the extension point, so plugin load order does not matter. statusWidgetTypeExtensionPoint is a worked example: healthcheck, incident, and maintenance contribute their status-page widgets, so status-page-backend depends only on backend-api, common, and its own status-page-common.

Backend plugins use a two-phase initialization to ensure cross-plugin communication works correctly:

graph TD
    A[Plugin Discovery] --> B[Load Plugin Module]
    B --> C[Create Plugin Schema]
    C --> D[Run Migrations]
    D --> E[Call register function]
    E --> F[Register Access Rules]
    E --> G[Register Services]
    E --> H[Register Extension Points]
    E --> I[Register Init Function]
    
    subgraph "Phase 2: Init"
        I --> J[Resolve Dependencies]
        J --> K[Call init - Register routers]
    end
    
    subgraph "Phase 3: After Plugins Ready"
        K --> L[All Plugins Initialized]
        L --> M[Call afterPluginsReady]
        M --> N[Cross-plugin RPC + Hooks]
    end
    
    N --> O[Plugin Active]

Key Point: The init function registers routers and services. The afterPluginsReady callback runs after ALL plugins have initialized, making it safe to:

  • Call other plugins via RPC
  • Subscribe to hooks (onHook)
  • Emit hooks (emitHook)
graph TD
    A[Plugin Discovery] --> B[Load Plugin Module]
    B --> C[Register APIs]
    C --> D[Register Routes]
    D --> E[Register Nav Items]
    E --> F[Register Extensions]
    F --> G[Plugin Active]

Each backend plugin gets its own isolated PostgreSQL schema:

Database: checkstack
├── Schema: public (core only)
├── Schema: plugin_catalog-backend
├── Schema: plugin_auth-backend
└── Schema: plugin_healthcheck-backend
  • Namespace isolation: No table name conflicts
  • Independent migrations: Each plugin manages its own schema
  • Security: Plugins can’t access each other’s data directly
  • Scalability: Easy to split into separate databases later

See Drizzle Schema Isolation for implementation details.

Extension points enable plugins to provide implementations for core functionality:

  • HealthCheckStrategy: Implement custom health check methods
  • ExporterStrategy: Export metrics and data in various formats
  • NotificationStrategy: Send notifications via different channels
  • AuthenticationStrategy: Integrate authentication providers
  • Slots: Inject UI components into predefined locations
  • Routes: Add new pages to the application
  • APIs: Provide client-side services

See Extension Points for detailed documentation.

Plugins use versioned configurations to support backward compatibility:

interface VersionedConfig<T> {
version: number;
pluginId: string;
data: T;
migratedAt?: Date;
originalVersion?: number;
}

This enables:

  • Schema evolution without breaking existing configs
  • Automatic migration of old configurations
  • Rollback support

See versioned-configs.md for details.

sequenceDiagram
    participant F as Frontend Plugin
    participant FA as Fetch API
    participant R as Router
    participant B as Backend Plugin
    
    F->>FA: Request with credentials
    FA->>R: HTTPS + JWT
    R->>R: Validate JWT
    R->>R: Check access
    R->>B: Route to plugin
    B->>R: Response
    R->>FA: JSON response
    FA->>F: Typed data
sequenceDiagram
    participant P1 as Plugin A
    participant S as Service Registry
    participant P2 as Plugin B
    
    P1->>S: Get service reference
    S->>P1: Service instance
    P1->>P2: Call via HTTPS + JWT
    P2->>P2: Validate service token
    P2->>P1: Response

Plugins can register custom WebSocket endpoints via the WebSocket Route Registry. All routes are automatically namespaced by plugin ID to prevent collisions:

// In satellite-backend's afterPluginsReady:
wsRegistry.register("/", wsHandler);
// → Available at /api/ws/satellite
// Plugins can register sub-paths too:
wsRegistry.register("/events", eventsHandler);
// → Available at /api/ws/{pluginId}/events

The registry uses the same scoped factory pattern as RPC and health check registries - plugins never provide their ID manually.

The satellite transport is the canonical example of a plugin-registered WebSocket route; see Satellites architecture for its enrollment handshake and dispatch/result protocol.

Note: The signal/realtime WebSocket (/api/signals/ws) uses Bun’s native pub/sub and is handled separately from the registry.

Access rules are defined in common packages and registered by backend plugins:

// In catalog-common
export const access = {
entityRead: {
id: "entity.read",
description: "Read Systems and Groups",
},
} satisfies Record<string, AccessRule>;
// In catalog-backend
env.registerAccessRules(accessRuleList);
// In catalog-frontend
const canRead = accessApi.useAccess(access.entityRead.id);

The core automatically prefixes access rules with the plugin ID: catalog.entity.read

  • Runtime: Bun
  • Framework: Hono (HTTP routing)
  • Database: PostgreSQL + Drizzle ORM
  • Validation: Zod
  • Testing: Bun test runner
  • Framework: React
  • Routing: React Router DOM
  • UI: ShadCN + Tailwind CSS
  • Build: Vite
  • Testing: Playwright (E2E)

All plugins run in a single process:

Terminal window
bun run dev

Each plugin can run independently:

Terminal window
# Terminal 1
bun run dev:backend --plugins=catalog-backend
# Terminal 2
bun run dev:backend --plugins=auth-backend
# Terminal 3
bun run dev:frontend

Mix and match based on scaling needs:

  • Core + frequently-used plugins in monolith
  • Resource-intensive plugins as separate services
  • Geographic distribution for compliance