Skip to content

Plugin API

For a step-by-step example, see the Build a Loyalty Plugin tutorial. For the conceptual model, see Plugin Architecture.

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


function defineCommercePlugin(manifest: CommercePluginManifest): CommercePlugin;
FieldTypeRequiredDescription
idstringYesUnique plugin identifier
versionstringYesSemantic version
permissionsPluginPermission[]NoPermission scopes declared by this plugin
schema() => Record<string, unknown>NoReturns Drizzle pgTable objects
hooks() => PluginHookRegistration[]NoReturns hook registrations
routes(ctx: PluginContext) => PluginRouteRegistration[]NoReturns HTTP route registrations
analyticsModels() => AnalyticsModel[]NoReturns analytics model definitions
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" },
],

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

database.db is typed as PluginDb. Use it directly for Drizzle queries without casting.


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.


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:

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


  1. Collection — Plugins are applied in array order during defineConfig(). Order matters: if Plugin B queries tables from Plugin A, Plugin A must come first.
  2. Permissions — Permission scopes are registered globally at boot.
  3. Schema — Table definitions are pushed into config.customSchemas[] and merged via buildSchema(config).
  4. Hooks — Hook registrations are merged into config.hooks.
  5. Routes — Route factories are chained and executed at kernel boot, receiving PluginContext.
  6. Analytics models — Models are pushed into config.analytics.models.

PackageDescription
@porulle/plugin-marketplaceMulti-vendor marketplace, commissions, sub-orders, financial ledger, disputes
@porulle/plugin-posPOS terminals, shifts, split payment, returns, Z-reports
@porulle/plugin-pos-restaurantRestaurant extensions: modifiers, tables, KDS, combos
@porulle/plugin-uomUnits of measure, category-based conversions, AP/EP yield
@porulle/plugin-procurementSuppliers, purchase orders, goods received notes
@porulle/plugin-warehouseStock transfers, wastage, stock reconciliation
@porulle/plugin-productionMulti-level BOM, cost rollup, production orders
@porulle/plugin-notificationsUnified dispatcher: SMS, push, print, templates, audit
@porulle/plugin-scheduled-ordersFuture/scheduled orders with auto-processing
@porulle/plugin-reviewsCustomer reviews, ratings, moderation, owner reply
@porulle/plugin-appointmentsAppointment scheduling, slot generation, double-booking prevention
@porulle/plugin-gift-cardsStored-value cards, balance tracking, checkout integration

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:

FieldTypeDescription
appOpenAPIHonoHono app with routes registered. Call app.request(url, init) to test.
kernelKernelBooted kernel. Use for direct service-layer assertions.
dbPluginDbDrizzle database instance. Use for direct query assertions.

Pass databaseAdapter in overrides to use a real PostgreSQL instance instead of PGlite.

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({ ... }),
});
ExportRolePermissions
testAdminActoradmin*:*
testStaffActorstaffcatalog, inventory, orders
testCustomerActorcustomercatalog:read, cart, orders:read:own
testNoPermActorcustomer(none)
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 });
}),
],

Plugin developers import from @porulle/db instead of drizzle-orm directly.

import { defineTable, column, eq, and, desc, sql } from "@porulle/db";

Wraps Drizzle’s pgTable with auto-injected fields for org-scoped tables:

Auto-injected fieldDescription
iduuid, primary key, defaultRandom()
organizationIdtext, NOT NULL, FK to organization.id
createdAttimestamptz, defaultNow(), NOT NULL
updatedAttimestamptz, defaultNow(), NOT NULL

Child tables (detected by FK to a table that has organizationId) get only id and createdAt — no duplicate organizationId.

BuilderOptions
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