An Ortha deployment is its plugin list: one array for the API, one for the admin. Add a capability by installing a package and adding a line; remove one by deleting the line.
The two arrays
This is what create-orthacms-app generates (optional plugins marked):
// apps/server/src/plugins.ts
export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
const content = ContentPlugin({
types: contentTypes, // the generated manifest, src/content/index.ts
migrations: {
dir: () => join(__dirname, '../../../apps/server/migrations'),
table: '__drizzle_migrations_content'
}
});
return [
DatabasePlugin({
connectionString: config.database.url,
outboxRetentionDays: config.database.outboxRetentionDays
}),
IdentityPlugin(config.plugins.identity),
WorkspacesPlugin(),
ActivityPlugin(),
...mailPlugin(config), // only when MAIL_PROVIDER is set
UsersPlugin(),
content,
ContentViewsPlugin({ content }),
ContentGraphqlPlugin({ content, ...config.plugins.contentGraphql }), // optional
SchemaBuilderPlugin(config.plugins.schemaBuilder),
I18nServerPlugin(config.plugins.i18n),
AlarmsPlugin(),
WebhooksPlugin(config.plugins.webhooks),
MediaServerPlugin({
provider: createLocalStorageProvider(config.plugins.media.storage),
config: config.plugins.media
}),
TransferPlugin(config.plugins.transfer),
SegmentsPlugin(config.plugins.segments),
ProtectionPlugin(),
CopilotPlugin({ providers: copilotProviders(config), config: config.plugins.copilot }),
McpPlugin({ config: config.plugins.mcp }) // optional, last
];
}// apps/admin/src/plugins.ts
export function buildPlugins(): AdminPlugin[] {
return [
IdentityPlugin(), // first: public sign-in / accept-invite routes
ShellPlugin(), // second: owns the layout and the auth gate
WorkspacesPlugin(),
InsightsPlugin(),
ContentPlugin(),
I18nPlugin(),
WysiwygPlugin(),
MediaPlugin(),
transferAdminPlugin(),
AlarmsPlugin(),
CopilotPlugin(),
SegmentsPlugin(),
ProtectionPlugin(),
UsersPlugin(),
ActivityPlugin(),
ApiTokensPlugin(),
WebhooksPlugin(),
SchemaBuilderPlugin()
];
}The catalogue
| Plugin | Server | Admin | Owns tables | Env vars |
|---|---|---|---|---|
| Database | database | — | yes | DATABASE_URL, OUTBOX_RETENTION_DAYS |
| Identity | identity-server | identity-admin | yes | ALLOWED_ORIGINS, SESSION_TTL_SECONDS, SSO_*, … |
| Workspaces | workspaces-server | workspaces-admin | yes | — |
| Activity | activity-server | activity-admin | yes | — |
mail-server | — | yes | MAIL_*, SMTP_*, APP_URL | |
| Users | users-server | users-admin | no | — |
| Content | content-server | content-admin | yes | — |
| Saved views | content-server | content-admin | yes | — |
| GraphQL | content-graphql | — | no | GRAPHQL_* |
| Schema builder | schema-builder-server | schema-builder-admin | no | SCHEMA_BUILDER, SCHEMA_BUILDER_* |
| Media | media-server | media-admin | yes | MEDIA_* |
| Localization | i18n-server | i18n-admin | no | — (config/i18n.ts) |
| Transfer | transfer-server | transfer-admin | no | — |
| Alarms | alarms-server | alarms-admin | yes | — |
| Segments | segments-server | segments-admin | yes | SEGMENTS_READER_TAGS_HEADER |
| Protection | protection-server | protection-admin | yes | — |
| Webhooks | webhooks-server | webhooks-admin | yes | WEBHOOKS_* |
| Copilot | copilot-server | copilot-admin | yes | COPILOT_*, ANTHROPIC_* |
| MCP | mcp-server | — | no | MCP_* |
| Shell | — | shell-admin | no | — |
| Rich text | — | wysiwyg-admin | no | — |
| Insights | — | insights-admin | no | — |
| API tokens | — | api-tokens-admin | no | — |
- Every package is published to npm under
@orthacms, in lockstep — one version across the set. - Saved views is a second
ServerPluginentry from@orthacms/content-server, not its own package. - Localization’s
locale/locale_group_idcolumns live on content’s tables. - The full environment table is in configuration.
Upgrade them together
@orthacms/* dependencies are pinned exactly. A partial upgrade can leave two
copies of a shared package in node_modules — two React contexts, and an admin
whose sidebar silently stops talking to its provider.
Calling admin routes
Two API surfaces, two credentials:
| Surface | Prefix | Credential | Workspace |
|---|---|---|---|
| Public delivery API, GraphQL, MCP | /api/v1/… | Authorization: Bearer orthacms_… | X-Workspace-Id when the token covers several |
| Admin API (every route on the plugin pages) | /api/… | orthacms_session cookie | X-Workspace-Id on workspace-scoped routes |
A script calls admin routes by signing in once and reusing the cookie:
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'
# { "ok": true } and a Set-Cookie: orthacms_session=…
curl https://cms.example.com/api/protection/queue \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' const login = await fetch('https://cms.example.com/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@example.com', password: process.env.ORTHA_PASSWORD })
});
const cookie = login.headers.get('set-cookie').split(';')[0]; // orthacms_session=…
const queue = await fetch('https://cms.example.com/api/protection/queue', {
headers: { Cookie: cookie, 'X-Workspace-Id': workspaceId }
}).then((r) => r.json()); - Writes pass
OriginGuard: a request with noOriginheader (curl, Node) or one listed inALLOWED_ORIGINSis accepted; any other origin is403. - The workspace id is the uuid in the admin URL (
/workspaces/<id>/…) and on the workspace’s settings page. - A permission the caller lacks is
403; routes list the key they need. - Every registered plugin’s routes are in the OpenAPI reference at
/reference(/reference/jsonraw) — on outside production, or withAPI_DOCS=true.
Order is migration order
Server plugins run their migrations in list order, with no transaction across plugins. A plugin whose tables reference another’s must come after it.
WorkspacesPluginafterIdentityPlugin—membershipsreferencesusers. Reversed, a freshorthacms migratefails withrelation "users" does not exist, while an existing database migrates fine.ContentViewsPluginafter identity, workspaces and content.- Add new plugins at the end unless you have a reason not to.
Order is not dependency injection: every plugin module is global and every
onPluginInit runs before the Nest app is created. In the admin, order decides
only the order of slot items and which plugin wins an id collision — plus the
two fixed positions above (identity first, shell second).
Config and registrations
A plugin takes at most two kinds of input:
- Config — typed values built from the environment in
apps/server/config/*.ts, assembled inorthacms.config.ts, read only through@orthacms/utils-serverreaders (so a blank.envvalue counts as absent). - Registrations — constructed objects (a storage backend, a model provider,
an SSO provider), created in
plugins.ts.
// apps/server/config/segments.ts — config built from the environment
import { headerSegmentResolver, type SegmentsPluginConfig } from '@orthacms/segments-server';
import { readEnv } from '@orthacms/utils-server';
export function segmentsConfig(): SegmentsPluginConfig {
return {
resolver: headerSegmentResolver(readEnv('SEGMENTS_READER_TAGS_HEADER') ?? 'x-reader-tags')
};
}// apps/server/src/plugins.ts — a registration: a constructed adapter
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});Pluggable backends
Four plugins take a port and a backend constructed at the composition root:
- Storage, model and SSO ports ship a conformance kit — see writing a storage, model or SSO provider. Mail has no kit yet.
- A capability a backend declares is checked at boot, not at the first request.
- The copilot’s scripted backend is a private test fixture: a deployment with no model key has no copilot.
Extension points at a glance
| Port or slot | Package | Used by |
|---|---|---|
contentPublishGuardRegistrar | content-server | Protection |
OutboxDispatcher.register | database | Activity, webhooks, protection — see database |
INSIGHTS_WIDGET_SLOT | insights-admin | Insights |
ENTRY_STATUS_ITEM_SLOT, ENTRY_TIMELINE_SLOT | content-admin | Alarms, activity — see the entry editor’s slots |
SIDEBAR_NAV_SLOT, HOME_SECTION_SLOT, COMMAND_SLOT | shell-admin | Shell |
WYSIWYG_MEDIA_SLOT | wysiwyg-admin | Rich text |
See writing a plugin and slots for the full contracts.