A server plugin is a factory returning a ServerPlugin: a name, a NestJS
module, and optionally its migrations and its OpenAPI contribution. This page
builds a complete one — reviews, comments on a content entry — with one
read route and one write route.
Layout
packages/reviews/server/
├── drizzle.config.ts → schema: ./src/lib/schema/index.ts, out: ./migrations
├── migrations/ → committed SQL (see Database access)
├── package.json → "name": "@acme/reviews-server"
└── src/
├── index.ts → export { ReviewsPlugin } …
└── lib/
├── reviews-plugin.ts
├── reviews.module.ts
├── reviews.tokens.ts
├── reviews.controller.ts
├── reviews.service.ts
├── create-review.dto.ts
└── schema/The table itself is on Database access.
1. The factory
// src/lib/reviews-plugin.ts
import { join } from 'node:path';
import type { ServerPlugin } from '@orthacms/bootstrap-server';
import { ReviewsModule } from './reviews.module';
import type { ReviewsPluginConfig } from './reviews.tokens';
export function ReviewsPlugin(config: ReviewsPluginConfig = {}): ServerPlugin {
return {
name: 'reviews',
module: ReviewsModule.forRoot(config),
migrations: {
// src/lib → package root. A thunk, so it resolves only at migrate time.
dir: () => join(__dirname, '../../migrations'),
// This plugin's own tracking table — keeps its history independent.
table: '__drizzle_migrations_reviews'
}
};
}Config arrives as an argument at the composition root. Don’t read
process.env inside a service.
2. Config token and module
// src/lib/reviews.tokens.ts
import { Inject } from '@nestjs/common';
export interface ReviewsPluginConfig {
/** Longest review body accepted. Defaults to 2000. */
maxBodyLength?: number;
}
export const REVIEWS_CONFIG = Symbol('REVIEWS_CONFIG');
export const InjectReviewsConfig = (): ParameterDecorator => Inject(REVIEWS_CONFIG);// src/lib/reviews.module.ts
import { Module, type DynamicModule } from '@nestjs/common';
import { ReviewsController } from './reviews.controller';
import { ReviewsService } from './reviews.service';
import { REVIEWS_CONFIG, type ReviewsPluginConfig } from './reviews.tokens';
@Module({})
export class ReviewsModule {
static forRoot(config: ReviewsPluginConfig): DynamicModule {
return {
module: ReviewsModule,
global: true,
controllers: [ReviewsController],
providers: [{ provide: REVIEWS_CONFIG, useValue: config }, ReviewsService],
exports: [ReviewsService]
};
}
}global: trueis the norm — every shipped plugin module is global. It is how a port binding reaches the plugin that declared it.- Export only what another plugin genuinely needs.
3. The route, the guards and the permission
// src/lib/create-review.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsUUID, MinLength } from 'class-validator';
export class ListReviewsQueryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
entryId!: string;
}
export class CreateReviewDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
entryId!: string;
@ApiProperty({ minLength: 1 })
@IsString()
@MinLength(1)
body!: string;
}// src/lib/reviews.controller.ts
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
CurrentUser,
OriginGuard,
PERMISSIONS,
PermissionsGuard,
RequirePermissions,
type PublicUser
} from '@orthacms/identity-server';
import { CurrentWorkspace, WorkspaceGuard } from '@orthacms/workspaces-server';
import { CreateReviewDto, ListReviewsQueryDto } from './create-review.dto';
import { ReviewsService } from './reviews.service';
@ApiTags('reviews')
@UseGuards(PermissionsGuard, WorkspaceGuard)
@Controller('reviews') // served at /api/reviews
export class ReviewsController {
constructor(private readonly reviews: ReviewsService) {}
@Get()
@RequirePermissions(PERMISSIONS.CONTENT_READ)
list(@CurrentWorkspace() workspaceId: string, @Query() query: ListReviewsQueryDto) {
return this.reviews.list(workspaceId, query.entryId);
}
@Post()
@UseGuards(OriginGuard) // CSRF check: name it on every cookie-authenticated write
@RequirePermissions(PERMISSIONS.CONTENT_UPDATE)
create(
@CurrentWorkspace() workspaceId: string,
@CurrentUser() user: PublicUser,
@Body() dto: CreateReviewDto
) {
return this.reviews.create(workspaceId, dto, { id: user.id, email: user.email });
}
}// src/lib/reviews.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDatabase, type Database, type EventActor } from '@orthacms/database';
import { and, desc, eq } from 'drizzle-orm';
import type { CreateReviewDto } from './create-review.dto';
import { reviews } from './schema';
import { InjectReviewsConfig, type ReviewsPluginConfig } from './reviews.tokens';
@Injectable()
export class ReviewsService {
constructor(
@InjectDatabase() private readonly db: Database,
@InjectReviewsConfig() private readonly config: ReviewsPluginConfig
) {}
list(workspaceId: string, entryId: string) {
return this.db
.select()
.from(reviews)
.where(and(eq(reviews.workspaceId, workspaceId), eq(reviews.entryId, entryId)))
.orderBy(desc(reviews.createdAt));
}
async create(workspaceId: string, dto: CreateReviewDto, actor: EventActor) {
const max = this.config.maxBodyLength ?? 2000;
if (dto.body.length > max) {
throw new BadRequestException(`body must be at most ${max} characters`);
}
const [review] = await this.db
.insert(reviews)
.values({ workspaceId, entryId: dto.entryId, authorId: actor.id, body: dto.body })
.returning();
return review;
}
}What each piece does:
| Piece | From | Effect |
|---|---|---|
| Session guard | identity, global | No session cookie → 401 |
PermissionsGuard + @RequirePermissions | @orthacms/identity-server | Missing key → 403 Insufficient permissions |
WorkspaceGuard | @orthacms/workspaces-server | Reads X-Workspace-Id; missing → 400, not a member → 403 |
@CurrentWorkspace() | @orthacms/workspaces-server | The checked workspace id |
OriginGuard | @orthacms/identity-server | Rejects a write from an origin not in the allow-list |
ValidationPipe | host, global | whitelist + forbidNonWhitelisted: unknown body keys → 400 |
@Public() | @orthacms/identity-server | Opts a route out of the session guard |
Calling it
The admin’s apiClient sends the session cookie and X-Workspace-Id for you.
From a terminal:
# Sign in — the session is an httpOnly cookie
curl -c cookies.txt -X POST https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"editor@example.com","password":"…"}'
curl -b cookies.txt -X POST https://cms.example.com/api/reviews \
-H 'X-Workspace-Id: 7f1c…' \
-H 'Content-Type: application/json' \
-d '{"entryId":"0b9e…","body":"Tighten the intro."}'
curl -b cookies.txt 'https://cms.example.com/api/reviews?entryId=0b9e…' \
-H 'X-Workspace-Id: 7f1c…' import { apiClient } from '@orthacms/utils-admin';
// baseURL is /api; the open workspace's X-Workspace-Id is attached for you
const { data } = await apiClient.post('/reviews', {
entryId,
body: 'Tighten the intro.'
}); // 201 Created
{
"id": "c2d4…",
"workspaceId": "7f1c…",
"entryId": "0b9e…",
"authorId": "a51e…",
"body": "Tighten the intro.",
"resolvedAt": null,
"createdAt": "2026-09-30T10:12:44.120Z"
}Error bodies are Nest’s defaults:
// 400 — no X-Workspace-Id
{ "message": "Missing or malformed X-Workspace-Id header.", "error": "Bad Request", "statusCode": 400 }
// 403 — the role lacks content:update
{ "message": "Insufficient permissions", "error": "Forbidden", "statusCode": 403 }
// 403 — signed in, but not a member of that workspace
{ "message": "You are not a member of this workspace.", "error": "Forbidden", "statusCode": 403 }
// 400 — body validation
{ "message": ["body must be longer than or equal to 1 characters"], "error": "Bad Request", "statusCode": 400 }Permissions come from the catalogue
- Always a constant:
PERMISSIONS.CONTENT_READ, never'content:read'. A typo is a route checking a key nobody holds. - The catalogue is closed.
PERMISSIONSand the role matrix live inpackages/identity/server/src/lib/rbac/system-roles.ts. - A new key means adding it to
PERMISSIONSand granting it inSYSTEM_ROLES— the admin role is an enumerated list, not a wildcard. There is no runtime role API.
Contributing to the API reference
- Every DTO property needs
@ApiProperty/@ApiPropertyOptional— the Swagger CLI plugin is not wired, so an undecorated property is missing from the reference. - A plugin with a new credential declares it in
docs:
return {
name: 'reviews',
module: ReviewsModule.forRoot(config),
docs: {
securitySchemes: {
reviewsKey: { type: 'apiKey', in: 'header', name: 'X-Reviews-Key' }
},
defaultSecurity: ['reviewsKey'] // a list is read as OR
}
};- A route whose shape is runtime data uses
docs.decorate(document)— it runs after the document is built, in registration order. Alarms uses it to describe its filter tree. Amend only your own operations.
Lifecycle
| Hook | Runs | Use for |
|---|---|---|
onPluginInit() | Before Nest creates the app, in list order. Nothing is injectable yet. | Opening a connection, validating config. A throw stops the boot, naming the plugin. |
OnApplicationBootstrap (a provider) | Inside app.init(), everything injected | Seeding, registering a subscriber, idempotent bootstrap |
OnModuleInit (a provider) | When the module is initialised | Registering tools with ToolRegistry |
Review-critical rules
- Lock a contended invariant; never count then write. Two requests both read “three of five used” and both insert. Take a row lock or use a constraint.
- Prefer a plain
uuidto a cross-plugin foreign key — see Database access.
Next
- Database access — the table, the migration, transactions.
- Domain events — emit
review.created. - Testing — drive these routes against real Postgres.