Features Apograph CMS on GitHub

Server plugins

The factory, the dynamic module, and how a plugin owns its own tables.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

A server plugin is a function returning a plain object:

interface ServerPlugin {
    name: string;
    module: Type | DynamicModule;
    onPluginInit?(): void | Promise<void>;
    migrations?: { dir: () => string; table: string };
    docs?: PluginApiDocs;
}

docs is the one people miss. It is this plugin’s contribution to the host’s OpenAPI document — the security schemes its guards accept, which of them to advertise as document-level alternatives, and an optional final pass over the generated document. The host merges every plugin’s contribution before it generates the reference.

Leave it out and your routes still work; they simply arrive in the API reference with no credential attached, so nobody trying a request from that page can authenticate one. A plugin that introduces a new way of authenticating is the case that needs it.

docs: {
    securitySchemes: {
        reviewsKey: { type: 'apiKey', in: 'header', name: 'X-Reviews-Key' }
    },
    defaultSecurity: ['reviewsKey']
}

defaultSecurity is read as OR: listing two scheme names means “either one”, not both.

The factory

// packages/reviews/server/src/lib/utils/reviews-plugin.ts
import { join } from 'node:path';
import type { ServerPlugin } from '@apograph/bootstrap-server';
import { ReviewsModule } from '../reviews.module';

export function ReviewsPlugin(config: ReviewsPluginConfig): ServerPlugin {
    return {
        name: 'reviews',
        module: ReviewsModule.forRoot(config),
        migrations: {
            dir: () => join(__dirname, '../../../migrations'),
            table: '__drizzle_migrations_reviews'
        }
    };
}

A factory rather than a bare module, because that is what lets configuration be passed at the composition root instead of read from process.env deep inside a service.

migrations.table is this plugin’s own tracking table. Every plugin has one, which is what keeps their migration histories independent.

The dynamic module

@Module({})
export class ReviewsModule {
    static forRoot(config: ReviewsPluginConfig): DynamicModule {
        return {
            module: ReviewsModule,
            controllers: [ListReviewsController, CreateReviewController],
            providers: [
                { provide: REVIEWS_CONFIG, useValue: config },
                ReviewsService
            ],
            exports: [ReviewsService]
        };
    }
}

Export only what another plugin legitimately needs. A service exported for convenience becomes a coupling somebody depends on.

Mark the module global: true when it binds a port other plugins consume — that is how a port implementation reaches the plugin that declared it.

Reaching the database

import { InjectDatabase, type Database } from '@apograph/database';

@Injectable()
export class ReviewsService {
    constructor(@InjectDatabase() private readonly db: Database) {}
}

Annotate with Database, not the dialect-specific type, so a dialect change is a one-line edit in one package rather than a sweep across every plugin.

Owning schema

Put your Drizzle tables in src/lib/schema and add a drizzle.config.ts pointing at them. Inside the Apograph monorepo the db:generate target then appears on the project automatically:

npx nx run reviews-server:db:generate --name=create_reviews

In an app scaffolded by create-apograph-app there is no Nx; run drizzle-kit generate against that config directly. Either way, commit the emitted SQL — apograph migrate (the host’s db:migrate in the monorepo) applies it with every other plugin’s, because your plugin’s migrations descriptor is what the host reads.

No foreign keys across plugin boundaries

Your table may not reference another plugin’s. A workspace_id or a user_id is a plain uuid column, and the relationship is enforced in your service layer.

That is what allows a plugin to be removed, and what stops the schema becoming one interlocked graph nobody can take a piece out of.

Guards and permissions

Use the existing ones. Identity exports the session guard and the permissions decorator; workspaces exports the membership guard and the current-workspace decorator.

@Controller('reviews')
@UseGuards(WorkspaceGuard)
export class ListReviewsController {
    @Get()
    @RequirePermissions(PERMISSIONS.REVIEWS_READ)
    list(@CurrentWorkspace() workspaceId: string) {}
}

Two rules that the codebase’s own review checklist treats as non-negotiable:

  • Permissions come from a constant, never a string literal. A typo in @RequirePermissions('reviews:raed') is a route that checks a permission nobody holds — or, worse, one that nobody notices is unchecked.
  • Lock a contended invariant; do not count then write. Reading a count and then inserting is a race. Take the lock, or use a constraint and handle the violation.

Lifecycle

onPluginInit runs before the Nest application is created, in list order. It is for setup that must complete before the app boots — opening a connection, validating configuration.

It is not the place for anything needing dependency injection: nothing is instantiated yet.

Contributing to the API reference

A plugin can declare the security schemes its guards accept, and decorate the generated OpenAPI document. The passes run in registration order, last writer winning.

The server-plugin skill in the repository is the full checklist, including the folder layout and the review-critical invariants.