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

How Ortha is put together

A host, two plugin contracts, one database connection, and the two ways plugins extend each other.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Ortha is a small, generic host that turns a list of plugins into an app. The host has no auth, no users, no content — plugins bring all of it.

Two hosts, one per runtime

// apps/server/src/main.ts
createServer({ plugins: buildPlugins(config), port: config.port, docs: config.docs });

// apps/admin/src/main.tsx
createAdmin({ plugins: buildPlugins() });
  • buildPlugins lives beside each host: apps/server/src/plugins.ts and apps/admin/src/plugins.ts.
  • createServer runs each plugin’s init hook in order, imports its NestJS module, applies the /api prefix and the validation pipe.
  • createAdmin mounts React, the router and providers, and registers each plugin’s routes.

The server plugin contract

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

A NestJS module, an optional init hook, the migrations it owns, and an optional OpenAPI contribution. See server plugins.

The admin plugin contract

type AdminPlugin = {
    name: string;
    routes?: RouteItem[];
    layout?: ReactNode;
    slots?: SlotContribution[];
};

Routes, an optional shell, and contributions into named slots. See admin plugins.

One database connection

  • @orthacms/database owns one Drizzle/pg pool, opened in its init hook — so it is first in the plugin list.
  • Inject it with @InjectDatabase(), or call getDatabase() / getPool().
  • It owns one table: outbox_events, the transactional outbox. Delivered rows are pruned after OUTBOX_RETENTION_DAYS (30; 0 keeps them).
  • Every other plugin owns its own tables and migrations, applied in plugin-list order. Workspaces references identity’s users, so it must come after identity.
  • Content rows carry workspace_id with no foreign key. Workspace isolation — and the read-only exception for shared workspaces — is enforced in the application layer.

How a request flows

  1. The admin sends a request through a shared axios client (/api base URL).
  2. AuthGuard (global) validates the session — database-backed, revocable, in an httpOnly cookie.
  3. Per-route guards: PermissionsGuard + @RequirePermissions('users:read') for RBAC, WorkspaceGuard for membership, ContentGrantGuard for the workspace’s content-type grants, OriginGuard on every write (CSRF).
  4. The use case runs in a UnitOfWork transaction and appends domain events to the outbox in that same transaction.
  5. After commit, the outbox dispatcher delivers each event to its subscribers — e.g. activity writes an activity_events row.
  6. The response returns; React Query updates.

Delivery is at-least-once; the audit insert is keyed on the event id, so a redelivery never double-records. An event that fails 15 times is parked:

GET  /api/activity/dead-letters             # activity:read
POST /api/activity/dead-letters/:id/retry   # activity:manage (administrators)

Two ways plugins extend each other

Both let one plugin extend another with no direct dependency.

Admin: named slots

A plugin defines a slot; others contribute data into it.

// shell/admin defines it
export const SIDEBAR_NAV_SLOT = createSlot<SidebarItem>('shell.sidebar.nav');

// workspaces/admin contributes
slots: [
    {
        slot: SIDEBAR_NAV_SLOT,
        items: [
            {
                labelId: 'workspaces.nav.label',
                defaultLabel: 'Workspaces',
                to: '/workspaces',
                group: 'directory',
                order: 10,
                icon: Layers
            }
        ]
    }
]

See slots.

Server: DI ports

  • The depended-upon plugin declares a Symbol token and an interface, and injects it @Optional().
  • The implementing plugin binds it.
  • Example: content declares CONTENT_ENTRY_EXTENSION; i18n binds it to add row-per-locale scoping.

A token binds once (Nest has no multi-provider). Where many contributions are needed, the seam is a registry: CONTENT_READ_SCOPE (segments registers into it; every fragment is AND-ed onto public reads, so it can only subtract rows), the publish guard, and the agent tool registry. See DI ports.

Packages resolve from source

  • Inside the monorepo, package exports point at ./src/index.ts via the "@orthacms/source" condition — no build step.
  • Run npx nx sync after changing cross-project dependencies.
  • Publishing has a pack step that rewrites each manifest for npm consumers.

What is deliberately absent

No external queue, worker process, cache, search cluster or websocket layer. The only server push is the copilot’s SSE run stream.

Outbox events leave the process through one door

The outbox is an internal bus. The webhooks plugin is the subscriber that turns events into HTTP: it queues a row per endpoint inside the dispatcher’s transaction and POSTs from a separate worker loop (ADR-0016). One slow receiver cannot stall the activity log.