Plugin Author Guide
Plugin author guide
Section titled “Plugin author guide”This page is the canonical end-to-end guide for building an ecommus plugin in 2026. It covers the manifest contract, the permission model (P1, enforced at registration time), the registry context API, the testing harness, code signing for production, and how to publish to the private npm registry.
If you’re new to ecommus plugins, read this once. The shorter docs in this section (Plugin Overview, Hooks & Filters, Settings, Testing) zoom into individual topics.
What a plugin is
Section titled “What a plugin is”An ecommus plugin is a workspace package that:
- Lives in
plugins/<plugin-name>/(in-tree) or as a separately-installed npm package - Exposes a default-exported
PluginInitfunction via its entrypoint (src/index.tsby default) - Carries an
ecommusblock in itspackage.jsondeclaring kind + permissions + dependencies - Calls into the
PluginContextit receives at init time to register routes, hooks, filters, admin slots, payment drivers, themes, migrations, dashboard widgets, niche presets, etc.
Plugins are not patched into core. They run inside the host process (apps/api, apps/admin) and are gated by the SDK. The host can revoke a plugin at any time without redeploying.
The manifest
Section titled “The manifest”Every plugin’s package.json carries an ecommus block:
{ "name": "@ecommus-plugin/my-feature", "version": "1.0.0", "type": "module", "main": "./src/index.ts", "dependencies": { "@ecommus/plugin-sdk": "*" }, "ecommus": { "kind": "plugin", "minCoreVersion": "0.3.0", "permissions": ["events.subscribe", "routes.register", "admin.ui.inject"], "dependencies": [], // other plugin names this one needs loaded first "entrypoint": "./src/index.ts" // optional, defaults to src/index.ts }}| Field | Required | Notes |
|---|---|---|
ecommus.kind | yes | Must be exactly "plugin" (other kinds reserved). |
ecommus.minCoreVersion | yes | Semver of the lowest @ecommus/core version your plugin works against. The loader rejects you if the host runs older. |
ecommus.permissions | yes (effectively) | Array of permission strings — see below. Empty array works for metadata-only plugins; anything that calls register* needs the matching permission. |
ecommus.dependencies | no | Other plugin names that must load before yours. Loader does topo sort. Cycles fail loud. |
ecommus.entrypoint | no | Default src/index.ts. |
ecommus.settingsSchema | no | If present, the admin UI auto-renders a settings form for your plugin. See Plugin Settings. |
Permissions (P1 — enforced)
Section titled “Permissions (P1 — enforced)”As of session 33 (P1), permissions are load-bearing. Calling a register* method without declaring the matching permission produces a warning (default) or throws PluginPermissionError (strict mode). The host enables strict mode in production.
The complete map (one row per PluginContext method that requires permission):
| Context method | Required permission |
|---|---|
registerPaymentDriver | payments.register |
registerShippingProvider | shipping.register |
registerProductType | products.write |
registerPricingStrategy | products.write |
registerHook | events.subscribe |
registerFilter | events.filter |
registerRoute | routes.register |
registerAdminRoute | admin.routes.register |
registerAdminSlot | admin.ui.inject |
registerSection | cms.write |
registerMigration | migrations.run |
registerSeeder | migrations.run |
registerDashboardWidget | dashboard.widget.register |
registerNichePreset | dashboard.preset.register |
registerPluginMeta | (none — metadata only, always allowed) |
Declare exactly the permissions your plugin needs. Over-declaring is harmless but smells; under-declaring breaks the plugin in strict mode.
Enforcement modes
Section titled “Enforcement modes”The host (apps/api) reads ECOMMUS_PERMISSION_ENFORCEMENT from env:
strict— undeclaredregister*calls throwPluginPermissionError, your plugin’s init() fails, plugin shows up as failed in the registry. Production default after the fleet has been migrated.warn(default) — undeclared calls log a warning + are still allowed. Catches drift without breaking dev.off— gate disabled entirely. Backwards-compat / debugging only.
You should test your plugin against strict before shipping. Easiest: set ECOMMUS_PERMISSION_ENFORCEMENT=strict in your dev env, run the unit tests.
The init function
Section titled “The init function”import type { PluginInit } from "@ecommus/plugin-sdk";
const init: PluginInit = async (ctx) => { ctx.registerPluginMeta({ id: "@ecommus-plugin/my-feature", label: "My Feature", category: "feature", description: "A short user-facing description", icon: "Sparkles", // any lucide-react icon name });
ctx.registerHook("order.placed", async (event) => { ctx.logger.info({ orderId: event.payload.orderId }, "order observed"); });};
export default init;Init runs once per host process boot, after the loader has resolved + verified your manifest. It receives a PluginContext scoped to your plugin (everything you register is tagged with your plugin name).
The PluginContext API
Section titled “The PluginContext API”interface PluginContext { // identity name: string; // your manifest name version: string; // your manifest version config: Record<string, unknown>; // settings the operator filled in (see settingsSchema)
// shared resources logger: PluginLogger; // pino-style scoped logger; use it, not console.log db?: PluginDb; // host-scoped DB handle; absent in tests + dry-runs
// registration methods (each requires the permission listed above) registerPaymentDriver(code: string, factory: PaymentDriverFactory): void; registerShippingProvider( code: string, factory: ShippingProviderFactory ): void; registerProductType(def: ProductTypeDefinition): void; registerPricingStrategy(def: PricingStrategyDefinition): void; registerHook(event: string, handler: HookHandler): void; registerFilter<T>(name: string, handler: FilterHandler<T>): void; registerRoute(reg: RouteRegistration): void; registerAdminRoute(reg: AdminRouteRegistration): void; registerAdminSlot(reg: AdminSlotRegistration): void; registerSection(reg: SectionRegistration): void; registerMigration(reg: MigrationRegistration): void; registerSeeder(reg: SeederRegistration): void; registerDashboardWidget(def: DashboardWidgetDefinition): void; registerNichePreset(def: NichePreset): void; registerPluginMeta(meta: PluginMeta): void;}Logger
Section titled “Logger”Use ctx.logger, not console.log. The logger is structured (Pino), scoped to your plugin name, and goes to the same pipeline the host uses:
ctx.logger.info({ userId: "abc" }, "user matched");ctx.logger.warn({ retry: 3 }, "remote timed out, retrying");ctx.logger.error({ err }, "sync failed — operator should investigate");ctx.logger.debug({ payload }, "debug only"); // only emitted if DEBUG_PLUGINS env is setctx.logger.error does not throw; the host catches your init exceptions, but it’s still your job to recover gracefully where possible.
Settings → ctx.config
Section titled “Settings → ctx.config”If your manifest has a settingsSchema, the admin UI auto-renders a form. The values the operator submits arrive on ctx.config as a plain object:
// manifest"settingsSchema": { "apiKey": { "type": "password", "label": "API Key", "required": true }, "syncInterval": { "type": "number", "label": "Sync interval (min)", "default": 30 }}// initconst apiKey = String(ctx.config.apiKey ?? "");const syncInterval = Number(ctx.config.syncInterval ?? 30);if (!apiKey) { ctx.logger.warn("apiKey not configured — plugin running in no-op mode"); return;}Reading ctx.config happens at init time. If the operator changes settings, the host re-runs init for your plugin (no full restart). Don’t cache config in module-scope variables.
Extension point cookbook
Section titled “Extension point cookbook”Hook (subscribe to a domain event)
Section titled “Hook (subscribe to a domain event)”ctx.registerHook("order.paid", async (event) => { await sendInvoice(event.payload.orderId);});Required permission: events.subscribe. See Hooks & Filters for the full event catalog.
Filter (modify a computed value)
Section titled “Filter (modify a computed value)”ctx.registerFilter<number>("product.price", async (price, context) => { if (context.product.tags.includes("vip-only")) return price * 0.8; return price;});Required permission: events.filter.
Route (Fastify route on the public API)
Section titled “Route (Fastify route on the public API)”ctx.registerRoute({ scope: "storefront", prefix: "/my-plugin", register: (app) => { app.get("/health", async () => ({ ok: true })); app.post("/webhook", { config: { rawBody: true } }, async (req) => { // your handler }); },});Required permission: routes.register.
Admin route (Fastify route on the admin API, behind auth)
Section titled “Admin route (Fastify route on the admin API, behind auth)”ctx.registerAdminRoute({ prefix: "/my-plugin", register: (app) => { app.post("/sync", async (req, reply) => { // requireAuth + requireRole('admin') applied automatically }); },});Required permission: admin.routes.register.
Admin UI slot (inject a React component)
Section titled “Admin UI slot (inject a React component)”ctx.registerAdminSlot({ slot: "product.editor.tabs", componentPath: "@ecommus-plugin/my-feature/ui/EditorTab", priority: 10,});Required permission: admin.ui.inject. The component path is resolved at admin build time. The UI component itself lives in ui/EditorTab.tsx; use the published admin slot types from @ecommus/plugin-sdk for prop typing.
Migration (additive schema change)
Section titled “Migration (additive schema change)”ctx.registerMigration({ name: "001_my_feature_table", up: ` CREATE TABLE IF NOT EXISTS my_feature_records ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX my_feature_records_tenant_idx ON my_feature_records (tenant_id); `,});Required permission: migrations.run. Migrations run in registration order, idempotently — IF NOT EXISTS is your friend.
Niche preset (default dashboard layout for a niche)
Section titled “Niche preset (default dashboard layout for a niche)”ctx.registerNichePreset({ nicheId: "my-niche", label: "My Niche", description: "Description shown in the niche picker", icon: "Sparkles", defaultLayout: [ { widgetId: "my-niche-revenue", w: 6, order: 1 }, { widgetId: "my-niche-bookings", w: 6, order: 2 }, ], sidebarNav: ["Bookings", "Customers", "Settings"], color: "#10b981",});Required permission: dashboard.preset.register.
Walkthrough: build a “hello world” plugin in 5 minutes
Section titled “Walkthrough: build a “hello world” plugin in 5 minutes”# In the ecommus repo rootmkdir -p plugins/hello-author-guide/srccd plugins/hello-author-guidepackage.json:
{ "name": "@ecommus-plugin/hello-author-guide", "version": "1.0.0", "private": true, "type": "module", "main": "./src/index.ts", "dependencies": { "@ecommus/plugin-sdk": "*" }, "ecommus": { "kind": "plugin", "minCoreVersion": "0.3.0", "permissions": ["routes.register", "events.subscribe"], "entrypoint": "./src/index.ts" }}src/index.ts:
import type { PluginInit } from "@ecommus/plugin-sdk";
const init: PluginInit = (ctx) => { ctx.registerPluginMeta({ id: "@ecommus-plugin/hello-author-guide", label: "Hello Author Guide", category: "feature", icon: "Sparkles", });
ctx.registerRoute({ scope: "storefront", prefix: "/hello", register: (app) => { app.get("/", async () => ({ message: "hello from a plugin" })); }, });
ctx.registerHook("order.placed", (event) => { ctx.logger.info( { orderId: event.payload.orderId }, "hello-author-guide: order observed" ); });};
export default init;That’s it. Restart the host (docker compose restart api), check the registry:
curl -s http://api.ecommus.local/health | jq '.plugins[] | select(.name=="@ecommus-plugin/hello-author-guide")'# { "name": "...", "version": "1.0.0", "ok": true }Test the route:
curl -s http://api.ecommus.local/hello# {"message":"hello from a plugin"}Place a test order — your plugin’s hook logs the orderId.
Testing locally
Section titled “Testing locally”Two test layers:
1. Unit tests against the registry directly
Section titled “1. Unit tests against the registry directly”__tests__/hello.test.ts:
import { describe, it, expect } from "vitest";import { PluginRegistry, type PluginManifest } from "@ecommus/plugin-sdk";import init from "../src/index.ts";
const manifest: PluginManifest = { name: "@ecommus-plugin/hello-author-guide", version: "1.0.0", ecommus: { kind: "plugin", minCoreVersion: "0.3.0", permissions: ["routes.register", "events.subscribe"], },};
describe("hello-author-guide plugin", () => { it("registers a storefront route + an order.placed hook", async () => { const registry = new PluginRegistry(); const ctx = registry.createContext(manifest, {}, undefined, "strict"); await init(ctx);
expect(registry.routes).toContainEqual( expect.objectContaining({ plugin: manifest.name, prefix: "/hello", }) ); expect(registry.hooks).toContainEqual( expect.objectContaining({ plugin: manifest.name, event: "order.placed", }) ); });});Run: npx vitest run --root plugins/hello-author-guide. See Testing Plugins for more depth.
2. Manifest test — verify your declared permissions match what your plugin actually calls
Section titled “2. Manifest test — verify your declared permissions match what your plugin actually calls”__tests__/manifest.test.ts:
import { describe, it, expect } from "vitest";import { readFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));const pkg = JSON.parse( readFileSync(join(__dirname, "..", "package.json"), "utf8"));
const ALLOWED_PERMISSIONS = new Set([ "payments.register", "shipping.register", "products.read", "products.write", "orders.read", "orders.write", "customers.read", "customers.write", "cms.write", "webhooks.register", "admin.ui.inject", "admin.routes.register", "events.subscribe", "events.filter", "migrations.run", "routes.register", "dashboard.widget.register", "dashboard.preset.register",]);
describe("manifest", () => { it("declares only known PluginPermission values", () => { for (const perm of pkg.ecommus.permissions ?? []) { expect(ALLOWED_PERMISSIONS.has(perm)).toBe(true); } });});If you forgot to declare a permission, your unit test in strict mode catches it: the register* call throws PluginPermissionError and the test fails with a clear message naming the missing permission.
Code signing (production only)
Section titled “Code signing (production only)”Per ADR-015 §6.7, production hosts run with ECOMMUS_REQUIRE_SIGNED_PLUGINS=true. Plugins missing or failing the .ecommus-signature sidecar are recorded as failed and their init is never called.
Workflow:
-
Generate a code-signing keypair (Ed25519). The provision script does this automatically; otherwise:
Terminal window openssl genpkey -algorithm ED25519 -out keys/code-signing-private.pemopenssl pkey -pubout -in keys/code-signing-private.pem -out keys/code-signing-public.pem -
Sign your plugin directory:
Terminal window node scripts/sign-plugin.mjs plugins/hello-author-guide# Writes plugins/hello-author-guide/.ecommus-signature -
The signature file is a JSON sidecar with the digest + signature. Commit it — the loader verifies on each boot.
-
Public key goes in the host’s env (
ECOMMUS_CODE_SIGNING_PUBLIC_KEYorECOMMUS_CODE_SIGNING_PUBLIC_KEY_FILE). Private key stays with the plugin author.
If you tamper with a signed plugin’s code without re-signing, the loader will refuse it. That’s the point.
Publishing to the private npm registry
Section titled “Publishing to the private npm registry”Premium plugins live on npm.ecommus.ro (Verdaccio). To publish:
-
Authenticate (one-time, replace
<your-author-token>with the token your operator assigned you):Terminal window npm config set "@ecommus-plugin:registry" "https://npm.ecommus.ro" --location=usernpm config set "//npm.ecommus.ro/:_authToken" "<your-author-token>" --location=user -
Publish:
Terminal window cd plugins/hello-author-guidenpm publish --access restricted -
Customers whose license includes your plugin can now install via:
Terminal window npm install @ecommus-plugin/hello-author-guide(Their
@ecommus-plugin:registryis set tonpm.ecommus.ro, their license-issued NPM token gates which packages they can pull.)
Best practices
Section titled “Best practices”| Do | Don’t |
|---|---|
Use ctx.logger, never console.log | Pollute stdout with raw console calls — they bypass the host’s log pipeline |
| Declare every permission your plugin actually uses | Over-declare to “be safe” — strict mode + audits will eventually call you out |
Treat ctx.db as scoped to the current tenant request | Run cross-tenant queries — that’s a security bug, not a feature |
Make migrations additive + IF NOT EXISTS | Drop columns in plugin migrations — backwards compat is your problem |
Pin minCoreVersion to what you actually tested against | Set it to 0.0.0 and pray; loader will let you boot but you’ll break later |
| Keep init() side-effects to register* calls | Open network connections / start background loops in init — host expects init to return fast |
| Persist long-running state in DB or Redis | Stash state in module-level variables — surviving a hot-reload is your job otherwise |
Run with ECOMMUS_PERMISSION_ENFORCEMENT=strict in tests | Ship without testing strict mode at least once |
Anti-patterns we’ve seen
Section titled “Anti-patterns we’ve seen”registerHook('*', ...)to “subscribe to all events” — there is no*event. Subscribe to the exact event names you need.- Direct DB access via
pgordrizzleimported separately — usectx.db. Otherwise tenant scoping is bypassed and your plugin won’t pass the security review. - Hard-coding tenant IDs in route handlers — every request that touches tenant data MUST resolve
req.tenantIdvia the host’s middleware (already wired for plugin admin routes). - Using
process.envdirectly for plugin config — define asettingsSchemaand usectx.config. Env vars are for host-level config, not per-plugin. - Calling
register*outsideinit— the registry only accepts registrations during init. Late-binding is rejected.
Where to look in the source
Section titled “Where to look in the source”- Plugin SDK contract:
packages/plugin-sdk/src/types.ts - Registry implementation:
packages/plugin-sdk/src/registry.ts - Loader (manifest discovery + signature verify + permission enforcement):
packages/plugin-sdk/src/loader.ts - Reference plugins (in-tree, all signed):
plugins/example-hello/— minimal route + hook + admin slotplugins/niche-booking/— full niche package with migrations + dashboard widgetsplugins/payment-netopia/— payment driver + isolation patternplugins/efactura-ro/— admin route + hook + Romanian e-invoicing integration
When in doubt, copy from those.
Getting your plugin into the catalog
Section titled “Getting your plugin into the catalog”If you’d like your plugin published to npm.ecommus.ro for purchase by other ecommus customers (revenue share applies), open a GitHub Discussion at github.com/MDLABS-cmd/ecommus/discussions with:
- A demo URL or video showing the plugin in action
- The repo URL (we’ll need read-only access for review)
- Your proposed pricing tier (community / pro / enterprise)
Review takes ~1-2 weeks. Approved plugins get author tokens for npm publish, a row in the operator’s catalog, and listing on marketplace.ecommus.ro.