Common Plugin Guidelines
Overview
Section titled “Overview”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.
Dependency Architecture Rules
Section titled “Dependency Architecture Rules”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.
When to Create a Common Plugin
Section titled “When to Create a Common Plugin”Create a common plugin when you need to:
- Define RPC Contracts: Create type-safe oRPC contracts that both frontend and backend implement
- Share Access Rule Definitions: Export access rule constants that both frontend and backend need to reference
- Share Type Definitions: Define types/interfaces used across frontend and backend (via Zod schemas)
- Share Validation Schemas: Define Zod schemas for data validation and type inference
- Share Constants: Export enums, configuration constants, or other shared values
What Belongs in Common Plugins
Section titled “What Belongs in Common Plugins”✅ Safe to Include
Section titled “✅ Safe to Include”-
RPC Contracts: Type-safe contract definitions using
@orpc/contractimport { 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>;
❌ Never Include
Section titled “❌ Never Include”- 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
Quick Start
Section titled “Quick Start”1. Scaffold Plugin with CLI
Section titled “1. Scaffold Plugin with CLI”The fastest way to create a common plugin is using the CLI:
bun run createInteractive prompts:
- Select
commonas the plugin type - Enter your plugin name (e.g.,
myfeature) - Provide a description (optional)
- 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 contract2. Install Dependencies
Section titled “2. Install Dependencies”cd plugins/myfeature-commonbun install3. Customize Your Contract
Section titled “3. Customize Your Contract”The generated plugin is a working example. Customize it for your domain:
Update Access Rules
Section titled “Update Access Rules”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);Define Your Schemas
Section titled “Define Your Schemas”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>;Update Your Contract
Section titled “Update Your Contract”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;4. Verify
Section titled “4. Verify”# Type checkbun run typecheck
# Lintbun run lintThat’s it! Your common package is ready to be consumed by backend and frontend plugins.
The @checkstack/common Core Package
Section titled “The @checkstack/common Core Package”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)
Access Rule Types
Section titled “Access Rule Types”import type { AccessRule, AccessLevel } from "@checkstack/common";
// AccessLevel: "read" | "manage"
// AccessRule interfaceinterface AccessRule { id: string; description?: string; isAuthenticatedDefault?: boolean; isPublicDefault?: boolean;}
// ResourceAccessRule extends AccessRule with resource and actioninterface ResourceAccessRule extends AccessRule { resource: string; level: AccessLevel;}accessPair
Section titled “accessPair”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", ... }qualifyAccessRuleId
Section titled “qualifyAccessRuleId”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
qualifyAccessRuleIddirectly. 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.
Package Structure
Section titled “Package Structure”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)Package Configuration
Section titled “Package Configuration”Mandatory Dependencies
Section titled “Mandatory Dependencies”The -common package must have these dependencies to support oRPC contracts:
{ "dependencies": { "@checkstack/common": "workspace:*", "@orpc/contract": "^1.13.2", "zod": "^3.23.0" }}package.json
Section titled “package.json”{ "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/commonfor shared type definitions likeAccessRule - Include
@orpc/contractandzodfor 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
tsconfig.json
Section titled “tsconfig.json”Common plugins should extend the shared common configuration:
{ "extends": "@checkstack/tsconfig/common.json", "include": ["src"]}See Monorepo Tooling for more information.
Mandatory Project Structure
Section titled “Mandatory Project Structure”To prevent circular dependencies (which cause ReferenceError: Cannot access 'X' before initialization at runtime), follow this strict file layout for all -common packages:
File Organization
Section titled “File Organization”src/access.ts: Define access rules usingaccessPairsrc/schemas.ts: Define all Zod schemas and derive typessrc/rpc-contract.ts: Define the oRPC contractsrc/index.ts: Barrel file that exports everything
The Golden Rule
Section titled “The Golden Rule”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 filesimport { access } from "./access";import { SystemSchema } from "./schemas";
// ❌ Bad - Creates circular dependencyimport { access, SystemSchema } from "./index";Example Structure
Section titled “Example Structure”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 importimport { 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 supportconst _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 rulesexport { access, accessRuleList } from "./access";
// Export schemas and typesexport * from "./schemas";
// CRITICAL: Use explicit named re-exports for the contract// Using export * can lead to silent export failures in some bundler configurationsexport { catalogContract, type CatalogContract } from "./rpc-contract";Defining RPC Contracts
Section titled “Defining RPC Contracts”Step 1: Define Domain Schemas
Section titled “Step 1: Define Domain Schemas”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>;Step 2: Define Input Schemas
Section titled “Step 2: Define Input Schemas”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.
Step 3: Define RPC Contract
Section titled “Step 3: Define RPC Contract”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 controlconst _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;Contract-Based Auth Enforcement
Section titled “Contract-Based Auth Enforcement”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[];}userType Options
Section titled “userType Options”| Value | Description |
|---|---|
"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) |
Backend Enforcement
Section titled “Backend Enforcement”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 middlewareconst 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 registrationexport const accessRuleList = Object.values(access);Note: Access rules with
isDefault: trueare automatically synced to the built-in “users” role on startup. See Backend Plugin Development for details.
In Backend Plugin
Section titled “In Backend Plugin”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); }, }); },});In Frontend Plugin
Section titled “In Frontend Plugin”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);
// ...};Benefits of This Approach
Section titled “Benefits of This Approach”- Type Safety: Full end-to-end type safety from DB to frontend
- Runtime Validation: Zod schemas provide runtime validation for all API inputs and outputs
- No Contract Drift: Compile-time errors if backend implementation doesn’t match contract
- Improved DX: Auto-completion and type checking for all RPC calls
- Single Source of Truth: Contract definition is the authoritative API specification
- Self-Documenting: Access requirements declared in contract metadata
- Refactoring Support: IDE can find all usages when renaming
- No Duplication: Eliminates hardcoded strings and duplicate type definitions
Migrating Legacy Interfaces
Section titled “Migrating Legacy Interfaces”In older parts of the codebase, contracts might be defined as simple TypeScript interfaces:
// Legacy src/rpc-contract.tsexport interface MyLegacyContract { getData: (id: string) => Promise<Data[]>; updateData: (input: { id: string; data: Partial<Data> }) => Promise<Data>;}To migrate to the oRPC pattern:
- Define Zod Schemas: Create schemas for
Data,CreateInput, etc., insrc/schemas.ts - Rewrite with oc: Use
oc.$meta<MyMetadata>({})to create a base builderconst _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),}; - Update Type Exports: Replace the
interfacewith atypeofexportexport type MyContract = typeof myContract; - Update Router: Refactor backend router to use
os.router()and manual middleware - Update Frontend: Import the client type from the common package (
MyClient)
Troubleshooting Type Export Issues
Section titled “Troubleshooting Type Export Issues”If you encounter TS2305: Module 'X' has no exported member 'Y' in the frontend after restructuring a common package:
1. Verify Named Re-exports
Section titled “1. Verify Named Re-exports”Ensure src/index.ts uses explicit named re-exports instead of export *:
// ✅ Good - Explicit named re-exportsexport { myContract, type MyContract } from "./rpc-contract";
// ❌ Risky - Can fail to propagate types in complex monoreposexport * 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.
2. Remove Stale dist Folder
Section titled “2. Remove Stale dist Folder”If a dist folder exists in your common package, delete it:
rm -rf distIn 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.
3. Clear Consumer Cache
Section titled “3. Clear Consumer Cache”The consumer (frontend/backend) might have a stale TypeScript cache:
rm -rf tsconfig.tsbuildinfo node_modules/.cacheRestart the TypeScript Language Server or bun run dev process to pick up new declaration files.
4. Verify No Circular Dependencies
Section titled “4. Verify No Circular Dependencies”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.
Naming Conventions
Section titled “Naming Conventions”- 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
Testing
Section titled “Testing”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); });});Next Steps
Section titled “Next Steps”- Backend Plugin Development - Implement contracts in backend routers
- Frontend Plugin Development - Consume contracts in frontend clients
- Extension Points
- Versioned Configurations