Skip to content

Plugin Development

A plugin is a TypeScript package that adds its own database tables, API routes and admin pages without Core containing plugin-specific business logic. Plugins are compiled into the API Worker at build time. There is no dynamic code loading. packages/plugin-hello is the minimal reference and packages/plugin-ecommerce is a full one.

For a step-by-step example, see Build a plugin.

  • Manifest: id, name, version, sdkVersion (must match the SDK exactly, or the Worker fails at cold start), optional description, dependencies, capabilities and permissions.
  • Registration: the manifest, an admin Hono sub-app, an optional public sub-app, an optional config schema. Registered in apps/api/src/plugins/registered-plugins.ts, the only Core file that imports a specific plugin.
  • PluginContext: what an admin route handler receives: db, user, hasRole(), media, config, events, email, payments, accounts and logger. Public routes get a smaller PluginPublicContext with no user or events, but an identity resolver for the shared session.
  • requirePluginRole(minimum): route middleware using Core’s role hierarchy. manifest.permissions is discovery metadata only, not checked at runtime.

There is one D1 database and one migration history. A plugin’s tables are defined in packages/database/schema/plugins/<id>.ts, prefixed plugin_<id>_, and migrated with the normal pipeline:

Terminal window
pnpm --filter @kenresoft-cms/database generate

Commit the migration before deploying with the plugin enabled. Only that plugin’s own repository code should query its tables. This is convention, not a mechanical boundary: PluginContext.db is the same Database type Core uses.

Non-secret config lives in Core’s plugin_settings table through ctx.config.get() and .set(), validated by the plugin’s own Zod schema. Never store secrets there. Secrets are Worker secrets set with wrangler secret put.

A plugin’s server code is in packages/plugin-<id>, but its admin nav entry and pages live in apps/admin/src/plugins/<id>/, registered in apps/admin/src/plugins/registry.ts. The split keeps apps/admin clonable on its own. Nav entries can be grouped, and disappear when the plugin is disabled.

Bundled plugins are enabled by default and toggled live from Installed Plugins. A plugin’s public and admin routes are gated per request, so a disabled plugin returns 404 without a redeploy. Disabling does not delete data.

ctx.events is an in-process, best-effort bus. Do not rely on it for anything that must not be lost. Use webhooks (database-backed, retried on the 5-minute cron) for durable delivery. Lifecycle hooks are declared but not executed. There is no per-capability context restriction.

The list of which plugins exist is configured separately in apps/api and apps/admin. Block-type contribution from plugins is not implemented.