Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Server plugins

The factory, the dynamic module, a guarded route, and how to call it.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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: true is 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:

PieceFromEffect
Session guardidentity, globalNo session cookie → 401
PermissionsGuard + @RequirePermissions@orthacms/identity-serverMissing key → 403 Insufficient permissions
WorkspaceGuard@orthacms/workspaces-serverReads X-Workspace-Id; missing → 400, not a member → 403
@CurrentWorkspace()@orthacms/workspaces-serverThe checked workspace id
OriginGuard@orthacms/identity-serverRejects a write from an origin not in the allow-list
ValidationPipehost, globalwhitelist + forbidNonWhitelisted: unknown body keys → 400
@Public()@orthacms/identity-serverOpts 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:

Example language
# 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. PERMISSIONS and the role matrix live in packages/identity/server/src/lib/rbac/system-roles.ts.
  • A new key means adding it to PERMISSIONS and granting it in SYSTEM_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

HookRunsUse 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 injectedSeeding, registering a subscriber, idempotent bootstrap
OnModuleInit (a provider)When the module is initialisedRegistering 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 uuid to a cross-plugin foreign key — see Database access.

Next