Plugin Architecture
Reference for how Octarq’s plugin system is built and how the commercial build (octarq-pro) composes on top of the open-source core without forking. For a plugin author’s how-to, see Writing a Plugin; this document details the architecture and design.
- octarq — the open-source core (
github.com/octarq-org/octarq), MIT. - octarq-pro — the private commercial build; consumes octarq as a Go module and mounts Pro features as plugins.
go.workwires.+../octarqlocally.
1. Principle: symmetric, no-fork plugins
A feature is a plugin with two mirror halves composed into the core, never a fork:
- Backend — a Go module implementing
plugin.Plugin. - Frontend — a JS package implementing
UIPlugin(from the shared SDK).
Both are composed at build time (not runtime): compile-time Go interface implementation + build-time frontend registry injection. One binary, go:embed. This fits the single-binary, self-hosted product and keeps the OSS/commercial line clean: the OSS build ships the plugin page shells that gracefully degrade (402 upsell / 404 neutral); the commercial build injects the real pages.
2. Backend contract (plugin/plugin.go)
type Plugin interface { Name() string // stable id; matches the UIPlugin.name Models() []any // GORM models (migrated for the plugin) Mount(mux Mux, ctx *Context) // register HTTP routes}// optional (each paired with a compile-time `var _ plugin.X = Plugin{}` assert):type MenuProvider interface { Menus() []MenuItem }type Starter interface { Start(ctx context.Context) } // runs after ALL Mounts// plus MCPProvider, OpenAPIContributor, Describer- The host calls
app.Use(p)beforeapp.Run(). Every plugin route is auto-gated bygatedMux(app/app.go): if the feature is disabled for the caller’s workspace it answers 404 before the handler runs. - Pro routes license-gate with 402 (
lic.HasTier(...)) so the frontend shows an upsell. - Plugins never import
internal/*; everything they need is onplugin.Context(DB, Guard, Encrypt/Decrypt, Audit, Notify, SendMail, OnEmail, DNS, Get/SetWorkspaceSetting, …). Context evolves additive-only. - Inter-plugin services: a provider calls
ctx.Provide("<plugin>.<service>", svc)during Mount; consumers resolve lazily (inStartor per-request) withplugin.LookupAs[T]and degrade when absent. Duplicate names fail startup. - AutoMigrate preflight (app/preflight.go): all models migrate once, after every registration; two different plugin model types claiming the same non-core table fail startup (mirroring a core table is the allowed convention).
3. Frontend contract & build-time composition
packages/plugin-sdk/ (published as @octarq/plugin-sdk) owns:
interface UIPlugin { name: string // matches Go Plugin.Name() routes: { path; Component: LazyPage; requiredTier?; requiredRole? }[] // React.lazy pages menu?: PluginMenuItem[] // same shape as backend MenuItem → areaForCategory widgets?: UIWidget[] // ExtensionSlot widgets (Overview renders "home-overview") areas?: UIArea[] // NEW top-level sidebar areas (string AreaId space) i18n?: { en; zh } // merged under the plugin's `name` namespace lockedFallback?: Component<{ status: number }> // 402/404 degrade}registerUIPlugin(p); uiRoutes(); uiMenus(); uiWidgets(slot); uiAreas(); uiPluginI18n()Core pages are UIPlugins too: links/mail/domains/abuse/audit/assets live in web/src/plugins/core/, always composed (imported from main.tsx before the #octarq-plugins manifest module). The shell (App.tsx) owns only auth, settings, org handling, Overview and the plugin pipeline; STATIC_AREAS holds only area/group shells, a menu’s category equals its group label, and icons are string keys resolved by the single PLUGIN_ICONS table (shell/areas.tsx).
The injection seam is a manifest (in the core web/) — WHICH plugins a build ships is data, not code:
web/octarq.plugins.json— the plugin manifest: a list of the UI plugins composed into this build. Each entry is a package specifier (its default export is the UIPlugin) or{ from, import }for a named/local export. The committed file is the OSS default edition: it lists the example plugin (@acme/octarq-plugin-hello), so the plugin system works out of the box.web/plugins-manifest.ts— a Vite plugin that serves the#octarq-pluginsvirtual module, generated from the active manifest (import+registerUIPluginfor each entry).web/src/main.tsximports#octarq-pluginsfor its side effects. This replaces the old two-file seam (index.ts/index.pro.ts) and theVITE_OCTARQ_PLUGINSswitch.- Choosing an edition = pointing at a different manifest, highest precedence first:
OCTARQ_PLUGINSenv (inline JSON array — dynamic CI injection, no file to edit) ›OCTARQ_PLUGINS_MANIFESTenv (path to a manifest file — a commercial build ships its own; octarq-pro points here) › the committedweb/octarq.plugins.json. - Result: a build never references a plugin its manifest doesn’t name (verified: the licenses page markers
LicensesPage/getApiIssued/No licenses issuedare absent from the OSS/example build, present only when a manifest composes@octarq-org/plugin-issuer).
web/src/App.tsx renders pluginRouteElements() (every element wrapped in PluginGate — 402 → upsell, 403 → access denied, 404/chunk failure → neutral note) and folds uiMenus() into the sidebar through the single mergeAreas pipeline (areaForCategory placement + advisory requiredRole filtering, member < admin < owner with instance-admin bypass, role from /api/auth/me); a route with no registered plugin 404-degrades (neutral note). Licenses is the reference plugin, now published as the standalone package @octarq-org/plugin-issuer (octarq-pro packages/).
4. Shared UI (@octarq/plugin-sdk)
The SDK re-exports the shared component library plugins build against, so a plugin page matches the app and gets accessibility for free:
- Backed by shadcn/ui + Base UI (
@base-ui/react) with the dark “glass” theme (chosen for Tailwind-4 support, single-binary embed, MIT, AI-codegen). cn()(clsx + tailwind-merge); Button/Badge viaclass-variance-authority; Modal→Base UI Dialog, Toggle→Base UI Switch (a11y/focus-trap/scroll-lock); plus Input/Textarea/Select/Tabs/Tooltip/Table/Skeleton.- Dependency inversion: the app consumes the package via a facade (
web/src/plugin-sdk/) that re-exports it; i18n-coupled bits (Code,LockedFeature) stay in the app facade. ~50../uiimporters unchanged. - For AI/LLM UIs (Inbox AI) the recommended layer is assistant-ui (same shadcn/Tailwind DNA) — not yet adopted.
5. Commercial embedding — how octarq-pro serves Pro pages
The core’s binary embeds webembed/dist (OSS dashboard, empty registry). The commercial build overrides it:
app.WithWebFS(fs.FS)(coreapp/app.go) — injection point; defaults to the embedded OSS FS when unset.OCTARQ_WEBEMBED_OUT(coreweb/vite.config.ts+ build script) — makes the dashboard build outDir overridable so the commercial build reuses the exact same build; default unchanged. (The buyer portal is no longer a core Vite entry — it moved to octarq-pro behindplugin.Context.HandleStatic; see Core Decoupling Audit §2.5.)- octarq-pro
webembed/— its own package embedding a dashboard built against octarq-pro’s plugin manifest intooctarq-pro/webembed/dist(viamake web, which runsOCTARQ_WEBEMBED_OUT=$(CURDIR)/webembed/dist OCTARQ_PLUGINS_MANIFEST=$(CURDIR)/octarq.plugins.json pnpm buildagainst../octarq/web).main.gocallsa.WithWebFS(webembed.FS()). CI’sdashboard.ymlbuilds and commits this dist with a Packages token so the private plugin packages resolve.
The committed pro dist means go build/Docker embed it with no cross-repo frontend build; make web (or dashboard.yml) regenerates it when the core dashboard or the plugin set changes.
Proven end-to-end: OSS dashboard has no Pro pages (404-degrade); octarq-pro’s embedded dashboard renders the real licenses page.
6. A Pro feature’s journey (end to end)
- Backend plugin (
plugin.Plugin) mounts routes, 402-gates on tier — lives in octarq-pro (or core for a community plugin). - Frontend page (
UIPlugin) built from@octarq/plugin-sdk, handles 402/404 (withPluginGateas the safety net). It ships as a standalone package (octarq-pro/packages/plugin-<feat>/, consuming the SDK +@octarq-org/api-client) named in the Pro manifest. - OSS build: manifest omits it → page 404-degrades (or shows upsell on 402).
- Commercial build: octarq-pro’s manifest names it → composed into the dist; octarq-pro embeds that dist via
WithWebFS; the licensed backend serves it.
7. Publishing the SDK
@octarq/plugin-sdk → GitHub Packages via changesets:
- Root
pnpm-workspace.yaml(packages/*),.changeset/,.github/workflows/publish-sdk.yml. - Package
publishConfig→npm.pkg.github.com, scope@octarq-org(= GitHub org). - Consumer
.npmrc:@octarq-org:registry=https://npm.pkg.github.com. See Publishing Guide. - Published (octarq-pro’s plugin packages consume
@octarq/plugin-sdkfrom GitHub Packages).
8. File map
Core (octarq):
plugin/plugin.go— backend contract +Context.app/app.go—Use,gatedMux,WithWebFS, CSRF wrap, server wiring.packages/plugin-sdk/— the SDK package (contract + shadcn UI).web/src/plugin-sdk/— app-side facade re-exporting the package.web/octarq.plugins.json— the plugin manifest (OSS default edition).web/plugins-manifest.ts— Vite plugin generating the#octarq-pluginsvirtual module from the manifest.web/src/plugins/PluginRoutes.tsx/PluginGate.tsx— route renderer + the centralized 402/403/404 degrade boundary.web/src/plugins/core/— octarq’s own core-feature UIPlugins (always composed frommain.tsx).web/vite.config.ts—octarqPlugins(),OCTARQ_WEBEMBED_OUT.examples/plugin-hello/web/— the example plugin, packaged as@acme/octarq-plugin-hello(OSS default).docs/{PLUGINS.md,PUBLISHING.md,ACCESSIBILITY.md}.
octarq-pro:
main.go—app.New().WithWebFS(webembed.FS()).Use(...).webembed/— embeds the Pro-injected dashboard;make webrebuilds.