Plugin API
For a step-by-step example, see the Build a Loyalty Plugin tutorial. For the conceptual model, see Plugin Architecture.
CommercePlugin
Section titled “CommercePlugin”A plugin is a config transform function:
type CommercePlugin = (config: CommerceConfig) => CommerceConfig | Promise<CommerceConfig>;Plugins are listed in config.plugins[] and applied sequentially during defineConfig().
defineCommercePlugin
Section titled “defineCommercePlugin”function defineCommercePlugin(manifest: CommercePluginManifest): CommercePlugin;CommercePluginManifest
Section titled “CommercePluginManifest”| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | string | Yes | Unique plugin identifier |
| version | string | Yes | Semantic version |
| permissions | PluginPermission[] | No | Permission scopes declared by this plugin |
| schema | () => Record<string, unknown> | No | Returns Drizzle pgTable objects |
| hooks | () => PluginHookRegistration[] | No | Returns hook registrations |
| routes | (ctx: PluginContext) => PluginRouteRegistration[] | No | Returns HTTP route registrations |
| analyticsModels | () => AnalyticsModel[] | No | Returns analytics model definitions |
| apiKeyScopes | () => Record<string, ApiKeyScopeDefinition> | No | Registers named API-key scopes merged into config.auth.apiKeyScopes |
PluginPermission
Section titled “PluginPermission”interface PluginPermission { scope: string; description: string;}Permissions declared here are registered with the authorization system at boot. Routes can require them via .permission("scope").
permissions: [ { scope: "wishlist:read", description: "View wishlists" }, { scope: "wishlist:write", description: "Create and modify wishlists" },],PluginContext
Section titled “PluginContext”Provided to the routes factory function at kernel boot time:
interface PluginContext { config: CommerceConfig; services: Record<string, unknown>; database: { db: PluginDb; transaction<T>(fn: (tx: PluginDb) => Promise<T>): Promise<T>; }; logger: PluginLogger; auth?: unknown;}database.db is typed as PluginDb. Use it directly for Drizzle queries without casting.
auth is the Better Auth instance. It is present when routes are mounted by createServer and absent in bare-kernel setups, so plugins must handle it being undefined. Plugins use it to mint or verify credentials at runtime — for example, plugin-pos PIN login.
apiKeyScopes
Section titled “apiKeyScopes”Registers named API-key scopes that get merged into config.auth.apiKeyScopes. A user config entry of the same name wins over the plugin’s.
apiKeyScopes?: () => Record<string, { prefix?: string; description?: string; permissions?: Record<string, string[]>; keyExpiration?: { minExpiresIn?: number; maxExpiresIn?: number };}>;keyExpiration bounds are in days and accept fractions (1/24 = one hour, 1/96 = 15 minutes). This is how plugin-pos mints short-lived per-shift keys:
apiKeyScopes: () => ({ pos: { prefix: "pos_shift_", description: "Per-shift POS key", permissions: { pos: ["operate"] }, keyExpiration: { minExpiresIn: 1 / 96 }, },}),PluginHookRegistration
Section titled “PluginHookRegistration”interface PluginHookRegistration { key: string; handler: (...args: unknown[]) => unknown;}key corresponds to any hook key (e.g., "checkout.beforeCreate", "orders.afterCreate"). The handler is appended to the array for that key in config.hooks.
router() builder
Section titled “router() builder”The router() builder provides a fluent API for defining plugin routes with built-in authentication, permissions, input validation, and OpenAPI documentation.
import { router } from "@porulle/core";
const routes = router("wishlist", "/api/wishlist") .get("/", { summary: "List wishlist items" }) .auth() .handler(async ({ actor, db }) => { const items = await db.select().from(wishlistItems) .where(eq(wishlistItems.customerId, actor.userId)); return { data: items }; }) .post("/", { summary: "Add item to wishlist" }) .auth() .permission("wishlist:write") .input(addItemSchema) .handler(async ({ input, actor, db }) => { const [item] = await db.insert(wishlistItems) .values({ customerId: actor.userId, ...input }) .returning(); return { data: item }; }) .delete("/:id", { summary: "Remove item from wishlist" }) .auth() .permission("wishlist:write") .handler(async ({ actor, db, params }) => { await db.delete(wishlistItems) .where(eq(wishlistItems.id, params.id)); return { data: { deleted: true } }; }) .build();router(tag, prefix) — creates a route group. tag is used for OpenAPI grouping; prefix is prepended to all route paths.
Chain methods:
| Method | Description |
|--------|-------------|
| .get(path, opts?) / .post() / .put() / .patch() / .delete() | Start a new route definition. opts accepts { summary } for OpenAPI. |
| .auth() | Require authentication. actor is guaranteed in the handler context. |
| .permission(scope) | Require a permission scope. Implicitly calls .auth(). |
| .input(zodSchema) | Validate the request body with a Zod schema. Parsed value is available as input. |
| .handler(fn) | The route handler. Receives { input, actor, services, db, params }. |
| .build() | Returns the array of PluginRouteRegistration objects. |
All routes defined via router() appear automatically in the OpenAPI spec at GET /api/doc and the Swagger UI at GET /api/reference.
Plugin lifecycle
Section titled “Plugin lifecycle”- Collection — Plugins are applied in array order during
defineConfig(). Order matters: if Plugin B queries tables from Plugin A, Plugin A must come first. - Permissions — Permission scopes are registered globally at boot.
- Schema — Table definitions are pushed into
config.customSchemas[]and merged viabuildSchema(config). - Hooks — Hook registrations are merged into
config.hooks. - Routes — Route factories are chained and executed at kernel boot, receiving
PluginContext. - Analytics models — Models are pushed into
config.analytics.models.
Official plugins
Section titled “Official plugins”| Package | Description |
|---------|-------------|
| @porulle/plugin-marketplace | Multi-vendor marketplace, commissions, sub-orders, financial ledger, disputes |
| @porulle/plugin-pos | POS terminals, shifts, split payment, returns, Z-reports |
| @porulle/plugin-pos-restaurant | Restaurant extensions: modifiers, tables, KDS, combos |
| @porulle/plugin-uom | Units of measure, category-based conversions, AP/EP yield |
| @porulle/plugin-procurement | Suppliers, purchase orders, goods received notes |
| @porulle/plugin-warehouse | Stock transfers, wastage, stock reconciliation |
| @porulle/plugin-production | Multi-level BOM, cost rollup, production orders |
| @porulle/plugin-notifications | Unified dispatcher: SMS, push, print, templates, audit |
| @porulle/plugin-scheduled-orders | Future/scheduled orders with auto-processing |
| @porulle/plugin-reviews | Customer reviews, ratings, moderation, owner reply |
| @porulle/plugin-appointments | Appointment scheduling, slot generation, double-booking prevention |
| @porulle/plugin-gift-cards | Stored-value cards, balance tracking, checkout integration |
Plugin testing helpers
Section titled “Plugin testing helpers”createPluginTestApp(plugin, overrides?)
Section titled “createPluginTestApp(plugin, overrides?)”Boots an in-memory PGlite database, pushes the merged schema (core + plugin tables), mounts the x-test-actor middleware, and registers all plugin routes.
import { createPluginTestApp } from "@porulle/core";
const { app, kernel, db } = await createPluginTestApp(myPlugin());Returns:
| Field | Type | Description |
|-------|------|-------------|
| app | OpenAPIHono | Hono app with routes registered. Call app.request(url, init) to test. |
| kernel | Kernel | Booted kernel. Use for direct service-layer assertions. |
| db | PluginDb | Drizzle database instance. Use for direct query assertions. |
Pass databaseAdapter in overrides to use a real PostgreSQL instance instead of PGlite.
jsonHeaders(actor?)
Section titled “jsonHeaders(actor?)”Builds request headers with x-test-actor injection:
import { jsonHeaders, testAdminActor } from "@porulle/core";
const res = await app.request("http://localhost/api/my-route", { method: "POST", headers: jsonHeaders(testAdminActor), body: JSON.stringify({ ... }),});Test actors
Section titled “Test actors”| Export | Role | Permissions |
|--------|------|-------------|
| testAdminActor | admin | *:* |
| testStaffActor | staff | catalog, inventory, orders |
| testCustomerActor | customer | catalog:read, cart, orders:read:own |
| testNoPermActor | customer | (none) |
Typed hook helpers
Section titled “Typed hook helpers”import { beforeHook, afterHook } from "@porulle/core";
hooks: () => [ afterHook<{ id: string; grandTotal: number }>("orders.afterCreate", async ({ result, context }) => { await context.jobs.enqueue("loyalty:award-points", { orderId: result.id }); }),],@porulle/db package
Section titled “@porulle/db package”Plugin developers import from @porulle/db instead of drizzle-orm directly.
import { defineTable, column, eq, and, desc, sql } from "@porulle/db";defineTable(name, columns)
Section titled “defineTable(name, columns)”Wraps Drizzle’s pgTable with auto-injected fields for org-scoped tables:
| Auto-injected field | Description |
|--------------------|-------------|
| id | uuid, primary key, defaultRandom() |
| organizationId | text, NOT NULL, FK to organization.id |
| createdAt | timestamptz, defaultNow(), NOT NULL |
| updatedAt | timestamptz, defaultNow(), NOT NULL |
Child tables (detected by FK to a table that has organizationId) get only id and createdAt — no duplicate organizationId.
column namespace
Section titled “column namespace”| Builder | Options |
|---------|---------|
| column.text(opts?) | unique, optional, enum, default |
| column.integer(opts?) | unique, optional, default |
| column.boolean(opts?) | optional, default |
| column.uuid(opts?) | optional, references (PgTable for FK) |
| column.timestamp(opts?) | optional, default ("now") |
| column.json(opts?) | optional, default |