Skip to content
Last updated Give Feedback

Plugin System Overview

This page is the 5-minute orientation. For the canonical end-to-end guide (manifest, permissions, extension-point cookbook, testing, signing, publishing), read Plugin Author Guide.

Plugins are the primary extension mechanism in ecommus. They register routes, subscribe to domain events, modify computed values via filters, inject admin UI, contribute payment / shipping drivers, niche presets, dashboard widgets, and migrations — without touching the core codebase.

At startup, the SDK walks the plugins/ directory at the repo root. Each subdirectory containing a package.json with an "ecommus" block gets loaded in dependency order (topological sort).

plugins/
├── example-hello/
│ ├── package.json ← must contain ecommus.kind === "plugin"
│ └── src/
│ └── index.ts ← default export = PluginInit function
└── niche-booking/
├── package.json
└── src/
└── index.ts

The manifest lives in package.json under the "ecommus" key:

{
"name": "@ecommus-plugin/example",
"version": "1.0.0",
"ecommus": {
"kind": "plugin",
"minCoreVersion": "0.3.0",
"permissions": ["events.subscribe", "routes.register"],
"dependencies": [],
"entrypoint": "./src/index.ts"
}
}
FieldRequiredPurpose
ecommus.kindyesMust be "plugin".
ecommus.minCoreVersionyesLowest @ecommus/core version your plugin works against.
ecommus.permissionseffectively yesWhat the SDK lets your plugin do — see below.
ecommus.dependenciesnoOther plugin names that must load first.
ecommus.entrypointnoDefault src/index.ts.
ecommus.settingsSchemanoIf present, admin UI auto-renders a settings form.

Permissions (P1 — enforced at registration time)

Section titled “Permissions (P1 — enforced at registration time)”

As of session 33 (P1), permissions are load-bearing. Every register* method on the PluginContext requires a matching permission to be declared in the manifest. Strict mode (production) throws PluginPermissionError on missing declarations; warn mode (default in dev) logs a warning + allows the call.

Context methodRequired permission
registerPaymentDriverpayments.register
registerShippingProvidershipping.register
registerProductTypeproducts.write
registerPricingStrategyproducts.write
registerHookevents.subscribe
registerFilterevents.filter
registerRouteroutes.register
registerAdminRouteadmin.routes.register
registerAdminSlotadmin.ui.inject
registerSectioncms.write
registerMigrationmigrations.run
registerSeedermigrations.run
registerDashboardWidgetdashboard.widget.register
registerNichePresetdashboard.preset.register
registerPluginMeta(none — metadata only)

The host’s enforcement mode is controlled by ECOMMUS_PERMISSION_ENFORCEMENT=strict|warn|off.

Every plugin exports a default PluginInit function:

import type { PluginInit } from "@ecommus/plugin-sdk";
const init: PluginInit = async (ctx) => {
ctx.registerPluginMeta({
id: "@ecommus-plugin/example",
label: "Example",
category: "feature",
icon: "Sparkles",
});
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. It receives a PluginContext scoped to your plugin (everything you register is tagged with your plugin name).

interface PluginContext {
// identity
name: string;
version: string;
config: Record<string, unknown>; // settings the operator filled in
// shared resources
logger: PluginLogger; // pino-style scoped logger
db?: PluginDb; // host-scoped DB handle (absent in tests)
// registration methods (each requires the permission listed above)
registerPaymentDriver(code, factory): void;
registerShippingProvider(code, factory): void;
registerProductType(def): void;
registerPricingStrategy(def): void;
registerHook(event, handler): void;
registerFilter<T>(name, handler): void;
registerRoute(reg): void;
registerAdminRoute(reg): void;
registerAdminSlot(reg): void;
registerSection(reg): void;
registerMigration(reg): void;
registerSeeder(reg): void;
registerDashboardWidget(def): void;
registerNichePreset(def): void;
registerPluginMeta(meta): void;
}

Use ctx.logger, not console.log. The logger is structured (Pino), goes to the host’s log pipeline, scoped to your plugin name.

ctx.registerHook("order.paid", async (event) => {
await sendInvoiceEmail(event.payload.orderId);
});

Permission: events.subscribe. See Hooks & Filters for the event catalog.

ctx.registerFilter<number>("product.price", async (price, context) => {
if (context.product.tags.includes("vip-only")) return price * 0.8;
return price;
});

Permission: events.filter.

Route (Fastify route on the storefront / API)

Section titled “Route (Fastify route on the storefront / API)”
ctx.registerRoute({
scope: "storefront",
prefix: "/my-plugin",
register: (app) => {
app.get("/health", async () => ({ ok: true }));
},
});

Permission: routes.register.

ctx.registerAdminSlot({
slot: "product.editor.tabs",
componentPath: "@ecommus-plugin/my-feature/ui/EditorTab",
priority: 10,
});

Permission: admin.ui.inject.

If your manifest declares a settingsSchema, the admin UI auto-renders a form at /admin/integrations/<plugin-name>/settings. The values land on ctx.config at the next init. See Plugin Settings for the schema format.

If you want a worked example, the in-tree plugins/example-hello/ shows the minimum (route + hook + admin slot). plugins/niche-booking/ shows the full niche-package shape (migrations + dashboard widgets + niche preset + multiple routes).