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:
This ensures clean separation of concerns and prevents runtime-specific code from leaking into shared packages.
Create a common plugin when you need to:
@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)),
};
export const access = {
entityRead: {
id: "entity.read",
description: "Read entity data",
},
};
export const ItemSchema = z.object({
id: z.string(),
name: z.string(),
});
export type Item = z.infer<typeof ItemSchema>;
fs, path, server code)window, document)The fastest way to create a common plugin is using the CLI:
bun run create
Interactive prompts:
common as the plugin typemyfeature)This will create a complete common package with:
@orpc/contract, zod)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
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;
# 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.
@checkstack/common Core PackageThe @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:
AccessRule, PluginMetadata)accessPair, qualifyAccessRuleId)Who can use it:
catalog-common)@checkstack/backend-api)@checkstack/frontend-api)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
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.
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:
workspace:* for internal dependencies@checkstack/common for shared type definitions like AccessRule@orpc/contract and zod for contract and schema definitions@checkstack/backend-api, @checkstack/frontend-api, or any runtime-specific packagesCommon 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:
src/access.ts: Define access rules using accessPairsrc/schemas.ts: Define all Zod schemas and derive typessrc/rpc-contract.ts: Define the oRPC contractsrc/index.ts: Barrel file that exports everythingInternal 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[];
}
| 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) |
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:
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: trueare 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);
// ...
};
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:
Data, CreateInput, etc., in src/schemas.tsoc.$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),
};
interface with a typeof export
export type MyContract = typeof myContract;
os.router() and manual middlewareMyClient)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:
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:
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.
@checkstack/<plugin>-commonentity.read, incident.manageentityRead, incidentManagecatalogContract, healthCheckContractindex.tsCommon plugins should be tested with unit tests that verify:
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);
});
});