Skip to content

Common Plugin Guidelines

Common plugins (e.g., catalog-common, healthcheck-common) are special plugin packages designed to define contracts and share types, schemas, and access rules between frontend and backend plugins. They contain code that is agnostic to the runtime environment and can safely be imported by both frontend and backend packages.

Key Purpose: Common packages define the contract for type-safe RPC communication using oRPC, serving as the single source of truth for API definitions.

Strict dependency isolation must be enforced:

  • Frontend plugins → May only depend on other frontend plugins OR common plugins
  • Backend plugins → May only depend on other backend plugins OR common plugins
  • Common plugins → May ONLY depend on other common plugins

This ensures clean separation of concerns and prevents runtime-specific code from leaking into shared packages.

Create a common plugin when you need to:

  1. Define RPC Contracts: Create type-safe oRPC contracts that both frontend and backend implement
  2. Share Access Rule Definitions: Export access rule constants that both frontend and backend need to reference
  3. Share Type Definitions: Define types/interfaces used across frontend and backend (via Zod schemas)
  4. Share Validation Schemas: Define Zod schemas for data validation and type inference
  5. Share Constants: Export enums, configuration constants, or other shared values
  • RPC Contracts: Type-safe contract definitions using @orpc/contract

    import { oc } from "@orpc/contract";
    const _base = oc.$meta<Metadata>({});
    export const myContract = {
    getData: _base
    .meta({ access: [access.read.id] })
    .output(z.array(DataSchema)),
    };
  • **Access Rule Definitions: Type-safe access rule objects and lists

    export const access = {
    entityRead: {
    id: "entity.read",
    description: "Read entity data",
    },
    };
  • Zod Schemas: For validation and type inference

    export const ItemSchema = z.object({
    id: z.string(),
    name: z.string(),
    });
  • Type Definitions: TypeScript types/interfaces (preferably inferred from Zod)

    export type Item = z.infer<typeof ItemSchema>;
  • Node.js-specific APIs (fs, path, server code)
  • Browser-specific APIs (window, document)
  • Database clients or ORM instances
  • HTTP clients or server frameworks
  • Backend business logic or services
  • React components or hooks

The fastest way to create a common plugin is using the CLI:

Terminal window
bun run create

Interactive prompts:

  1. Select common as the plugin type
  2. Enter your plugin name (e.g., myfeature)
  3. Provide a description (optional)
  4. Confirm to generate

This will create a complete common package with:

  • ✅ Package configuration with required dependencies (@orpc/contract, zod)
  • ✅ TypeScript configuration
  • ✅ Access rule definitions (read/manage pattern)
  • ✅ Example Zod schemas with input/output types
  • ✅ Complete oRPC contract with CRUD operations
  • ✅ Barrel exports following circular dependency prevention
  • ✅ Initial changeset for version management

Generated structure:

plugins/myfeature-common/
├── .changeset/
│ └── initial.md # Version changeset
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
├── README.md # Documentation
└── src/
├── index.ts # Barrel exports
├── access.ts # Access rule definitions
├── schemas.ts # Zod schemas
└── rpc-contract.ts # oRPC contract
Terminal window
cd plugins/myfeature-common
bun install

The generated plugin is a working example. Customize it for your domain:

src/access.ts:

import { accessPair } from "@checkstack/common";
export const access = {
myFeatureRead: accessPair(
"myfeature",
"read",
"Read myfeature data"
),
myFeatureManage: accessPair(
"myfeature",
"manage",
"Manage myfeature data"
),
};
export const accessRuleList = Object.values(access);

src/schemas.ts:

import { z } from "zod";
// Output schema (matches database)
export const MyItemSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type MyItem = z.infer<typeof MyItemSchema>;
// Input schema (omits id and timestamps)
export const CreateMyItemSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
export type CreateMyItem = z.infer<typeof CreateMyItemSchema>;

src/rpc-contract.ts:

import { oc } from "@orpc/contract";
import { z } from "zod";
import { MyItemSchema, CreateMyItemSchema } from "./schemas";
import { access } from "./access";
export interface MyFeatureMetadata {
access?: string[];
}
const _base = oc.$meta<MyFeatureMetadata>({});
export const myFeatureContract = {
getItems: _base
.meta({ access: [access.myFeatureRead.id] })
.output(z.array(MyItemSchema)),
createItem: _base
.meta({ access: [access.myFeatureManage.id] })
.input(CreateMyItemSchema)
.output(MyItemSchema),
};
export type MyFeatureContract = typeof myFeatureContract;
Terminal window
# Type check
bun run typecheck
# Lint
bun run lint

That’s it! Your common package is ready to be consumed by backend and frontend plugins.

The @checkstack/common package is a special core package located in core/common/ that provides shared type definitions and utilities used across the entire codebase. This is the foundation that all common plugins can depend on.

What it contains:

  • Core type definitions (e.g., AccessRule, PluginMetadata)
  • Access rule utilities (accessPair, qualifyAccessRuleId)
  • Fundamental interfaces used across plugins
  • Zero runtime dependencies

Who can use it:

  • ✅ All common plugins (including plugin-specific common packages like catalog-common)
  • ✅ Backend API packages (like @checkstack/backend-api)
  • ✅ Frontend API packages (like @checkstack/frontend-api)
  • ✅ Backend and frontend plugins (when they need core types)
import type { AccessRule, AccessLevel } from "@checkstack/common";
// AccessLevel: "read" | "manage"
// AccessRule interface
interface AccessRule {
id: string;
description?: string;
isAuthenticatedDefault?: boolean;
isPublicDefault?: boolean;
}
// ResourceAccessRule extends AccessRule with resource and action
interface ResourceAccessRule extends AccessRule {
resource: string;
level: AccessLevel;
}

Creates a standardized resource access rule with automatic ID generation:

import { accessPair } from "@checkstack/common";
const accessRule = accessPair(
"catalog", // resource name
"read", // action ("read" | "manage")
"Read catalog data", // optional description
{ // optional options
isAuthenticatedDefault: true, // assign to default "users" role
isPublicDefault: false, // assign to "anonymous" role
}
);
// Result: { id: "catalog.read", resource: "catalog", action: "read", ... }

Creates a fully-qualified access rule ID by prefixing with the plugin ID. This is used internally by the RPC middleware and SignalService for authorization checks:

import { qualifyAccessRuleId } from "@checkstack/common";
import { pluginMetadata } from "./plugin-metadata";
import { access } from "./access";
const qualifiedId = qualifyAccessRuleId(pluginMetadata, access.catalogRead);
// Result: "catalog.catalog.read" (format: ${pluginId}.${accessRule.id})

Note: You typically don’t need to call qualifyAccessRuleId directly. The platform handles access rule namespacing automatically during registration and authorization checks.

This ensures that all packages can reference core types without creating circular dependencies or violating the architecture rules.

A typical common plugin structure:

plugins/
catalog-common/
package.json
tsconfig.json
src/
index.ts # Barrel export
access.ts # Access rule definitions
schemas.ts # Zod schemas and type definitions
rpc-contract.ts # oRPC contract definition
constants.ts # Shared constants (optional)
utils.ts # Pure utility functions (optional)

The -common package must have these dependencies to support oRPC contracts:

{
"dependencies": {
"@checkstack/common": "workspace:*",
"@orpc/contract": "^1.13.2",
"zod": "^3.23.0"
}
}
{
"name": "@checkstack/catalog-common",
"version": "0.0.1",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts"
}
},
"dependencies": {
"@checkstack/common": "workspace:*",
"@orpc/contract": "^1.13.2",
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

Key points:

  • Use workspace:* for internal dependencies
  • Only depend on @checkstack/common for shared type definitions like AccessRule
  • Include @orpc/contract and zod for contract and schema definitions
  • Do NOT depend on @checkstack/backend-api, @checkstack/frontend-api, or any runtime-specific packages
  • Common plugins must maintain minimal dependencies to ensure they can be safely imported anywhere

Common plugins should extend the shared common configuration:

{
"extends": "@checkstack/tsconfig/common.json",
"include": ["src"]
}

See Monorepo Tooling for more information.

To prevent circular dependencies (which cause ReferenceError: Cannot access 'X' before initialization at runtime), follow this strict file layout for all -common packages:

  1. src/access.ts: Define access rules using accessPair
  2. src/schemas.ts: Define all Zod schemas and derive types
  3. src/rpc-contract.ts: Define the oRPC contract
  4. src/index.ts: Barrel file that exports everything

Internal package files (like rpc-contract.ts) MUST NEVER import from ./index.

Doing so creates a circular loop when the barrel file also exports the contract, leading to uninitialized variable errors in tests and at runtime.

// ✅ Good - Import from specific files
import { access } from "./access";
import { SystemSchema } from "./schemas";
// ❌ Bad - Creates circular dependency
import { access, SystemSchema } from "./index";

src/access.ts:

import { accessPair } from "@checkstack/common";
export const access = {
catalogRead: accessPair(
"catalog",
"read",
"Read catalog entities"
),
catalogManage: accessPair(
"catalog",
"manage",
"Manage catalog entities"
),
};
export const accessRuleList = Object.values(access);

src/schemas.ts:

import { z } from "zod";
export const SystemSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(["healthy", "degraded", "unhealthy"]),
metadata: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type System = z.infer<typeof SystemSchema>;
export const CreateSystemSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});

src/rpc-contract.ts:

import { oc } from "@orpc/contract";
import { z } from "zod";
import { SystemSchema, CreateSystemSchema } from "./schemas"; // Direct import
import { access } from "./access"; // Direct import
// 1. Define metadata type (must match backend-api's ProcedureMetadata structure)
export interface CatalogMetadata {
access?: string[];
}
// 2. Create base builder with metadata support
const _base = oc.$meta<CatalogMetadata>({});
export const catalogContract = {
getSystems: _base
.meta({ access: [access.catalogRead.id] })
.output(z.array(SystemSchema)),
createSystem: _base
.meta({ access: [access.catalogManage.id] })
.input(CreateSystemSchema)
.output(SystemSchema),
};
export type CatalogContract = typeof catalogContract;

src/index.ts:

// Export access rules
export { access, accessRuleList } from "./access";
// Export schemas and types
export * from "./schemas";
// CRITICAL: Use explicit named re-exports for the contract
// Using export * can lead to silent export failures in some bundler configurations
export { catalogContract, type CatalogContract } from "./rpc-contract";

Define the core data models using Zod in src/schemas.ts. Match database output types exactly (using z.date() for timestamps and .nullable() where appropriate):

import { z } from "zod";
export const SystemSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(["healthy", "degraded", "unhealthy"]),
metadata: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type System = z.infer<typeof SystemSchema>;

Best Practice: Always omit the id field from creation schemas. The backend should generate unique identifiers.

export const CreateSystemSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
status: z.enum(["healthy", "degraded", "unhealthy"]).optional(),
});
export const UpdateSystemSchema = CreateSystemSchema.partial();

Using z.enum ensures frontend gets exact type inference for allowed values, avoiding unsafe type casting.

Define your contract in src/rpc-contract.ts using the oc builder from @orpc/contract.

import { oc } from "@orpc/contract";
import type { ProcedureMetadata } from "@checkstack/common";
import { z } from "zod";
import { SystemSchema, CreateSystemSchema, UpdateSystemSchema } from "./schemas";
import { access } from "./access";
// Use ProcedureMetadata from @checkstack/common for full auth control
const _base = oc.$meta<ProcedureMetadata>({});
export const catalogContract = {
// User-only endpoints with access requirements
getSystems: _base
.meta({ userType: "user", access: [access.catalogRead.id] })
.output(z.array(SystemSchema)),
getSystem: _base
.meta({ userType: "user", access: [access.catalogRead.id] })
.input(z.string())
.output(SystemSchema),
createSystem: _base
.meta({ userType: "user", access: [access.catalogManage.id] })
.input(CreateSystemSchema)
.output(SystemSchema),
// Public endpoint (no auth required)
getPublicInfo: _base
.meta({ userType: "anonymous" })
.output(z.object({ version: z.string() })),
// Service-to-service endpoint
internalSync: _base
.meta({ userType: "service" })
.output(z.void()),
};
export type CatalogContract = typeof catalogContract;

The ProcedureMetadata interface from @checkstack/common provides declarative auth control:

import type { ProcedureMetadata } from "@checkstack/common";
// ProcedureMetadata interface:
interface ProcedureMetadata {
userType?: "anonymous" | "user" | "service" | "authenticated";
access?: string[];
}
ValueDescription
"anonymous"No authentication required (public endpoints)
"user"Only real users (frontend authenticated)
"service"Only services (backend-to-backend)
"authenticated"Either users or services, but must be authenticated (default)

The autoAuthMiddleware from @checkstack/backend-api automatically enforces auth based on contract metadata:

import { implement } from "@orpc/server";
import { autoAuthMiddleware, type RpcContext, type RealUser } from "@checkstack/backend-api";
import { catalogContract } from "@checkstack/catalog-common";
// Create implementer with context and auth middleware
const os = implement(catalogContract)
.$context<RpcContext>()
.use(autoAuthMiddleware);
// Auth and access rules are automatically enforced!
return os.router({
getSystems: os.getSystems.handler(async ({ context }) => {
// context.user is guaranteed to be RealUser by contract meta
const userId = (context.user as RealUser).id;
// ...
}),
});

This approach:

  • Self-documenting: Security requirements are visible in the contract
  • Automatic enforcement: No manual middleware chaining needed
  • Type-safe: Contract meta determines context.user type

In Common Plugin (catalog-common/src/access.ts)

Section titled “In Common Plugin (catalog-common/src/access.ts)”
import { accessPair } from "@checkstack/common";
export const access = {
catalogRead: accessPair(
"catalog",
"read",
"Read catalog entities",
{ isAuthenticatedDefault: true } // Auto-assigned to "users" role
),
catalogManage: accessPair(
"catalog",
"manage",
"Manage catalog entities"
),
};
// Export as array for backend registration
export const accessRuleList = Object.values(access);

Note: Access rules with isDefault: true are automatically synced to the built-in “users” role on startup. See Backend Plugin Development for details.

import { implement } from "@orpc/server";
import { autoAuthMiddleware, type RpcContext, type RealUser } from "@checkstack/backend-api";
import { catalogContract, accessRuleList } from "@checkstack/catalog-common";
export default createBackendPlugin({
metadata: pluginMetadata,
register(env) {
// Register all access rules with the core
env.registerAccessRules(accessRuleList);
env.registerInit({
// ...
init: async ({ rpc }) => {
// Contract-based implementation with auto auth enforcement
const os = implement(catalogContract)
.$context<RpcContext>()
.use(autoAuthMiddleware);
const router = os.router({
getSystems: os.getSystems.handler(async ({ context }) => {
// Auth and access rules auto-enforced from contract meta
const userId = (context.user as RealUser).id;
// Implementation...
}),
});
rpc.registerRouter(router);
},
});
},
});
import { access } from "@checkstack/catalog-common";
import { useApi, accessApiRef } from "@checkstack/frontend-api";
export const CatalogConfigPage = () => {
const accessApi = useApi(accessApiRef);
// Use typed access rule constants - no hardcoded strings!
const canManage = accessApi.useAccess(access.catalogManage.id);
// ...
};
  1. Type Safety: Full end-to-end type safety from DB to frontend
  2. Runtime Validation: Zod schemas provide runtime validation for all API inputs and outputs
  3. No Contract Drift: Compile-time errors if backend implementation doesn’t match contract
  4. Improved DX: Auto-completion and type checking for all RPC calls
  5. Single Source of Truth: Contract definition is the authoritative API specification
  6. Self-Documenting: Access requirements declared in contract metadata
  7. Refactoring Support: IDE can find all usages when renaming
  8. No Duplication: Eliminates hardcoded strings and duplicate type definitions

In older parts of the codebase, contracts might be defined as simple TypeScript interfaces:

// Legacy src/rpc-contract.ts
export interface MyLegacyContract {
getData: (id: string) => Promise<Data[]>;
updateData: (input: { id: string; data: Partial<Data> }) => Promise<Data>;
}

To migrate to the oRPC pattern:

  1. Define Zod Schemas: Create schemas for Data, CreateInput, etc., in src/schemas.ts
  2. Rewrite with oc: Use oc.$meta<MyMetadata>({}) to create a base builder
    const _base = oc.$meta<MyMetadata>({});
    export const myContract = {
    getData: _base
    .meta({ access: [access.myRead.id] })
    .input(z.string())
    .output(z.array(DataSchema)),
    updateData: _base
    .meta({ access: [access.myManage.id] })
    .input(z.object({ id: z.string(), data: DataSchema.partial() }))
    .output(DataSchema),
    };
  3. Update Type Exports: Replace the interface with a typeof export
    export type MyContract = typeof myContract;
  4. Update Router: Refactor backend router to use os.router() and manual middleware
  5. Update Frontend: Import the client type from the common package (MyClient)

If you encounter TS2305: Module 'X' has no exported member 'Y' in the frontend after restructuring a common package:

Ensure src/index.ts uses explicit named re-exports instead of export *:

// ✅ Good - Explicit named re-exports
export { myContract, type MyContract } from "./rpc-contract";
// ❌ Risky - Can fail to propagate types in complex monorepos
export * from "./rpc-contract";

This is the most common cause of TS2305 in complex monorepos, as export * can fail to propagate types if the compiler doesn’t trace the internal dependency tree correctly.

If a dist folder exists in your common package, delete it:

Terminal window
rm -rf dist

In source-resolving monorepos, a stale dist folder can shadow your updated source files, causing the compiler to see old types or no types at all.

The consumer (frontend/backend) might have a stale TypeScript cache:

Terminal window
rm -rf tsconfig.tsbuildinfo node_modules/.cache

Restart the TypeScript Language Server or bun run dev process to pick up new declaration files.

If a member remains missing despite being in src/index.ts, it is likely being silently omitted due to a circular dependency loop. NEVER import from the barrel file within its children.

  • Package Name: @checkstack/<plugin>-common
  • Access rule IDs: Use dot notation: entity.read, incident.manage
  • Access rule constants: Use camelCase: entityRead, incidentManage
  • Contract Names: Use camelCase suffix: catalogContract, healthCheckContract
  • Exports: Always use barrel exports in index.ts

Common plugins should be tested with unit tests that verify:

  • Type definitions are correct
  • Validation schemas work as expected
  • Utility functions produce correct outputs
  • Access rule lists contain expected access rules
  • Contract metadata is properly defined

Since common plugins have minimal runtime dependencies, they’re easy to test:

import { describe, expect, test } from "bun:test";
import { access, accessRuleList } from "./access";
import { SystemSchema } from "./schemas";
describe("Access Rules", () => {
test("access rule list contains all access rules", () => {
expect(accessRuleList).toHaveLength(2);
});
test("access rule IDs are correctly formatted", () => {
expect(access.catalogRead.id).toBe("catalog.read");
});
});
describe("Schemas", () => {
test("SystemSchema validates correct data", () => {
const result = SystemSchema.safeParse({
id: "sys-1",
name: "Test System",
status: "healthy",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.success).toBe(true);
});
test("SystemSchema rejects invalid status", () => {
const result = SystemSchema.safeParse({
id: "sys-1",
name: "Test System",
status: "invalid",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.success).toBe(false);
});
});