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() });buildPluginslives beside each host:apps/server/src/plugins.tsandapps/admin/src/plugins.ts.createServerruns each plugin’s init hook in order, imports its NestJS module, applies the/apiprefix and the validation pipe.createAdminmounts 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/databaseowns one Drizzle/pgpool, opened in its init hook — so it is first in the plugin list.- Inject it with
@InjectDatabase(), or callgetDatabase()/getPool(). - It owns one table:
outbox_events, the transactional outbox. Delivered rows are pruned afterOUTBOX_RETENTION_DAYS(30;0keeps 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_idwith no foreign key. Workspace isolation — and the read-only exception for shared workspaces — is enforced in the application layer.
How a request flows
- The admin sends a request through a shared axios client (
/apibase URL). AuthGuard(global) validates the session — database-backed, revocable, in an httpOnly cookie.- Per-route guards:
PermissionsGuard+@RequirePermissions('users:read')for RBAC,WorkspaceGuardfor membership,ContentGrantGuardfor the workspace’s content-type grants,OriginGuardon every write (CSRF). - The use case runs in a
UnitOfWorktransaction and appends domain events to the outbox in that same transaction. - After commit, the outbox dispatcher delivers each event to its subscribers — e.g. activity writes an
activity_eventsrow. - 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
Symboltoken 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
exportspoint at./src/index.tsvia the"@orthacms/source"condition — no build step. - Run
npx nx syncafter changing cross-project dependencies. - Publishing has a
packstep 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.