Skip to content
Last updated Give Feedback

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.

An ecommus plugin is a workspace package that:

  1. Lives in plugins/<plugin-name>/ (in-tree) or as a separately-installed npm package
  2. Exposes a default-exported PluginInit function via its entrypoint (src/index.ts by default)
  3. Carries an ecommus block in its package.json declaring kind + permissions + dependencies
  4. Calls into the PluginContext it 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.

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
}
}
FieldRequiredNotes
ecommus.kindyesMust be exactly "plugin" (other kinds reserved).
ecommus.minCoreVersionyesSemver of the lowest @ecommus/core version your plugin works against. The loader rejects you if the host runs older.
ecommus.permissionsyes (effectively)Array of permission strings — see below. Empty array works for metadata-only plugins; anything that calls register* needs the matching permission.
ecommus.dependenciesnoOther plugin names that must load before yours. Loader does topo sort. Cycles fail loud.
ecommus.entrypointnoDefault src/index.ts.
ecommus.settingsSchemanoIf present, the admin UI auto-renders a settings form for your plugin. See Plugin Settings.

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 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, always allowed)

Declare exactly the permissions your plugin needs. Over-declaring is harmless but smells; under-declaring breaks the plugin in strict mode.

The host (apps/api) reads ECOMMUS_PERMISSION_ENFORCEMENT from env:

  • strict — undeclared register* calls throw PluginPermissionError, 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.

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).

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;
}

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 set

ctx.logger.error does not throw; the host catches your init exceptions, but it’s still your job to recover gracefully where possible.

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 }
}
// init
const 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.

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

Required permission: events.subscribe. See Hooks & Filters for the full event catalog.

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.

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.

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.

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”
Terminal window
# In the ecommus repo root
mkdir -p plugins/hello-author-guide/src
cd plugins/hello-author-guide

package.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:

Terminal window
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:

Terminal window
curl -s http://api.ecommus.local/hello
# {"message":"hello from a plugin"}

Place a test order — your plugin’s hook logs the orderId.

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.

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:

  1. Generate a code-signing keypair (Ed25519). The provision script does this automatically; otherwise:

    Terminal window
    openssl genpkey -algorithm ED25519 -out keys/code-signing-private.pem
    openssl pkey -pubout -in keys/code-signing-private.pem -out keys/code-signing-public.pem
  2. Sign your plugin directory:

    Terminal window
    node scripts/sign-plugin.mjs plugins/hello-author-guide
    # Writes plugins/hello-author-guide/.ecommus-signature
  3. The signature file is a JSON sidecar with the digest + signature. Commit it — the loader verifies on each boot.

  4. Public key goes in the host’s env (ECOMMUS_CODE_SIGNING_PUBLIC_KEY or ECOMMUS_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.

Premium plugins live on npm.ecommus.ro (Verdaccio). To publish:

  1. 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=user
    npm config set "//npm.ecommus.ro/:_authToken" "<your-author-token>" --location=user
  2. Publish:

    Terminal window
    cd plugins/hello-author-guide
    npm publish --access restricted
  3. Customers whose license includes your plugin can now install via:

    Terminal window
    npm install @ecommus-plugin/hello-author-guide

    (Their @ecommus-plugin:registry is set to npm.ecommus.ro, their license-issued NPM token gates which packages they can pull.)

DoDon’t
Use ctx.logger, never console.logPollute stdout with raw console calls — they bypass the host’s log pipeline
Declare every permission your plugin actually usesOver-declare to “be safe” — strict mode + audits will eventually call you out
Treat ctx.db as scoped to the current tenant requestRun cross-tenant queries — that’s a security bug, not a feature
Make migrations additive + IF NOT EXISTSDrop columns in plugin migrations — backwards compat is your problem
Pin minCoreVersion to what you actually tested againstSet it to 0.0.0 and pray; loader will let you boot but you’ll break later
Keep init() side-effects to register* callsOpen network connections / start background loops in init — host expects init to return fast
Persist long-running state in DB or RedisStash state in module-level variables — surviving a hot-reload is your job otherwise
Run with ECOMMUS_PERMISSION_ENFORCEMENT=strict in testsShip without testing strict mode at least once
  • registerHook('*', ...) to “subscribe to all events” — there is no * event. Subscribe to the exact event names you need.
  • Direct DB access via pg or drizzle imported separately — use ctx.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.tenantId via the host’s middleware (already wired for plugin admin routes).
  • Using process.env directly for plugin config — define a settingsSchema and use ctx.config. Env vars are for host-level config, not per-plugin.
  • Calling register* outside init — the registry only accepts registrations during init. Late-binding is rejected.
  • 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 slot
    • plugins/niche-booking/ — full niche package with migrations + dashboard widgets
    • plugins/payment-netopia/ — payment driver + isolation pattern
    • plugins/efactura-ro/ — admin route + hook + Romanian e-invoicing integration

When in doubt, copy from those.

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.