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;

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

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


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 },
},
}),

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:

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


  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.

| 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-giftcards | Stored-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:

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

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({ ... }),
});

| Export | Role | Permissions | |--------|------|-------------| | testAdminActor | admin | *:* | | testStaffActor | staff | catalog, inventory, orders | | testCustomerActor | customer | catalog:read, cart, orders:read:own | | testNoPermActor | customer | (none) |

For any other shape, build one with createTestActor({ permissions, role }) — it fills the rest (test organization, a stable user id) so a test names only what it is about.

Type a handler with AfterHook / BeforeHook and register it by key:

import { resolveOrgIdForCommerce, type AfterHook } from "@porulle/core";
const awardPoints: AfterHook<{ id: string; grandTotal: number }> = async ({ result, context }) => {
await context.jobs.enqueue(
"loyalty:award-points",
{ orderId: result.id },
// The org the event belongs to; a blank organizationId is refused at enqueue.
{ organizationId: resolveOrgIdForCommerce(context.actor, context.commerceConfig) },
);
};
hooks: () => [{ key: "orders.afterCreate", handler: awardPoints }],

Define plugin-owned tables with Drizzle’s pgTable and column builders imported from @porulle/core/drizzle (same copy the kernel uses — avoids type drift). Every org-scoped table needs an organizationId text column; add indexes and unique constraints in the table callback as needed.

src/schema.ts
import {
pgTable,
uuid,
text,
timestamp,
index,
uniqueIndex,
} from "@porulle/core/drizzle";
import { organization } from "@porulle/core/auth-schema";
import { sellableEntities } from "@porulle/core/schema";
export const wishlistItems = pgTable("wishlist_items", {
id: uuid("id").defaultRandom().primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
entityId: uuid("entity_id")
.notNull()
.references(() => sellableEntities.id, { onDelete: "cascade" }),
note: text("note"),
addedAt: timestamp("added_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
orgIdx: index("idx_wishlist_items_org").on(table.organizationId),
orgUserEntityUnique: uniqueIndex("wishlist_items_org_user_entity_unique")
.on(table.organizationId, table.userId, table.entityId),
}));

Query helpers (eq, and, desc, sql, …) also come from @porulle/core/drizzle.