This is the full developer documentation for Porulle
# Porulle
> Headless commerce for TypeScript. Self-host. Own the stack.
Porulle (pronounced *poh-ROO-leh*) is a TypeScript-first headless commerce framework. Install it into any TypeScript project, point it at PostgreSQL, and you have a hardened REST API in minutes.
```bash
bunx @porulle/cli init my-store
cd my-store && bun install && bun run db:push && bun run dev
```
The API is live at `http://localhost:4000`. The OpenAPI explorer is at `/api/reference`.
***
Get Started
[Introduction](/get-started/intro/) — what Porulle is and who it is for.
[Install](/get-started/install/) — prerequisites, packages, database setup.
[Quickstart](/get-started/quickstart/) — a working commerce API in five minutes.
Building a Store
[Authentication](/building/authentication/) — roles, API keys, social login.
[Email Notifications](/building/email/) — order confirmations, password resets, transactional sends.
[Analytics](/building/analytics/) — SQL-backed metrics with scoped access.
Extending Porulle
[Hooks](/extending/hooks/) — intercept any operation with before/after handlers.
[Custom Tables](/extending/custom-tables/) — add app-level Drizzle tables to the org-scoped schema.
[Payment Adapter](/extending/payment-adapter/) — implement the contract for any payment rail.
Frontend Integration
[Next.js](/frontend/nextjs/) — Hono mounted in a route handler.
[TanStack Start](/frontend/tanstack-start/) — server functions calling LocalAPI in-process.
[Typed SDK Client](/frontend/sdk/) — `@porulle/sdk` + React Query bindings.
Running in Production
[Deploy](/production/deployment/) — Bun, Node.js, Cloudflare Workers, Fly.io.
[Multi-Tenancy](/production/multi-tenancy/) — B2C single-storefront vs B2B multi-tenant.
[Webhooks and Audit](/production/webhooks-and-audit/) — signed delivery + idempotency.
Reference
[Configuration](/reference/configuration/) — every `defineConfig` option.
[REST API](/reference/rest-api/) — 116 paths, 146 operations.
[Adapters](/reference/adapters/) — database, payment, storage, search.
Concepts
[Architecture](/concepts/architecture/) — the kernel, modules, adapters, interfaces.
[Plugin Architecture](/concepts/plugin-architecture/) — why plugins are config transforms.
[Result Types](/concepts/result-types/) — why services return Result\.
Tutorials
[Your First Store](/tutorials/first-store/) — products, inventory, cart, checkout end-to-end.
[Build a Loyalty Plugin](/tutorials/build-a-plugin/) — hooks, schema, routes in one plugin.
[Tea Shop POS](/tutorials/tea-shop-pos/) — terminals, shifts, split payment, Z-reports.
***
**Status:** v0.1.0 alpha. The REST API, multi-tenant kernel, plugin contract, and adapter contracts are stable. Agent-native primitives (principal model, multi-protocol gateway) are Phase 2. See the [Security Model](/production/security-model/) for the full threat model and Phase 2 roadmap.
# Building a Store
> Task-focused guides for shipping a Porulle-powered store — auth, payments, email, analytics, and the bundled commerce plugins.
You have Porulle installed and a database wired up. Now you need to ship a store. This section is the recipe shelf — each page solves one task end-to-end.
## Customer-facing
[Section titled “Customer-facing”](#customer-facing)
[Authentication ](/building/authentication/)Roles, permissions, API keys, social login. The auth surface adopters configure first.
[Email Notifications ](/building/email/)Order confirmations, password resets, custom transactional emails through Resend or SES.
[Analytics ](/building/analytics/)SQL-backed analytics with scoped access for admin / vendor / customer views.
## Retail operations
[Section titled “Retail operations”](#retail-operations)
[Store Settings ](/building/settings/)Org-scoped typed settings — currency, timezone, branding, policies — that other features read at runtime.
[Receipts & Invoices ](/building/receipts-and-invoices/)HTML receipts, dependency-free PDF invoices, and atomic idempotent fiscal invoice numbering.
[Tax Classes ](/building/tax/)Per-product tax rates with variant overrides and per-line, discount-aware checkout tax.
[Refunds & Exchanges ](/building/refunds-and-exchanges/)Line-level refunds with daily caps and an undo window, plus atomic POS exchanges.
## Bundled plugins
[Section titled “Bundled plugins”](#bundled-plugins)
[Gift Cards ](/building/gift-cards/)Issue, redeem, and reconcile gift cards as a first-class commerce primitive.
[Point of Sale ](/building/pos/)In-store terminals, shift management, split payments, end-of-day Z-reports.
[Layaway ](/building/layaway/)Partial-payment plans: reserve stock with a deposit, pay in installments, auto-complete to an order.
[Supply Chain ](/building/supply-chain/)Procurement, production runs, warehouse transfers, wastage tracking.
## Where to next
[Section titled “Where to next”](#where-to-next)
* [**Extending Porulle**](/extending/) — when the bundled features don’t cover your case and you need to add hooks, plugins, or custom routes.
* [**Reference**](/reference/) — the exhaustive API + configuration surface.
* [**Concepts**](/concepts/) — design rationale for the entity model, hook pipeline, and identity layer.
# Provisioning via the Admin API
> Use @porulle/sdk to provision your store — create products, set inventory, publish, manage customers — with end-to-end TypeScript types.
The Porulle SDK is a typed wrapper around your server’s REST API. You generate types from your live server’s OpenAPI spec, so every admin call is type-checked against your exact deployment — including plugin routes you’ve added.
This guide takes you from “fresh API key” to “first product live and inventoried” in under five minutes.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* A running Porulle server (local or production). If you don’t have one, follow the [Quickstart](/get-started/quickstart/).
* An admin API key. Create one with the CLI:
```bash
porulle api-key create --server https://api.acme.com --name admin-cli --scopes "*:*"
```
Caution
The key is shown once — paste it into your password manager or `.env` immediately.
## Step 1 — Install + generate types
[Section titled “Step 1 — Install + generate types”](#step-1--install--generate-types)
1. Install the SDK and the codegen toolchain.
```bash
bun add @porulle/sdk
bun add -d openapi-typescript
```
2. Generate types from your live server. The codegen reads `/api/doc` (which serves your OpenAPI 3.1 spec) and writes a typed `paths` object.
```bash
bunx @porulle/sdk generate \
--url https://api.acme.com/api/doc \
--out src/api-types.ts
```
Tip
Re-run codegen any time you ship plugin route changes or add custom admin endpoints. The types should ride with the deployment.
3. Create the typed client.
src/api.ts
```ts
import { createPorulleClient } from "@porulle/sdk";
import type { paths } from "./api-types";
export const api = createPorulleClient({
baseUrl: process.env.PORULLE_BASE_URL!,
apiKey: process.env.PORULLE_ADMIN_KEY!,
});
```
## Step 2 — Create your first product
[Section titled “Step 2 — Create your first product”](#step-2--create-your-first-product)
Every catalog item is a `sellable_entity`. Products, services, gift cards, subscriptions all use the same shape — what differs is the `type` and the typed `fields`.
scripts/seed-product.ts
```ts
import { api } from "../src/api";
const created = await api.POST("/api/admin/entities", {
body: {
type: "product",
name: "Cotton Tee",
slug: "cotton-tee",
description: "Heavyweight 240gsm cotton, screen-printed.",
fields: {
weight: 240, // grams — declared in commerce.config.ts
material: "cotton",
},
variants: [
{ sku: "TEE-S-BLK", optionValues: { size: "S", color: "black" }, price: 2900 },
{ sku: "TEE-M-BLK", optionValues: { size: "M", color: "black" }, price: 2900 },
{ sku: "TEE-L-BLK", optionValues: { size: "L", color: "black" }, price: 2900 },
{ sku: "TEE-S-WHT", optionValues: { size: "S", color: "white" }, price: 2900 },
{ sku: "TEE-M-WHT", optionValues: { size: "M", color: "white" }, price: 2900 },
],
},
});
if (created.error) {
console.error("Failed:", created.error);
process.exit(1);
}
console.log(`Created entity ${created.data.id} with ${created.data.variants.length} variants`);
```
Prices are integers in the smallest currency unit (cents for USD, øre for DKK). Currency itself comes from `commerce.config.ts` — never pass it per request.
## Step 3 — Set inventory levels
[Section titled “Step 3 — Set inventory levels”](#step-3--set-inventory-levels)
A product with no inventory shows as out of stock. Set per-variant levels per location.
scripts/seed-inventory.ts
```ts
import { api } from "../src/api";
const entityId = "ent_…"; // from Step 2
await api.POST("/api/admin/inventory/levels", {
body: {
entityId,
locationId: "wh_main", // create locations via /api/admin/locations
levels: [
{ variantId: "var_s_black", available: 50 },
{ variantId: "var_m_black", available: 80 },
{ variantId: "var_l_black", available: 30 },
],
},
});
```
For multi-warehouse stores, create one call per location. The `inventory.afterChange` hook fires on each call — useful for syncing to external WMS.
## Step 4 — Publish
[Section titled “Step 4 — Publish”](#step-4--publish)
Entities are created in `draft` status. Move them to `published` to expose on the storefront.
```ts
await api.PATCH("/api/admin/entities/{id}", {
params: { path: { id: entityId } },
body: { status: "published" },
});
```
The product is now reachable at:
* `GET /api/catalog/entities/cotton-tee` — public catalog read
* `GET /api/search?q=cotton+tee` — full-text search
* Whatever storefront you point at the API
## Common admin operations
[Section titled “Common admin operations”](#common-admin-operations)
* Customers
```ts
// Create a customer
const { data: customer } = await api.POST("/api/admin/customers", {
body: { email: "kai@acme.com", firstName: "Kai", lastName: "Lin" },
});
// Find by email
const { data: existing } = await api.GET("/api/admin/customers", {
params: { query: { email: "kai@acme.com" } },
});
```
* Orders
```ts
// List orders
const { data: orders } = await api.GET("/api/admin/orders", {
params: { query: { status: "confirmed", limit: 50 } },
});
// Refund
await api.POST("/api/admin/orders/{id}/refund", {
params: { path: { id: order.id } },
body: { amount: 1000, reason: "damaged in transit" },
});
```
* Promotions
```ts
await api.POST("/api/admin/promotions", {
body: {
code: "SUMMER25",
type: "percentage",
value: 25,
validFrom: new Date().toISOString(),
validUntil: new Date(Date.now() + 7 * 86400 * 1000).toISOString(),
minOrderTotal: 5000,
},
});
```
* Media upload
```ts
const file = await Bun.file("./tee-front.jpg").arrayBuffer();
const { data: upload } = await api.POST("/api/admin/media", {
body: file,
headers: { "Content-Type": "image/jpeg" },
});
await api.POST("/api/admin/entities/{id}/media", {
params: { path: { id: entityId } },
body: { mediaId: upload.id, role: "primary" },
});
```
The kernel validates the upload’s magic bytes and rejects spoofed MIME types.
## Error handling
[Section titled “Error handling”](#error-handling)
Every SDK call returns `{ data, error }`. Errors are typed — narrow on `error.code` for behavior.
```ts
const { data, error } = await api.POST("/api/admin/entities", { body: {...} });
if (error) {
switch (error.code) {
case "VALIDATION_ERROR":
console.error("Bad input:", error.fields);
break;
case "DUPLICATE_SLUG":
console.error("Slug already taken — pick another");
break;
case "PERMISSION_DENIED":
console.error("API key lacks the entities:create scope");
break;
default:
console.error("Unexpected:", error);
}
return;
}
// data is fully typed beyond this point
```
The kernel never throws across module boundaries — see the [Result Types concept](/concepts/result-types/) for the discipline.
## Idempotency
[Section titled “Idempotency”](#idempotency)
For mutating endpoints, pass an `Idempotency-Key` header. Repeating the same key returns the cached response without re-running the mutation.
```ts
await api.POST("/api/admin/entities", {
body: {...},
headers: { "Idempotency-Key": "seed-2026-05-cotton-tee" },
});
```
Keys live for 24 hours.
## Putting it together
[Section titled “Putting it together”](#putting-it-together)
A complete seed script for a fresh production store:
scripts/seed.ts
```ts
import { api } from "../src/api";
async function main() {
// 1. Locations
const { data: warehouse } = await api.POST("/api/admin/locations", {
body: { name: "Main Warehouse", code: "wh_main", type: "warehouse" },
});
// 2. Products
const products = [
{ name: "Cotton Tee", slug: "cotton-tee", price: 2900 },
{ name: "Wool Hoodie", slug: "wool-hoodie", price: 8900 },
{ name: "Canvas Tote", slug: "canvas-tote", price: 1900 },
];
for (const p of products) {
const { data: entity } = await api.POST("/api/admin/entities", {
body: {
type: "product",
name: p.name,
slug: p.slug,
variants: [{ sku: `${p.slug}-default`, price: p.price }],
},
});
await api.POST("/api/admin/inventory/levels", {
body: {
entityId: entity.id,
locationId: warehouse.id,
levels: [{ variantId: entity.variants[0].id, available: 100 }],
},
});
await api.PATCH("/api/admin/entities/{id}", {
params: { path: { id: entity.id } },
body: { status: "published" },
});
console.log(`✓ ${p.name} live at /products/${p.slug}`);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
```
```bash
PORULLE_BASE_URL=https://api.acme.com \
PORULLE_ADMIN_KEY=pak_live_… \
bun run scripts/seed.ts
```
## Where to next
[Section titled “Where to next”](#where-to-next)
* [Authentication](/building/authentication/) — API key scopes, what each scope allows
* [Custom Tables](/extending/custom-tables/) — extend the schema for fields the kernel doesn’t model
* [REST API Reference](/reference/rest-api/) — every endpoint, request body, and response shape
* [Typed SDK Client](/frontend/sdk/) — frontend usage with React Query bindings
# Analytics
> Query metrics, add custom analytics models, and scope data by role.
## Default setup
[Section titled “Default setup”](#default-setup)
Analytics works out of the box with zero configuration. The kernel instantiates a `DrizzleAnalyticsAdapter` that compiles analytics queries into SQL and runs them directly against your PostgreSQL database.
On startup, the kernel registers four built-in models:
\| Model | What it covers | |-------|----------------| | `Orders` | Order count, revenue, status breakdown | | `OrderLineItems` | Per-item revenue, quantity, product breakdown | | `Customers` | Customer count, new vs returning | | `Inventory` | Stock levels, reservation status |
If the marketplace plugin is installed, three additional models are registered automatically: `VendorOrders`, `VendorBalance`, `VendorReviews`.
For the complete list of measures, dimensions, segments, and filter operators, see the [Analytics Reference](/reference/analytics/).
## Query analytics
[Section titled “Query analytics”](#query-analytics)
Use the `analytics.query` service method from a hook, custom route, or script:
```typescript
import { buildAnalyticsScope } from "@porulle/core";
const scope = buildAnalyticsScope(actor);
const result = await kernel.analytics.query(
{
model: "Orders",
measures: ["count", "revenue"],
dimensions: ["status"],
filters: [{ dimension: "createdAt", operator: "gte", value: "2026-01-01" }],
limit: 100,
},
scope,
);
```
All queries run within an `AnalyticsScope` that restricts data visibility based on the caller’s role. Always pass a scope — never query without one. Scopes are created exclusively through `buildAnalyticsScope`.
## Add custom analytics models
[Section titled “Add custom analytics models”](#add-custom-analytics-models)
Plugins contribute analytics models via the `analyticsModels` manifest slot in `defineCommercePlugin`:
```typescript
import type { AnalyticsModel } from "@porulle/core";
import { defineCommercePlugin } from "@porulle/core";
const subscriptionsModel: AnalyticsModel = {
name: "Subscriptions",
title: "Subscriptions",
sql: "SELECT * FROM subscriptions",
measures: {
count: { type: "count", title: "Subscription Count" },
mrr: { type: "sum", sql: "monthly_amount", title: "Monthly Recurring Revenue" },
},
dimensions: {
status: { type: "string", sql: "status", title: "Status" },
startedAt: { type: "time", sql: "started_at", title: "Started At" },
customerId: { type: "string", sql: "customer_id", title: "Customer ID" },
},
};
export const subscriptionsPlugin = defineCommercePlugin({
id: "subscriptions",
version: "1.0.0",
analyticsModels: () => [subscriptionsModel],
});
```
Models contributed this way are registered at kernel startup. They appear in `GET /api/analytics/meta` and can be queried through `GET /api/analytics/query` like any built-in model.
## Scope rules
[Section titled “Scope rules”](#scope-rules)
Analytics queries enforce role-based scoping automatically. A customer actor can only see their own data. A vendor actor sees only their vendor’s orders. An admin actor sees all data for the organization.
The `buildAnalyticsScope` function reads the actor’s role and organization ID and builds the appropriate filter set. Passing the wrong scope (or no scope) is a security defect — the query will return either wrong data or fail validation.
## REST endpoints
[Section titled “REST endpoints”](#rest-endpoints)
Query analytics via the REST API with the `analytics:read` permission:
```bash
# List available models, measures, and dimensions
curl "http://localhost:4000/api/analytics/meta" \
-H "x-api-key: dev-staff-key"
# Query orders by status
curl -X POST "http://localhost:4000/api/analytics/query" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"model": "Orders",
"measures": ["count", "revenue"],
"dimensions": ["status"],
"limit": 50
}'
```
## Retail reports
[Section titled “Retail reports”](#retail-reports)
Alongside the semantic query engine above, Porulle ships a pack of canned, parameterized retail reports. The query engine composes arbitrary measures and dimensions across models; these reports are fixed, purpose-built views — a daily journal, a tax summary, inventory aging — that you fetch by name and narrow with date parameters. Both require the `analytics:read` permission scope (granted to owner, admin, and manager).
There are no new tables — each report runs parameterized SQL over the existing orders, line-item, inventory, and entity data.
\| Report | What it returns | |--------|-----------------| | `daily-journal` | Per-day sales journal: order rows plus a summary with prior-day deltas | | `tax-summary` | Tax collected over a range, bucketed per local day, with prior-period deltas | | `inventory-aging` | On-hand stock bucketed by days since last restock (0-30 / 31-60 / 61-90 / 90+) | | `sell-through` | Units sold in the range vs current on-hand, per entity | | `reorder-needed` | Inventory rows at or below their reorder threshold | | `staff-sales` | Orders and revenue grouped by `orders.metadata.staffId` |
List the available reports, then fetch one by name. Single-day reports take an optional `date`; range reports take optional `from` and `to` (inclusive). All dates use `YYYY-MM-DD`. When a range is omitted it defaults to the first of the current month through today.
```bash
# List available reports
curl "http://localhost:4000/api/analytics/reports" \
-H "x-api-key: dev-staff-key"
# A single day's sales journal
curl "http://localhost:4000/api/analytics/reports/daily-journal?date=2026-07-01" \
-H "x-api-key: dev-staff-key"
# Tax collected over a range
curl "http://localhost:4000/api/analytics/reports/tax-summary?from=2026-06-01&to=2026-06-30" \
-H "x-api-key: dev-staff-key"
```
`GET /api/analytics/reports` returns `{ data: [{ name, description }, ...] }`; `GET /api/analytics/reports/{name}` returns `{ data: }`.
Calendar math uses the store’s timezone, read from the `general.timezone` [settings group](/building/settings/) (default UTC), so every day boundary and range default is computed in local time. Financial reports exclude `cancelled` and `voided` orders.
## Related
[Section titled “Related”](#related)
* [Analytics Reference](/reference/analytics/) — all models, measures, dimensions, filter operators, and scope rules
* [Store Settings](/building/settings/) — the timezone reports bucket by
* [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — example of contributing a plugin with hooks and routes
* [Plugin Architecture](/concepts/plugin-architecture/) — `analyticsModels` manifest slot
# Authentication
> Roles, permissions, API keys, social login, 2FA, and store resolution.
Porulle uses [Better Auth](https://better-auth.com/) for authentication. This guide covers configuring roles, permissions, API keys, social login, and multi-store resolution in `defineConfig`. For the underlying design rationale, see [Identity and Store Resolution](/concepts/identity-model/).
## Roles and permissions
[Section titled “Roles and permissions”](#roles-and-permissions)
Define roles in `auth.roles`. Each role has an array of permission strings in `"module:action"` or `"module:action:scope"` format.
commerce.config.ts
```ts
import { defineConfig } from "@porulle/core";
export default defineConfig({
auth: {
roles: {
owner: { permissions: ["*:*"] },
admin: { permissions: ["*:*"] },
staff: {
permissions: [
"catalog:create", "catalog:update", "catalog:read",
"inventory:adjust", "inventory:read",
"orders:create", "orders:read", "orders:update",
"customers:read",
"cart:create", "cart:update",
],
},
customer: {
permissions: [
"catalog:read",
"cart:create", "cart:read", "cart:update",
"orders:create", "orders:read:own",
"customers:read:self", "customers:update:self",
],
},
},
},
});
```
Available permission scopes:
\| Module | Actions | Scoped variants | |--------|---------|-----------------| | `catalog` | `create`, `read`, `update`, `delete` | — | | `orders` | `create`, `read`, `update` | `read:own` | | `customers` | `read`, `update` | `read:self`, `update:self` | | `cart` | `create`, `read`, `update` | — | | `inventory` | `read`, `adjust` | — | | `media` | `read`, `write` | — | | `pricing` | `read`, `manage` | — | | `promotions` | `read`, `manage` | — | | `webhooks` | `manage` | — | | `audit` | `read` | — | | `jobs` | `admin` | — | | `*` | `*` | Wildcard — grants everything |
Plugins declare additional scopes (e.g., `loyalty:read`, `pos:operate`, `gift-cards:admin`). Check the plugin’s manifest for its permission names.
## Trusted origins
[Section titled “Trusted origins”](#trusted-origins)
Better Auth requires `trustedOrigins` for CSRF protection. Browsers send session cookies with cross-origin requests — without an explicit allowlist, the server rejects them.
```ts
auth: {
trustedOrigins: [
"http://localhost:4000",
"https://store.example.com",
"https://admin.example.com",
],
}
```
Add every origin that will make authenticated requests.
## API keys
[Section titled “API keys”](#api-keys)
```ts
auth: {
apiKeys: { enabled: true },
}
```
Clients authenticate by sending the key in the `x-api-key` header:
```bash
curl -H "x-api-key: your-key" http://localhost:4000/api/catalog/entities
```
The development key `dev-staff-key` is available only when `NODE_ENV !== "production"`. Generate production keys with:
```bash
bunx @porulle/cli api-key create --name "storefront" --role staff
```
## Social login
[Section titled “Social login”](#social-login)
```ts
auth: {
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
}
```
## Two-factor authentication
[Section titled “Two-factor authentication”](#two-factor-authentication)
```ts
auth: {
twoFactor: {
enabled: true,
requiredForRoles: ["owner", "admin"],
},
}
```
If you omit `requiredForRoles`, 2FA is optional for all users.
## Bearer token authentication
[Section titled “Bearer token authentication”](#bearer-token-authentication)
After sign-in, use the session token as a Bearer token for non-browser clients:
```bash
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/sign-in/email \
-H "content-type: application/json" \
-d '{"email":"admin@example.com","password":"secret"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/catalog/entities
```
## React Native / Expo
[Section titled “React Native / Expo”](#react-native--expo)
```bash
npx expo install @better-auth/expo expo-secure-store
```
lib/auth-client.ts
```ts
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: "https://api.example.com",
plugins: [
expoClient({
scheme: "myapp",
storagePrefix: "myapp",
storage: SecureStore,
}),
],
});
```
The `expoClient` plugin handles token storage, refresh, and header injection automatically.
## Multi-store resolution
[Section titled “Multi-store resolution”](#multi-store-resolution)
For multi-tenant SaaS deployments, configure `storeResolver` to map requests to organization IDs:
```ts
auth: {
strictOrgResolution: true,
storeResolver: async (request) => {
const storeId = request.headers.get("x-store-id");
return storeId ?? null;
},
}
```
If `strictOrgResolution` is `true` and resolution returns `null`, the request is rejected with HTTP 503. Single-store deployments do not need `storeResolver`.
## Session options
[Section titled “Session options”](#session-options)
```ts
auth: {
requireEmailVerification: true,
sessionDuration: 60 * 60 * 24 * 7, // 7 days in seconds
}
```
## Authentication methods summary
[Section titled “Authentication methods summary”](#authentication-methods-summary)
\| Method | Header | Best for | |--------|--------|----------| | Session cookie | Auto-set by browser | Web storefronts, admin panels | | Bearer token | `Authorization: Bearer ` | Mobile apps, SPAs, server-to-server | | API key | `x-api-key: ` | External integrations, CI, AI agents |
## Security model
[Section titled “Security model”](#security-model)
The adopter security contract is in the [Security Model](/production/security-model/). It documents rate limits, cookie hygiene (`__Secure-` prefix, `HttpOnly`, `SameSite: lax`), the two org resolution profiles (B2C single-storefront, B2B multi-tenant), and Phase 2 gaps (agent attestation, per-region data residency).
# Channel Connectors
> Connect external Shopify / WooCommerce stores, mirror their catalog and inventory, and inject paid orders back into the merchant's store.
Channel Connectors let a platform built on Porulle list the catalogs of **many independent merchants who each run their own Shopify or WooCommerce store**, sell them through one unified checkout (the shopper pays **once, on the platform**), and **inject each merchant’s paid slice back into their own store as an already-paid order** — so the merchant’s existing fulfilment, notifications, and returns run unchanged.
Porulle owns the **commerce truth** (price-at-sale, payment, refund authority); the merchant store stays the control plane for **catalog, stock, and fulfilment**. The connector is a pure API client — **nothing is installed inside the merchant’s store**; they provide credentials and connect.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/plugin-channel-connector @porulle/adapter-shopify @porulle/adapter-woocommerce
```
The engine plugin is **standalone** — it does not require `plugin-marketplace`. Providers are thin adapter packages you register with it.
## Register
[Section titled “Register”](#register)
commerce.config.ts
```ts
import { channelConnectorPlugin } from "@porulle/plugin-channel-connector";
import { shopifyConnector } from "@porulle/adapter-shopify";
import { wooConnector } from "@porulle/adapter-woocommerce";
export default defineConfig({
plugins: [
channelConnectorPlugin({
connectors: [shopifyConnector(), wooConnector()],
}),
],
});
```
Include the plugin schema and push the new tables:
drizzle.config.ts
```ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-channel-connector/src/schema.ts",
],
```
```bash
bunx drizzle-kit push --config drizzle.config.ts
```
## Connect a store: two front doors
[Section titled “Connect a store: two front doors”](#connect-a-store-two-front-doors)
A merchant provides credentials once — a Shopify Admin API access token, or a WooCommerce consumer key + secret. Your onboarding backend makes one call:
```ts
await fetch(`${PORULLE}/api/channels/stores`, {
method: "POST",
headers: { "x-api-key": platformApiKey, "content-type": "application/json" },
body: JSON.stringify({
provider: "shopify",
storeDomain: "acme.myshopify.com",
credentials: { accessToken: "shpat_..." },
}),
});
// → 201 { id, provider, status: "connected", credentials: "[redacted]" }
```
Credentials are stored org-scoped and **redacted on every read**. Connecting a store automatically:
1. **Imports the catalog** — the store’s products land in `sellable_entities`, each with a `channel_entity_map` row linking it to its external id, and `sourceStoreId` marking it channel-owned.
2. **Mirrors inventory** in *external-owns-stock* mode (the store is the source of truth; level-set writes).
3. **Registers webhooks** on the store (product / inventory / order / refund events) pointing at Porulle.
Both catalog import and inventory sync run as durable jobs, keyed per store.
For one-click onboarding, configure OAuth on the same plugin and register the provider adapters with their OAuth options:
```ts
channelConnectorPlugin({
connectors: [
shopifyConnector({
clientId: process.env.SHOPIFY_CLIENT_ID,
clientSecret: process.env.SHOPIFY_CLIENT_SECRET,
appUrl: "https://platform.example",
}),
wooConnector(),
],
oauth: {
stateSecret: process.env.CHANNEL_OAUTH_SECRET!,
postConnectRedirect: "https://platform.example/stores",
},
});
```
The `GET /api/channels/oauth/{provider}/start` route requires `channels:manage`, accepts `shop` for Shopify or `store` for WooCommerce, and redirects to the provider’s approval screen. The unauthenticated GET/POST callback verifies the signed, single-use state and calls the connector’s optional `completeAuth(request, { storeDomain })`. Shopify exchanges its callback code; WooCommerce receives `{ consumer_key, consumer_secret }` in the server-to-server POST callback. Both then call the same `connectStore` pipeline shown above, so imports, webhook registration, and credential redaction behave identically to paste onboarding.
The adapter contract for this capability is:
```ts
buildAuthUrl(params): Result;
completeAuth(request, ctx): Promise;
storeDomain: string;
}, ChannelConnectorError>>;
```
OAuth is optional: connectors without these methods continue to support paste onboarding, and routes return a clear configuration/capability error when OAuth is not enabled.
## Selling
[Section titled “Selling”](#selling)
Once connected, imported products are **ordinary `sellable_entities`** — the same catalog API, the same cart, the same checkout. The connector is invisible at the point of sale.
Channel-owned entities are **write-protected**: they’re editable only by the sync/system actor (via the `catalog:sync` permission), so staff edits can’t fight the merchant store, which owns catalog truth.
Before payment is captured, a `checkout.beforePayment` hook does a **live stock read** against the merchant store for every channel line (a read, not a reserve). Out-of-stock lines are blocked before charging; an unreachable store **fails that line closed** (the rest of the cart proceeds). Checks run in parallel across stores.
## Order injection
[Section titled “Order injection”](#order-injection)
When a checkout completes, an `orders.afterCreate` hook groups the order’s line items by owning store and enqueues one `channel/push-order` job per store. Each job injects the merchant’s slice into their store as an **already-paid order** — Shopify `financial_status: paid`, WooCommerce `set_paid: true` — carrying the platform’s exact price-at-sale amounts and the real customer name / email / shipping address, so the merchant’s normal fulfilment runs.
Export state lives on the per-merchant slice (`channel_order_exports`): `pending → exported → confirmed | failed | abandoned`. A permanently-failing injection ends `abandoned → auto-refund` on a tiered SLA (definitive failures refund fast; transient ones retry, then refund). Failed exports are queryable and re-triggerable:
```ts
await fetch(`${PORULLE}/api/channels/exports?state=failed`);
await fetch(`${PORULLE}/api/channels/exports/${id}/retry`, { method: "POST" });
```
## Two-way sync
[Section titled “Two-way sync”](#two-way-sync)
Inbound webhooks (per-store HMAC-verified, deduped) keep Porulle current:
* **Catalog / inventory** changes converge the mirror (level-set; a deleted product is soft-archived, never hard-deleted, so order history survives).
* **Fulfilment / tracking** lands on the order timeline.
* **Store-side refunds** drive a **cross-boundary refund**: a verified `refunds/create` webhook triggers a platform-side refund computed from what the customer actually paid, executed through the hardened refund engine. Small, clean refunds auto-execute; large / new-merchant / divergent ones queue for **operator approval**:
```ts
await fetch(`${PORULLE}/api/channels/refund-requests`); // pending approvals
await fetch(`${PORULLE}/api/channels/refund-requests/${id}/approve`, { method: "POST" });
```
## Reconciliation
[Section titled “Reconciliation”](#reconciliation)
Webhooks optimize for speed; reconciliation optimizes for truth. A scheduled `channel/reconcile` job per store does a full diff of the store’s catalog + inventory against Porulle’s mirror via the id-map — importing what’s missing, converging drift, soft-archiving what’s gone — and emits a drift report. Query a store’s sync health:
```ts
await fetch(`${PORULLE}/api/channels/stores/${storeId}/reconcile-status`);
// { lastReconcileAt, report: { imported, converged, archived }, driftAlert }
```
## REST surface
[Section titled “REST surface”](#rest-surface)
\| Route | Purpose | |---|---| | `POST /api/channels/stores` | Connect a store | | `GET /api/channels/stores` · `/{id}` | List / get stores (credentials redacted) | | `POST /api/channels/stores/{id}/disconnect` | Disconnect (clears credentials) | | `GET /api/channels/exports?state=failed` · `POST …/{id}/retry` | Failed order exports + re-trigger | | `POST /api/channels/webhooks/{storeId}` | Inbound per-store webhooks (HMAC) | | `POST /api/channels/compliance/{provider}` | Inbound app-level compliance webhooks (GDPR) | | `GET /api/channels/refund-requests` · `POST …/{id}/approve` | Cross-boundary refund approval queue | | `GET /api/channels/stores/{storeId}/reconcile-status` | Per-store sync health + drift report |
## Notes
[Section titled “Notes”](#notes)
* **Standalone.** The connector works for a single-store brand or a giant multi-merchant aggregator with the same engine — no `plugin-marketplace` dependency.
* **Nothing installed on the merchant side.** Merchants may paste credentials or use one-click OAuth (Shopify App / WooCommerce `/wc-auth`) through the generic engine routes, not a per-provider plugin.
* Shopify public-app distribution (listed or unlisted) requires `customers/data_request`, `customers/redact`, `shop/redact`, and `app/uninstalled` compliance webhooks. The three GDPR topics are delivered to a single app-level URL (`POST /api/channels/compliance/shopify`), signed with the app client secret, and resolve the store by `shop_domain` in the payload. Public-app operators set this URL in `shopify.app.toml`; custom/single-store apps do not need it. `app/uninstalled` is registered per-store via the Admin API and also arrives app-secret-signed at the compliance URL — both paths are supported.
* To support a new platform, implement the `ChannelConnector` interface — see [Store Connector](/extending/store-connector).
# Email Notifications
> Order confirmations, password resets, and custom templates with Resend or AWS SES.
## Development: console adapter
[Section titled “Development: console adapter”](#development-console-adapter)
Use the built-in console adapter during development. It logs every email to your terminal — no API key, no SMTP server, no external service needed.
commerce.config.ts
```ts
import { consoleEmailAdapter } from "@porulle/core";
export default defineConfig({
email: consoleEmailAdapter(),
});
```
When an email is triggered, you see output like:
```plaintext
============================================================
EMAIL: order-confirmation
============================================================
To: customer@example.com
Template: order-confirmation
Data:
orderId: "ord_abc123"
total: 13997
currency: "USD"
============================================================
```
## Production: Resend
[Section titled “Production: Resend”](#production-resend)
```bash
bun add @porulle/adapter-resend
```
commerce.config.ts
```ts
import { resendAdapter } from "@porulle/adapter-resend";
export default defineConfig({
email: resendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: "Acme Store ",
}),
});
```
## Production: AWS SES
[Section titled “Production: AWS SES”](#production-aws-ses)
```bash
bun add @porulle/adapter-ses
```
commerce.config.ts
```ts
import { sesAdapter } from "@porulle/adapter-ses";
export default defineConfig({
email: sesAdapter({
region: "us-east-1",
from: "Acme Store ",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
}),
});
```
The sender address must be verified in SES. If your account is in the SES sandbox, recipient addresses must also be verified.
Both adapters ship with default HTML templates for all built-in email types. No additional configuration is needed for basic use.
## Customize templates
[Section titled “Customize templates”](#customize-templates)
Override subjects and HTML bodies per template key:
```ts
email: resendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: "Acme Store ",
subjects: {
"order-confirmation": (data) => `Thanks for your order #${String(data.orderId).slice(0, 8)}!`,
},
templates: {
"order-confirmation": (data) => `
View Order
`,
},
}),
```
If you use Resend’s dashboard templates, pass template IDs:
```ts
email: resendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: "Acme Store ",
resendTemplateIds: {
"order-confirmation": "tmpl_abc123",
"password-reset": "tmpl_def456",
},
}),
```
## Built-in template keys
[Section titled “Built-in template keys”](#built-in-template-keys)
\| Template | Triggered by | Data fields | |----------|-------------|-------------| | `order-confirmation` | Checkout completion | `orderId`, `total`, `currency` | | `order-status-change` | Order status hook | `orderId`, `newStatus`, `previousStatus` | | `password-reset` | Better Auth password reset | `url` | | `email-verification` | Better Auth email verification | `url` | | `appointment:reminder` | Appointment plugin (24h + 1h before) | `bookingId`, `reminderType` | | `appointment:confirmation-notice` | Appointment booking confirmed | `bookingId`, `providerId` | | `appointment:cancellation-notice` | Appointment booking cancelled | `bookingId` | | `appointment:no-show-notice` | Appointment marked no-show | `bookingId` |
## Write a custom email adapter
[Section titled “Write a custom email adapter”](#write-a-custom-email-adapter)
The email interface requires a single `send` method:
```typescript
interface EmailAdapter {
send(input: {
template: string;
to: string;
data?: Record;
}): Promise;
}
```
Any object matching this signature works. Example SendGrid adapter:
```typescript
import sgMail from "@sendgrid/mail";
export function sendgridAdapter(options: { apiKey: string; from: string }) {
sgMail.setApiKey(options.apiKey);
return {
async send(input: { template: string; to: string; data?: Record }) {
await sgMail.send({
to: input.to,
from: options.from,
subject: input.template,
html: `
${JSON.stringify(input.data, null, 2)}
`,
});
},
};
}
```
## Related
[Section titled “Related”](#related)
* [Adapter Reference](/reference/adapters/) — full `EmailAdapter` interface definition
* [Hook System guide](/extending/hooks/) — register hooks that trigger emails
* [Deployment guide](/production/deployment/) — environment variables for API keys
# Gift Cards
> Gift card entity type, balance tracking, and redemption at checkout.
The gift card plugin adds a `gift_cards` entity type, balance ledger, and a checkout hook that applies card balances as a payment method.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/plugin-gift-cards
```
## Register
[Section titled “Register”](#register)
commerce.config.ts
```ts
import { giftCardsPlugin } from "@porulle/plugin-gift-cards";
export default defineConfig({
plugins: [giftCardsPlugin()],
});
```
Update `drizzle.config.ts` to include the plugin schema:
drizzle.config.ts
```ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-gift-cards/src/schema.ts",
],
```
Push the new tables and restart:
```bash
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts
```
## Issue a gift card
[Section titled “Issue a gift card”](#issue-a-gift-card)
```bash
curl -X POST http://localhost:4000/api/gift-cards \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"amount": 5000, "currency": "USD", "recipientEmail": "friend@example.com"}'
```
The response includes a `code` field. Share the code with the recipient.
## Redeem at checkout
[Section titled “Redeem at checkout”](#redeem-at-checkout)
Pass the gift card code in the checkout request:
```bash
curl -X POST http://localhost:4000/api/checkout \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"cartId": "...",
"giftCardCode": "GC-XXXX-YYYY",
"paymentMethodId": "stripe",
"currency": "USD",
"shippingAddress": { ... }
}'
```
The plugin applies the gift card balance first and charges the remainder to the payment method. If the gift card covers the full order, no payment method is required.
## Check a balance
[Section titled “Check a balance”](#check-a-balance)
```bash
curl "http://localhost:4000/api/gift-cards/GC-XXXX-YYYY/balance" \
-H "x-api-key: dev-staff-key"
```
## Related
[Section titled “Related”](#related)
* [Plugin API Reference](/reference/plugins/) — gift card plugin manifest and endpoints
* [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — learn how plugins define schema and hooks
* [Hook System guide](/extending/hooks/) — how the redemption hook integrates with checkout
# Layaway
> Partial-payment plans — deposit, installments, auto-complete on full payment, and forfeit.
The layaway plugin lets a customer reserve items with a deposit and pay the balance in installments. Stock is held while the plan is active; when the balance is fully paid the plan auto-completes into a core order, and if the customer walks away the plan is forfeited and the hold released.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/plugin-layaway
```
## Register
[Section titled “Register”](#register)
commerce.config.ts
```ts
import { layawayPlugin } from "@porulle/plugin-layaway";
export default defineConfig({
plugins: [
layawayPlugin({
defaultDepositPercent: 25,
onForfeit: async (layaway) => {
// Policy hook — deposit retention, customer notification, etc.
await notifyCustomer(layaway.customerId, "layaway-forfeited");
},
}),
],
});
```
`defaultDepositPercent` (default `20`) applies when a create request supplies neither `depositAmount` nor `depositPercent`. `onForfeit` runs **after** a plan is forfeited; if it throws, the error surfaces to the caller.
Update `drizzle.config.ts`:
drizzle.config.ts
```ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-layaway/src/schema.ts",
],
```
Push the new tables and restart:
```bash
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts
```
This adds two tables: `layaways`, `layaway_payments`.
## Lifecycle
[Section titled “Lifecycle”](#lifecycle)
A plan starts `active`. Stock for its items is reserved on create, keyed by the layaway id. From there it moves to one of two terminal states:
* **`completed`** — cumulative payments reach the plan total. A core order is created (cross-linked via `metadata.layawayId`) and the stock hold is released.
* **`forfeited`** — the plan is forfeited via the manage endpoint. The stock hold is released and the `onForfeit` hook runs.
On create, if the stock reservation fails the plan is rolled back.
## Quick reference
[Section titled “Quick reference”](#quick-reference)
\| Operation | Endpoint | Permission | |-----------|----------|------------| | Create plan | `POST /api/layaways` | `layaway:operate` | | List plans | `GET /api/layaways` | `layaway:operate` | | Get plan | `GET /api/layaways/:id` | `layaway:operate` | | Record payment | `POST /api/layaways/:id/payments` | `layaway:operate` | | Forfeit plan | `POST /api/layaways/:id/forfeit` | `layaway:manage` |
All monetary values are integers in the smallest currency unit (cents for USD).
## Create a plan
[Section titled “Create a plan”](#create-a-plan)
A plan lists the items to reserve and sets a deposit. Supply `depositAmount` (minor units) or `depositPercent` (0–100); if neither is given, `defaultDepositPercent` applies. `depositAmount` cannot exceed the plan total. An optional `initialPayment` is recorded immediately.
```bash
curl -X POST http://localhost:4000/api/layaways \
-H "x-api-key: dev-staff-key" \
-d '{
"currency": "USD",
"customerId": "cust_123",
"items": [
{ "entityId": "ent_sofa", "variantId": "var_grey", "title": "Grey Sofa", "quantity": 1, "unitPrice": 120000 }
],
"depositPercent": 25,
"initialPayment": { "amount": 30000, "method": "card", "reference": "****4321" }
}'
```
Returns `{ data: { layaway, payments } }`. The plan total here is `120000` (1,200.00 USD) and the initial payment of `30000` covers the 25% deposit.
## Record an installment
[Section titled “Record an installment”](#record-an-installment)
The plan must be `active`. A payment that exceeds the remaining balance is rejected.
```bash
curl -X POST http://localhost:4000/api/layaways/$LAYAWAY_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{ "amount": 45000, "method": "cash" }'
```
Returns `{ data: { layaway, payment, completed } }`. With `30000` already paid, this brings the total to `75000` — still short of `120000`, so `completed` is `false`.
## Final payment auto-completes
[Section titled “Final payment auto-completes”](#final-payment-auto-completes)
When cumulative paid reaches the plan total, the plan auto-completes: a core order is created and the stock hold is released.
```bash
curl -X POST http://localhost:4000/api/layaways/$LAYAWAY_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{ "amount": 45000, "method": "cash" }'
```
```json
{
"data": {
"layaway": { "id": "lay_abc", "status": "completed", "orderId": "ord_789" },
"payment": { "amount": 45000, "method": "cash" },
"completed": true
}
}
```
The created order carries `metadata.layawayId` pointing back at the plan.
## Forfeit
[Section titled “Forfeit”](#forfeit)
Forfeiting releases the stock hold, marks the plan `forfeited`, and runs the `onForfeit` hook.
```bash
curl -X POST http://localhost:4000/api/layaways/$LAYAWAY_ID/forfeit \
-H "x-api-key: dev-staff-key" \
-d '{ "reason": "customer cancelled" }'
```
Returns `{ data: }`. Deposit money is **not** auto-refunded or retained by the plugin — that policy lives in your `onForfeit` hook.
## Permissions
[Section titled “Permissions”](#permissions)
* `layaway:operate` — create plans and record payments.
* `layaway:manage` — forfeit plans.
Because completing a plan creates a core order, the actor recording the final payment also needs the core `orders:create` scope. Grant it alongside `layaway:operate` for any actor who records installments.
## Related
[Section titled “Related”](#related)
* [Plugin API Reference](/reference/plugins/) — manifest, PluginContext, and router() builder
* [REST API Reference](/reference/rest-api/) — full endpoint catalog
* [Database Schema](/reference/database-schema/) — table definitions
* [Point of Sale](/building/pos/) — in-store terminals and shifts
# Point of Sale
> POS terminals, cashier shifts, split payment, and Z-reports.
The POS plugin adds terminals, shifts, transactions, and multi-method payment splitting to any Porulle instance. It does not require a storefront — it’s designed for brick-and-mortar retail, pop-up shops, and hybrid online+in-store operations.
For a complete step-by-step walkthrough, see the [Tea Shop POS tutorial](/tutorials/tea-shop-pos/).
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/plugin-pos
```
## Register
[Section titled “Register”](#register)
commerce.config.ts
```ts
import { posPlugin } from "@porulle/plugin-pos";
export default defineConfig({
plugins: [posPlugin()],
});
```
Update `drizzle.config.ts`:
drizzle.config.ts
```ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-pos/src/schema.ts",
],
```
Push the new tables and restart:
```bash
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts
```
This adds six tables: `pos_terminals`, `pos_shifts`, `pos_transactions`, `pos_payments`, `pos_cash_events`, `pos_return_items`.
## Core objects
[Section titled “Core objects”](#core-objects)
**Terminal** — represents a physical register or device. Created once and reused across shifts.
**Shift** — a cashier session tied to one terminal. Tracks opening float, cash events, sales totals, and cash variance. Must be opened before transactions can be created.
**Transaction** — a sale linked to a shift. Accumulates payments until completed or voided. Voided transactions do not count toward shift totals.
**Payment** — one payment method entry on a transaction. Multiple payments on a single transaction = split payment.
## Quick reference
[Section titled “Quick reference”](#quick-reference)
\| Operation | Endpoint | |-----------|----------| | Register terminal | `POST /api/pos/terminals` | | Open shift | `POST /api/pos/shifts/open` | | Create transaction | `POST /api/pos/transactions` | | Add payment | `POST /api/pos/transactions/:id/payments` | | Complete transaction | `POST /api/pos/transactions/:id/complete` | | Void transaction | `POST /api/pos/transactions/:id/void` | | Close shift | `POST /api/pos/shifts/:id/close` | | Z-report | `GET /api/pos/shifts/:id/report` |
All monetary values are integers in the smallest currency unit (cents for USD).
## Split payment
[Section titled “Split payment”](#split-payment)
To split a transaction across multiple payment methods, POST multiple payments before completing:
```bash
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{"method": "cash", "amount": 3500}'
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{"method": "card", "amount": 1500, "reference": "****4321"}'
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/complete \
-H "x-api-key: dev-staff-key"
```
## PIN authentication
[Section titled “PIN authentication”](#pin-authentication)
Cashiers share a terminal but authenticate individually with a numeric PIN. A successful PIN login mints a short-lived, per-shift API key scoped to `pos:operate` that the terminal then sends as `x-api-key` for that operator’s session. PINs live in the `pos_operator_pins` table, hashed with PBKDF2-SHA256 via Web Crypto (Workers-safe) — never stored in plaintext.
Set or rotate an operator’s PIN with `PUT /api/pos/auth/pin` (permission `pos:admin`). `pin` is 4–8 digits (`/^\d{4,8}$/`); set `canOverride` to allow this operator to approve manager overrides:
```bash
curl -X PUT http://localhost:4000/api/pos/auth/pin \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$OPERATOR_ID'", "pin": "4821", "canOverride": true}'
```
Returns `{ data: { operatorId, canOverride } }`.
The operator logs in with `POST /api/pos/auth/pin-login` (permission `pos:operate` — the terminal’s device credential authorizes the login). The operator must have an open shift:
```bash
curl -X POST http://localhost:4000/api/pos/auth/pin-login \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$OPERATOR_ID'", "pin": "4821"}'
```
Returns `{ data: { operatorId, shiftId, terminalId, apiKey, expiresAt } }`. `apiKey` is the raw minted key — scoped to `pos:operate`, prefixed `pos_shift_`, with a short TTL (default 12h, minimum 15 min). The terminal sends it as `x-api-key` for the rest of the session:
```bash
curl -X POST http://localhost:4000/api/pos/transactions \
-H "x-api-key: pos_shift_..."
```
Approve a restricted action with `POST /api/pos/auth/override` (permission `pos:operate`). `action` is a short string describing what’s being approved (max 200). Only operators whose PIN was set with `canOverride: true` may approve:
```bash
curl -X POST http://localhost:4000/api/pos/auth/override \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$MANAGER_ID'", "pin": "9930", "action": "void completed transaction"}'
```
Returns `{ data: { approved: true, operatorId, action, approvedAt } }`.
Service failures here — a bad PIN or no open shift — surface as `500` with the message, not `401`/`422`; handle them on the `500` path.
## Cash variance
[Section titled “Cash variance”](#cash-variance)
When closing a shift, pass the cashier’s physical count as `closingCount`. The engine calculates:
```plaintext
expected cash = opening float + all cash payments across completed transactions
cash variance = closing count - expected cash
```
A negative variance means cash is short. A positive variance means there’s more cash than expected.
## Order notes & timeline
[Section titled “Order notes & timeline”](#order-notes--timeline)
Every core order carries free-text notes and a merged activity timeline — useful at the counter for flagging a held order or reviewing what happened to a return. These are core endpoints (permission `orders:read` to read, `orders:update` to write), not POS-specific.
Add a note (optionally pinned to the top) and list them (pinned-first, then newest-first):
```bash
curl -X POST http://localhost:4000/api/orders/$ORDER_ID/notes \
-H "x-api-key: dev-staff-key" \
-d '{ "body": "Customer to collect Saturday", "pinned": true }'
curl http://localhost:4000/api/orders/$ORDER_ID/notes \
-H "x-api-key: dev-staff-key"
```
The timeline merges status changes, notes, and refund events (including undos) newest-first:
```bash
curl http://localhost:4000/api/orders/$ORDER_ID/timeline \
-H "x-api-key: dev-staff-key"
```
```json
{ "data": [{ "type": "refund", "at": "2026-07-02T10:15:00Z", "actor": "user_9", "summary": "Refund of 1800", "data": {} }] }
```
Delete a note with `DELETE /api/orders/{id}/notes/{noteId}`.
## Related
[Section titled “Related”](#related)
* [Tea Shop POS tutorial](/tutorials/tea-shop-pos/) — full 10-step walkthrough
* [Plugin API Reference](/reference/plugins/) — all POS endpoints and data types
* [Refunds & Exchanges](/building/refunds-and-exchanges/) — line-level refunds, policy caps, and POS exchanges
# Receipts & Invoices
> Render receipts and invoices, produce dependency-free PDF invoices, and allocate fiscal invoice numbers.
The documents module renders an order as an HTML receipt, an HTML invoice, or a dependency-free PDF invoice (A4), and can email the invoice. Fiscal invoice numbers are allocated atomically per organization and issued idempotently per order — the same order always gets the same number.
Access rides on the order’s read permission. The caller needs `orders:read`, or `orders:read:own` if they are the order’s own customer. There is no separate documents permission.
## Set your store branding first
[Section titled “Set your store branding first”](#set-your-store-branding-first)
Receipts and invoices pull store identity from the `branding` settings group: `storeName`, `receiptHeader`, `receiptFooter`, `logoUrl`, `address`, `phone`, `email`, `taxId`. Set them before rendering:
```bash
curl -X PATCH "http://localhost:4000/api/settings/branding" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"storeName": "Acme Tea Co.", "address": "12 High St", "email": "hi@acme.test"}'
```
See [Store Settings](/building/settings/) for the full list of branding fields.
## Render a receipt or invoice
[Section titled “Render a receipt or invoice”](#render-a-receipt-or-invoice)
Render the HTML receipt:
```bash
curl "http://localhost:4000/api/orders/$ORDER_ID/receipt.html" \
-H "x-api-key: dev-staff-key"
```
Render the HTML invoice:
```bash
curl "http://localhost:4000/api/orders/$ORDER_ID/invoice.html" \
-H "x-api-key: dev-staff-key"
```
Render the PDF invoice and save it to a file. The response is `application/pdf` with header `content-disposition: inline; filename="invoice-.pdf"`:
```bash
curl "http://localhost:4000/api/orders/$ORDER_ID/invoice.pdf" \
-H "x-api-key: dev-staff-key" \
-o invoice.pdf
```
The PDF writer is dependency-free — no native dependencies — so it runs on serverless and Workers.
Monetary values on the rendered documents are integers in the smallest currency unit (cents).
## Fiscal invoice numbers
[Section titled “Fiscal invoice numbers”](#fiscal-invoice-numbers)
Invoice numbers look like `INV-000001`: a prefix followed by a 6-digit, zero-padded sequence allocated atomically per organization. The prefix comes from the `documents` settings group key `invoicePrefix` (default `"INV-"`).
The fiscal number is issued on the first invoice render for an order — the first `invoice.pdf` or `invoice.html` call. Numbering is idempotent: rendering the same order’s invoice again returns the same number. It is unique on `(org, order, type)`.
Set the prefix through settings:
```bash
curl -X PATCH "http://localhost:4000/api/settings/documents" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"invoicePrefix": "INV-"}'
```
## Email an invoice
[Section titled “Email an invoice”](#email-an-invoice)
Email the invoice to a recipient. This requires an email adapter to be configured — otherwise the call fails with HTTP 422 and the message “No email adapter is configured.”:
```bash
curl -X POST "http://localhost:4000/api/orders/$ORDER_ID/invoice/email" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"to": "customer@acme.test"}'
```
```json
{ "data": { "sent": true, "invoiceNumber": "INV-000001", "to": "customer@acme.test" } }
```
## Storage
[Section titled “Storage”](#storage)
Two tables back this module: `invoice_sequences` holds a per-`(org, series)` counter, and `order_documents` holds one row per `(org, order, type)` where `type` is `"invoice"` or `"receipt"`. See the [Database Schema](/reference/database-schema/) for the full column list.
## Related
[Section titled “Related”](#related)
* [Store Settings](/building/settings/) — set `branding` and the `documents` invoice prefix
* [REST API Reference](/reference/rest-api/)
* [Database Schema](/reference/database-schema/)
# Refunds & Exchanges
> Line-level refunds with per-operator policy caps and undo, plus atomic POS exchanges.
Refunds are built into core orders — no plugin needed. You refund specific line quantities, and each order line carries a `refundedQuantity` (the units already refunded). A per-operator daily cap and a time-boxed undo window keep the till honest. POS exchanges (return + replacement in one shot) live in the POS plugin.
All monetary values are integers in the smallest currency unit (cents for USD).
## Refund order lines
[Section titled “Refund order lines”](#refund-order-lines)
Refund one or more line quantities with `POST /api/orders/{id}/refunds` (permission `orders:update`). Each line names a `lineItemId` and a positive integer `quantity`; `reason` is optional (max 500 characters).
```bash
curl -X POST http://localhost:4000/api/orders/$ORDER_ID/refunds \
-H "x-api-key: dev-staff-key" \
-d '{
"lines": [{ "lineItemId": "'$LINE_ID'", "quantity": 1 }],
"reason": "Damaged in transit"
}'
```
Returns `201 { data: { order, refund } }`. Per-unit refund amount is derived from the line’s price plus tax minus discount. Refunding more than `quantity - refundedQuantity` on a line is rejected with `422`. If the order has a captured payment, the money is refunded through the payment adapter, clamped to the remaining captured amount.
Each refund is written to the `order_refunds` ledger table and records an order status-history audit entry, so it appears on the [order timeline](/building/pos/).
List an order’s refunds newest-first with `GET /api/orders/{id}/refunds` (permission `orders:read`):
```bash
curl http://localhost:4000/api/orders/$ORDER_ID/refunds \
-H "x-api-key: dev-staff-key"
```
Returns `200 { data: }`.
## Daily refund cap
[Section titled “Daily refund cap”](#daily-refund-cap)
Set an optional per-operator daily refund cap in the `policies` [settings group](/building/settings/). `refundDailyCap` is a number in minor units. When set, a refund that would push the operator’s same-day refunded total over the cap is rejected with `403` and a message stating the cap, the used-today total, and the requested amount. The daily total is accounted in the store’s timezone (settings `general.timezone`).
```bash
curl -X PATCH http://localhost:4000/api/settings/policies \
-H "x-api-key: dev-staff-key" \
-d '{ "refundDailyCap": 50000 }'
```
Check the acting operator’s remaining headroom for today with `GET /api/orders/refunds/cap` (permission `orders:read`):
```bash
curl http://localhost:4000/api/orders/refunds/cap \
-H "x-api-key: dev-staff-key"
```
Returns `200 { data: { cap, usedToday, remaining } }`. `cap` and `remaining` are `null` when no cap is set.
## Undo a refund
[Section titled “Undo a refund”](#undo-a-refund)
Reverse a refund within the undo window with `POST /api/orders/{id}/refunds/{refundId}/undo` (permission `orders:update`). This restores the refunded quantities and returns `200 { data: { order, refund } }`.
```bash
curl -X POST http://localhost:4000/api/orders/$ORDER_ID/refunds/$REFUND_ID/undo \
-H "x-api-key: dev-staff-key"
```
The window is `refundUndoWindowMinutes` in the `policies` settings group (default 15). After that many minutes, undo is rejected with `422`.
Undo is a compensating ledger op only — it reverses the ledger and restores quantities, but re-collecting the cash you already handed back is the operator’s manual step.
## POS exchanges
[Section titled “POS exchanges”](#pos-exchanges)
An exchange takes items back and issues replacements in a single operation. `POST /api/pos/exchanges` (permission `pos:manage`) requires the actor to also hold core `orders:update` (for the return refund) and `orders:create` (for the replacement order).
```bash
curl -X POST http://localhost:4000/api/pos/exchanges \
-H "x-api-key: dev-staff-key" \
-d '{
"shiftId": "'$SHIFT_ID'",
"terminalId": "'$TERMINAL_ID'",
"originalOrderId": "'$ORDER_ID'",
"returnItems": [
{ "originalLineItemId": "'$LINE_ID'", "quantity": 1, "reason": "defective" }
],
"replacementItems": [
{ "entityId": "'$PRODUCT_ID'", "title": "Green Tea 250g", "quantity": 1, "unitPrice": 1800 }
]
}'
```
Returns `201 { data: { transaction, refundId, returnTotal, replacementOrderId, replacementTotal, netDelta } }`. `reason` is one of `"defective"`, `"wrong_item"`, `"changed_mind"`, or `"other"`.
**Atomicity.** The return refund and the replacement order are created in one database transaction — if either fails, nothing is committed: no money moves and no order is created. POS bookkeeping rows are written after commit.
**Even vs uneven.** `netDelta = replacementTotal - returnTotal`. An even exchange (`netDelta` 0) auto-completes immediately. An uneven exchange stays open so the operator settles the difference through normal tendering: `netDelta > 0` means the customer owes more; `netDelta < 0` means the store owes the customer.
**Error shape.** POS plugin route and service failures surface as `500` with the service’s message (they are thrown as plain errors), not as `4xx`. Handle exchange errors on the `500` path.
## Related
[Section titled “Related”](#related)
* [Settings](/building/settings/) — the `policies` group, `refundDailyCap`, and `refundUndoWindowMinutes`
* [Point of Sale](/building/pos/) — shifts, terminals, and the order timeline
* [REST API Reference](/reference/rest-api/) — all refund and exchange endpoints
* [Database Schema](/reference/database-schema/) — the `order_refunds` ledger table
# Store Settings
> Org-scoped, typed settings groups that other modules and plugins read at runtime.
Store Settings are org-scoped key/value settings stored as typed groups. Each group is a flat JSON object under a name like `general`, `branding`, or `policies`. Other modules and plugins read them at runtime — reports read the timezone, documents read branding, refunds read policies — so a single change to a group updates behavior everywhere that group is consumed.
All settings endpoints require the `settings:manage` permission scope.
## Settings groups
[Section titled “Settings groups”](#settings-groups)
A group is addressed by name. The `group` path parameter must match `/^[a-z][a-z0-9_-]*$/`.
Three groups are validated. Unknown groups accept any object; setting an invalid value on a validated group fails with HTTP 422.
**`general`** — locale and formatting defaults.
\| Field | Rule | |-------|------| | `currency` | ISO 4217 code, `/^[A-Z]{3}$/` | | `timezone` | valid IANA timezone | | `locale` | string, minimum 2 characters |
All three are optional and nullable.
**`branding`** — store identity used on receipts and invoices. All fields are optional strings: `storeName`, `receiptHeader`, `receiptFooter`, `logoUrl`, `address`, `phone`, `email`, `taxId`.
**`policies`** — a free-form record of string, number, boolean, or null values. Two well-known keys are read by the refunds feature:
\| Key | Meaning | |-----|---------| | `refundDailyCap` | number — per-operator daily refund cap in the smallest currency unit (cents) | | `refundUndoWindowMinutes` | number — minutes a refund can be undone (default 15) |
## Read and write settings
[Section titled “Read and write settings”](#read-and-write-settings)
Get all groups at once:
```bash
curl "http://localhost:4000/api/settings" \
-H "x-api-key: dev-staff-key"
```
```json
{ "data": { "general": { "currency": "USD" }, "branding": { "storeName": "Acme" } } }
```
Get a single group. An unset group returns `{}`:
```bash
curl "http://localhost:4000/api/settings/general" \
-H "x-api-key: dev-staff-key"
```
```json
{ "data": { "currency": "USD", "timezone": "America/New_York" } }
```
Update a group with `PATCH`. The body is a flat JSON object and is **shallow-merged** into the current value. The response returns the merged value:
```bash
curl -X PATCH "http://localhost:4000/api/settings/general" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"currency": "USD", "timezone": "America/New_York"}'
```
To remove a key, set it to `null`. A `null` value **deletes** that key rather than storing it:
```bash
curl -X PATCH "http://localhost:4000/api/settings/general" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"locale": null}'
```
## Reading settings at runtime
[Section titled “Reading settings at runtime”](#reading-settings-at-runtime)
Modules, hooks, and plugins read settings through `kernel.services.settings.read(orgId, group)`. It is org-keyed and needs no actor. An unset group returns `{}`.
```typescript
const general = await kernel.services.settings.read(orgId, "general");
const timezone = (general.timezone as string) ?? "UTC";
```
## Storage
[Section titled “Storage”](#storage)
Settings live in the `store_settings` table — one row per `(organization_id, group)`, with the group’s JSON in a `value` column. See the [Database Schema](/reference/database-schema/) for the full column list.
## Permissions
[Section titled “Permissions”](#permissions)
Reading and writing settings requires the `settings:manage` scope. It is granted to the owner and admin roles and to the manager role.
## Related
[Section titled “Related”](#related)
* [Receipts & Invoices](/building/receipts-and-invoices/) — reads the `branding` and `documents` groups
* [Refunds & Exchanges](/building/refunds-and-exchanges/) — reads the `policies` group
* [REST API Reference](/reference/rest-api/)
* [Database Schema](/reference/database-schema/)
# Supply Chain
> Purchase orders, receiving, and warehouse management.
The supply chain plugin adds purchase orders (POs), goods receiving, multi-warehouse management, and supplier relationships. It extends the inventory module with inbound logistics — tracking stock from supplier to warehouse.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/plugin-supply-chain
```
## Register
[Section titled “Register”](#register)
commerce.config.ts
```ts
import { supplyChainPlugin } from "@porulle/plugin-supply-chain";
export default defineConfig({
plugins: [supplyChainPlugin()],
});
```
Update `drizzle.config.ts`:
drizzle.config.ts
```ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-supply-chain/src/schema.ts",
],
```
Push the new tables and restart:
```bash
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts
```
## Core objects
[Section titled “Core objects”](#core-objects)
**Supplier** — a vendor you purchase inventory from. Has a name, contact, and lead time.
**Purchase order (PO)** — an order placed with a supplier for specific quantities of entities. Moves through states: `draft → submitted → acknowledged → partially_received → received → cancelled`.
**PO line item** — one line on a PO: entity, variant, quantity ordered, unit cost.
**Receiving** — recording the actual quantities received against a PO. Creates inventory level adjustments when goods arrive.
**Warehouse** — a location where inventory is stored. Already part of the core inventory module; the supply chain plugin ties POs to specific warehouses.
## Quick reference
[Section titled “Quick reference”](#quick-reference)
\| Operation | Endpoint | |-----------|----------| | Create supplier | `POST /api/supply-chain/suppliers` | | Create PO | `POST /api/supply-chain/purchase-orders` | | Submit PO | `POST /api/supply-chain/purchase-orders/:id/submit` | | Receive goods | `POST /api/supply-chain/purchase-orders/:id/receive` | | List POs | `GET /api/supply-chain/purchase-orders` |
## Create a purchase order
[Section titled “Create a purchase order”](#create-a-purchase-order)
```bash
curl -X POST http://localhost:4000/api/supply-chain/purchase-orders \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"supplierId": "supplier-uuid",
"warehouseId": "warehouse-uuid",
"expectedArrival": "2026-06-01",
"lineItems": [
{ "entityId": "product-uuid", "variantId": null, "quantity": 100, "unitCost": 1500 }
]
}'
```
## Receive goods
[Section titled “Receive goods”](#receive-goods)
When stock arrives, record the received quantities:
```bash
curl -X POST http://localhost:4000/api/supply-chain/purchase-orders/$PO_ID/receive \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"receivedItems": [
{ "lineItemId": "line-item-uuid", "quantityReceived": 80 }
],
"notes": "Partial shipment — remaining 20 units en route"
}'
```
Receiving adjusts inventory levels at the specified warehouse. If the received quantity is less than ordered, the PO status becomes `partially_received`. When all items are received, it moves to `received`.
## Related
[Section titled “Related”](#related)
* [Plugin API Reference](/reference/plugins/) — all supply chain endpoints and data types
* [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — learn how plugins extend the engine
* [Custom Tables guide](/extending/custom-tables/) — add custom tables alongside plugin tables
# Tax Classes
> Tag products with named tax rates and compute per-line, class-based tax at checkout.
Tax classes let you tag products and variants with a named rate. At checkout, per-line tax is computed by class, and any order-level discount is pro-rated across lines before tax is applied.
Rates are stored as integer basis points (`rateBps`): `1800` means 18%. The order persists a per-line `taxAmount` (and `discountAmount`) computed at checkout — monetary amounts are integers in the smallest currency unit (cents for USD).
## Create tax classes
[Section titled “Create tax classes”](#create-tax-classes)
All tax class endpoints live under `/api/tax/classes` and require the `tax:manage` permission scope — including listing.
A class `name` is a lowercase slug matching `/^[a-z][a-z0-9_-]*$/` (for example `standard` or `zero`). Set `isDefault` to mark the class used for lines with no class of their own; only one default exists per org, so setting a new default clears the old one. `isActive` defaults to `true`.
```bash
# Create a default standard class at 18%
curl -X POST "http://localhost:4000/api/tax/classes" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"name": "standard",
"rateBps": 1800,
"isDefault": true
}'
# Create a zero-rated class
curl -X POST "http://localhost:4000/api/tax/classes" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"name": "zero",
"rateBps": 0
}'
```
Each call returns `201` with `{ "data": }`.
## Assign a class to a product
[Section titled “Assign a class to a product”](#assign-a-class-to-a-product)
`taxClass` is a first-class field on catalog entities and variants — a string holding the class name. Set it through catalog create or update:
```bash
# Assign the "standard" class when creating an entity
curl -X POST "http://localhost:4000/api/catalog/entities" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"name": "Ceylon Black Tea",
"taxClass": "standard"
}'
```
`POST /api/catalog/entities` and `PATCH /api/catalog/entities/{id}` both accept `taxClass`; passing `null` on update clears it. Variant create (`POST .../variants`) also accepts `taxClass`, and a variant’s class **overrides** its entity’s class.
## How checkout resolves the rate
[Section titled “How checkout resolves the rate”](#how-checkout-resolves-the-rate)
For each line, the rate is resolved in this order, highest priority first:
1. The variant’s `taxClass`.
2. The entity’s `taxClass`.
3. The org’s default class.
4. Otherwise `0` — the line is untaxed.
Only **active** classes count. When an org has defined active classes, they take precedence over runtime region rates and the tax adapter.
Order-level discounts are pro-rated before tax. A cart-level discount that is not attributed to specific lines is split across lines by each line’s value share; the rounding remainder is absorbed by the last line so the totals stay exact. Tax is then computed on the discounted base.
## List / update / delete
[Section titled “List / update / delete”](#list--update--delete)
```bash
# List all classes (ordered by name)
curl "http://localhost:4000/api/tax/classes" \
-H "x-api-key: dev-staff-key"
# Update a class — any subset of name, rateBps, isDefault, isActive
curl -X PATCH "http://localhost:4000/api/tax/classes/$CLASS_ID" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"rateBps": 1500}'
# Delete a class
curl -X DELETE "http://localhost:4000/api/tax/classes/$CLASS_ID" \
-H "x-api-key: dev-staff-key"
```
List returns `200` with `{ "data": }`, update returns `200` with `{ "data": }`, and delete returns `200` with `{ "data": { "deleted": true } }`.
Tax classes are stored in the `tax_classes` table (with `name` unique per org, `rate_bps`, `is_default`, `is_active`). The assigned class lives in a nullable `tax_class` column on both `sellable_entities` and `variants`. See the schema reference for the full column list.
## Related
[Section titled “Related”](#related)
* [REST API Reference](/reference/rest-api/) — all tax and catalog endpoints
* [Database Schema](/reference/database-schema/) — `tax_classes` and the `tax_class` columns
* [Store Settings](/building/settings/) — org-level configuration
# Changelog
> Release history for Porulle.
## v0.8.0 (2026-07-02)
[Section titled “v0.8.0 (2026-07-02)”](#v080-2026-07-02)
Retail-operations release, driven by the ordereka-fashion-pos field study. The whole `@porulle/*` family is versioned in lockstep — the jump from v0.1.0 reflects intermediate releases whose notes live in each package’s `CHANGELOG.md`.
### Core
[Section titled “Core”](#core)
* **Store settings** — org-scoped typed setting groups (`general`, `branding`, `policies`) at `GET/PATCH /api/settings` behind `settings:manage`, plus a `kernel.services.settings.read(orgId, group)` runtime API that plugins and modules read from. New `store_settings` table. See [Store Settings](/building/settings/).
* **Receipts & invoices** — render an order as an HTML receipt, HTML invoice, or dependency-free PDF invoice (`GET /api/orders/:id/{invoice.pdf,invoice.html,receipt.html}`), plus `POST .../invoice/email`. Fiscal invoice numbers are allocated atomically per org and issued idempotently per order. New `invoice_sequences`, `order_documents` tables. See [Receipts & Invoices](/building/receipts-and-invoices/).
* **Retail reports** — a canned report pack at `GET /api/analytics/reports/*` (`daily-journal`, `tax-summary`, `inventory-aging`, `sell-through`, `reorder-needed`, `staff-sales`) with store-timezone calendar math and prior-period deltas, behind `analytics:read`. See [Retail reports](/building/analytics/#retail-reports).
* **One-call variants** — `POST /api/catalog/entities/:id/variants/quick` and `/bulk` upsert option axes inline and seed a zero-stock inventory level so variants are sellable immediately.
* **Refund policy** — line-level refunds (`refunded_quantity` column, `order_refunds` ledger) with an optional per-operator daily cap (`policies.refundDailyCap`, `403` when exceeded) and an audited undo window (`policies.refundUndoWindowMinutes`, default 15). Endpoints under `/api/orders/:id/refunds`. See [Refunds & Exchanges](/building/refunds-and-exchanges/).
* **Order notes & timeline** — `POST/GET/DELETE /api/orders/:id/notes` (pinned-first) and `GET /api/orders/:id/timeline` merging status history, notes, and refund events newest-first. New `order_notes` table.
* **Product tax classes** — first-class `taxClass` on entities and variants, `/api/tax/classes` CRUD behind `tax:manage`, and per-line checkout tax by class with order-level discounts pro-rated across lines. New `tax_classes` table; classes take precedence over region rates when defined. See [Tax Classes](/building/tax/).
* **Integrator quick wins** — `config.routes(app, kernel, auth)` now receives the Better Auth instance; `requirePerm` is a public export; orders and checkout accept an `idempotencyKey` (unique per org) so offline queues and retries replay exactly once.
* **Admin/operator API gaps** — fulfillments with tracking, pricing-modifier CRUD, order line-item editing, cart listing + abandoned-checkout recovery, runtime shipping zones/rates + tax rates, and staff/RBAC admin REST. New scopes: `cart:manage`, `shipping:manage`, `tax:manage`, `staff:manage`.
### Plugin system
[Section titled “Plugin system”](#plugin-system)
* **`PluginContext.auth`** — plugins now receive the Better Auth instance (when mounted by `createServer`) to mint/verify credentials.
* **Manifest `apiKeyScopes`** — plugins register named API-key scopes (with `keyExpiration` bounds, in days) merged into `config.auth.apiKeyScopes`. See [Plugin Contract](/extending/plugin-contract/#contributing-api-key-scopes).
### Plugins
[Section titled “Plugins”](#plugins)
* **`@porulle/plugin-pos`** — PIN authentication (`PUT/POST /api/pos/auth/*`: set PIN, PIN login minting a short-lived per-shift API key, manager override; PBKDF2 via Web Crypto) and exchanges (`POST /api/pos/exchanges` running the return refund and replacement order in one transaction; even exchanges self-settle). See [Point of Sale](/building/pos/).
* **`@porulle/plugin-layaway`** *(new)* — partial-payment plans: reserve stock with a deposit, record installments, auto-complete to a cross-linked core order at full payment, forfeit policy hook. See [Layaway](/building/layaway/).
### Adapters
[Section titled “Adapters”](#adapters)
* **`@porulle/adapter-neon`** *(new)* — Workers-grade Neon `DatabaseAdapter`: HTTP driver for queries, a fresh WebSocket pool per transaction, Hyperdrive-aware.
### SDK
[Section titled “SDK”](#sdk)
* **`OfflineQueue`** — offline-first operation queue in `@porulle/sdk` with pluggable persistence, idempotency-key stamping (pairs with core replay for exactly-once), FIFO drain with exponential backoff, and observable state. See [Offline queue](/frontend/sdk/#offline-queue).
***
## v0.1.0 — Alpha (2025-05-11)
[Section titled “v0.1.0 — Alpha (2025-05-11)”](#v010--alpha-2025-05-11)
Initial public release.
### Core
[Section titled “Core”](#core-1)
* **Kernel**: `createKernel` / `createServer` factory with modular service container
* **Catalog**: `sellable_entities` unified entity model — products, digital downloads, services, courses, gift cards, and custom types driven by configuration
* **Inventory**: Per-warehouse stock tracking with optimistic locking (`version` column), movement ledger, and automatic reservation at checkout
* **Cart**: Session-based carts with price snapshot on add, promotion application, and merge on sign-in
* **Checkout**: Compensation-based pipeline — validate → reserve → price → tax → payment intent → order. Full rollback on failure.
* **Orders**: State machine (`pending → confirmed → processing → fulfilled/partially_fulfilled → refunded`), fulfillment records (physical, digital download, service), and status history
* **Pricing**: Time-bounded, quantity-tiered, customer-group prices; multiple currencies; modifier support
* **Promotions**: Six promotion types — `percentage_off_order`, `fixed_off_order`, `percentage_off_item`, `fixed_off_item`, `free_shipping`, `buy_x_get_y`; usage limits; stackability rules
* **Search**: `SearchAdapter` interface; built-in Meilisearch and PostgreSQL full-text implementations
* **Analytics**: Semantic query compiler with built-in models for orders, line items, inventory, and customers; role-scoped query execution
* **Job queue**: Durable `commerce_jobs` table with three runner strategies (cron endpoint, in-process polling, custom worker); exponential backoff; concurrency keys
* **Webhooks**: HMAC-SHA256-signed delivery for 14 event types; retry with exponential backoff; delivery audit in `webhook_deliveries`
* **Audit log**: Append-only `commerce_audit_log` for all commerce events with actor tracking
### Plugin system
[Section titled “Plugin system”](#plugin-system-1)
* **`defineCommercePlugin`**: Config transform pattern — schema, hooks, routes, permissions, analytics models
* **`@porulle/db`**: `defineTable`, `column` namespace with auto-injected `id`, `organizationId`, `createdAt`, `updatedAt`
* **`router()` builder**: Fluent route registration with `.auth()`, `.permission()`, `.input()`, OpenAPI auto-documentation
* **`createPluginTestApp`**: In-memory PGlite environment for plugin route testing
### Official plugins
[Section titled “Official plugins”](#official-plugins)
\| Plugin | Status | |--------|--------| | `@porulle/plugin-marketplace` | Alpha | | `@porulle/plugin-pos` | Alpha | | `@porulle/plugin-pos-restaurant` | Alpha | | `@porulle/plugin-uom` | Alpha | | `@porulle/plugin-procurement` | Alpha | | `@porulle/plugin-warehouse` | Alpha | | `@porulle/plugin-production` | Alpha | | `@porulle/plugin-notifications` | Alpha | | `@porulle/plugin-scheduled-orders` | Alpha | | `@porulle/plugin-reviews` | Alpha | | `@porulle/plugin-appointments` | Alpha | | `@porulle/plugin-gift-cards` | Alpha |
### Adapters
[Section titled “Adapters”](#adapters-1)
\| Adapter | Package | |---------|---------| | PostgreSQL (Drizzle) | `@porulle/adapter-postgres` | | AWS S3 | `@porulle/adapter-s3` | | Cloudflare R2 | `@porulle/adapter-r2` | | Local filesystem | `@porulle/adapter-local-storage` | | Stripe | `@porulle/adapter-stripe` | | Resend | `@porulle/adapter-resend` | | AWS SES v2 | `@porulle/adapter-ses` | | Meilisearch | `@porulle/adapter-meilisearch` | | PostgreSQL full-text search | `@porulle/adapter-pg-search` | | TaxJar | `@porulle/adapter-taxjar` | | Manual / flat-rate tax | `@porulle/adapter-tax-manual` |
### Authentication
[Section titled “Authentication”](#authentication)
* Better Auth integration with email/password, social login, and organization plugin
* Three-layer identity: auth identity (Better Auth), org membership, commerce profile (customer / vendor / staff)
* Scoped API keys: `catalog:read`, `orders:*`, `*:*`, and custom scopes per plugin
* Session-based customer portal (`/api/me`) with per-customer data isolation
### Breaking changes
[Section titled “Breaking changes”](#breaking-changes)
None — this is the initial release.
### Known limitations
[Section titled “Known limitations”](#known-limitations)
* Alpha: schema migrations are additive-only; no automated migration diffing for breaking column changes yet
* `@porulle/plugin-pos-restaurant` and `@porulle/plugin-production` APIs are subject to change before stable
* Analytics custom model joins are not yet supported (single-table models only)
# Concepts
> The why behind Porulle's design — kernel, modules, adapters, plugin transforms, the hook pipeline, the identity model.
These pages cover *why* Porulle is shaped the way it is — the trade-offs considered, the alternatives rejected, the reasoning behind the current architecture.
Read these when you want to deepen your mental model, not when you want to accomplish a specific task. For tasks, head to [Building a Store](/building/) or [Extending Porulle](/extending/). For exact API surface, [Reference](/reference/).
[Architecture ](/concepts/architecture/)Kernel, modules, adapters, interfaces — and why the boundaries sit where they do. Start here.
[The Entity Model ](/concepts/entity-model/)Why one sellable\_entities table for products, services, gift cards, subscriptions. Config-driven behavior over code-driven polymorphism.
[Plugin Architecture ](/concepts/plugin-architecture/)Why plugins are config-transform functions, not class hierarchies. The PayloadCMS lineage.
[Hook Pipeline ](/concepts/hook-pipeline/)How the checkout pipeline executes, and why the compensation pattern keeps the system consistent under partial failure.
[Result Types ](/concepts/result-types/)Why services return Result instead of throwing. The discipline that makes services composable across interfaces.
[Identity and Store Resolution ](/concepts/identity-model/)How customers, admins, vendors, and anonymous users are identified — and the org resolution profiles that determine how requests find their tenant.
# Architecture
> Kernel, modules, adapters, interfaces, and why the boundaries sit where they do.
Porulle is built around four ideas: a kernel that boots and owns all services, domain modules that encapsulate business logic, swappable adapters for external concerns, and thin interface layers that expose the kernel to the outside world. This page explains how those pieces connect and why the boundaries are where they are.
## The kernel
[Section titled “The kernel”](#the-kernel)
Everything starts with `createKernel(config)` (called internally by `createServer`). The kernel is a plain function. It reads the frozen `CommerceConfig`, instantiates every repository and service, wires them together, registers hook handlers, and returns a `Kernel` object. That object is the single source of truth for the running application: it holds all services, the database connection, and the hook registry.
The kernel does not know about HTTP. It does not import Hono or any transport library. This separation is intentional — the same kernel can be used behind a REST server, a test harness, a CLI tool, or any custom interface an adopter builds on top, without changing any business logic.
`createServer(config)` is a thin wrapper that creates a kernel, sets up authentication, mounts the REST router, and applies custom routes from config or plugins. The server depends on the kernel; the kernel depends on nothing above it.
## The module pattern
[Section titled “The module pattern”](#the-module-pattern)
Each business domain — catalog, cart, orders, inventory, pricing, promotions, fulfillment, customers, webhooks, analytics, media, search, tax, shipping — is a module. A module has three parts:
* **Schema**: Drizzle table definitions. These live alongside the module code, not in a separate migrations folder, because the schema *is* the code.
* **Repository**: Data access. The repository is the only layer that reads or writes the database directly. All repositories use Drizzle ORM against PostgreSQL. Tests use PGlite (in-process WASM PostgreSQL) with real SQL against the same repository code — no mocks.
* **Service**: Business logic. Services accept domain-level inputs, call repositories, run hooks, and return `Result` values. Services never import Hono, never read HTTP headers, and never return HTTP status codes.
This three-part structure is consistent across all modules. If you understand how `catalog` works, you understand how `inventory` works. The consistency is more valuable than any individual design choice within it.
### Why services, not direct DB access
[Section titled “Why services, not direct DB access”](#why-services-not-direct-db-access)
Routes could call repositories directly. Fewer files, less indirection. Porulle does not do this for two reasons.
First, business rules accumulate. Creating an order is not just inserting a row — it validates the cart, resolves prices, checks inventory, applies promotions, calculates tax and shipping, authorizes payment, and fires hooks. That logic needs a home that is not a route handler.
Second, multiple interfaces may need the same logic. The REST API and any adopter-built interface (CLI, RPC, agent shim) both need to create orders. If the logic lives in a route handler, every other interface duplicates it. If it lives in a service, every interface calls the same function.
## The adapter pattern
[Section titled “The adapter pattern”](#the-adapter-pattern)
Several concerns are external: which database you use, which payment processor, where files are stored, how search works, how tax is calculated. Each is represented by an adapter interface.
```ts
interface PaymentAdapter {
readonly providerId: string;
createPaymentIntent(params: CreatePaymentIntentParams): Promise>;
capturePayment(paymentIntentId: string): Promise>;
refundPayment(paymentId: string, amount: number): Promise>;
}
```
Adapters return `Result`, not thrown exceptions. A Stripe failure is `{ ok: false, error: { code: "PAYMENT_FAILED", message: "..." } }`, not an uncaught promise rejection. See [Result Types](/concepts/result-types/) for the reasoning.
Swapping an adapter is a config change — you pass a different adapter to `defineConfig` and nothing else changes. No service code changes.
Current adapter interfaces: `DatabaseAdapter`, `PaymentAdapter`, `StorageAdapter`, `SearchAdapter`, `TaxAdapter`, `AnalyticsAdapter`, `EmailAdapter`. See [Adapter Interfaces](/reference/adapters/) for full type definitions.
## The interface layer
[Section titled “The interface layer”](#the-interface-layer)
Porulle ships one interface: **REST via Hono**. Routes parse HTTP requests, call services, and translate `Result` into HTTP responses using `mapErrorToStatus` (`packages/core/src/interfaces/rest/error.ts`). Error codes map to status codes: `NOT_FOUND` → 404, `VALIDATION_FAILED` → 422, `FORBIDDEN` → 403.
The kernel is interface-agnostic. Adopters who want MCP, UCP, ACP, or custom RPC wrap the REST API or call the in-process `LocalAPI` from a companion package. The engine stays REST-only.
A hook context includes an `origin` field (`"rest"` or `"local"`) for cases where behavior should differ by caller, but most hooks ignore it.
The customer portal (`/api/me/*`) provides authenticated customers with access to their own orders, addresses, and profile. It uses the same kernel services with customer-scoped access controls.
## Config-driven design
[Section titled “Config-driven design”](#config-driven-design)
`defineConfig` is the entry point for application authors. It:
1. Merges user input with sensible defaults
2. Applies all plugins in order (each plugin is a config transform function)
3. Freezes the result to prevent runtime mutation
The frozen config is passed to `createKernel`, which reads but never modifies it. There is no `setConfig` or `updateConfig` API — the config is fixed at startup.
This is inspired by PayloadCMS, which uses the same config-as-code approach. Porulle’s config controls commerce-specific concerns: entity types, payment adapters, shipping rules, tax providers, and checkout hooks. See [Plugin Architecture](/concepts/plugin-architecture/) for how plugins interact with this pipeline.
## Why Hono
[Section titled “Why Hono”](#why-hono)
1. **Runtime-agnostic**: Hono runs on Node.js, Bun, and Cloudflare Workers without code changes. Commerce workloads benefit from both edge deployment (catalog reads) and traditional servers (checkout transactions).
2. **Lightweight**: Near-zero overhead. The router is fast, middleware is composable, no hidden magic.
3. **TypeScript-first**: Route parameter mismatches, middleware variable access, and response types caught at compile time.
## Why Drizzle
[Section titled “Why Drizzle”](#why-drizzle)
* **Schema-as-code**: Drizzle schemas are plain TypeScript. They can be imported, composed, and extended by plugins — essential for the plugin system, where plugins contribute their own tables via `customSchemas[]`.
* **SQL-like API**: Drizzle’s query builder mirrors SQL. If you know SQL, you know Drizzle.
* **Migration tooling**: `drizzle-kit` generates migrations from schema diffs, supports push-based development without migration files during prototyping.
* **Performance**: Minimal queries, prepared statement support. Checkout latency is directly affected by database performance.
The trade-off: Drizzle is less mature than Prisma and has a smaller ecosystem. The schema-as-code approach aligns with Porulle’s config-driven architecture in a way Prisma’s separate schema file format does not.
## Related
[Section titled “Related”](#related)
* [Plugin Architecture](/concepts/plugin-architecture/) — how plugins extend behavior without modifying the engine
* [Hook Pipeline](/concepts/hook-pipeline/) — how checkout orchestration works
* [Adapter Interfaces](/reference/adapters/) — full type definitions for every adapter interface
* [Configuration Reference](/reference/configuration/) — every `defineConfig` option
# The Entity Model
> Why Porulle uses a single table for all sellable things, and how config-driven behavior replaces code-driven polymorphism.
Every commerce system has to answer a fundamental question: how do you represent the things you sell? A fashion store sells physical products with sizes and colors. A learning platform sells courses with access periods. A restaurant sells menu items with modifiers. These are different enough that most platforms either pick one model and force everything into it, or create separate subsystems per type.
Porulle takes a third approach: one table, many types, with config-driven behavior.
## Why one table for everything
[Section titled “Why one table for everything”](#why-one-table-for-everything)
The `sellable_entities` table stores all sellable things regardless of type. A product, a digital download, and a course are all rows in the same table. The `type` column distinguishes them; a `metadata` JSONB column holds type-specific data.
The temptation to use one table per entity type is strong — a `products` table and a `courses` table feel more natural to model, and you get proper column types for each field. But in a commerce engine, the downstream systems matter more than the catalog model.
Cart, checkout, orders, inventory, pricing, promotions, search, and fulfillment all reference sellable things. If products and courses live in separate tables, every one of these systems needs to know about both tables. A cart line item needs a polymorphic foreign key. An order needs to join multiple tables to hydrate its line items. A promotion applying to “any item over $50” needs to query across tables.
With a unified table, none of these systems need to know about entity types at all. A cart line item references `sellable_entities.id`. An order joins one table. A promotion filters on price, not type. The complexity lives in one place — the entity config — instead of being smeared across every service.
This also matters for plugins. A reviews plugin that attaches ratings to sellable entities works for products, courses, and menu items without adaptation. If entities lived in separate tables, the plugin would need a per-type adapter or a polymorphic join strategy.
## Why not table inheritance or polymorphic associations
[Section titled “Why not table inheritance or polymorphic associations”](#why-not-table-inheritance-or-polymorphic-associations)
PostgreSQL supports table inheritance (`CREATE TABLE products INHERITS sellable_entities`). Porulle does not use it for two reasons.
First, PostgreSQL table inheritance has real limitations: foreign keys don’t propagate to child tables, `SELECT * FROM sellable_entities` does not return child rows by default, unique constraints across the hierarchy are not enforced. These are not theoretical concerns — they cause production bugs that are hard to diagnose.
Second, inheritance ties the schema to entity types. Adding a new entity type means creating a new table and running a migration. With JSONB, adding a new entity type is a config change — no migration, no schema change, no deployment.
Polymorphic associations (storing `entity_type` + `entity_id` as a pair, common in Ruby on Rails) cannot be enforced with foreign keys, make joins verbose, and scatter type awareness across every table that references entities. The unified table avoids all of this.
## How config replaces code
[Section titled “How config replaces code”](#how-config-replaces-code)
In a traditional commerce platform, entity-type-specific behavior lives in code — subclasses, interfaces, or strategy patterns. Adding a new entity type requires writing new code.
Porulle moves this behavior into configuration:
commerce.config.ts
```ts
entities: {
product: {
fields: [
{ name: "weight", type: "number", unit: "grams" },
{ name: "material", type: "text" },
],
variants: { enabled: true, optionTypes: ["size", "color"] },
fulfillment: "physical",
},
course: {
fields: [{ name: "modules", type: "json" }],
variants: { enabled: false },
fulfillment: "digital-access",
},
}
```
Four systems read this config and adjust behavior accordingly.
**The catalog service** uses `fields` to validate metadata on create and update. Declaring `weight` as a number rejects strings. An undeclared field is rejected as unknown. A type with no field definitions accepts anything in metadata.
**The shipping calculator** uses `fulfillment` to decide whether a line item incurs shipping cost during checkout. `"physical"` means shipping applies. `"digital"`, `"digital-download"`, `"digital-access"` mean shipping is skipped. A single checkout can contain a hoodie and a PDF — the shipping calculator charges shipping only for the physical item.
**The kernel** uses `hooks` to register entity-scoped lifecycle callbacks. A `beforeCreate` hook on `product` fires only when creating products, not courses. This prevents cross-type interference and allows different validation rules per type.
## The trade-off: metadata is not schema-enforced
[Section titled “The trade-off: metadata is not schema-enforced”](#the-trade-off-metadata-is-not-schema-enforced)
The cost of JSONB is that the database does not enforce the schema. A `weight` field declared as `number` in config is validated at the application layer, not by a PostgreSQL `CHECK` constraint or a `NUMERIC` column type. Direct SQL writes bypass this validation.
In practice this is acceptable:
1. All writes go through the catalog service, which validates against config. Direct SQL writes are exceptional.
2. JSONB supports indexing (`CREATE INDEX ON sellable_entities USING GIN (metadata jsonb_path_ops)`), so query performance is not significantly worse than typed columns for most access patterns.
3. The flexibility of adding fields without migrations outweighs the risk of schema drift from direct writes.
If strict schema enforcement is critical, use [custom tables](/extending/custom-tables/) to create a typed extension table with proper columns and a foreign key back to `sellable_entities`.
## Where this model breaks down
[Section titled “Where this model breaks down”](#where-this-model-breaks-down)
The unified entity model works well when entity types share the same lifecycle (create → publish → price → sell → fulfill). It breaks down when types need fundamentally different lifecycles.
A subscription with recurring billing has a different lifecycle than a one-time purchase. A booking with time slots has a different lifecycle than a physical shipment. These differences can be handled with hooks and custom fulfillment adapters to a point, but if the lifecycle diverges enough, the shared model becomes a constraint.
This is why plugins like `@porulle/plugin-appointments` exist as separate modules rather than as entity types. They add their own tables, services, and routes *alongside* the entity model rather than fitting into `sellable_entities`. The entity model is the default for straightforward “list it, price it, sell it, fulfill it” flows. When the flow itself is different, build a plugin.
## Related
[Section titled “Related”](#related)
* [Configuration Reference](/reference/configuration/) — full `EntityConfig` type definition
* [Hook System Reference](/reference/hooks/) — entity hooks and the three-slot execution model
* [Plugin Architecture](/concepts/plugin-architecture/) — how plugins extend behavior without modifying the entity model
* [Add Custom Tables](/extending/custom-tables/) — when JSONB metadata is not enough and you need typed columns
# The Full Thesis
> The original founding RFC for Porulle, dated 2026-03-08, preserved verbatim with name substitutions. Some sections describe plans that have since changed.
This is a historical artifact
This is the original founding RFC, written in March 2026 before the framework had a name or a line of code. It is preserved here verbatim with one mechanical change — every reference to “UnifiedCommerce Engine” has been swapped to “Porulle”.
**Some sections describe plans that have since changed:**
* §20 (MCP Integration) and §21 (Cube.js Analytics) describe an AI-native / semantic-analytics layer that was removed from the v0.1.0 alpha. Agent-native primitives are now an explicit Phase 2 commitment, not a v0.1.0 feature.
* §33 (Phased Delivery Plan) is now history — Phases 0–5 were completed in May 2026 and the framework shipped at v0.1.0 alpha.
* Some package paths reference `@unifiedcommerce/*` — the published packages live at `@porulle/*` on npm.
For the distilled current-day version, read [The Thesis](/concepts/thesis/). For what actually shipped, see [Architecture](/concepts/architecture/) and the rest of the docs.
## 1. Summary
[Section titled “1. Summary”](#1-summary)
This RFC proposes the architecture for a new headless commerce kernel — a programmable, serverless-first engine that treats every sellable thing as a first-class citizen, whether that thing is a physical product with size variants, a digital download, a consulting session, an online course, an office chair in an internal inventory system, or a line item on a point-of-sale terminal.
The engine is not an “e-commerce platform” in the traditional sense. It is a commerce primitive layer — a set of composable building blocks that let developers construct any transactional system. It ships with strong opinions about developer experience, AI interoperability, and analytical capability, but remains agnostic about the storefront, the deployment target, and the database vendor.
The engine draws direct architectural inspiration from PayloadCMS and Strapi: configuration is code, behavior is declared through co-located lifecycle hooks, and the extension model uses schema registration and hook composition rather than pub/sub event systems. This eliminates the indirection, invisible execution ordering, and debugging difficulty that event-driven architectures introduce. A developer reads the config file and knows exactly what happens at every lifecycle point in the system.
Authentication is fully delegated to better-auth, a framework-agnostic TypeScript authentication library that shares the same Drizzle database instance as the engine. Authorization (role-to-permission mapping and enforcement) is the engine’s responsibility, built on top of better-auth’s identity layer.
The name “Porulle” is a working title. The engine itself is the subject of this RFC.
***
## 2. Motivation and Problem Statement
[Section titled “2. Motivation and Problem Statement”](#2-motivation-and-problem-statement)
### 2.1 The Current Landscape is Fragmented
[Section titled “2.1 The Current Landscape is Fragmented”](#21-the-current-landscape-is-fragmented)
The commerce tooling ecosystem has fractured into silos. Shopify excels at DTC storefronts but fights you if you want to build an internal inventory tool. Several open-source alternatives are composable but carry strong opinions about Node.js long-running servers and event-driven architectures that scatter behavior across subscriber files. Saleor gives you GraphQL but locks you into a Python/Django world. CommerceJS and BigCommerce are SaaS-first, which means vendor lock-in is the default posture.
None of these systems were designed from the ground up to answer the question: “What if everything you sell — physical goods, digital goods, services, appointments, subscriptions, internal assets — could share the same transactional kernel?“
### 2.2 Serverless is No Longer Optional
[Section titled “2.2 Serverless is No Longer Optional”](#22-serverless-is-no-longer-optional)
The deployment landscape has shifted. Cloudflare Workers, Vercel Edge Functions, AWS Lambda, and Deno Deploy have proven that compute-at-the-edge is viable for real workloads. PayloadCMS demonstrated that a full CMS can run inside a Cloudflare Worker by embracing the right abstractions (no filesystem dependency, no long-lived process assumptions, adapter-based database access). Commerce engines should follow this path.
Running a commerce backend should not require provisioning a VPS, managing a process supervisor, or paying for idle compute. A developer should be able to deploy a fully functional commerce API with `npx deploy` and have it running at the edge in under sixty seconds.
### 2.3 AI is Not a Feature — It is an Interface
[Section titled “2.3 AI is Not a Feature — It is an Interface”](#23-ai-is-not-a-feature--it-is-an-interface)
Every major commerce platform is bolting on “AI features” as afterthoughts: chatbots that summarize order history, recommendation engines that run as separate microservices. This misses the point entirely.
AI should be a first-class interface to the commerce engine, not an add-on. This means the engine must expose its capabilities through the Model Context Protocol (MCP) from day one, allowing any LLM — whether it is Claude, GPT, or a locally-hosted model — to read catalogs, create orders, adjust pricing, generate reports, and manage inventory through tool calls. An AI agent should be able to operate the commerce engine with the same fidelity as a human developer using the REST API.
### 2.4 Analytics Should Not Be an Afterthought
[Section titled “2.4 Analytics Should Not Be an Afterthought”](#24-analytics-should-not-be-an-afterthought)
Commerce platforms generate enormous volumes of structured data — orders, line items, customer interactions, inventory movements, pricing changes. Yet analytics is typically offloaded to external BI tools through ETL pipelines. This RFC proposes embedding a semantic analytics layer (built on Cube.js) directly into the engine, so that every deployment ships with a queryable analytical model that can be extended by developers and consumed by AI agents.
### 2.5 Event Buses Add Complexity Without Proportional Value
[Section titled “2.5 Event Buses Add Complexity Without Proportional Value”](#25-event-buses-add-complexity-without-proportional-value)
Many commerce engines use an internal event bus / pub-sub pattern for inter-module communication. While this pattern has merit in distributed microservice architectures, it introduces three problems in a monolithic kernel:
First, indirection. A developer creates an order and, to understand what happens next, must grep the codebase for every subscriber to the `order.created` event. Those subscribers can be in any file, any plugin, registered at any time during boot. The execution order depends on registration order, which is invisible at the call site.
Second, silent failure. When an event listener throws, the typical pattern is to log and continue, which means you can end up in a half-executed state where the order was created but inventory was never reserved because a listener failed silently.
Third, two extension models. The event bus sits alongside lifecycle hooks, giving developers two different mechanisms to extend behavior. This is cognitive overhead that provides no architectural benefit in a single-process kernel.
PayloadCMS and Strapi proved that co-located lifecycle hooks are simpler to write, simpler to debug, and simpler to reason about. This engine follows that pattern: lifecycle hooks are the single extension primitive. There is no event bus.
### 2.6 Authentication is a Solved Problem
[Section titled “2.6 Authentication is a Solved Problem”](#26-authentication-is-a-solved-problem)
Building authentication from scratch in a commerce engine is a security risk and a waste of engineering time. Password hashing, session management, CSRF protection, OAuth flows, two-factor authentication, email verification, password reset — these are well-understood problems with battle-tested solutions. The engine delegates authentication entirely to better-auth, a TypeScript-native, framework-agnostic authentication library that runs on the same database as the engine.
***
## 3. Design Principles
[Section titled “3. Design Principles”](#3-design-principles)
These are the non-negotiable architectural principles that every design decision in this RFC must satisfy. When two goals conflict, this ordering determines which wins.
### 3.1 Developer Experience Above All
[Section titled “3.1 Developer Experience Above All”](#31-developer-experience-above-all)
The engine is a tool for developers. Every API surface, every configuration format, every error message, and every CLI command must be designed with the assumption that the developer’s time is the most expensive resource in the system. TypeScript types must be end-to-end. Configuration must be code, not YAML. Errors must be actionable, not cryptic. A developer should open the config file and understand the entire behavior of the system without reading any other file.
### 3.2 Serverless-First, Not Serverless-Only
[Section titled “3.2 Serverless-First, Not Serverless-Only”](#32-serverless-first-not-serverless-only)
Every module in the engine must be capable of running inside a serverless function with a cold start budget of under 50ms for the critical path. This means no heavy ORM initialization, no filesystem assumptions, no in-process caching that requires warm state.
However, the engine must also run perfectly well in a traditional Node.js long-running server for developers who prefer that model. The contract is: serverless is the primary deployment target, and anything that works in serverless works everywhere else by definition.
### 3.3 Zero Vendor Lock-In
[Section titled “3.3 Zero Vendor Lock-In”](#33-zero-vendor-lock-in)
The engine must be deployable to Cloudflare Workers, Vercel Edge Functions, AWS Lambda, Deno Deploy, and bare Node.js without code changes. This is achieved through an adapter pattern: every infrastructure dependency (database, storage, cache, queue) is accessed through an adapter interface. Swapping from Cloudflare D1 to Vercel Postgres to a self-hosted PostgreSQL instance should require changing a single configuration line, not refactoring application code.
### 3.4 AI-Native from Day Zero
[Section titled “3.4 AI-Native from Day Zero”](#34-ai-native-from-day-zero)
The MCP server implementation is not a plugin. It is a core module that ships with the engine and is tested with the same rigor as the REST API. Every resource the engine manages must be accessible through MCP tools. The analytical layer must be queryable through MCP. The engine must produce structured context that LLMs can reason about.
### 3.5 Composition Over Configuration
[Section titled “3.5 Composition Over Configuration”](#35-composition-over-configuration)
The engine ships with sensible defaults for common commerce patterns, but it must never force a developer into a pattern. Every behavior should be composable: swap the pricing engine, replace the fulfillment logic, add a custom order state, extend the catalog schema. The hook system is the single extension mechanism, and it must be powerful enough to change any behavior without forking the core.
### 3.6 One Extension Primitive
[Section titled “3.6 One Extension Primitive”](#36-one-extension-primitive)
There is exactly one way to extend the engine’s behavior: lifecycle hooks. There is no event bus. There is no separate pub/sub system. There is no “pipeline” abstraction that differs from hooks. Hooks are declared in the config file for application-level behavior. Plugins register additional hooks through the plugin context. The checkout flow is hooks. Webhook delivery is hooks. Analytics recording is hooks. One pattern, used everywhere, understood immediately.
### 3.7 Do Not Build What is Already Solved
[Section titled “3.7 Do Not Build What is Already Solved”](#37-do-not-build-what-is-already-solved)
Authentication, email delivery, tax calculation, search indexing, payment processing — these are infrastructure concerns with mature, battle-tested libraries. The engine uses adapter interfaces and delegates to purpose-built libraries (better-auth for identity, Stripe for payments, TaxJar for tax, Meilisearch for search) rather than reimplementing them.
***
## 4. High-Level Architecture
[Section titled “4. High-Level Architecture”](#4-high-level-architecture)
The engine is organized as a layered kernel with clear boundaries between each layer.
```plaintext
+------------------------------------------------------------------+
| Consumer Interfaces |
| [ REST API ] [ GraphQL ] [ MCP Server ] [ Admin SDK ] |
+------------------------------------------------------------------+
| Route Extensions |
| [ Config Routes ] [ Plugin Routes ] [ Custom Middleware ] |
+------------------------------------------------------------------+
| Application Layer |
| [ Catalog ] [ Cart ] [ Checkout ] [ Orders ] [ Customers ] |
| [ Inventory ] [ Fulfillment ] [ Pricing ] [ Promotions ] |
+------------------------------------------------------------------+
| Core Kernel Layer |
| [ Lifecycle Hook Registry ] [ State Machine ] |
| [ Validation ] [ Result Types ] [ Serialization ] |
+------------------------------------------------------------------+
| Auth Layer (better-auth) |
| [ Sessions ] [ OAuth ] [ 2FA ] [ API Keys ] [ Orgs ] |
| [ Permission Resolution ] [ Actor Construction ] |
+------------------------------------------------------------------+
| Analytics Layer |
| [ Cube.js Semantic Layer ] [ Pre-built Models ] |
| [ Custom Measures/Dimensions ] [ MCP Query Interface ] |
+------------------------------------------------------------------+
| Infrastructure Adapters |
| [ Database ] [ Cache ] [ Storage ] [ Queue ] [ Search ] |
| [ Payment ] [ Tax ] [ Shipping ] [ Email ] |
+------------------------------------------------------------------+
| Deployment Adapters |
| [ Cloudflare ] [ Vercel ] [ AWS Lambda ] [ Node.js ] |
+------------------------------------------------------------------+
```
The Consumer Interfaces layer receives external requests and translates them into application-layer operations. Every interface (REST, GraphQL, MCP) calls the same underlying service methods. The interface layer is a thin translation skin.
The Route Extensions layer allows developers to add custom endpoints in the config file and plugins to register additional routes. Custom routes have full access to the kernel’s services, database, and hook registry.
The Application Layer contains all business logic. Each module is a self-contained service with its own validation rules and lifecycle hook execution. Modules communicate through the service container — a module that needs to check inventory calls `context.services.inventory`, never an event emission.
The Auth Layer is powered by better-auth. It handles all authentication concerns. The engine builds its authorization model (role-to-permission mapping and enforcement) on top of better-auth’s identity layer.
The Core Kernel Layer provides the lifecycle hook registry, state machines, validation primitives, and the Result type.
The Analytics Layer reads from the same database through Cube.js, translating raw tables into business concepts.
The Infrastructure Adapters layer provides concrete implementations of abstract interfaces.
The Deployment Adapters layer translates between the engine’s HTTP handler and each deployment target’s format.
***
## 5. Technology Decisions and Rationale
[Section titled “5. Technology Decisions and Rationale”](#5-technology-decisions-and-rationale)
### 5.1 Language: TypeScript (Strict Mode, No Exceptions)
[Section titled “5.1 Language: TypeScript (Strict Mode, No Exceptions)”](#51-language-typescript-strict-mode-no-exceptions)
The entire engine is written in TypeScript with `strict: true`, `noUncheckedIndexedAccess: true`, and `exactOptionalPropertyTypes: true`. Every function has explicit return types. Every error is a typed discriminated union, not a thrown exception.
### 5.2 HTTP Framework: Hono
[Section titled “5.2 HTTP Framework: Hono”](#52-http-framework-hono)
Hono is the HTTP framework for all API surfaces. It runs identically on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node.js. It has zero dependencies, sub-millisecond cold starts, and a middleware model that aligns with the lifecycle hook design.
src/runtime/server.ts
```typescript
// This is the entry point. Every deployment adapter wraps this.
import { Hono } from "hono";
import type { CommerceConfig } from "../config/types";
import { createRestRoutes } from "../interfaces/rest";
import { createGraphQLHandler } from "../interfaces/graphql";
import { createMCPHandler } from "../interfaces/mcp";
import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal";
import { createAuth } from "../auth/setup";
import { authMiddleware } from "../auth/middleware";
import { createPOSAuthRoutes } from "../auth/pos";
import { createKernel } from "../kernel";
export function createServer(config: CommerceConfig) {
const kernel = createKernel(config);
const auth = createAuth(kernel.database, config);
const app = new Hono();
// 1. Apply global middleware from config.
if (config.middleware) {
for (const mw of config.middleware) {
app.use("*", mw);
}
}
// 2. Mount better-auth handler. Serves all /api/auth/* routes.
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
// 3. Mount POS PIN auth routes if enabled.
if (config.auth?.posPin?.enabled) {
app.route("/api/auth/pos", createPOSAuthRoutes(auth, kernel));
}
// 4. Apply auth middleware to all subsequent routes.
app.use("*", authMiddleware(auth, config));
// 5. Mount core routes.
app.route("/api", createRestRoutes(kernel));
app.route("/graphql", createGraphQLHandler(kernel));
app.route("/mcp", createMCPHandler(kernel, config.mcpTools));
// 6. Mount customer self-service routes.
app.route("/api/me", createCustomerPortalRoutes(kernel));
// 7. Mount plugin routes.
for (const route of kernel.pluginRoutes) {
app.on(route.method, route.path, route.handler);
}
// 8. Mount config-level custom routes.
if (config.routes) {
config.routes(app, kernel);
}
return app;
}
```
### 5.3 ORM and Database Access: Drizzle ORM
[Section titled “5.3 ORM and Database Access: Drizzle ORM”](#53-orm-and-database-access-drizzle-orm)
Drizzle ORM is the database access layer. It generates SQL at build time, has zero runtime overhead, produces fully typed query results, and works in serverless environments without connection pooling hacks. It supports PostgreSQL, MySQL, SQLite (including Cloudflare D1), and Turso/libSQL.
Drizzle was chosen over Prisma because Prisma requires a binary query engine that adds cold start latency and complicates edge deployments. Drizzle was chosen over Kysely because Drizzle provides a schema-as-code model that aligns with the engine’s config-driven philosophy.
### 5.4 Authentication: better-auth
[Section titled “5.4 Authentication: better-auth”](#54-authentication-better-auth)
better-auth is the authentication layer. It is framework-agnostic, TypeScript-native, and runs on the same database as the engine through the Drizzle adapter. It provides email/password auth, social login, session management, two-factor authentication, API keys, and organization-based multi-tenancy with RBAC — all through a plugin system. The engine does not implement any authentication logic. Section 14 covers the integration in detail.
### 5.5 Configuration as Code
[Section titled “5.5 Configuration as Code”](#55-configuration-as-code)
Following the PayloadCMS model, the engine is configured through a single TypeScript file. This file is the source of truth. Every lifecycle hook, every entity type, every field definition, every fulfillment strategy, every plugin, every custom route, and every auth setting — all declared in one file.
commerce.config.ts
```typescript
// This is the developer's entry point. Everything starts here.
import { defineConfig } from "@porulle/core";
import { cloudflareD1 } from "@porulle/adapter-d1";
import { stripePayment } from "@porulle/adapter-stripe";
import { resendEmail } from "@porulle/adapter-resend";
import { taxjarTax } from "@porulle/adapter-taxjar";
import { analyticsPlugin } from "@porulle/plugin-analytics";
import { posPlugin } from "@porulle/plugin-pos";
// Hook functions -- imported so you can cmd-click to the source.
import { validateProductMetadata, generateSlugFromTitle } from "./hooks/catalog";
import { syncToSearchIndex, removeFromSearchIndex } from "./hooks/search";
import { validateStock, resolvePrice, recalculateCartTotals } from "./hooks/cart";
import {
validateCartNotEmpty, resolveCurrentPrices, checkInventoryAvailability,
applyPromotionCodes, calculateTax, calculateShipping, validatePaymentMethod,
authorizePayment, capturePayment, reserveInventory, initiateFulfillment,
sendConfirmation, recordAnalyticsEvent
} from "./hooks/checkout";
import {
validateTransition, checkRefundEligibility,
releaseOrReserveInventory, sendStatusEmail
} from "./hooks/orders";
// Custom route modules.
import { wishlistRoutes, wishlists } from "./routes/wishlist";
import { storeLocatorRoutes, storeLocations } from "./routes/store-locator";
export default defineConfig({
// -- Database adapter. Swap this line to change databases. --
database: cloudflareD1({ binding: "DB" }),
// -- Authentication (better-auth). See Section 14. --
auth: {
requireEmailVerification: true,
sessionDuration: 60 * 60 * 24 * 7,
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
twoFactor: { enabled: true, requiredForRoles: ["owner", "admin", "vendor"] },
apiKeys: { enabled: true },
posPin: { enabled: true },
roles: {
owner: { permissions: ["*:*"] },
admin: { permissions: ["*:*"] },
store_manager: {
permissions: ["catalog:*", "orders:*", "customers:read", "inventory:*", "analytics:query", "pricing:*"],
},
pos_operator: {
permissions: ["catalog:read", "orders:create", "orders:read", "inventory:read"],
},
vendor: {
permissions: ["catalog:create", "catalog:read", "catalog:update", "orders:read", "inventory:read", "inventory:adjust", "analytics:query"],
},
ai_agent: {
permissions: ["catalog:read", "orders:read", "analytics:query", "mcp:access"],
},
},
customerPermissions: [
"catalog:read", "cart:create", "cart:read", "cart:update",
"orders:create", "orders:read:own", "customers:read:self", "customers:update:self",
"wishlist:read:own", "wishlist:create:own", "wishlist:delete:own",
],
},
// -- Entity types. Each defines fields, variants, fulfillment, and hooks. --
entities: {
product: {
fields: [
{ name: "weight", type: "number", unit: "grams" },
{ name: "dimensions", type: "json", schema: dimensionsSchema },
],
variants: { enabled: true, optionTypes: ["size", "color", "material"] },
fulfillment: "physical",
hooks: {
beforeCreate: [validateProductMetadata, generateSlugFromTitle],
afterCreate: [syncToSearchIndex],
beforeUpdate: [validateProductMetadata],
afterUpdate: [syncToSearchIndex],
beforeDelete: [checkNoActiveOrders],
afterDelete: [removeFromSearchIndex],
},
},
service: {
fields: [
{ name: "duration", type: "number", unit: "minutes" },
{ name: "provider", type: "relation", target: "staff" },
],
variants: { enabled: false },
fulfillment: "appointment",
hooks: {
afterCreate: [syncToSearchIndex],
afterUpdate: [syncToSearchIndex, notifySchedulingSystem],
},
},
course: {
fields: [
{ name: "modules", type: "json", schema: courseModulesSchema },
{ name: "accessDuration", type: "number", unit: "days" },
],
variants: { enabled: true, optionTypes: ["tier"] },
fulfillment: "digital-access",
hooks: {
afterCreate: [syncToSearchIndex],
afterUpdate: [syncToSearchIndex],
},
},
digitalDownload: {
fields: [
{ name: "fileAssetId", type: "text" },
{ name: "maxDownloads", type: "number" },
],
variants: { enabled: false },
fulfillment: "digital-download",
hooks: { afterCreate: [syncToSearchIndex] },
},
internalAsset: {
fields: [
{ name: "assetTag", type: "text" },
{ name: "department", type: "text" },
{ name: "condition", type: "select", options: ["new", "good", "fair", "poor"] },
],
variants: { enabled: false },
fulfillment: "internal-transfer",
hooks: {},
},
},
// -- Cart lifecycle hooks. --
cart: {
ttlMinutes: 60 * 24 * 7,
hooks: {
beforeAddItem: [validateStock, resolvePrice],
afterAddItem: [recalculateCartTotals],
beforeRemoveItem: [],
afterRemoveItem: [recalculateCartTotals],
beforeUpdateQuantity: [validateStock],
afterUpdateQuantity: [recalculateCartTotals],
},
},
// -- Checkout lifecycle hooks. The checkout IS the hook chain. --
checkout: {
hooks: {
beforeCreate: [
validateCartNotEmpty, resolveCurrentPrices, checkInventoryAvailability,
applyPromotionCodes, calculateTax, calculateShipping,
validatePaymentMethod, authorizePayment,
],
afterCreate: [
capturePayment, reserveInventory, initiateFulfillment,
sendConfirmation, recordAnalyticsEvent,
],
},
},
// -- Order lifecycle hooks. --
orders: {
hooks: {
beforeCreate: [],
afterCreate: [recordAnalyticsEvent],
beforeStatusChange: [validateTransition, checkRefundEligibility],
afterStatusChange: [releaseOrReserveInventory, sendStatusEmail, recordAnalyticsEvent],
beforeDelete: [blockDeletionIfFulfilled],
},
},
// -- Inventory lifecycle hooks. --
inventory: {
hooks: { afterAdjust: [checkLowStockThreshold, recordAnalyticsEvent] },
},
// -- Infrastructure adapters. --
payments: [stripePayment({ secretKey: process.env.STRIPE_SECRET_KEY })],
tax: taxjarTax({ apiKey: process.env.TAXJAR_API_KEY }),
email: resendEmail({ apiKey: process.env.RESEND_API_KEY }),
// -- Plugins. --
plugins: [
analyticsPlugin({ cubeApiUrl: process.env.CUBE_API_URL }),
posPlugin({ terminalMode: true }),
],
// -- AI/MCP configuration. --
mcp: {
enabled: true,
capabilities: [
"catalog:read", "catalog:write", "orders:read", "orders:write",
"analytics:query", "inventory:read", "inventory:write",
],
},
// -- Custom schemas for custom routes. Included in migration generation. --
customSchemas: [wishlists, storeLocations],
// -- Custom routes. Mounted alongside core routes. --
routes: (app, kernel) => {
app.route("/api/wishlist", wishlistRoutes(kernel));
app.route("/api/store-locator", storeLocatorRoutes(kernel));
},
// -- Custom MCP tools. Merged with core tools. --
mcpTools: (kernel) => [
{
name: "wishlist_get",
description: "Get a customer's wishlist with current pricing.",
inputSchema: {
type: "object",
required: ["customerId"],
properties: { customerId: { type: "string" } },
},
handler: async (params: any) => {
const items = await kernel.database
.select().from(wishlists)
.where(eq(wishlists.customerId, params.customerId));
return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
},
},
],
// -- Global middleware. --
middleware: [],
});
```
### 5.6 Why Not tRPC?
[Section titled “5.6 Why Not tRPC?”](#56-why-not-trpc)
tRPC provides excellent type safety between client and server, but it is designed for TypeScript-to-TypeScript communication. A headless commerce engine must serve clients written in any language. GraphQL and REST provide language-agnostic interfaces.
***
## 6. Core Kernel Design
[Section titled “6. Core Kernel Design”](#6-core-kernel-design)
The kernel provides four critical primitives: the lifecycle hook registry, the state machine, the Result type, and the error taxonomy. There is no event bus. There is no pub/sub system.
### 6.1 Lifecycle Hook Registry
[Section titled “6.1 Lifecycle Hook Registry”](#61-lifecycle-hook-registry)
The hook registry manages hook registration, execution order, and transactional integrity. Hooks are categorized into two types.
`before` hooks receive the operation data and can modify it or throw to abort the operation. They share a database transaction — if any `before` hook throws, the entire transaction rolls back.
`after` hooks receive the committed result and perform side effects. An `after` hook that throws surfaces an error to the caller but does not roll back the committed operation.
This is not an event emitter. There is no mitt, no EventEmitter, no pub/sub library. The hook system is a `Map` of string keys to ordered arrays of functions, executed in a sequential `for` loop. Each `before` hook receives the output of the previous hook. The execution order is deterministic: prepended hooks first (from plugins), then configured hooks (from the config file, in array order), then appended hooks (from plugins).
src/kernel/hooks/types.ts
```typescript
export type BeforeHook = (args: {
data: TData;
operation: "create" | "update" | "delete" | "statusChange" | "addItem" | "removeItem";
context: HookContext;
}) => Promise | TData;
export type AfterHook = (args: {
data: TData | null;
result: TData;
operation: "create" | "update" | "delete" | "statusChange" | "addItem" | "removeItem";
context: HookContext;
}) => Promise | void;
export interface HookContext {
/** The authenticated user, API key, or MCP agent. */
actor: Actor;
/** Database transaction handle. Before-hooks share this transaction. */
tx: Transaction;
/** Structured logger scoped to this operation. */
logger: Logger;
/** Access to all registered services. */
services: ServiceContainer;
/** Mutable scratchpad for hooks to pass data between themselves. */
metadata: Record;
}
```
src/kernel/hooks/registry.ts
```typescript
export class HookRegistry {
private registry = new Map();
/** Called once during boot. Stores hooks from commerce.config.ts. */
registerConfigHooks(hookName: string, handlers: Function[]): void {
this.ensureEntry(hookName);
this.registry.get(hookName)!.configured = handlers;
}
/** Called by plugins. Runs AFTER config hooks. */
append(hookName: string, handler: Function): void {
this.ensureEntry(hookName);
this.registry.get(hookName)!.appended.push(handler);
}
/** Called by plugins. Runs BEFORE config hooks. */
prepend(hookName: string, handler: Function): void {
this.ensureEntry(hookName);
this.registry.get(hookName)!.prepended.push(handler);
}
/** Returns the flat, deterministically ordered hook array. */
resolve(hookName: string): Function[] {
const entry = this.registry.get(hookName);
if (!entry) return [];
return [...entry.prepended, ...entry.configured, ...entry.appended];
}
private ensureEntry(hookName: string): void {
if (!this.registry.has(hookName)) {
this.registry.set(hookName, { prepended: [], configured: [], appended: [] });
}
}
}
```
src/kernel/hooks/executor.ts
```typescript
export async function runBeforeHooks(
hooks: BeforeHook[], data: T, operation: string, context: HookContext
): Promise {
let current = data;
for (const hook of hooks) {
current = await hook({ data: current, operation: operation as any, context });
}
return current;
}
export async function runAfterHooks(
hooks: AfterHook[], originalData: T | null, committedResult: T,
operation: string, context: HookContext
): Promise {
const errors: HookError[] = [];
for (const hook of hooks) {
try {
await hook({ data: originalData, result: committedResult, operation: operation as any, context });
} catch (error) {
errors.push({
hookName: hook.name || "(anonymous)",
message: error instanceof Error ? error.message : String(error),
});
context.logger.error(`After-hook "${hook.name}" failed`, { error });
}
}
return { errors, hasErrors: errors.length > 0 };
}
export interface HookReport {
errors: HookError[];
hasErrors: boolean;
}
export interface HookError {
hookName: string;
message: string;
}
```
### 6.2 How a Service Method Uses Hooks
[Section titled “6.2 How a Service Method Uses Hooks”](#62-how-a-service-method-uses-hooks)
This is the concrete pattern every module follows:
```typescript
// src/modules/catalog/service.ts (create method)
async create(input: CreateEntityInput, actor: Actor): Promise> {
// 1. Resolve hooks from the registry.
const beforeHooks = [
...this.hooks.resolve("catalog.beforeCreate"),
...this.hooks.resolve(`catalog.${input.type}.beforeCreate`),
] as BeforeHook[];
const afterHooks = [
...this.hooks.resolve("catalog.afterCreate"),
...this.hooks.resolve(`catalog.${input.type}.afterCreate`),
] as AfterHook[];
try {
// 2. Execute inside a transaction. Before-hooks share the tx.
const { entity, context } = await this.db.transaction(async (tx) => {
const context: HookContext = {
actor, tx,
logger: createScopedLogger("catalog.create", { entityType: input.type }),
services: this.services,
metadata: {},
};
// 3. Run before-hooks. Each receives the output of the previous one.
const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
// 4. Perform the database write.
const [entity] = await tx.insert(sellableEntities).values({
type: processedInput.type,
slug: processedInput.slug,
status: "draft",
metadata: processedInput.metadata ?? {},
}).returning();
if (processedInput.attributes) {
await tx.insert(sellableAttributes).values({
entityId: entity.id,
locale: processedInput.attributes.locale ?? "en",
title: processedInput.attributes.title,
description: processedInput.attributes.description,
});
}
return { entity, context };
});
// 5. Run after-hooks outside the transaction.
const hookReport = await runAfterHooks(afterHooks, null, entity, "create", context);
return Ok(entity, hookReport.hasErrors ? { hookErrors: hookReport.errors } : undefined);
} catch (error) {
if (error instanceof CommerceError) return Err(error);
throw error;
}
}
```
### 6.3 State Machine
[Section titled “6.3 State Machine”](#63-state-machine)
src/kernel/state-machine/machine.ts
```typescript
export interface StateDefinition {
states: readonly TState[];
initial: TState;
transitions: Record;
terminal: readonly TState[];
}
export const orderStateMachine: StateDefinition<
"pending" | "confirmed" | "processing" | "partially_fulfilled" |
"fulfilled" | "cancelled" | "refunded"
> = {
states: ["pending", "confirmed", "processing", "partially_fulfilled",
"fulfilled", "cancelled", "refunded"],
initial: "pending",
transitions: {
pending: ["confirmed", "cancelled"],
confirmed: ["processing", "cancelled"],
processing: ["partially_fulfilled", "fulfilled", "cancelled"],
partially_fulfilled: ["fulfilled", "cancelled"],
fulfilled: ["refunded"],
cancelled: [],
refunded: [],
},
terminal: ["cancelled", "refunded"],
};
export function canTransition(
machine: StateDefinition, from: TState, to: TState
): boolean {
return machine.transitions[from].includes(to);
}
export function assertTransition(
machine: StateDefinition, from: TState, to: TState
): void {
if (!canTransition(machine, from, to)) {
throw new CommerceInvalidTransitionError(
`Cannot transition from "${from}" to "${to}". ` +
`Allowed transitions from "${from}": [${machine.transitions[from].join(", ")}]`
);
}
}
```
### 6.4 Result Type (No Thrown Exceptions for Business Logic)
[Section titled “6.4 Result Type (No Thrown Exceptions for Business Logic)”](#64-result-type-no-thrown-exceptions-for-business-logic)
src/kernel/result.ts
```typescript
export type Result =
| { ok: true; value: T; meta?: Record }
| { ok: false; error: E };
export function Ok(value: T, meta?: Record): Result {
return { ok: true, value, meta };
}
export function Err(error: E): Result {
return { ok: false, error };
}
```
### 6.5 Structured Error Taxonomy
[Section titled “6.5 Structured Error Taxonomy”](#65-structured-error-taxonomy)
src/kernel/errors.ts
```typescript
export interface CommerceError {
code: string;
message: string;
details?: unknown;
}
export class CommerceNotFoundError implements CommerceError {
code = "NOT_FOUND" as const;
constructor(public message: string, public details?: unknown) {}
}
// NOT_FOUND -> 404
export class CommerceValidationError implements CommerceError {
code = "VALIDATION_FAILED" as const;
constructor(public message: string, public fieldErrors?: FieldError[], public details?: unknown) {}
}
// VALIDATION_FAILED -> 422
export class CommerceForbiddenError implements CommerceError {
code = "FORBIDDEN" as const;
constructor(public message: string, public details?: unknown) {}
}
// FORBIDDEN -> 403
export class CommerceConflictError implements CommerceError {
code = "CONFLICT" as const;
constructor(public message: string, public details?: unknown) {}
}
// CONFLICT -> 409
export class CommerceInvalidTransitionError implements CommerceError {
code = "INVALID_TRANSITION" as const;
constructor(public message: string, public details?: unknown) {}
}
// INVALID_TRANSITION -> 422
```
***
## 7. The Catalog System: Universal Sellable Entity Model
[Section titled “7. The Catalog System: Universal Sellable Entity Model”](#7-the-catalog-system-universal-sellable-entity-model)
Instead of separate tables for “products”, “services”, and “courses”, the engine uses a single polymorphic entity model called `SellableEntity`. From the kernel’s perspective, all sellable things have a price, can be added to a cart, can be checked out, and have a fulfillment strategy. The differences (physical products need shipping; courses need access provisioning) belong in the fulfillment layer, not the catalog layer. The unified model enables mixed carts.
### 7.1 Entity Schema
[Section titled “7.1 Entity Schema”](#71-entity-schema)
src/modules/catalog/schema.ts
```typescript
import { pgTable, text, timestamp, jsonb, boolean, integer, uuid, index } from "drizzle-orm/pg-core";
export const sellableEntities = pgTable("sellable_entities", {
id: uuid("id").defaultRandom().primaryKey(),
type: text("type").notNull(), // "product", "service", "course", "digital", "internal_asset"
slug: text("slug").notNull().unique(),
status: text("status", {
enum: ["draft", "active", "archived", "discontinued"],
}).notNull().default("draft"),
isVisible: boolean("is_visible").notNull().default(false),
metadata: jsonb("metadata").$type>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
}, (table) => ({
typeIdx: index("idx_sellable_entities_type").on(table.type),
statusIdx: index("idx_sellable_entities_status").on(table.status),
slugIdx: index("idx_sellable_entities_slug").on(table.slug),
}));
export const sellableAttributes = pgTable("sellable_attributes", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
locale: text("locale").notNull().default("en"),
title: text("title").notNull(),
subtitle: text("subtitle"),
description: text("description"),
richDescription: jsonb("rich_description"), // ProseMirror-compatible JSON.
seoTitle: text("seo_title"),
seoDescription: text("seo_description"),
}, (table) => ({
entityLocaleIdx: index("idx_sellable_attrs_entity_locale").on(table.entityId, table.locale),
}));
export const sellableCustomFields = pgTable("sellable_custom_fields", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
fieldName: text("field_name").notNull(),
fieldType: text("field_type", {
enum: ["text", "number", "boolean", "date", "json", "relation"],
}).notNull(),
textValue: text("text_value"),
numberValue: integer("number_value"),
booleanValue: boolean("boolean_value"),
dateValue: timestamp("date_value", { withTimezone: true }),
jsonValue: jsonb("json_value"),
}, (table) => ({
entityFieldIdx: index("idx_custom_fields_entity_field").on(table.entityId, table.fieldName),
textValueIdx: index("idx_custom_fields_text").on(table.fieldName, table.textValue),
numberValueIdx: index("idx_custom_fields_number").on(table.fieldName, table.numberValue),
}));
export const categories = pgTable("categories", {
id: uuid("id").defaultRandom().primaryKey(),
parentId: uuid("parent_id").references((): any => categories.id, { onDelete: "set null" }),
slug: text("slug").notNull().unique(),
sortOrder: integer("sort_order").notNull().default(0),
metadata: jsonb("metadata").$type>().default({}),
});
export const entityCategories = pgTable("entity_categories", {
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
categoryId: uuid("category_id").references(() => categories.id, { onDelete: "cascade" }).notNull(),
sortOrder: integer("sort_order").notNull().default(0),
});
```
### 7.2 Catalog Service Interface
[Section titled “7.2 Catalog Service Interface”](#72-catalog-service-interface)
src/modules/catalog/service.ts
```typescript
export interface CatalogService {
create(input: CreateEntityInput, actor: Actor): Promise>;
update(id: string, input: UpdateEntityInput, actor: Actor): Promise>;
delete(id: string, actor: Actor): Promise>;
getById(id: string, options?: GetOptions): Promise>;
getBySlug(slug: string, options?: GetOptions): Promise>;
list(params: ListParams): Promise>>;
publish(id: string, actor: Actor): Promise>;
archive(id: string, actor: Actor): Promise>;
setAttributes(entityId: string, locale: string, attrs: SetAttributesInput): Promise>;
getAttributes(entityId: string, locale: string): Promise>;
addToCategory(entityId: string, categoryId: string): Promise>;
removeFromCategory(entityId: string, categoryId: string): Promise>;
}
export interface GetOptions {
includeAttributes?: boolean | { locales: string[] };
includeVariants?: boolean;
includePricing?: boolean;
includeInventory?: boolean;
includeMedia?: boolean;
includeCategories?: boolean;
}
```
***
## 8. Variant and Option Architecture
[Section titled “8. Variant and Option Architecture”](#8-variant-and-option-architecture)
Variants represent the concrete, purchasable configurations of a sellable entity. A t-shirt might have “Blue / Large” and “Red / Small” variants. A course might have “Basic Tier” and “Pro Tier” variants.
```typescript
// src/modules/catalog/schema.ts (continued)
export const optionTypes = pgTable("option_types", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
name: text("name").notNull(), // "size", "color"
displayName: text("display_name").notNull(), // "Size", "Color"
sortOrder: integer("sort_order").notNull().default(0),
});
export const optionValues = pgTable("option_values", {
id: uuid("id").defaultRandom().primaryKey(),
optionTypeId: uuid("option_type_id").references(() => optionTypes.id, { onDelete: "cascade" }).notNull(),
value: text("value").notNull(), // "S", "M", "L"
displayValue: text("display_value").notNull(), // "Small", "Medium", "Large"
sortOrder: integer("sort_order").notNull().default(0),
metadata: jsonb("metadata").$type>().default({}),
});
export const variants = pgTable("variants", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
sku: text("sku").unique(),
barcode: text("barcode"),
status: text("status", { enum: ["active", "discontinued"] }).notNull().default("active"),
sortOrder: integer("sort_order").notNull().default(0),
metadata: jsonb("metadata").$type>().default({}),
});
export const variantOptionValues = pgTable("variant_option_values", {
variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }).notNull(),
optionValueId: uuid("option_value_id").references(() => optionValues.id, { onDelete: "cascade" }).notNull(),
});
```
The engine does not auto-generate the Cartesian product of all option values. A variant generation utility supports three modes: “all” (full Cartesian product), “manual” (explicit specification), and “matrix” (inclusion/exclusion rules).
***
## 9. Pricing Engine
[Section titled “9. Pricing Engine”](#9-pricing-engine)
The pricing engine is separated from the catalog. Prices are resolved at query time through a pipeline. All monetary amounts are stored as integers in the smallest currency unit (USD $29.99 = 2999). This eliminates floating-point errors entirely.
src/modules/pricing/schema.ts
```typescript
export const prices = pgTable("prices", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }),
variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }),
amount: integer("amount").notNull(),
currency: text("currency").notNull().default("USD"),
customerGroupId: uuid("customer_group_id"),
minQuantity: integer("min_quantity").notNull().default(1),
maxQuantity: integer("max_quantity"),
validFrom: timestamp("valid_from", { withTimezone: true }),
validUntil: timestamp("valid_until", { withTimezone: true }),
});
export const priceModifiers = pgTable("price_modifiers", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
type: text("type", {
enum: ["percentage_discount", "fixed_discount", "markup", "override"],
}).notNull(),
value: integer("value").notNull(), // Percentage: basis points (1000 = 10%). Fixed: smallest unit.
priority: integer("priority").notNull().default(0),
conditions: jsonb("conditions").$type().default([]),
validFrom: timestamp("valid_from", { withTimezone: true }),
validUntil: timestamp("valid_until", { withTimezone: true }),
});
```
### 9.1 Price Resolution
[Section titled “9.1 Price Resolution”](#91-price-resolution)
src/modules/pricing/resolver.ts
```typescript
export interface PriceResolutionContext {
entityId: string;
variantId: string | null;
currency: string;
quantity: number;
customerId: string | null;
customerGroupIds: string[];
timestamp: Date;
}
export interface ResolvedPrice {
baseAmount: number;
finalAmount: number;
currency: string;
appliedModifiers: AppliedModifier[];
breakdown: PriceBreakdownStep[];
}
export interface PriceBreakdownStep {
label: string; // "Base price", "Volume discount (10%)"
amount: number; // Amount after this step.
delta: number; // Change from previous step. Negative = discount.
}
// Resolution pipeline:
// 1. Find applicable base prices (entity/variant, currency, customer group, quantity range, validity).
// 2. Select most specific base price.
// 3. Apply modifiers in priority order.
// 4. Return final price with full breakdown.
```
***
## 10. Cart and Checkout Pipeline
[Section titled “10. Cart and Checkout Pipeline”](#10-cart-and-checkout-pipeline)
### 10.1 Cart Schema
[Section titled “10.1 Cart Schema”](#101-cart-schema)
The cart is a server-side entity. Server-side carts allow AI agents to manage carts through MCP, POS terminals to share state, and analytics to track composition in real time.
src/modules/cart/schema.ts
```typescript
export const carts = pgTable("carts", {
id: uuid("id").defaultRandom().primaryKey(),
customerId: uuid("customer_id"),
status: text("status", {
enum: ["active", "merged", "checked_out", "abandoned"],
}).notNull().default("active"),
currency: text("currency").notNull().default("USD"),
metadata: jsonb("metadata").$type>().default({}),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const cartLineItems = pgTable("cart_line_items", {
id: uuid("id").defaultRandom().primaryKey(),
cartId: uuid("cart_id").references(() => carts.id, { onDelete: "cascade" }).notNull(),
entityId: uuid("entity_id").references(() => sellableEntities.id).notNull(),
variantId: uuid("variant_id").references(() => variants.id),
quantity: integer("quantity").notNull().default(1),
unitPriceSnapshot: integer("unit_price_snapshot").notNull(),
currency: text("currency").notNull(),
metadata: jsonb("metadata").$type>().default({}),
addedAt: timestamp("added_at", { withTimezone: true }).defaultNow().notNull(),
});
```
### 10.2 Checkout as Hooks
[Section titled “10.2 Checkout as Hooks”](#102-checkout-as-hooks)
There is no separate “checkout pipeline” abstraction. The checkout flow is the `checkout.beforeCreate` and `checkout.afterCreate` hook chains declared in the config. Each step is a hook function, run in array order, sharing a database transaction. If any `beforeCreate` hook throws, the entire transaction rolls back.
src/hooks/checkout.ts
```typescript
export const validateCartNotEmpty: BeforeHook = async ({ data, context }) => {
const cart = await context.services.cart.getById(data.cartId);
if (!cart.ok || cart.value.lineItems.length === 0) {
throw new CommerceValidationError("Cannot checkout an empty cart.");
}
data.lineItems = cart.value.lineItems;
return data;
};
export const resolveCurrentPrices: BeforeHook = async ({ data, context }) => {
for (const item of data.lineItems) {
const price = await context.services.pricing.resolve({
entityId: item.entityId, variantId: item.variantId,
currency: data.currency, quantity: item.quantity, customerId: data.customerId,
});
if (!price.ok) throw new CommerceValidationError(`Cannot resolve price for ${item.entityId}.`);
item.resolvedUnitPrice = price.value.finalAmount;
item.resolvedTotal = price.value.finalAmount * item.quantity;
}
data.subtotal = data.lineItems.reduce((sum, li) => sum + li.resolvedTotal, 0);
return data;
};
export const checkInventoryAvailability: BeforeHook = async ({ data, context }) => {
for (const item of data.lineItems) {
const available = await context.services.inventory.getAvailable(item.entityId, item.variantId);
if (!available.ok || available.value < item.quantity) {
throw new CommerceValidationError(
`Insufficient stock for "${item.title}". Available: ${available.ok ? available.value : 0}, requested: ${item.quantity}.`
);
}
}
return data;
};
export const authorizePayment: BeforeHook = async ({ data, context }) => {
const result = await context.services.payments.authorize({
amount: data.total, currency: data.currency,
paymentMethodId: data.paymentMethodId,
metadata: { checkoutId: data.checkoutId },
});
if (!result.ok) throw new CommerceValidationError(`Payment authorization failed: ${result.error.message}`);
data.paymentIntentId = result.value.id;
return data;
};
export const capturePayment: AfterHook = async ({ result: order, context }) => {
await context.services.payments.capture(context.metadata.paymentIntentId as string);
};
export const reserveInventory: AfterHook = async ({ result: order, context }) => {
for (const li of order.lineItems) {
await context.services.inventory.reserve({
entityId: li.entityId, variantId: li.variantId,
quantity: li.quantity, orderId: order.id,
});
}
};
export const sendConfirmation: AfterHook = async ({ result: order, context }) => {
if (order.customerId) {
const customer = await context.services.customers.getById(order.customerId);
if (customer.ok && customer.value.email) {
await context.services.email.send({
template: "order-confirmation", to: customer.value.email, data: { order },
});
}
}
};
```
***
## 11. Order Lifecycle and State Machine
[Section titled “11. Order Lifecycle and State Machine”](#11-order-lifecycle-and-state-machine)
Orders use the state machine from Section 6.3. The order service enforces transitions by running `beforeStatusChange` hooks, then `afterStatusChange` hooks for side effects.
src/modules/orders/schema.ts
```typescript
export const orders = pgTable("orders", {
id: uuid("id").defaultRandom().primaryKey(),
orderNumber: text("order_number").notNull().unique(), // "ORD-2026-00001"
customerId: uuid("customer_id"),
status: text("status").notNull().default("pending"),
currency: text("currency").notNull(),
subtotal: integer("subtotal").notNull(),
taxTotal: integer("tax_total").notNull(),
shippingTotal: integer("shipping_total").notNull(),
discountTotal: integer("discount_total").notNull().default(0),
grandTotal: integer("grand_total").notNull(),
metadata: jsonb("metadata").$type>().default({}),
placedAt: timestamp("placed_at", { withTimezone: true }).defaultNow().notNull(),
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
});
export const orderLineItems = pgTable("order_line_items", {
id: uuid("id").defaultRandom().primaryKey(),
orderId: uuid("order_id").references(() => orders.id, { onDelete: "cascade" }).notNull(),
entityId: uuid("entity_id").references(() => sellableEntities.id).notNull(),
entityType: text("entity_type").notNull(),
variantId: uuid("variant_id"),
sku: text("sku"),
title: text("title").notNull(), // Snapshot at order time.
quantity: integer("quantity").notNull(),
unitPrice: integer("unit_price").notNull(),
totalPrice: integer("total_price").notNull(),
taxAmount: integer("tax_amount").notNull().default(0),
discountAmount: integer("discount_amount").notNull().default(0),
fulfillmentStatus: text("fulfillment_status").notNull().default("unfulfilled"),
metadata: jsonb("metadata").$type>().default({}),
});
export const orderStatusHistory = pgTable("order_status_history", {
id: uuid("id").defaultRandom().primaryKey(),
orderId: uuid("order_id").references(() => orders.id, { onDelete: "cascade" }).notNull(),
fromStatus: text("from_status").notNull(),
toStatus: text("to_status").notNull(),
reason: text("reason"),
changedBy: text("changed_by").notNull(), // User ID, "system", "mcp-agent:"
changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
});
```
***
## 12. Fulfillment Abstraction Layer
[Section titled “12. Fulfillment Abstraction Layer”](#12-fulfillment-abstraction-layer)
Fulfillment is where entity types diverge. The engine uses a strategy pattern: each entity type declares a fulfillment strategy in the config, and the fulfillment module dispatches to the appropriate handler.
src/modules/fulfillment/types.ts
```typescript
export interface FulfillmentStrategy {
type: string;
canFulfill(lineItem: OrderLineItem, context: HookContext): Promise>;
fulfill(lineItem: OrderLineItem, context: HookContext): Promise>;
reverse(fulfillmentId: string, context: HookContext): Promise>;
}
// Built-in strategies:
// "physical" - Ship via carrier (Shippo, EasyPost, manual).
// "digital-download" - Generate signed download URL with TTL and max-downloads.
// "digital-access" - Grant access to content (LMS webhook, internal flag).
// "appointment" - Schedule via calendar adapter (Google Calendar, Cal.com).
// "internal-transfer" - Record asset transfer between departments.
```
***
## 13. Payment Adapter System
[Section titled “13. Payment Adapter System”](#13-payment-adapter-system)
src/modules/payments/adapter.ts
```typescript
export interface PaymentAdapter {
readonly providerId: string;
createPaymentIntent(params: CreatePaymentIntentParams): Promise>;
capturePayment(paymentIntentId: string, amount?: number): Promise>;
refundPayment(paymentId: string, amount: number, reason?: string): Promise>;
cancelPaymentIntent(paymentIntentId: string): Promise>;
verifyWebhook(request: Request): Promise>;
}
export interface CreatePaymentIntentParams {
amount: number;
currency: string;
orderId: string;
customerId?: string;
metadata?: Record;
terminalId?: string; // For POS.
}
```
***
## 14. Authentication: better-auth Integration
[Section titled “14. Authentication: better-auth Integration”](#14-authentication-better-auth-integration)
Authentication is fully delegated to better-auth. The engine does not implement password hashing, session management, CSRF protection, OAuth flows, or any other authentication concern. better-auth runs on the same Drizzle database instance, using the same connection, the same migration pipeline, and the same transaction manager.
### 14.1 Why better-auth
[Section titled “14.1 Why better-auth”](#141-why-better-auth)
better-auth is a framework-agnostic TypeScript authentication library with first-class Hono support and a Drizzle ORM adapter. It provides email/password auth, social login (Google, GitHub, Apple, Discord, and more), session management, two-factor authentication (TOTP), passkeys, API keys, and organization-based multi-tenancy with RBAC. It runs in-process, on your database, under your control. There is no external SaaS dependency.
The alignment with the engine’s stack is precise: same language (TypeScript strict), same database layer (Drizzle), same HTTP framework (Hono), same deployment targets (Cloudflare Workers, Vercel Edge, Node.js).
### 14.2 better-auth Setup
[Section titled “14.2 better-auth Setup”](#142-better-auth-setup)
src/auth/setup.ts
```typescript
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { twoFactor } from "better-auth/plugins";
import { apiKey } from "better-auth/plugins";
export function createAuth(db: DatabaseAdapter, config: CommerceConfig) {
return betterAuth({
database: drizzleAdapter(db, { provider: config.database.provider }),
emailAndPassword: {
enabled: true,
requireEmailVerification: config.auth?.requireEmailVerification ?? true,
sendResetPassword: async ({ user, url }) => {
await config.email.send({
template: "password-reset",
to: user.email,
data: { resetUrl: url, userName: user.name },
});
},
sendVerificationEmail: async ({ user, url }) => {
await config.email.send({
template: "email-verification",
to: user.email,
data: { verifyUrl: url, userName: user.name },
});
},
},
socialProviders: config.auth?.socialProviders ?? {},
session: {
expiresIn: config.auth?.sessionDuration ?? 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
plugins: [
organization({
roles: buildOrganizationRoles(config.auth?.roles ?? {}),
}),
...(config.auth?.twoFactor?.enabled
? [twoFactor({ issuer: config.storeName ?? "Porulle" })]
: []),
...(config.auth?.apiKeys?.enabled ? [apiKey()] : []),
],
user: {
additionalFields: {
vendorId: { type: "string", required: false },
posOperatorPin: { type: "string", required: false },
},
},
});
}
```
### 14.3 Auth Middleware: Translating Identity to Actor
[Section titled “14.3 Auth Middleware: Translating Identity to Actor”](#143-auth-middleware-translating-identity-to-actor)
The auth middleware runs on every request. It resolves the better-auth session (or API key) and constructs the engine’s `Actor` type.
src/auth/middleware.ts
```typescript
export function authMiddleware(auth: BetterAuthInstance, config: CommerceConfig): MiddlewareHandler {
return async (c, next) => {
// 1. Try session-based auth (cookies, bearer token).
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (session) {
c.set("actor", {
type: "user",
userId: session.user.id,
email: session.user.email,
name: session.user.name,
vendorId: session.user.vendorId ?? null,
organizationId: session.session.activeOrganizationId ?? null,
role: session.session.activeOrganizationRole ?? "customer",
permissions: resolvePermissions(session, config),
} satisfies Actor);
await next();
return;
}
// 2. Try API key auth (machine-to-machine, MCP agents).
const apiKeyHeader = c.req.header("x-api-key") || c.req.header("authorization")?.replace("Bearer ", "");
if (apiKeyHeader && config.auth?.apiKeys?.enabled) {
try {
const keyResult = await auth.api.verifyApiKey({ key: apiKeyHeader });
if (keyResult) {
c.set("actor", {
type: "api_key",
userId: keyResult.userId,
email: null,
name: keyResult.name ?? "API Key",
vendorId: null,
organizationId: null,
role: "api_key",
permissions: keyResult.permissions ?? config.auth?.roles?.ai_agent?.permissions ?? [],
} satisfies Actor);
await next();
return;
}
} catch { /* Invalid key. Fall through to anonymous. */ }
}
// 3. No valid auth. Actor is null (anonymous).
c.set("actor", null);
await next();
};
}
function resolvePermissions(session: BetterAuthSession, config: CommerceConfig): string[] {
const role = session.session.activeOrganizationRole;
if (!role) {
return config.auth?.customerPermissions ?? [
"catalog:read", "cart:create", "cart:read", "cart:update",
"orders:create", "orders:read:own", "customers:read:self", "customers:update:self",
];
}
const roleConfig = config.auth?.roles?.[role];
return roleConfig ? roleConfig.permissions : [];
}
```
### 14.4 The Actor Type
[Section titled “14.4 The Actor Type”](#144-the-actor-type)
src/auth/types.ts
```typescript
export interface Actor {
type: "user" | "api_key";
userId: string;
email: string | null;
name: string;
vendorId: string | null;
organizationId: string | null;
role: string;
permissions: string[];
}
```
### 14.5 POS PIN Authentication
[Section titled “14.5 POS PIN Authentication”](#145-pos-pin-authentication)
POS terminals use a simplified auth flow. The operator enters a numeric PIN. This route validates the PIN and creates a standard better-auth session with a shorter TTL.
src/auth/pos.ts
```typescript
import { Hono } from "hono";
import { eq } from "drizzle-orm";
export function createPOSAuthRoutes(auth: BetterAuthInstance, kernel: Kernel) {
const router = new Hono();
// POST /api/auth/pos/pin
router.post("/pin", async (c) => {
const { pin, terminalId } = await c.req.json<{ pin: string; terminalId: string }>();
if (!pin || !terminalId) {
return c.json({ error: { code: "VALIDATION_FAILED", message: "PIN and terminal ID are required." } }, 422);
}
const users = await kernel.database
.select().from(user)
.where(eq(user.posOperatorPin, hashPin(pin)))
.limit(1);
if (!users.length) {
return c.json({ error: { code: "FORBIDDEN", message: "Invalid credentials." } }, 401);
}
const operator = users[0];
// Create a better-auth session scoped to one 8-hour shift.
const session = await auth.api.createSession({
userId: operator.id,
expiresIn: 60 * 60 * 8,
});
kernel.logger.info("POS session started", {
operatorId: operator.id, operatorName: operator.name, terminalId,
});
return c.json({
data: {
session: { token: session.token, expiresAt: session.expiresAt },
operator: { id: operator.id, name: operator.name, role: "pos_operator" },
terminalId,
},
});
});
// POST /api/auth/pos/clock-out
router.post("/clock-out", async (c) => {
const actor = c.get("actor");
if (!actor) return c.json({ error: { code: "FORBIDDEN", message: "Not authenticated." } }, 401);
const token = c.req.header("authorization")?.replace("Bearer ", "");
if (token) await auth.api.revokeSession({ token });
kernel.logger.info("POS session ended", { operatorId: actor.userId });
return c.json({ data: { clockedOut: true } });
});
return router;
}
```
### 14.6 Routes Provided by better-auth
[Section titled “14.6 Routes Provided by better-auth”](#146-routes-provided-by-better-auth)
better-auth automatically serves these routes. The engine does not reimplement any of them:
\| Route | Method | Purpose | |-------|--------|---------| | `/api/auth/sign-up` | POST | Create account (email/password). | | `/api/auth/sign-in/email` | POST | Sign in with email/password. | | `/api/auth/sign-in/social` | POST | Initiate social login. | | `/api/auth/callback/:provider` | GET | OAuth callback. | | `/api/auth/sign-out` | POST | Sign out, revoke session. | | `/api/auth/session` | GET | Get current session. | | `/api/auth/forgot-password` | POST | Send password reset email. | | `/api/auth/reset-password` | POST | Reset password with token. | | `/api/auth/verify-email` | GET | Verify email with token. | | `/api/auth/two-factor/enable` | POST | Enable 2FA (TOTP). | | `/api/auth/two-factor/verify` | POST | Verify 2FA code. | | `/api/auth/organization/create` | POST | Create organization. | | `/api/auth/organization/invite` | POST | Invite member. | | `/api/auth/organization/set-active` | POST | Set active organization for session. | | `/api/auth/api-key/create` | POST | Generate API key. | | `/api/auth/api-key/revoke` | POST | Revoke API key. |
The engine adds `/api/auth/pos/pin` and `/api/auth/pos/clock-out` for POS authentication.
***
## 15. Authorization: Permission Model
[Section titled “15. Authorization: Permission Model”](#15-authorization-permission-model)
Authentication (“who is this person?”) is better-auth’s job. Authorization (“what can this person do?”) is the engine’s job.
### 15.1 Permission Check
[Section titled “15.1 Permission Check”](#151-permission-check)
src/auth/permissions.ts
```typescript
export function assertPermission(actor: Actor | null, required: string): void {
if (!actor) throw new CommerceForbiddenError("Authentication required.");
if (actor.permissions.includes("*:*")) return;
const [resource] = required.split(":");
if (actor.permissions.includes(`${resource}:*`)) return;
if (actor.permissions.includes(required)) return;
throw new CommerceForbiddenError(
`Permission "${required}" is required. Your role "${actor.role}" does not include this permission.`
);
}
export function assertOwnership(actor: Actor | null, resourceOwnerId: string | null): void {
if (!actor) throw new CommerceForbiddenError("Authentication required.");
if (actor.permissions.includes("*:*")) return;
if (actor.userId !== resourceOwnerId) {
throw new CommerceForbiddenError("You do not have access to this resource.");
}
}
```
### 15.2 Usage in Service Methods
[Section titled “15.2 Usage in Service Methods”](#152-usage-in-service-methods)
```typescript
async create(input: CreateEntityInput, actor: Actor): Promise> {
assertPermission(actor, "catalog:create");
// ... hook + create logic ...
}
async getById(id: string, actor: Actor): Promise> {
assertPermission(actor, "orders:read");
const order = await this.db.select().from(orders).where(eq(orders.id, id)).limit(1);
if (!order.length) return Err(new CommerceNotFoundError(`Order not found.`));
// If actor only has "orders:read:own", enforce ownership.
if (!actor.permissions.includes("orders:read") && actor.permissions.includes("orders:read:own")) {
assertOwnership(actor, order[0].customerId);
}
return Ok(order[0]);
}
```
***
## 16. Customer Portal and Self-Service
[Section titled “16. Customer Portal and Self-Service”](#16-customer-portal-and-self-service)
Customers need to see their orders, track shipments, manage their profile, reorder, and access digital purchases. The engine provides dedicated endpoints under `/api/me` that are automatically scoped to the authenticated customer.
### 16.1 Customer Portal Routes
[Section titled “16.1 Customer Portal Routes”](#161-customer-portal-routes)
src/interfaces/rest/customer-portal.ts
```typescript
import { Hono } from "hono";
import type { Kernel } from "../../kernel";
export function createCustomerPortalRoutes(kernel: Kernel) {
const router = new Hono();
// All /api/me routes require authentication.
router.use("*", async (c, next) => {
if (!c.get("actor")) return c.json({ error: { code: "FORBIDDEN", message: "Authentication required." } }, 401);
await next();
});
// GET /api/me/profile
router.get("/profile", async (c) => {
const actor = c.get("actor") as Actor;
const customer = await kernel.services.customers.getByUserId(actor.userId);
if (!customer.ok) return c.json({ error: customer.error }, 404);
return c.json({ data: customer.value });
});
// PATCH /api/me/profile
router.patch("/profile", async (c) => {
const actor = c.get("actor") as Actor;
assertPermission(actor, "customers:update:self");
const body = await c.req.json();
const result = await kernel.services.customers.updateByUserId(actor.userId, body);
if (!result.ok) return c.json({ error: result.error }, 422);
return c.json({ data: result.value });
});
// GET /api/me/addresses
router.get("/addresses", async (c) => {
const actor = c.get("actor") as Actor;
const addresses = await kernel.services.customers.getAddresses(actor.userId);
return c.json({ data: addresses.ok ? addresses.value : [] });
});
// POST /api/me/addresses
router.post("/addresses", async (c) => {
const actor = c.get("actor") as Actor;
const body = await c.req.json();
const result = await kernel.services.customers.addAddress(actor.userId, body);
if (!result.ok) return c.json({ error: result.error }, 422);
return c.json({ data: result.value }, 201);
});
// DELETE /api/me/addresses/:id
router.delete("/addresses/:id", async (c) => {
const actor = c.get("actor") as Actor;
const result = await kernel.services.customers.deleteAddress(actor.userId, c.req.param("id"));
if (!result.ok) return c.json({ error: result.error }, 404);
return c.json({ data: { deleted: true } });
});
// GET /api/me/orders
router.get("/orders", async (c) => {
const actor = c.get("actor") as Actor;
const result = await kernel.services.orders.listByCustomer(actor.userId, {
page: parseInt(c.req.query("page") ?? "1"),
limit: parseInt(c.req.query("limit") ?? "20"),
status: c.req.query("status") ?? undefined,
});
if (!result.ok) return c.json({ error: result.error }, 500);
return c.json({ data: result.value.items, meta: result.value.pagination });
});
// GET /api/me/orders/:idOrNumber
router.get("/orders/:idOrNumber", async (c) => {
const actor = c.get("actor") as Actor;
const id = c.req.param("idOrNumber");
const result = isUUID(id)
? await kernel.services.orders.getById(id, actor)
: await kernel.services.orders.getByNumber(id, actor);
if (!result.ok) return c.json({ error: result.error }, mapErrorToStatus(result.error));
return c.json({ data: result.value });
});
// GET /api/me/orders/:idOrNumber/tracking
router.get("/orders/:idOrNumber/tracking", async (c) => {
const actor = c.get("actor") as Actor;
const id = c.req.param("idOrNumber");
const orderResult = isUUID(id)
? await kernel.services.orders.getById(id, actor)
: await kernel.services.orders.getByNumber(id, actor);
if (!orderResult.ok) return c.json({ error: orderResult.error }, mapErrorToStatus(orderResult.error));
const fulfillments = await kernel.services.fulfillment.getByOrderId(orderResult.value.id);
if (!fulfillments.ok) return c.json({ error: fulfillments.error }, 500);
return c.json({
data: fulfillments.value.map((f) => ({
fulfillmentId: f.id,
status: f.status,
carrier: f.carrier ?? null,
trackingNumber: f.trackingNumber ?? null,
trackingUrl: f.trackingUrl ?? null,
estimatedDelivery: f.estimatedDelivery ?? null,
shippedAt: f.shippedAt ?? null,
deliveredAt: f.deliveredAt ?? null,
lineItems: f.lineItems.map((li) => ({ title: li.title, quantity: li.quantity, sku: li.sku })),
})),
});
});
// GET /api/me/orders/:orderId/downloads
router.get("/orders/:orderId/downloads", async (c) => {
const actor = c.get("actor") as Actor;
const orderResult = await kernel.services.orders.getById(c.req.param("orderId"), actor);
if (!orderResult.ok) return c.json({ error: orderResult.error }, mapErrorToStatus(orderResult.error));
const digitalItems = orderResult.value.lineItems.filter((li) => li.entityType === "digitalDownload");
const downloads = await Promise.all(
digitalItems.map(async (li) => {
const dl = await kernel.services.fulfillment.getDownloadUrl(orderResult.value.id, li.id, actor.userId);
return {
lineItemId: li.id, title: li.title,
downloadUrl: dl.ok ? dl.value.url : null,
downloadsRemaining: dl.ok ? dl.value.remaining : 0,
expiresAt: dl.ok ? dl.value.expiresAt : null,
};
})
);
return c.json({ data: downloads });
});
// GET /api/me/courses
router.get("/courses", async (c) => {
const actor = c.get("actor") as Actor;
const result = await kernel.services.fulfillment.getDigitalAccess(actor.userId, "course");
if (!result.ok) return c.json({ error: result.error }, 500);
return c.json({
data: result.value.map((a) => ({
entityId: a.entityId, title: a.title,
accessGrantedAt: a.grantedAt, accessExpiresAt: a.expiresAt,
isActive: a.isActive, orderId: a.orderId,
})),
});
});
// POST /api/me/orders/:orderId/reorder
router.post("/orders/:orderId/reorder", async (c) => {
const actor = c.get("actor") as Actor;
const orderResult = await kernel.services.orders.getById(c.req.param("orderId"), actor);
if (!orderResult.ok) return c.json({ error: orderResult.error }, mapErrorToStatus(orderResult.error));
const cartResult = await kernel.services.cart.create({
customerId: actor.userId, currency: orderResult.value.currency,
});
if (!cartResult.ok) return c.json({ error: cartResult.error }, 500);
const addResults = await Promise.all(
orderResult.value.lineItems.map((li) =>
kernel.services.cart.addItem({
cartId: cartResult.value.id, entityId: li.entityId,
variantId: li.variantId ?? undefined, quantity: li.quantity,
}, actor)
)
);
const failures = addResults
.map((r, i) => (!r.ok ? { item: orderResult.value.lineItems[i].title, reason: r.error.message } : null))
.filter(Boolean);
return c.json({
data: {
cartId: cartResult.value.id,
itemsAdded: addResults.filter((r) => r.ok).length,
itemsFailed: failures,
},
}, 201);
});
return router;
}
```
### 16.2 Customer Portal Route Summary
[Section titled “16.2 Customer Portal Route Summary”](#162-customer-portal-route-summary)
\| Route | Method | Purpose | |-------|--------|---------| | `/api/me/profile` | GET | Get customer profile. | | `/api/me/profile` | PATCH | Update profile. | | `/api/me/addresses` | GET | List saved addresses. | | `/api/me/addresses` | POST | Add address. | | `/api/me/addresses/:id` | DELETE | Remove address. | | `/api/me/orders` | GET | List orders (paginated, filterable). | | `/api/me/orders/:idOrNumber` | GET | Get single order with details. | | `/api/me/orders/:idOrNumber/tracking` | GET | Shipment tracking. | | `/api/me/orders/:orderId/downloads` | GET | Download links for digital products. | | `/api/me/courses` | GET | Purchased courses with access status. | | `/api/me/orders/:orderId/reorder` | POST | Create cart from previous order. |
***
## 17. Tenant and Multi-Store Architecture
[Section titled “17. Tenant and Multi-Store Architecture”](#17-tenant-and-multi-store-architecture)
The engine supports multi-tenancy at the database level. Every major table includes a `tenantId` column. In single-tenant mode, this is a default value with negligible overhead. In multi-tenant mode, all queries are scoped through Drizzle middleware. better-auth’s organization plugin provides the tenant identity — an “organization” maps to a “tenant” or “store.”
***
## 18. API Surface: GraphQL and REST
[Section titled “18. API Surface: GraphQL and REST”](#18-api-surface-graphql-and-rest)
### 18.1 REST API
[Section titled “18.1 REST API”](#181-rest-api)
src/interfaces/rest/routes/catalog.ts
```typescript
export function catalogRoutes(kernel: Kernel) {
const router = new Hono();
router.get("/entities", async (c) => {
const params = parseListParams(c.req.query());
const result = await kernel.services.catalog.list(params);
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
return c.json({ data: result.value.items, meta: result.value.pagination });
});
router.get("/entities/:idOrSlug", async (c) => {
const identifier = c.req.param("idOrSlug");
const options = parseGetOptions(c.req.query());
const result = isUUID(identifier)
? await kernel.services.catalog.getById(identifier, options)
: await kernel.services.catalog.getBySlug(identifier, options);
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
return c.json({ data: result.value });
});
router.post("/entities", async (c) => {
const input = await c.req.json();
const result = await kernel.services.catalog.create(input, c.get("actor"));
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
return c.json({ data: result.value }, 201);
});
return router;
}
```
### 18.2 GraphQL API
[Section titled “18.2 GraphQL API”](#182-graphql-api)
The GraphQL schema is programmatically generated from the commerce config. Custom fields automatically appear in the schema.
***
## 19. Route Extension System
[Section titled “19. Route Extension System”](#19-route-extension-system)
Two mechanisms for custom endpoints: config-level routes and plugin-level routes. Both use standard Hono handlers with full kernel access.
### 19.1 Config-Level Custom Routes
[Section titled “19.1 Config-Level Custom Routes”](#191-config-level-custom-routes)
```typescript
// routes/wishlist.ts -- complete example
import { Hono } from "hono";
import { pgTable, uuid, timestamp, index } from "drizzle-orm/pg-core";
import { eq, and } from "drizzle-orm";
import type { Kernel } from "@porulle/core";
import { assertPermission } from "@porulle/core/auth";
export const wishlists = pgTable("wishlists", {
id: uuid("id").defaultRandom().primaryKey(),
customerId: uuid("customer_id").notNull(),
entityId: uuid("entity_id").notNull(),
variantId: uuid("variant_id"),
addedAt: timestamp("added_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
customerIdx: index("idx_wishlists_customer").on(table.customerId),
}));
export function wishlistRoutes(kernel: Kernel) {
const router = new Hono();
router.get("/", async (c) => {
const actor = c.get("actor");
if (!actor) return c.json({ error: "Authentication required." }, 401);
assertPermission(actor, "wishlist:read:own");
const items = await kernel.database.select().from(wishlists)
.where(eq(wishlists.customerId, actor.userId));
const hydrated = await Promise.all(items.map(async (item) => {
const entity = await kernel.services.catalog.getById(item.entityId, {
includeAttributes: true, includePricing: true,
});
return { wishlistItemId: item.id, addedAt: item.addedAt,
entity: entity.ok ? entity.value : null, variantId: item.variantId };
}));
return c.json({ data: hydrated });
});
router.post("/", async (c) => {
const actor = c.get("actor");
if (!actor) return c.json({ error: "Authentication required." }, 401);
assertPermission(actor, "wishlist:create:own");
const body = await c.req.json<{ entityId: string; variantId?: string }>();
const entity = await kernel.services.catalog.getById(body.entityId);
if (!entity.ok) return c.json({ error: "Entity not found." }, 404);
const existing = await kernel.database.select().from(wishlists)
.where(and(eq(wishlists.customerId, actor.userId), eq(wishlists.entityId, body.entityId)))
.limit(1);
if (existing.length > 0) return c.json({ error: "Already in wishlist." }, 409);
const [item] = await kernel.database.insert(wishlists).values({
customerId: actor.userId, entityId: body.entityId, variantId: body.variantId,
}).returning();
return c.json({ data: item }, 201);
});
router.delete("/:itemId", async (c) => {
const actor = c.get("actor");
if (!actor) return c.json({ error: "Authentication required." }, 401);
assertPermission(actor, "wishlist:delete:own");
const [deleted] = await kernel.database.delete(wishlists)
.where(and(eq(wishlists.id, c.req.param("itemId")), eq(wishlists.customerId, actor.userId)))
.returning();
if (!deleted) return c.json({ error: "Not found." }, 404);
return c.json({ data: { deleted: true } });
});
return router;
}
```
### 19.2 Plugin-Level Routes
[Section titled “19.2 Plugin-Level Routes”](#192-plugin-level-routes)
Plugins register routes through `ctx.routes.add()`. These are the same Hono handlers, registered programmatically.
### 19.3 Custom MCP Tools
[Section titled “19.3 Custom MCP Tools”](#193-custom-mcp-tools)
Custom MCP tools declared in the config are merged with core tools so AI agents can access both.
***
## 20. AI-Native Layer and MCP Integration
[Section titled “20. AI-Native Layer and MCP Integration”](#20-ai-native-layer-and-mcp-integration)
The MCP integration is a native interface to the commerce kernel. It is not an API wrapper.
### 20.1 MCP Server Implementation
[Section titled “20.1 MCP Server Implementation”](#201-mcp-server-implementation)
src/interfaces/mcp/server.ts
```typescript
export function registerMCPCapabilities(kernel: Kernel) {
return {
tools: [
{
name: "catalog_search",
description: "Search the catalog. Filter by type, status, category, price range, free-text. Returns paginated results with pricing and availability.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" }, type: { type: "string", enum: ["product", "service", "course", "digital", "internal_asset"] },
status: { type: "string", enum: ["draft", "active", "archived"] },
categorySlug: { type: "string" }, minPrice: { type: "number" }, maxPrice: { type: "number" },
currency: { type: "string", default: "USD" }, page: { type: "number", default: 1 }, limit: { type: "number", default: 20 },
},
},
handler: async (params: any) => {
const result = await kernel.services.catalog.list({
filter: buildFilterFromMCPParams(params),
sort: { field: "createdAt", direction: "desc" },
pagination: { page: params.page ?? 1, limit: params.limit ?? 20 },
});
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "catalog_create_entity",
description: "Create a new catalog entity. Requires type, slug, title. Created in draft status.",
inputSchema: {
type: "object", required: ["type", "slug", "title"],
properties: {
type: { type: "string" }, slug: { type: "string" },
title: { type: "string" }, description: { type: "string" }, metadata: { type: "object" },
},
},
handler: async (params: any) => {
const result = await kernel.services.catalog.create({
type: params.type, slug: params.slug,
attributes: { locale: "en", title: params.title, description: params.description },
metadata: params.metadata,
}, kernel.getMCPActor());
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "cart_create",
description: "Create a new shopping cart. Returns cart ID for subsequent operations.",
inputSchema: { type: "object", properties: { customerId: { type: "string" }, currency: { type: "string", default: "USD" } } },
handler: async (params: any) => {
const result = await kernel.services.cart.create(params);
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "cart_add_item",
description: "Add item to cart. If entity has variants and no variantId provided, fails with available variants.",
inputSchema: {
type: "object", required: ["cartId", "entityId"],
properties: { cartId: { type: "string" }, entityId: { type: "string" }, variantId: { type: "string" }, quantity: { type: "number", default: 1 } },
},
handler: async (params: any) => {
const result = await kernel.services.cart.addItem(params, kernel.getMCPActor());
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "order_get",
description: "Get order details by ID or order number. Includes line items, payment, fulfillment, and AI context (available transitions, permitted actions, summary).",
inputSchema: { type: "object", properties: { orderId: { type: "string" }, orderNumber: { type: "string" } } },
handler: async (params: any) => {
const result = params.orderId
? await kernel.services.orders.getById(params.orderId, kernel.getMCPActor())
: await kernel.services.orders.getByNumber(params.orderNumber, kernel.getMCPActor());
if (!result.ok) return { error: result.error };
const enriched = enrichOrderForAgent(result.value, kernel.getMCPActor().permissions);
return { content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }] };
},
},
{
name: "inventory_check",
description: "Check stock levels for one or more entities/variants.",
inputSchema: { type: "object", required: ["entityIds"], properties: { entityIds: { type: "array", items: { type: "string" } } } },
handler: async (params: any) => {
const result = await kernel.services.inventory.checkMultiple(params.entityIds);
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "inventory_adjust",
description: "Adjust inventory. Positive adds stock, negative removes. Requires reason.",
inputSchema: {
type: "object", required: ["entityId", "adjustment", "reason"],
properties: { entityId: { type: "string" }, variantId: { type: "string" }, adjustment: { type: "number" }, reason: { type: "string" } },
},
handler: async (params: any) => {
const result = await kernel.services.inventory.adjust(params, kernel.getMCPActor());
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
{
name: "analytics_query",
description: "Query analytics. Measures: revenue, order_count, average_order_value, items_sold, unique_customers, cart_abandonment_rate, inventory_value, gross_margin. Dimensions: time, entity_type, category, customer_segment, payment_method, fulfillment_type, geography. Granularity: hour, day, week, month, quarter, year.",
inputSchema: {
type: "object", required: ["measures"],
properties: {
measures: { type: "array", items: { type: "string" } },
dimensions: { type: "array", items: { type: "string" } },
timeDimension: { type: "object", properties: {
dimension: { type: "string" }, granularity: { type: "string" }, dateRange: { type: "array", items: { type: "string" } },
}},
filters: { type: "array", items: { type: "object" } },
limit: { type: "number", default: 100 },
},
},
handler: async (params: any) => {
const result = await kernel.services.analytics.query(params);
if (!result.ok) return { error: result.error };
return { content: [{ type: "text", text: JSON.stringify(result.value, null, 2) }] };
},
},
],
resources: [
{
uri: "commerce://schema/entity-types",
name: "Entity Type Schema",
description: "Complete schema of all entity types, fields, variants, fulfillment strategies.",
mimeType: "application/json",
handler: async () => ({ content: [{ type: "text", text: JSON.stringify(kernel.config.entities, null, 2) }] }),
},
{
uri: "commerce://schema/order-states",
name: "Order State Machine",
description: "All order states and valid transitions.",
mimeType: "application/json",
handler: async () => ({ content: [{ type: "text", text: JSON.stringify(orderStateMachine, null, 2) }] }),
},
],
};
}
```
### 20.2 AI Context Enrichment
[Section titled “20.2 AI Context Enrichment”](#202-ai-context-enrichment)
src/interfaces/mcp/context-enrichment.ts
```typescript
export function enrichOrderForAgent(order: Order, permissions: string[]): EnrichedOrder {
return {
...order,
_context: {
availableTransitions: getAvailableTransitions(order.status, orderStateMachine),
permittedActions: computePermittedActions(order, permissions),
summary: `Order ${order.orderNumber}: ${order.lineItems.reduce((s, li) => s + li.quantity, 0)} item(s) ` +
`totaling ${formatMoney(order.grandTotal, order.currency)}. Status: ${order.status}.`,
relatedQueries: [
{ tool: "inventory_check", params: { entityIds: order.lineItems.map((li) => li.entityId) } },
],
},
};
}
```
### 20.3 MCP Transport
[Section titled “20.3 MCP Transport”](#203-mcp-transport)
src/interfaces/mcp/transport.ts
```typescript
export function createMCPHandler(kernel: Kernel, customTools?: Function) {
const router = new Hono();
const capabilities = registerMCPCapabilities(kernel);
if (customTools) capabilities.tools.push(...customTools(kernel));
router.get("/sse", async (c) => {
return streamSSE(c, async (stream) => { await handleMCPSession(stream, capabilities, kernel); });
});
router.post("/tools/:toolName", async (c) => {
const tool = capabilities.tools.find((t) => t.name === c.req.param("toolName"));
if (!tool) return c.json({ error: "Tool not found" }, 404);
return c.json(await tool.handler(await c.req.json()));
});
return router;
}
```
***
## 21. Analytics Layer: Cube.js Integration
[Section titled “21. Analytics Layer: Cube.js Integration”](#21-analytics-layer-cubejs-integration)
### 21.1 Pre-Built Data Models
[Section titled “21.1 Pre-Built Data Models”](#211-pre-built-data-models)
cube/schema/Orders.js
```javascript
cube("Orders", {
sql: `SELECT * FROM orders`,
joins: {
OrderLineItems: { relationship: "hasMany", sql: `${CUBE}.id = ${OrderLineItems}.order_id` },
Customers: { relationship: "belongsTo", sql: `${CUBE}.customer_id = ${Customers}.id` },
},
measures: {
count: { type: "count" },
revenue: { sql: "grand_total", type: "sum", title: "Total Revenue" },
averageOrderValue: { sql: "grand_total", type: "avg" },
subtotalRevenue: { sql: "subtotal", type: "sum" },
taxCollected: { sql: "tax_total", type: "sum" },
shippingRevenue: { sql: "shipping_total", type: "sum" },
discountsGiven: { sql: "discount_total", type: "sum" },
uniqueCustomers: { sql: "customer_id", type: "countDistinct" },
},
dimensions: {
id: { sql: "id", type: "string", primaryKey: true },
orderNumber: { sql: "order_number", type: "string" },
status: { sql: "status", type: "string" },
currency: { sql: "currency", type: "string" },
placedAt: { sql: "placed_at", type: "time" },
},
preAggregations: {
dailyRevenue: {
measures: [Orders.revenue, Orders.count, Orders.averageOrderValue, Orders.uniqueCustomers],
dimensions: [Orders.status, Orders.currency],
timeDimension: Orders.placedAt, granularity: "day",
refreshKey: { every: "1 hour" },
},
},
});
// cube/schema/Inventory.js
cube("Inventory", {
sql: `SELECT * FROM inventory_levels`,
measures: {
totalOnHand: { sql: "quantity_on_hand", type: "sum" },
totalReserved: { sql: "quantity_reserved", type: "sum" },
totalAvailable: { sql: `${CUBE}.quantity_on_hand - ${CUBE}.quantity_reserved`, type: "sum" },
inventoryValue: { sql: `${CUBE}.quantity_on_hand * ${CUBE}.unit_cost`, type: "sum" },
lowStockCount: { type: "count", filters: [{ sql: `${CUBE}.quantity_on_hand <= ${CUBE}.reorder_threshold` }] },
},
dimensions: {
entityId: { sql: "entity_id", type: "string", primaryKey: true },
warehouseId: { sql: "warehouse_id", type: "string" },
lastRestockedAt: { sql: "last_restocked_at", type: "time" },
},
});
```
### 21.2 Developer Extension
[Section titled “21.2 Developer Extension”](#212-developer-extension)
Developers add Cube.js model files to a configured directory. Plugins register models via `ctx.analytics.registerModel()`.
***
## 22. Plugin and Extension System
[Section titled “22. Plugin and Extension System”](#22-plugin-and-extension-system)
### 22.1 Plugin Interface
[Section titled “22.1 Plugin Interface”](#221-plugin-interface)
```typescript
export interface CommercePlugin {
name: string;
version: string;
register(context: PluginContext): Promise | void;
boot?(context: PluginContext): Promise | void;
}
export interface PluginContext {
hooks: { append(hookName: string, handler: Function): void; prepend(hookName: string, handler: Function): void; };
mcp: MCPToolRegistry;
routes: RouteRegistry;
analytics: AnalyticsModelRegistry;
database: { registerSchema(schema: Record): void; query: DatabaseQueryInterface; };
config: CommerceConfig;
logger: Logger;
services: ServiceContainer;
}
```
### 22.2 The Three Extension Primitives
[Section titled “22.2 The Three Extension Primitives”](#222-the-three-extension-primitives)
**Schema registration.** Join tables that reference core tables. Core schema is never modified.
**Hook composition.** Append or prepend to any lifecycle point.
**Service/route/MCP registration.** Additive — never replaces core functionality.
***
## 23. Reference Plugin: Marketplace
[Section titled “23. Reference Plugin: Marketplace”](#23-reference-plugin-marketplace)
The marketplace introduces vendors, order splitting, commissions, and payouts. It exercises all three extension primitives.
### 23.1 Marketplace Schema
[Section titled “23.1 Marketplace Schema”](#231-marketplace-schema)
packages/plugin-marketplace/src/schema.ts
```typescript
export const vendors = pgTable("vendors", {
id: uuid("id").defaultRandom().primaryKey(),
slug: text("slug").notNull().unique(),
name: text("name").notNull(),
email: text("email").notNull(),
status: text("status", { enum: ["pending_review", "approved", "suspended", "deactivated"] }).notNull().default("pending_review"),
commissionRate: integer("commission_rate").notNull().default(1000),
payoutMethod: text("payout_method", { enum: ["stripe_connect", "bank_transfer", "manual"] }).notNull().default("stripe_connect"),
payoutAccountId: text("payout_account_id"),
payoutSchedule: text("payout_schedule", { enum: ["instant", "daily", "weekly", "biweekly", "monthly"] }).notNull().default("weekly"),
minimumPayoutAmount: integer("minimum_payout_amount").notNull().default(1000),
description: text("description"),
logo: text("logo_url"),
metadata: jsonb("metadata").$type>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
// Join table. Core sellable_entities is NOT modified.
export const vendorEntities = pgTable("vendor_entities", {
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
vendorId: uuid("vendor_id").references(() => vendors.id, { onDelete: "cascade" }).notNull(),
}, (table) => ({
entityIdx: index("idx_vendor_entities_entity").on(table.entityId),
vendorIdx: index("idx_vendor_entities_vendor").on(table.vendorId),
}));
export const vendorSubOrders = pgTable("vendor_sub_orders", {
id: uuid("id").defaultRandom().primaryKey(),
parentOrderId: uuid("parent_order_id").references(() => orders.id, { onDelete: "cascade" }).notNull(),
vendorId: uuid("vendor_id").references(() => vendors.id).notNull(),
subOrderNumber: text("sub_order_number").notNull().unique(),
status: text("status", { enum: ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled", "refunded"] }).notNull().default("pending"),
subtotal: integer("subtotal").notNull(),
commissionAmount: integer("commission_amount").notNull(),
vendorPayout: integer("vendor_payout").notNull(),
currency: text("currency").notNull(),
trackingNumber: text("tracking_number"),
trackingUrl: text("tracking_url"),
shippedAt: timestamp("shipped_at", { withTimezone: true }),
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const vendorPayouts = pgTable("vendor_payouts", {
id: uuid("id").defaultRandom().primaryKey(),
vendorId: uuid("vendor_id").references(() => vendors.id).notNull(),
status: text("status", { enum: ["pending", "processing", "completed", "failed"] }).notNull().default("pending"),
amount: integer("amount").notNull(),
currency: text("currency").notNull(),
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
periodEnd: timestamp("period_end", { withTimezone: true }).notNull(),
subOrderIds: jsonb("sub_order_ids").$type().notNull(),
payoutReference: text("payout_reference"),
paidAt: timestamp("paid_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
```
### 23.2 Marketplace Plugin Registration
[Section titled “23.2 Marketplace Plugin Registration”](#232-marketplace-plugin-registration)
packages/plugin-marketplace/src/index.ts
```typescript
export function marketplacePlugin(options: MarketplacePluginOptions): CommercePlugin {
return {
name: "@porulle/plugin-marketplace",
version: "0.1.0",
register(ctx: PluginContext) {
ctx.database.registerSchema(schema);
// Vendor validation on catalog create.
ctx.hooks.append("catalog.beforeCreate", async ({ data, context }) => {
if (context.actor.vendorId) {
const vendor = await context.tx.select().from(schema.vendors)
.where(eq(schema.vendors.id, context.actor.vendorId)).limit(1);
if (!vendor.length || vendor[0].status !== "approved")
throw new CommerceForbiddenError("Vendor account must be approved.");
context.metadata.vendorId = context.actor.vendorId;
}
return data;
});
// Link entity to vendor after creation.
ctx.hooks.append("catalog.afterCreate", async ({ result, context }) => {
if (context.metadata.vendorId) {
await context.tx.insert(schema.vendorEntities).values({
entityId: result.id, vendorId: context.metadata.vendorId as string,
});
}
});
// Scope catalog to vendor or approved vendors.
ctx.hooks.append("catalog.beforeList", async ({ data, context }) => {
if (context.actor.vendorId) {
data.filters.push({ type: "join", table: "vendor_entities", on: "entity_id", where: { vendor_id: context.actor.vendorId } });
} else if (!context.actor.permissions.includes("catalog:*")) {
data.filters.push({ type: "join", table: "vendor_entities", on: "entity_id",
innerJoin: { table: "vendors", on: "id", where: { status: "approved" } } });
}
return data;
});
// Enrich reads with vendor info.
ctx.hooks.append("catalog.afterRead", async ({ result, context }) => {
const vl = await context.tx.select({ id: schema.vendors.id, name: schema.vendors.name, slug: schema.vendors.slug })
.from(schema.vendorEntities).innerJoin(schema.vendors, eq(schema.vendors.id, schema.vendorEntities.vendorId))
.where(eq(schema.vendorEntities.entityId, result.id)).limit(1);
if (vl.length > 0) result.vendor = vl[0];
});
// Split orders into vendor sub-orders after creation.
ctx.hooks.append("orders.afterCreate", async ({ result: order, context }) => {
const lineItemsByVendor = await groupLineItemsByVendor(context.tx, order.lineItems);
for (const [vendorId, lineItems] of Object.entries(lineItemsByVendor)) {
const vendor = await context.tx.select().from(schema.vendors).where(eq(schema.vendors.id, vendorId)).limit(1);
if (!vendor.length) continue;
const subtotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
const commission = Math.round(subtotal * (vendor[0].commissionRate / 10000));
await context.tx.insert(schema.vendorSubOrders).values({
parentOrderId: order.id, vendorId,
subOrderNumber: `${order.orderNumber}-V${vendorId.slice(0, 4).toUpperCase()}`,
subtotal, commissionAmount: commission, vendorPayout: subtotal - commission, currency: order.currency,
});
await context.services.email.send({
template: "vendor-new-order", to: vendor[0].email,
data: { order, vendorLineItems: lineItems, commission, payout: subtotal - commission },
});
}
});
// Prevent marking fulfilled unless all sub-orders delivered.
ctx.hooks.append("orders.beforeStatusChange", async ({ data, context }) => {
if (data.newStatus === "fulfilled") {
const subOrders = await context.tx.select().from(schema.vendorSubOrders)
.where(eq(schema.vendorSubOrders.parentOrderId, data.orderId));
if (subOrders.length > 0 && !subOrders.every((so) => so.status === "delivered")) {
throw new CommerceValidationError("Not all vendor sub-orders delivered.",
subOrders.map((so) => ({ subOrderNumber: so.subOrderNumber, status: so.status })));
}
}
return data;
});
// Marketplace routes, MCP tools, and analytics models.
ctx.routes.add("get", "/api/marketplace/vendors", listVendors(ctx));
ctx.routes.add("post", "/api/marketplace/vendors", createVendor(ctx));
ctx.routes.add("post", "/api/marketplace/vendors/:id/approve", approveVendor(ctx));
ctx.routes.add("get", "/api/marketplace/orders/:orderId/sub-orders", listSubOrders(ctx));
ctx.routes.add("post", "/api/marketplace/payouts/process", processPayouts(ctx));
ctx.mcp.registerTool({ name: "marketplace_vendor_list", description: "List vendors.", /* ... */ handler: async (p) => { /* ... */ } });
ctx.mcp.registerTool({ name: "marketplace_sub_orders", description: "Sub-orders for an order.", /* ... */ handler: async (p) => { /* ... */ } });
ctx.mcp.registerTool({ name: "marketplace_payout_summary", description: "Vendor payout summary.", /* ... */ handler: async (p) => { /* ... */ } });
ctx.analytics.registerModel({
name: "MarketplaceSubOrders", sql: `SELECT * FROM vendor_sub_orders`,
measures: {
subOrderCount: { type: "count" }, totalRevenue: { sql: "subtotal", type: "sum" },
totalCommissions: { sql: "commission_amount", type: "sum" }, totalVendorPayouts: { sql: "vendor_payout", type: "sum" },
},
dimensions: { vendorId: { sql: "vendor_id", type: "string" }, status: { sql: "status", type: "string" }, createdAt: { sql: "created_at", type: "time" } },
});
},
};
}
```
### 23.3 Consumer Activation
[Section titled “23.3 Consumer Activation”](#233-consumer-activation)
```typescript
plugins: [
marketplacePlugin({ defaultCommissionRate: 1500, payoutSchedule: "weekly", minimumPayoutAmount: 5000, requireApproval: true }),
],
```
***
## 24. Serverless Deployment Architecture
[Section titled “24. Serverless Deployment Architecture”](#24-serverless-deployment-architecture)
adapters/cloudflare/src/index.ts
```typescript
import { createServer } from "@porulle/core";
import config from "../../commerce.config";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
const server = createServer({
...config, database: cloudflareD1({ binding: env.DB }),
storage: cloudflareR2({ binding: env.ASSETS }), cache: cloudflareKV({ binding: env.CACHE }),
});
return server.fetch(request, env, ctx);
},
};
// adapters/vercel/src/index.ts
import { createServer } from "@porulle/core";
import { handle } from "hono/vercel";
import config from "../../commerce.config";
const server = createServer({
...config, database: vercelPostgres({ connectionString: process.env.POSTGRES_URL }),
storage: vercelBlob({ token: process.env.BLOB_READ_WRITE_TOKEN }),
});
export const GET = handle(server);
export const POST = handle(server);
export const PUT = handle(server);
export const DELETE = handle(server);
export const config = { runtime: "edge" };
// adapters/node/src/index.ts
import { serve } from "@hono/node-server";
import { createServer } from "@porulle/core";
import config from "../../commerce.config";
const server = createServer({
...config, database: nodePostgres({ connectionString: process.env.DATABASE_URL }),
storage: localFileStorage({ basePath: "./uploads" }),
});
serve({ fetch: server.fetch, port: 3000 });
```
CLI:
```plaintext
npx @porulle/cli init my-store
npx @porulle/cli generate migration
npx @porulle/cli deploy --target cloudflare
npx @porulle/cli deploy --target vercel
npx @porulle/cli dev
```
***
## 25. Database Strategy and Portability
[Section titled “25. Database Strategy and Portability”](#25-database-strategy-and-portability)
\| Database | Adapter Package | Best For | |-----------------------|---------------------------------------|--------------------------------| | PostgreSQL | @porulle/adapter-postgres | Production self-hosted | | Cloudflare D1 (SQLite)| @porulle/adapter-d1 | Cloudflare Workers | | Turso (libSQL) | @porulle/adapter-turso | Edge-native with replication | | Vercel Postgres | @porulle/adapter-vercel-pg | Vercel deployments | | PlanetScale (MySQL) | @porulle/adapter-planetscale | Serverless MySQL | | SQLite (local) | @porulle/adapter-sqlite | Development and testing |
Migrations are generated by Drizzle Kit from schema definitions (core + plugin + custom schemas in one pipeline). better-auth’s tables are included.
***
## 26. Webhooks and External Notifications
[Section titled “26. Webhooks and External Notifications”](#26-webhooks-and-external-notifications)
Webhook delivery is an `afterHook`, not a separate event system.
src/modules/webhooks/hook.ts
```typescript
export const deliverWebhooks: AfterHook = async ({ result, operation, context }) => {
const eventName = `${context.metadata.moduleName}.${operation}`;
const endpoints = await context.services.webhooks.getEndpointsForEvent(eventName);
for (const endpoint of endpoints) {
await context.services.queue.enqueue("webhook.deliver", {
endpointId: endpoint.id, event: eventName, payload: result,
});
}
};
// src/modules/webhooks/schema.ts
export const webhookEndpoints = pgTable("webhook_endpoints", {
id: uuid("id").defaultRandom().primaryKey(),
url: text("url").notNull(),
secret: text("secret").notNull(),
events: jsonb("events").$type().notNull(),
isActive: boolean("is_active").notNull().default(true),
metadata: jsonb("metadata").$type>().default({}),
});
export const webhookDeliveries = pgTable("webhook_deliveries", {
id: uuid("id").defaultRandom().primaryKey(),
endpointId: uuid("endpoint_id").references(() => webhookEndpoints.id).notNull(),
eventName: text("event_name").notNull(),
payload: jsonb("payload").notNull(),
statusCode: integer("status_code"),
attemptCount: integer("attempt_count").notNull().default(0),
nextRetryAt: timestamp("next_retry_at", { withTimezone: true }),
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
failedAt: timestamp("failed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
```
Payloads are signed with HMAC-SHA256 using the endpoint’s secret (header: `X-Commerce-Signature`).
***
## 27. Media and Asset Management
[Section titled “27. Media and Asset Management”](#27-media-and-asset-management)
```typescript
export interface StorageAdapter {
readonly providerId: string;
upload(key: string, data: ArrayBuffer | ReadableStream, contentType: string): Promise>;
getUrl(key: string): Promise>;
getSignedUrl(key: string, expiresIn: number): Promise>;
delete(key: string): Promise>;
list(prefix: string): Promise>;
}
export const mediaAssets = pgTable("media_assets", {
id: uuid("id").defaultRandom().primaryKey(),
storageKey: text("storage_key").notNull(),
filename: text("filename").notNull(),
contentType: text("content_type").notNull(),
size: integer("size").notNull(),
width: integer("width"), height: integer("height"),
alt: text("alt"),
metadata: jsonb("metadata").$type>().default({}),
uploadedAt: timestamp("uploaded_at", { withTimezone: true }).defaultNow().notNull(),
});
export const entityMedia = pgTable("entity_media", {
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }),
mediaAssetId: uuid("media_asset_id").references(() => mediaAssets.id, { onDelete: "cascade" }).notNull(),
role: text("role", { enum: ["primary", "gallery", "thumbnail", "video", "document"] }).notNull(),
sortOrder: integer("sort_order").notNull().default(0),
});
```
***
## 28. Search and Indexing
[Section titled “28. Search and Indexing”](#28-search-and-indexing)
```typescript
export interface SearchAdapter {
readonly providerId: string;
index(document: SearchDocument): Promise>;
remove(documentId: string): Promise>;
search(query: SearchQuery): Promise>;
suggest(prefix: string, options?: SuggestOptions): Promise>;
}
// Adapters: meilisearch, algolia, typesense, pg-search (PostgreSQL full-text, zero deps).
```
Search indexing is handled by `afterCreate`/`afterUpdate` hooks on catalog entities (`syncToSearchIndex` in the config).
***
## 29. Tax and Compliance Abstraction
[Section titled “29. Tax and Compliance Abstraction”](#29-tax-and-compliance-abstraction)
```typescript
export interface TaxAdapter {
readonly providerId: string;
calculate(params: TaxCalculationParams): Promise>;
reportTransaction(params: TaxReportParams): Promise>;
voidTransaction(transactionId: string): Promise>;
}
// Adapters: taxjar, avalara, tax-manual (flat-rate).
```
***
## 30. Developer Experience Surface
[Section titled “30. Developer Experience Surface”](#30-developer-experience-surface)
### 30.1 Type-Safe SDK
[Section titled “30.1 Type-Safe SDK”](#301-type-safe-sdk)
```typescript
import { createClient } from "@porulle/sdk";
const commerce = createClient({ baseUrl: "https://api.mystore.com" });
const { data: orders } = await commerce.me.orders.list({ page: 1, limit: 10 });
const { data: tracking } = await commerce.me.orders.tracking("ORD-2026-00001");
const { data: downloads } = await commerce.me.orders.downloads("order-uuid");
await commerce.me.profile.update({ firstName: "Jane" });
await commerce.me.orders.reorder("order-uuid");
```
### 30.2 Local Development
[Section titled “30.2 Local Development”](#302-local-development)
```bash
npx @porulle/cli init my-commerce --template starter
cd my-commerce
npm run dev
# REST at /api, Customer portal at /api/me, Auth at /api/auth,
# GraphQL at /graphql, MCP at /mcp. Hot reload on config changes.
```
### 30.3 Error Messages
[Section titled “30.3 Error Messages”](#303-error-messages)
```plaintext
CommerceForbiddenError: Permission "catalog:create" is required.
Your role "customer" does not include this permission.
Customers can browse the catalog (catalog:read) but cannot create listings.
```
```plaintext
CommerceValidationError: Cannot add item to cart.
Entity "blue-widget" (type: product) has variants enabled,
but no variantId was provided. This entity has 6 active variants.
To list available variants:
GET /api/catalog/entities/blue-widget?include=variants
```
***
## 31. Dependency Map
[Section titled “31. Dependency Map”](#31-dependency-map)
### 31.1 Core Runtime (required for every deployment)
[Section titled “31.1 Core Runtime (required for every deployment)”](#311-core-runtime-required-for-every-deployment)
\| Package | Role | |---------|------| | `typescript` | Language. Strict mode. | | `hono` | HTTP framework. Zero deps, all targets. | | `drizzle-orm` | Database access. Zero runtime overhead. | | `drizzle-kit` | Migration generation. Dev dependency. | | `better-auth` | Authentication. Drizzle adapter, Hono support, orgs, 2FA, API keys. |
### 31.2 Database Drivers (one required)
[Section titled “31.2 Database Drivers (one required)”](#312-database-drivers-one-required)
\| Package | Adapter | |---------|---------| | `postgres` (postgres.js) | adapter-postgres | | `better-sqlite3` | adapter-sqlite | | `@libsql/client` | adapter-turso | | `@vercel/postgres` | adapter-vercel-pg | | `@planetscale/database` | adapter-planetscale |
### 31.3 Analytics
[Section titled “31.3 Analytics”](#313-analytics)
\| Package | Role | |---------|------| | `@cubejs-backend/server-core` | Semantic analytics layer | | `@cubejs-backend/api-gateway` | Cube.js REST API |
### 31.4 Infrastructure Adapters (optional)
[Section titled “31.4 Infrastructure Adapters (optional)”](#314-infrastructure-adapters-optional)
\| Category | Package | Adapter | |----------|---------|---------| | Payment | `stripe` | adapter-stripe | | Search | `meilisearch` | adapter-meilisearch | | Search | `algoliasearch` | adapter-algolia | | Tax | `taxjar` | adapter-taxjar | | Email | `resend` | adapter-resend | | Storage | `@aws-sdk/client-s3` | adapter-s3 |
### 31.5 Deployment (dev dependencies)
[Section titled “31.5 Deployment (dev dependencies)”](#315-deployment-dev-dependencies)
\| Package | Context | |---------|---------| | `@hono/node-server` | Node.js adapter | | `wrangler` | Cloudflare CLI | | `vercel` | Vercel CLI |
### 31.6 CLI (dev dependencies)
[Section titled “31.6 CLI (dev dependencies)”](#316-cli-dev-dependencies)
\| Package | Role | |---------|------| | `citty` | CLI framework | | `consola` | CLI logging | | `giget` | Template scaffolding |
### 31.7 What is NOT a Dependency
[Section titled “31.7 What is NOT a Dependency”](#317-what-is-not-a-dependency)
No Express/Fastify/Koa (Hono). No Prisma (Drizzle). No Redis (optional adapter). No Bull/BullMQ (adapter-based queuing). No event emitter library (no mitt, no nanoevents). No Passport.js/next-auth/lucia/clerk (better-auth).
***
## 32. Migration and Adoption Strategy
[Section titled “32. Migration and Adoption Strategy”](#32-migration-and-adoption-strategy)
\| Source Platform | Import Adapter | Supported Data | |-----------------|----------------|----------------| | Shopify | @porulle/import-shopify | Products, variants, orders, customers | | WooCommerce | @porulle/import-woocommerce | Products, orders, customers | | CSV/JSON | @porulle/import-flat | Any entity type |
The engine supports incremental adoption. Start with the catalog module and add cart, checkout, orders later.
***
## 33. Phased Delivery Plan
[Section titled “33. Phased Delivery Plan”](#33-phased-delivery-plan)
### Phase 0: Foundation (Weeks 1-4)
[Section titled “Phase 0: Foundation (Weeks 1-4)”](#phase-0-foundation-weeks-1-4)
Core kernel (hook registry, state machine, result types, errors), config-as-code system, database adapters (PostgreSQL, SQLite), Hono server, better-auth integration with session middleware, POS PIN auth, deployment adapters (Cloudflare, Node.js), CLI scaffolding.
Exit criteria: `npx init`, sign up, sign in, valid session on health check. Deployable to Cloudflare and Node.js.
### Phase 1: Catalog and Inventory (Weeks 5-8)
[Section titled “Phase 1: Catalog and Inventory (Weeks 5-8)”](#phase-1-catalog-and-inventory-weeks-5-8)
Universal sellable entity model, variants, custom fields, media assets, inventory tracking, REST API, MCP server with catalog and inventory tools. Auth and permissions enforced.
Exit criteria: Create entities, manage variants, track inventory, query through REST and MCP.
### Phase 2: Cart, Checkout, and Orders (Weeks 9-14)
[Section titled “Phase 2: Cart, Checkout, and Orders (Weeks 9-14)”](#phase-2-cart-checkout-and-orders-weeks-9-14)
Cart module, checkout hook chain, order lifecycle, Stripe adapter, fulfillment strategies, webhooks, customer portal (order history, tracking, downloads, reorder).
Exit criteria: Full purchase flow — sign up, browse, add to cart, checkout with Stripe, view orders, track shipments, download digital purchases. AI agent can do the same through MCP.
### Phase 3: Pricing, Tax, and Promotions (Weeks 15-18)
[Section titled “Phase 3: Pricing, Tax, and Promotions (Weeks 15-18)”](#phase-3-pricing-tax-and-promotions-weeks-15-18)
Pricing engine with resolution pipeline, TaxJar integration, promotion system.
Exit criteria: Volume discounts, customer-group pricing, scheduled price changes work through checkout.
### Phase 4: Analytics and AI Enrichment (Weeks 19-22)
[Section titled “Phase 4: Analytics and AI Enrichment (Weeks 19-22)”](#phase-4-analytics-and-ai-enrichment-weeks-19-22)
Cube.js integration, pre-built models, analytics MCP tools, AI context enrichment, GraphQL API.
Exit criteria: AI agent answers “What was our revenue by category last month?” accurately.
### Phase 5: Ecosystem and Polish (Weeks 23-26)
[Section titled “Phase 5: Ecosystem and Polish (Weeks 23-26)”](#phase-5-ecosystem-and-polish-weeks-23-26)
Plugin docs, marketplace reference plugin, Vercel adapter, generated TypeScript SDK, import utilities, Meilisearch adapter, POS plugin.
Exit criteria: Migrate a Shopify store, deploy to Vercel, build a Next.js storefront with the SDK, extend with marketplace plugin.
***
## 34. Open Questions and Future Work
[Section titled “34. Open Questions and Future Work”](#34-open-questions-and-future-work)
### 34.1 Open Questions
[Section titled “34.1 Open Questions”](#341-open-questions)
Multi-currency pricing: exchange rate management needs a dedicated design pass.
Subscription and recurring billing: billing lifecycle (trials, grace periods, proration) needs its own state machine.
Product bundles: inventory and fulfillment questions around composite entities are unresolved.
Passkeys: should better-auth’s passkey support be enabled by default for customers?
Partial shipments: customer portal tracking response needs clear per-item status when orders are split across fulfillments.
### 34.2 Future Work
[Section titled “34.2 Future Work”](#342-future-work)
Visual admin panel generated from config. Storefront SDKs (Next.js, Nuxt, SvelteKit, Astro) with better-auth client components. Multi-vendor marketplace promoted to first-class. Real-time inventory sync. Rules engine. Customer notification preferences.
***
## Appendix A: Package Structure
[Section titled “Appendix A: Package Structure”](#appendix-a-package-structure)
```plaintext
packages/
core/
src/
kernel/
hooks/ # Registry, executor, types.
state-machine/ # State machine definitions.
result.ts # Result type.
errors.ts # Error taxonomy.
auth/
setup.ts # better-auth configuration.
middleware.ts # Session resolution, Actor construction.
permissions.ts # Permission checks, ownership.
pos.ts # POS PIN auth routes.
types.ts # Actor type.
modules/
catalog/ # Entities, variants, categories.
cart/ # Cart management.
orders/ # Order lifecycle.
inventory/ # Stock tracking, reservation.
pricing/ # Price resolution.
fulfillment/ # Fulfillment dispatch.
payments/ # Payment adapter.
customers/ # Profiles, addresses, groups.
media/ # Assets.
search/ # Search adapter.
tax/ # Tax adapter.
webhooks/ # Delivery as hooks.
analytics/ # Cube.js integration.
interfaces/
rest/
routes/ # Core REST routes.
customer-portal/ # /api/me routes.
graphql/
mcp/
runtime/
server.ts
config/
types.ts
defaults.ts
adapters/
adapter-postgres/ adapter-d1/ adapter-turso/
adapter-vercel-pg/ adapter-sqlite/ adapter-planetscale/
adapter-stripe/ adapter-r2/ adapter-s3/
adapter-resend/ adapter-taxjar/ adapter-meilisearch/
deployment/
cloudflare/ vercel/ node/ lambda/
plugins/
plugin-marketplace/ plugin-pos/
plugin-subscriptions/ plugin-loyalty/
sdk/client/
cli/
cube/schema/
```
***
## Appendix B: Inventory Schema
[Section titled “Appendix B: Inventory Schema”](#appendix-b-inventory-schema)
```typescript
export const inventoryLevels = pgTable("inventory_levels", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }),
warehouseId: uuid("warehouse_id").references(() => warehouses.id).notNull(),
quantityOnHand: integer("quantity_on_hand").notNull().default(0),
quantityReserved: integer("quantity_reserved").notNull().default(0),
quantityIncoming: integer("quantity_incoming").notNull().default(0),
unitCost: integer("unit_cost"),
reorderThreshold: integer("reorder_threshold"),
reorderQuantity: integer("reorder_quantity"),
lastRestockedAt: timestamp("last_restocked_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
entityVariantWarehouseIdx: index("idx_inventory_entity_variant_warehouse")
.on(table.entityId, table.variantId, table.warehouseId),
}));
export const warehouses = pgTable("warehouses", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
code: text("code").notNull().unique(),
address: jsonb("address").$type(),
isActive: boolean("is_active").notNull().default(true),
priority: integer("priority").notNull().default(0),
metadata: jsonb("metadata").$type>().default({}),
});
export const inventoryMovements = pgTable("inventory_movements", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id").references(() => sellableEntities.id).notNull(),
variantId: uuid("variant_id").references(() => variants.id),
warehouseId: uuid("warehouse_id").references(() => warehouses.id).notNull(),
type: text("type", { enum: ["receipt", "sale", "return", "adjustment", "transfer", "reservation", "release"] }).notNull(),
quantity: integer("quantity").notNull(),
referenceType: text("reference_type"),
referenceId: text("reference_id"),
reason: text("reason"),
performedBy: text("performed_by").notNull(),
performedAt: timestamp("performed_at", { withTimezone: true }).defaultNow().notNull(),
});
```
***
## Appendix C: Customer Schema
[Section titled “Appendix C: Customer Schema”](#appendix-c-customer-schema)
```typescript
export const customers = pgTable("customers", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull().unique(), // Links to better-auth's user table.
email: text("email").unique(),
phone: text("phone"),
firstName: text("first_name"),
lastName: text("last_name"),
metadata: jsonb("metadata").$type>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const customerAddresses = pgTable("customer_addresses", {
id: uuid("id").defaultRandom().primaryKey(),
customerId: uuid("customer_id").references(() => customers.id, { onDelete: "cascade" }).notNull(),
type: text("type", { enum: ["shipping", "billing"] }).notNull(),
isDefault: boolean("is_default").notNull().default(false),
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
line1: text("line1").notNull(),
line2: text("line2"),
city: text("city").notNull(),
state: text("state"),
postalCode: text("postal_code"),
country: text("country").notNull(),
phone: text("phone"),
});
export const customerGroups = pgTable("customer_groups", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull().unique(),
description: text("description"),
metadata: jsonb("metadata").$type>().default({}),
});
export const customerGroupMembers = pgTable("customer_group_members", {
customerId: uuid("customer_id").references(() => customers.id, { onDelete: "cascade" }).notNull(),
groupId: uuid("group_id").references(() => customerGroups.id, { onDelete: "cascade" }).notNull(),
});
```
better-auth manages the `user` and `session` tables. The `customers` table links to better-auth’s user table through the `userId` column and stores commerce-specific data.
***
This RFC represents the complete architectural vision for the Porulle engine. Every code sample is a blueprint — it should compile with minor imports added. The single extension primitive (lifecycle hooks), the co-located config-as-code model, the join-table-only plugin schema strategy, the delegation of authentication to better-auth, and the customer self-service portal are the architectural decisions that differentiate this engine from every existing commerce platform.
The next step is to review this RFC with the full engineering team, resolve the open questions in Section 34, and begin Phase 0 implementation.
# Hook Pipeline
> How the checkout pipeline executes, why it is split into two phases, and how the compensation pattern keeps the system consistent.
Checkout is the most complex operation in any commerce engine. It touches inventory, pricing, promotions, tax, shipping, payments, orders, fulfillment, and email — all in a single user action. This page explains how Porulle orchestrates this process, why the pipeline is split into transactional and non-transactional phases, and how the compensation pattern keeps the system consistent when things fail.
## Checkout as orchestration, not a single transaction
[Section titled “Checkout as orchestration, not a single transaction”](#checkout-as-orchestration-not-a-single-transaction)
A naive approach wraps everything in one database transaction: check inventory, create the order, capture payment, decrement stock, initiate fulfillment. If anything fails, roll back the transaction and the world is consistent.
This does not work in practice. Payment capture is an external API call. You cannot roll back a Stripe charge by aborting a PostgreSQL transaction. Email delivery, fulfillment initiation, and webhook delivery are external side effects that cannot participate in a database transaction.
Porulle splits checkout into two phases: **before hooks** that run inside a transaction, and **after hooks** that run outside it.
## Before hooks: validation and enrichment
[Section titled “Before hooks: validation and enrichment”](#before-hooks-validation-and-enrichment)
Before hooks run inside a database transaction. They prepare and validate the checkout data before the order is created. The core before hooks execute in this order:
1. **validateCartNotEmpty** — loads the cart, confirms it has line items, enriches each item with catalog data
2. **resolveCurrentPrices** — resolves the current price for each line item via the pricing service, calculating subtotals
3. **checkInventoryAvailability** — confirms sufficient stock exists for every line item
4. **applyPromotionCodes** — applies promotion codes, calculates discounts, detects free shipping
5. **calculateTax** — calculates tax via the configured tax adapter
6. **calculateShipping** — calculates shipping costs based on weight, address, and cart value
7. **validatePaymentMethod** — confirms a payment method was provided
8. **authorizePayment** — authorizes (but does not capture) the payment
Each before hook receives the `CheckoutData` object and returns a modified version of it. The output of one hook becomes the input of the next. This is the key property: before hooks form a pipeline where each step enriches data for downstream steps. `resolveCurrentPrices` must run before `applyPromotionCodes` because promotions need base prices. `calculateShipping` must run after `applyPromotionCodes` because free-shipping promotions affect the shipping calculation.
If any before hook throws, the transaction aborts. The order is not created. Payment authorization is the last step precisely because it is the hardest to undo — if tax calculation fails, no payment was authorized, so there is nothing to reverse.
After the before hooks complete and the transaction commits, the order is created in the database. At this point, the order exists but is in a `pending` state. No money has been captured, no inventory has been decremented, and no fulfillment has been initiated.
## After hooks: side effects with compensation
[Section titled “After hooks: side effects with compensation”](#after-hooks-side-effects-with-compensation)
After hooks run outside the transaction. The core after hooks are:
1. **completeCheckout** — the compensation chain (see below)
2. **recordAnalyticsEvent** — records the order creation event for analytics
After hooks receive the `result` (the created order) but cannot modify it. They are fire-and-forget side effects. If an after hook fails, the order still exists. The engine collects after-hook errors into a `hookErrors` array and includes them in the response metadata, but the 201 response is still sent.
This is deliberate. Failing the entire checkout if an analytics event fails to record is worse than succeeding with a warning. After hooks should be resilient to failure.
## The compensation pattern
[Section titled “The compensation pattern”](#the-compensation-pattern)
`completeCheckout` is the most important after hook. It runs a compensation chain — a sequence of steps where each step can be reversed if a later step fails. This is sometimes called the saga pattern.
The steps in the chain:
1. **reserveInventory** — decrements available stock for each line item
2. **capturePayment** — captures the previously authorized payment
3. **initiateFulfillment** — creates a fulfillment record (best-effort)
4. **sendConfirmation** — sends a confirmation email (best-effort)
If step 2 (capture payment) fails, the chain reverses step 1: inventory reservations are released. If step 1 (reserve inventory) fails, no payment is captured. The system returns to a consistent state either way.
Steps 3 and 4 are best-effort. They do not define compensate functions. If email delivery fails, the order is not cancelled — it just does not get a confirmation email. Not all side effects are equally critical.
### How compensation works internally
[Section titled “How compensation works internally”](#how-compensation-works-internally)
The `runCompensationChain` executor iterates through the steps in order. For each step:
* If `step.run()` returns `{ ok: true, value }`, the step is recorded as completed with its output value.
* If `step.run()` returns `{ ok: false, error }`, the executor stops and compensates all previously completed steps in reverse order.
Each step’s `compensate` function receives the output from its own `run` function. The reserve-inventory step’s compensate function receives the reservation IDs it created, so it knows exactly which reservations to release.
Compensation failures are logged but do not override the original error. If inventory reservation fails and then the compensation for a prior step also fails, the original “inventory reservation failed” error is still returned. The compensation failure becomes an operational concern that requires manual review — logged at error level, but it does not mask the root cause.
### Why compensation instead of distributed transactions
[Section titled “Why compensation instead of distributed transactions”](#why-compensation-instead-of-distributed-transactions)
Distributed transactions (two-phase commit) are the textbook solution. They are also impractical for commerce workloads:
* Payment providers do not support 2PC. Stripe’s API is request-response.
* Fulfillment systems are often external (third-party logistics). You cannot enlist them in a distributed transaction.
* 2PC requires all participants to be available simultaneously. A temporary email outage would block checkout.
The compensation pattern accepts that failures will happen and provides a structured way to reverse completed work.
## Before vs. after: a mental model
[Section titled “Before vs. after: a mental model”](#before-vs-after-a-mental-model)
Think of before hooks as **guards** and after hooks as **reactions**.
Before hooks answer: “Should this operation proceed, and with what data?” They validate, enrich, and transform. They can reject the operation by throwing. They return modified data that flows to the next hook.
After hooks answer: “Now that this operation has happened, what else should happen?” They reserve inventory, capture payment, send emails, fire webhooks, record analytics. They cannot change the result. They should be resilient to failure.
This split maps naturally to the transactional boundary. Guards run inside the transaction where they can read consistent data and rely on rollback for safety. Reactions run outside the transaction where they can call external services without holding a database connection open.
## Hook context
[Section titled “Hook context”](#hook-context)
Every hook — before and after — receives a `HookContext` with:
* **actor**: The authenticated user or API key that initiated the operation
* **tx**: The database transaction (available in before hooks; may be `null` in after hooks)
* **logger**: A structured Pino logger scoped to the current request
* **services**: The full service container
* **context**: A mutable `Record` for passing data between hooks
* **requestId**: A unique identifier for the current request
* **origin**: Which interface triggered the operation (`"rest"` or `"local"`)
* **kernel**: The kernel instance (typed as `unknown` to avoid circular imports)
The `context` bag is the only way for a before hook to pass data to an after hook. The `authorizePayment` before hook stashes the payment intent ID in `context.paymentIntentId`, and the `completeCheckout` after hook reads it from there.
## Extending the pipeline
[Section titled “Extending the pipeline”](#extending-the-pipeline)
Plugin authors can add hooks at `checkout.beforeCreate` and `checkout.afterCreate`. These run after the core hooks. A plugin might:
* Add a before hook to validate a custom field (e.g., a minimum order amount)
* Add a before hook to apply a loyalty discount
* Add an after hook to award loyalty points after order creation
* Add an after hook to sync the order to an external ERP
See [Plugin Architecture](/concepts/plugin-architecture/) for how plugin hooks are registered and merged into the hook pipeline.
## Related
[Section titled “Related”](#related)
* [Use Hooks guide](/extending/hooks/) — register hooks in config, modules, or plugins
* [Hook System Reference](/reference/hooks/) — type signatures, context fields, and available hook keys
# Identity and Store Resolution
> How Porulle resolves who a user is, which store they belong to, and what they can do — across single-store, marketplace, and multi-store SaaS deployments.
Commerce platforms have an identity problem that most web applications do not. In a typical SaaS app, a user is a user. In commerce, a single deployment might serve customers browsing a storefront, staff processing orders, vendors managing their own catalog, AI agents placing orders on behalf of customers, and platform administrators overseeing everything. Each persona sees different data, has different permissions, and arrives through a different interface.
## Why identity is not just authentication
[Section titled “Why identity is not just authentication”](#why-identity-is-not-just-authentication)
Better Auth handles authentication — verifying that a user is who they claim to be. But authentication answers only one question: “Is this person real?”
Commerce needs three more:
1. **Which store do they belong to?** In a multi-store deployment, a customer on Store A should not see Store B’s catalog.
2. **What role do they have?** A staff member who can process orders is different from a customer who can only view their own.
3. **What commerce profile do they have?** Addresses, order history, loyalty points — these are commerce concerns, not auth concerns.
Porulle separates these into three layers.
## The three identity layers
[Section titled “The three identity layers”](#the-three-identity-layers)
**Layer 1: Auth identity (Better Auth)**
The `user` table holds authentication identity — email, password hash, name, OAuth accounts. This is the only layer that exists for every signed-in user. Better Auth manages it entirely.
**Layer 2: Organization membership (Better Auth organization plugin)**
The `member` table holds org memberships with roles. An admin is a user who is a member of an organization with role `"admin"`. A staff member has role `"staff"`. Customers typically have no org membership at all.
**Layer 3: Commerce profile (Porulle customers table)**
The `customers` table holds commerce-specific data — addresses, phone, metadata, order history references. This layer is created lazily, only when a user first interacts with commerce (places an order, visits their profile page, goes through checkout). An admin who never buys anything never gets a customer profile.
This separation matters because the layers have different lifecycles. A user can exist in Better Auth without being a member of any organization and without having a customer profile. A platform admin might be a member of every organization but have zero customer profiles.
## Why customers are not org members
[Section titled “Why customers are not org members”](#why-customers-are-not-org-members)
The first instinct when building multi-tenancy is to make everyone an org member. Porulle rejects this for three reasons.
**Scale.** A store with 100,000 customers would have 100,000 rows in the `member` table. The `member` table is designed for tens of users (staff, admins), not hundreds of thousands. It is queried on every request to resolve roles, and large membership lists degrade auth middleware performance.
**Cross-store customers.** In a multi-store SaaS, a customer might shop at multiple stores. Adding them to every store’s organization creates N membership rows and forces the user to “switch” active organizations — a concept that makes sense for staff but is bewildering for shoppers.
**Simplicity.** Most Porulle deployments are single-store. Requiring org membership for customers adds complexity that 90% of users never need.
Instead, customers get their role from a fallback. The auth middleware checks: does this user have an `activeOrganizationRole` from Better Auth? If yes, use it (they are admin, staff, vendor). If no, default to `"customer"` with the permissions defined in `config.auth.customerPermissions`. No org membership row needed.
## How store resolution works
[Section titled “How store resolution works”](#how-store-resolution-works)
The question “which store does this request belong to?” has different answers depending on the deployment pattern.
### Single store
[Section titled “Single store”](#single-store)
The engine auto-creates a default organization at boot. The constant `DEFAULT_ORG_ID` (value: `"org_default"`, defined in `packages/core/src/auth/org.ts`) is the universal fallback — whenever an actor has no org membership, `resolveOrgId()` returns this value.
Every user, every product, every order belongs to this default org. The developer never thinks about organizations — the engine handles it silently.
### Marketplace
[Section titled “Marketplace”](#marketplace)
One organization, multiple vendors operating within it. The marketplace plugin (`@porulle/plugin-marketplace`) adds a `vendorId` field to the auth user table and a `vendor` entity with its own catalog, orders, and commissions. Vendor-scoped queries filter by `vendorId` in addition to `organizationId`.
Customers shop across all vendors in a single checkout. Orders are split into vendor-specific sub-orders by the marketplace plugin.
### Multi-store SaaS
[Section titled “Multi-store SaaS”](#multi-store-saas)
Multiple organizations, each representing an independent store. This requires store resolution — when a customer visits Store A’s frontend, the engine needs to know to scope all queries to Store A’s organization.
The `auth.storeResolver` config option handles this. It is a function that receives the raw `Request` object and returns an organization ID:
commerce.config.ts
```ts
auth: {
strictOrgResolution: true,
storeResolver: async (request) => {
const storeId = request.headers.get("x-store-id");
return storeId ?? null;
},
}
```
Common resolution strategies:
* **Header-based**: The frontend sends an `x-store-id` header. Simple, works with any frontend framework.
* **Domain-based**: Each store has its own domain (e.g., `marinabay.platform.com`). The resolver looks up the org by domain from the database.
* **Path-based**: The URL contains the store slug. Requires URL rewriting.
If no `storeResolver` is configured, the middleware falls back to `org_default`. Single-store and marketplace deployments work without any configuration — the resolver is purely additive.
When `strictOrgResolution: true` is set and resolution fails, the request is rejected with HTTP 503. Without it, requests fall into the default org. Production B2B multi-tenant deployments must configure `storeResolver`.
## Why organizations scope everything
[Section titled “Why organizations scope everything”](#why-organizations-scope-everything)
Porulle uses the word “organization” for what most commerce platforms call a “store” or “tenant.” Better Auth ships an `organization` plugin with member management, invitations, and role-based access control. Renaming it to “store” would mean forking or wrapping Better Auth’s API.
The mapping is simple: one organization is one store. One catalog, one set of orders, one set of customers, one set of prices.
### Why shared tables, not schema-per-tenant
[Section titled “Why shared tables, not schema-per-tenant”](#why-shared-tables-not-schema-per-tenant)
Three isolation strategies exist for multi-tenant systems. Database-per-tenant is operationally expensive (Shopify uses it, but they have the engineering team). Schema-per-tenant has per-schema migration complexity and no Drizzle support for dynamic schema selection. Porulle uses shared tables with row-level scoping — all tenants share the same tables, with an `organizationId` column on every top-level table.
The trade-off: shared tables mean a bug in query scoping could leak data between tenants. Porulle mitigates this at three layers:
1. Every service resolves the org from the actor context internally, so callers never handle org IDs directly.
2. The `defineTable` abstraction auto-injects the `organizationId` column.
3. The scoped DB proxy auto-stamps `organizationId` on INSERT operations.
The multi-org isolation test suite (`packages/core/test/multi-org-isolation.test.ts`) verifies that cross-org access fails for every operation.
### Why composite unique constraints
[Section titled “Why composite unique constraints”](#why-composite-unique-constraints)
Six columns that were previously globally unique — `sellable_entities.slug`, `categories.slug`, `brands.slug`, `warehouses.code`, `promotions.code`, `customers.email` — became composite unique on `(organizationId, column)`. This allows two organizations to have the same product slug or warehouse code.
This is essential for vertical SaaS: if a restaurant platform onboards 500 restaurants, each must be able to name their category “appetizers” without conflicting with every other restaurant.
## Where customer profiles are created
[Section titled “Where customer profiles are created”](#where-customer-profiles-are-created)
A customer profile is not created on signup. It is created lazily, the first time a user interacts with a commerce-specific endpoint:
* `GET /api/me/profile` — the customer portal auto-creates the profile via `getOrCreateByUserId()`
* `POST /api/checkout` — checkout looks up or creates the customer profile
* Any customer portal endpoint (`/api/me/addresses`, `/api/me/orders`) — same lazy creation
In a multi-store SaaS, a customer gets a separate profile in each store they shop at — created when they first interact with that store.
## Trade-offs of this model
[Section titled “Trade-offs of this model”](#trade-offs-of-this-model)
**Gained:** A clean separation between auth (who are you?), access (what can you do?), and commerce (what have you bought?). Each layer can evolve independently.
**Given up:** Immediacy. When a customer signs up, there is no customer profile until they do something. The admin panel’s customer list does not show “registered but never purchased” users — they exist only in Better Auth’s user table.
**Risk:** The `storeResolver` is trusted. If a malicious frontend sends a wrong `x-store-id` header, the customer sees the wrong store’s data. For domain-based resolution this is less of a concern (the domain is controlled by DNS), but for header-based resolution the frontend must be trusted.
## Related
[Section titled “Related”](#related)
* [Multi-Tenancy guide](/production/multi-tenancy/) — how to configure multi-store deployments
* [Authentication guide](/building/authentication/) — roles, permissions, API keys, social login
* [Configuration Reference](/reference/configuration/) — `storeResolver` parameter documentation
* [Security Model](/production/security-model/) — org resolution profiles, rate limits, cookie hygiene
# Plugin Architecture
> Why plugins are config transforms, what that enables, and where it falls short.
The plugin system in Porulle is intentionally simple: a plugin is a function that receives a `CommerceConfig` and returns a modified `CommerceConfig`. There are no base classes, no registration hooks, no lifecycle methods, and no dependency injection.
## Plugins as config transforms
[Section titled “Plugins as config transforms”](#plugins-as-config-transforms)
When you call `defineConfig`, the engine merges your input with defaults and then applies each plugin sequentially:
```ts
for (const plugin of config.plugins ?? []) {
config = await plugin(config);
}
return Object.freeze(config);
```
Each plugin receives the full config and returns a new one. A plugin can add schema tables, register hooks, contribute REST routes, or modify any existing config property. The engine does not limit what a plugin can touch.
This model is directly inspired by PayloadCMS, where the entire CMS config is a single object that plugins transform. Plugins are composable and transparent — you can read a plugin’s code and see exactly what it does to the config, without tracing through registration APIs or event emitters.
## Why not class-based plugins
[Section titled “Why not class-based plugins”](#why-not-class-based-plugins)
Many commerce platforms use class-based plugin systems where plugins instantiate or extend service classes, and Shopify’s app extensions implement interfaces with lifecycle hooks. Porulle deliberately avoids this pattern.
**No inheritance hierarchy.** Class-based plugins create an implicit hierarchy: the base class defines what can be overridden, and plugins must conform to that contract. When the base class changes, plugins break. Config transforms have no base class — a plugin is just a function.
**Composability.** Two class-based plugins that override the same method conflict. Config transforms compose naturally: the second plugin sees the config that the first plugin produced. If two plugins both add hooks to `checkout.beforeCreate`, both hooks run. If two plugins both add schema tables, both tables exist.
**Transparency.** A config transform is a pure function (or close to it). You can log the config before and after a plugin runs and see exactly what changed. Class-based plugins hide their effects behind method overrides and state mutations.
The trade-off: plugins cannot intercept each other’s behavior at a fine-grained level. Service-class plugin systems can override each other’s methods at runtime; a Porulle plugin cannot. This is intentional — fine-grained overrides create brittle coupling between plugins. Hooks provide a structured way to modify behavior at well-defined points.
## `defineCommercePlugin`: syntactic sugar
[Section titled “defineCommercePlugin: syntactic sugar”](#definecommerceplugin-syntactic-sugar)
Writing raw config transforms is flexible but verbose. `defineCommercePlugin` wraps a declarative manifest into a config transform:
```ts
import { defineCommercePlugin } from "@porulle/core";
const myPlugin = defineCommercePlugin({
id: "my-plugin",
version: "1.0.0",
schema: () => ({ loyaltyPoints, loyaltyTransactions }),
hooks: () => [
{ key: "checkout.afterCreate", handler: awardLoyaltyPoints },
],
routes: (ctx) => [
{ method: "GET", path: "/api/loyalty/:customerId", handler: getLoyaltyBalance },
],
});
```
Under the hood, `defineCommercePlugin` converts this manifest into a config transform that pushes schema objects into `config.customSchemas[]`, merges hook registrations into `config.hooks`, and chains route registrations onto `config.routes`.
You can always write a raw config transform for more control. `defineCommercePlugin` does not add any capability that a raw transform cannot express — it reduces boilerplate for the common case. See [Plugin API Reference](/reference/plugins/) for the full manifest specification.
## Schema collection
[Section titled “Schema collection”](#schema-collection)
Plugins contribute Drizzle table definitions via the `schema` field. These are collected into `config.customSchemas[]` during the plugin pipeline. At boot, the kernel makes them available to repositories and migrations.
Plugin tables must be discoverable by `drizzle-kit`. The recommended approach is a glob pattern in `drizzle.config.ts`:
drizzle.config.ts
```ts
export default defineConfig({
dialect: "postgresql",
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/plugin-*/src/schema.ts",
"./src/plugins/*/schema.ts", // app-level plugins
],
});
```
Table names contributed by plugins must not collide with core table names. Plugin authors should prefix their tables (e.g., `loyalty_points`, `pos_sessions`) to avoid conflicts. There is no namespacing mechanism beyond convention.
## Deferred route execution
[Section titled “Deferred route execution”](#deferred-route-execution)
Routes need access to the kernel — they call services and query the database. But during the plugin pipeline, the kernel does not exist yet. The config is still being assembled.
Porulle solves this with deferred execution. Plugin routes are registered as factory functions: `(ctx: PluginContext) => PluginRouteRegistration[]`. The factory is called later, during `createServer`, when the kernel is available.
This means plugin routes cannot run during the config pipeline. They receive a `PluginContext` with the final config, services, database, and logger, and they register their handlers against that context.
## Hook merging
[Section titled “Hook merging”](#hook-merging)
Plugin hooks are merged into a flat map: `config.hooks` is a `Record`. At kernel boot, `registerConfiguredHooks` reads this map and appends each handler to the `HookRegistry`. The registry supports multiple handlers per hook key, and they run in registration order.
* Multiple plugins can register handlers for the same hook. They all run.
* Plugin hooks run after core hooks. The core checkout pipeline is hard-coded in the checkout route; plugin hooks from `config.hooks["checkout.beforeCreate"]` are appended after.
* Hooks registered via `defineConfig({ hooks: { ... } })` and hooks registered via plugins are indistinguishable at runtime.
See [Hook Pipeline](/concepts/hook-pipeline/) for how hooks execute.
## The escape hatch
[Section titled “The escape hatch”](#the-escape-hatch)
Not everything needs to be a plugin. `defineConfig` accepts top-level fields that let you extend the engine without wrapping things in a plugin:
* `config.schema` — additional Drizzle tables
* `config.routes` — a function that receives the Hono app and the kernel, for arbitrary routes
* `config.hooks` — a flat map of hook handlers
* `config.middleware` — Hono middleware applied to all routes
If you are building a reusable extension, use `defineCommercePlugin`. If you are adding project-specific customization, the escape hatches are simpler and more direct.
## Trade-offs and limitations
[Section titled “Trade-offs and limitations”](#trade-offs-and-limitations)
**No inter-plugin dependencies.** Plugins are config transforms applied sequentially. Plugin B sees the config that Plugin A produced, but there is no formal dependency declaration. If Plugin B depends on tables that Plugin A creates, Plugin A must be listed first in the `plugins` array.
**No plugin lifecycle hooks.** There is no `onBoot`, `onShutdown`, or `onReady` callback. If a plugin needs to run setup logic at boot, it must use an `afterCreate` hook on a relevant entity or add initialization logic to its route factory. This is a deliberate omission — lifecycle hooks add complexity and create ordering problems.
**All-or-nothing config access.** Plugins receive the full `CommerceConfig`. A malicious or buggy plugin can delete fields, overwrite adapters, or clear the hook map. There is no sandboxing. Trust is established at the code level, not at runtime — plugins are installed by the application developer.
The plugin contract documents the rules every plugin must follow to inherit the framework’s security guarantees. Read it before shipping a plugin: [Plugin Contract](/extending/plugin-contract/).
## Related
[Section titled “Related”](#related)
* [Build a Loyalty Plugin tutorial](/tutorials/build-a-plugin/) — hands-on introduction to `defineCommercePlugin`
* [Hook Pipeline](/concepts/hook-pipeline/) — how plugin hooks execute relative to core hooks
* [Plugin API Reference](/reference/plugins/) — full manifest specification including the `router()` builder
# Result Types
> Why Porulle uses Result instead of thrown exceptions, and where the trade-offs land.
Every service method and adapter in Porulle returns `Result` instead of throwing exceptions. This is a deliberate choice with real trade-offs.
## The type
[Section titled “The type”](#the-type)
```ts
type Result =
| { ok: true; value: T; meta?: Record }
| { ok: false; error: E };
```
Two constructors make it ergonomic:
```ts
Ok(product) // { ok: true, value: product }
Err({ code: "NOT_FOUND", message: "Product not found" }) // { ok: false, error: ... }
```
The `ok` field is a discriminant. TypeScript narrows the type after a check: if `result.ok` is `true`, `result.value` is available; if `false`, `result.error` is available. No casting required.
## Why not throw
[Section titled “Why not throw”](#why-not-throw)
**Exceptions are invisible in the type system.** A function signature `getProduct(id: string): Promise` says nothing about what happens when the product does not exist. Does it throw? Return `null`? Return `undefined`? You have to read the implementation to find out.
`getProduct(id: string): Promise>` is explicit. The caller knows the operation might fail, what the failure looks like, and is forced to handle both cases.
**Exceptions are easy to forget.** A `try/catch` is opt-in. Forgotten, the exception propagates up the call stack until something catches it or the process crashes. In a multi-layer architecture (route → service → repository → adapter), an uncaught adapter exception surfaces as a generic 500 with no useful context.
`Result` forces inspection of `result.ok` before accessing the value. You can still ignore the error — write `result.value!` with a non-null assertion — but that is a conscious choice, not an oversight.
**Exceptions do not compose well.** Consider a checkout that calls five services: pricing, inventory, promotions, tax, and payments. With exceptions, each call needs its own `try/catch` to handle failures differently. With results:
```ts
const price = await pricing.resolve(params);
if (!price.ok) return Err(price.error);
const stock = await inventory.getAvailable(entityId);
if (!stock.ok) return Err(stock.error);
```
More verbose, but the control flow is visible. No hidden jumps. You can read top-to-bottom and understand every path.
## CommerceError
[Section titled “CommerceError”](#commerceerror)
The error type is structured, not a bare string:
```ts
interface CommerceError {
code: string;
message: string;
details?: unknown;
}
```
Concrete error classes map to HTTP status codes at the REST boundary:
\| Class | Code | HTTP Status | |-------|------|-------------| | `CommerceNotFoundError` | `NOT_FOUND` | 404 | | `CommerceValidationError` | `VALIDATION_FAILED` | 422 | | `CommerceForbiddenError` | `FORBIDDEN` | 403 | | `CommerceConflictError` | `CONFLICT` | 409 | | `CommerceInvalidTransitionError` | `INVALID_TRANSITION` | 422 |
Services do not know about HTTP. `mapErrorToStatus` in `packages/core/src/interfaces/rest/error.ts` handles the translation. A different interface (CLI, RPC, MCP layer) can map the same error differently.
## Adapters return Results too
[Section titled “Adapters return Results too”](#adapters-return-results-too)
Payment adapter `createPaymentIntent` returns `Promise>`, not `Promise`. A Stripe network timeout is `{ ok: false, error: { code: "PAYMENT_FAILED", message: "..." } }`, not an uncaught rejection.
Payment declines, tax service outages, and storage timeouts are normal operational events, not exceptional conditions. Treating them as return values makes calling code explicit about degraded states.
## The honest cost: verbosity
[Section titled “The honest cost: verbosity”](#the-honest-cost-verbosity)
```ts
// With exceptions — concise but opaque
const product = await catalog.getById(id);
const price = await pricing.resolve({ entityId: product.id });
// With Result — verbose but explicit
const productResult = await catalog.getById(id);
if (!productResult.ok) return Err(productResult.error);
const product = productResult.value;
const priceResult = await pricing.resolve({ entityId: product.id });
if (!priceResult.ok) return Err(priceResult.error);
const price = priceResult.value;
```
The Result version is twice as many lines. This is the cost of making error handling explicit. In practice it is manageable because most service methods have a small number of fallible calls, the pattern is consistent, and the error handling is local — you see it right where the call happens.
## Where exceptions are still used
[Section titled “Where exceptions are still used”](#where-exceptions-are-still-used)
`Result` is the convention for services and adapters, but exceptions are not gone entirely.
**Before hooks** throw `CommerceValidationError` to abort the pipeline. This is a control-flow mechanism: throwing inside a transactional pipeline triggers a rollback. The checkout route wraps the before-hook pipeline in a `try/catch` and converts the exception to an error response.
**Unexpected errors** (null pointer dereferences, type errors) are not wrapped in `Result`. They propagate as exceptions and are caught by a top-level error handler that returns a 500. `Result` is for expected, domain-level failures. An exception means something unexpected happened. This boundary matters: `Result` means “this operation can fail in known ways.” Keeping it clear prevents the pattern from degenerating into wrapping every possible failure.
## Related
[Section titled “Related”](#related)
* [Adapter Interfaces](/reference/adapters/) — type definitions for `Result` and all adapter interfaces
* [Build a Payment Adapter](/extending/payment-adapter/) — uses `Result` throughout the adapter interface
# The Thesis
> Why Porulle exists — the question we set out to answer, the principles that fell out of the answer, and what we refused to build.
> *Porul* (பொருள்) is the Tamil word for thing, substance, merchandise, and meaning. The framework is named for the fourth reading.
Porulle started with a single question.
> **What if everything you sell — physical goods, digital goods, services, appointments, subscriptions, internal assets — shared the same transactional kernel?**
Not the same storefront. Not the same checkout button. The same *kernel* — one catalog model, one cart, one order lifecycle, one inventory primitive, one permission system, one audit log. Different surfaces on top, one kernel underneath.
The commerce tooling that existed in early 2026 could not answer that question. Shopify is excellent at DTC storefronts and fights you the moment you try to use it as an internal inventory tool. Several open-source alternatives are composable but carry strong opinions about long-running servers and event-driven architectures that scatter behavior across subscriber files you can never find. Saleor gives you GraphQL but locks you into a Python/Django world. CommerceJS and BigCommerce are SaaS-first — vendor lock-in is the default posture, not the exception.
None of these were *designed* to answer the unified-kernel question. They were designed for storefronts, and everything else came after.
We wanted the answer.
***
## The problem with the answer
[Section titled “The problem with the answer”](#the-problem-with-the-answer)
Once you commit to “one kernel, every sellable thing,” a series of design problems unfold in sequence.
**A product is not a service is not a subscription.** A physical t-shirt has size and color and weight and a fulfillment workflow. A consulting hour has a duration and a provider and a calendar. A SaaS subscription has a billing interval and a renewal hook and a proration calculation. The kernel must hold all of these without forcing any of them into the shape of the others.
The answer is a single `sellable_entities` table with typed fields per entity type, defined in `commerce.config.ts`. The kernel does not know what a “product” is. It knows what a `sellable_entity` is, and the application developer declares the fields each type carries.
**Behavior must be extensible without inheritance.** Class-based plugin systems where every plugin extends a service class create implicit hierarchies — when the base class changes, plugins break. Two class-based plugins that override the same method conflict silently. We did not want that.
The answer is config transforms. A plugin is a function that takes a `CommerceConfig` and returns a modified one. Two plugins that both register a `checkout.afterCreate` hook? Both hooks run, in registration order, observably. Two plugins that both add a schema table? Both tables exist. The composition story is the same as your reducer story — no surprises.
**Event buses look composable until you debug one.** “What happens after I create an order?” should be answerable by reading code, not by greping for `order.created` subscribers across 40 files registered at unknowable points during boot. Event-driven architectures in monolithic kernels are indirection without a redeeming property — silent failures, invisible execution order, two extension models living side by side.
The answer is co-located lifecycle hooks. One extension primitive. Hooks declared in the config file run for application behavior. Plugin hooks merge into the same registry. There is no event bus to disappear into.
**Authentication is too important to build twice.** Password hashing, session management, CSRF, OAuth, 2FA, email verification, password reset — these are well-understood problems with battle-tested solutions. Building them from scratch in a commerce engine is a security risk and a waste of engineering time.
The answer is delegation: Better Auth handles identity. The kernel handles authorization (role-to-permission mapping, multi-tenant scoping, API key scopes). The two layers share a Drizzle database instance but have non-overlapping responsibilities.
***
## What fell out
[Section titled “What fell out”](#what-fell-out)
Solving these in sequence produced a set of principles. They are not aspirational. They are the constraints that made the rest of the framework possible.
### Developer experience above all
[Section titled “Developer experience above all”](#developer-experience-above-all)
The framework is a tool for developers. Every API surface, every config shape, every error message, and every CLI command assumes the developer’s time is the most expensive resource in the system. TypeScript types are end-to-end — from `commerce.config.ts` through the kernel into the SDK. Errors are actionable, not cryptic. Open the config file and you understand the entire system without reading any other file.
### Adapter discipline
[Section titled “Adapter discipline”](#adapter-discipline)
Every infrastructure dependency — database, payment, storage, search, email, tax — is accessed through an interface. Vendor SDKs never leak into core. Swapping from Postgres to Neon to Supabase, or from Stripe to a new payment rail, is a config change, not a refactor. The kernel does not know what S3 is. It knows what a `StorageAdapter` is, and `@porulle/adapter-s3` is one such implementation.
### Multi-tenant by default
[Section titled “Multi-tenant by default”](#multi-tenant-by-default)
Every row is scoped to an `organizationId`. Cross-tenant queries are not a feature you opt into — they are a closed surface enforced at the repository layer. The audit log records every mutation with the acting principal, the organization, and the affected entity. If you point a fresh deployment at the wrong tenant, you cannot see the other tenant’s data even if you try.
### Result types, not exceptions
[Section titled “Result types, not exceptions”](#result-types-not-exceptions)
Services return `Result` — `Ok(value)` or `Err(CommerceError)`. They never throw across module boundaries. Why: error paths are part of the API contract, not a side effect. The caller decides what to do with a `DUPLICATE_SLUG` error; the kernel does not unwind a stack and hope the right handler catches it. This makes the framework safe in serverless runtimes where uncaught exceptions cost you a cold start.
### One extension primitive
[Section titled “One extension primitive”](#one-extension-primitive)
Lifecycle hooks. Plugin manifests reduce boilerplate, but they compile to the same hook registrations. There is no separate “pipeline” abstraction. There is no event bus. There is no plugin-only API that the application config cannot also reach. One pattern, used everywhere, understood immediately.
### Composition over configuration
[Section titled “Composition over configuration”](#composition-over-configuration)
The framework ships with sensible defaults for common commerce patterns, but it never forces a pattern. Swap the pricing engine, replace the fulfillment logic, add a custom order state, extend the catalog schema. Every behavior is composable. If you can express it as a hook, a plugin, or a custom route, the kernel does not stand in your way.
### Do not build what is already solved
[Section titled “Do not build what is already solved”](#do-not-build-what-is-already-solved)
Authentication is Better Auth. Payments are Stripe (or whatever you adapt). Tax is TaxJar (or a flat-rate adapter). Search is Meilisearch or PostgreSQL FTS. Email is Resend or SES. The kernel orchestrates; it does not reinvent.
***
## What we refused to build
[Section titled “What we refused to build”](#what-we-refused-to-build)
Equally important to the principles is what we left out. Each of these was considered and rejected, not forgotten.
**No GraphQL.** REST + an OpenAPI spec gets you a generated typed client (`@porulle/sdk`) and the entire HTTP ecosystem for free. GraphQL adds a query layer, a resolver layer, a schema-stitching story, and a “now plugins must register their resolvers in the right shape” problem. We didn’t want any of it for the v0.1.0 surface.
**No event bus.** Already discussed.
**No service-class inheritance.** Already discussed.
**No “AI as a feature.”** AI is an interface, not a bolt-on. v0.1.0 ships with the agent-readable REST surface; deeper agent-native primitives (principal model rework, multi-protocol gateway, conversation layer) are explicit Phase 2 — not implicit Phase 1 promises that quietly never arrive.
**No long-running background process assumptions.** Every module is capable of running inside a serverless function with a sub-50ms cold start on the critical path. Long-running deployment still works perfectly, but it is not the contract. The contract is: serverless is the primary target, anything that works there works everywhere else by definition.
***
## What this gets you
[Section titled “What this gets you”](#what-this-gets-you)
The kernel is roughly 32 packages: a core, four adapters per concern (database, payment, storage, search, email, tax), a typed SDK, a CLI, and a set of optional plugins (POS, loyalty, gift cards, reviews, wishlists, marketplace, supply chain, restaurant POS layer, scheduled orders, appointments). They all version together via Changesets — `@porulle/core` 0.2 means every package goes to 0.2.
You install what you need:
```bash
bun add @porulle/core @porulle/adapter-postgres @porulle/adapter-stripe
```
You write a `commerce.config.ts` that declares your entity types, picks your adapters, and lists your plugins. You boot it. You have a hardened REST API with multi-tenancy, audit, search, checkout, fulfillment, refunds, payouts, and an OpenAPI spec.
The full original founding RFC — written before the framework had its name, before the first line of code, with sections that since changed (MCP and Cube.js were ripped out, the phased delivery is now history) — is preserved at [The Full Thesis](/concepts/full-thesis/) for readers who want the artifact in its original shape.
***
## Where to next
[Section titled “Where to next”](#where-to-next)
[The Full Thesis ](/concepts/full-thesis/)The original founding RFC, dated 2026-03-08. Some sections describe plans that have since changed — preserved as historical lore.
[Architecture ](/concepts/architecture/)What actually shipped — kernel layers, hook pipeline, adapter discipline.
[Get Started ](/get-started/quickstart/)From zero to a working commerce API in five minutes.
# Extending Porulle
> Hooks, custom tables, payment adapters, plugins — the framework surface for adopters who need to add behavior the kernel doesn't ship.
When Porulle’s bundled features don’t match your case, you extend the framework rather than fork it. Every extension point is a config-time contribution: hooks intercept operations, custom tables share the schema, plugins compose multiple extensions into one config-transform. No subclassing, no monkey-patching.
[Hooks ](/extending/hooks/)Intercept any operation with before/after handlers. Before runs in the transaction; after runs outside it.
[Custom Tables ](/extending/custom-tables/)Add app-level Drizzle tables that share the org-scoped schema. No plugin wrapper needed.
[Payment Adapter ](/extending/payment-adapter/)Implement the PaymentAdapter contract for any rail — Stripe is the reference implementation.
[Testing ](/extending/testing/)Plugin test apps, PGlite-backed integration tests, the actor injection pattern.
[TypeScript Patterns ](/extending/typescript/)Result discipline, narrowing CommerceError, plugin context types.
## Adopter contracts
[Section titled “Adopter contracts”](#adopter-contracts)
These three contracts are normative — every extension must follow them:
* [**Plugin Contract**](/extending/plugin-contract/) — what plugin authors MUST do (actor as required parameter, `Result`, org scoping, no body-supplied identity)
* [**Payment Adapter Contract**](/extending/payment-adapter-contract/) — accurate `amountCaptured`, idempotency, timing-safe webhook verification
* [**Security Model**](/production/security-model/) — the framework’s threat model and adopter-side responsibilities
## Where to next
[Section titled “Where to next”](#where-to-next)
* [**Reference → Plugins**](/reference/plugins/) — `defineCommercePlugin` manifest reference
* [**Reference → Hooks**](/reference/hooks/) — every available hook key with type signatures
* [**Concepts → Plugin Architecture**](/concepts/plugin-architecture/) — why plugins are config transforms
* [**Tutorials → Build a Loyalty Plugin**](/tutorials/build-a-plugin/) — a full plugin walkthrough
# Custom Tables
> Create app-level database tables with foreign key references to core tables.
Custom tables let you add application-specific data to the same database as your commerce data, with foreign key constraints to core tables like `sellable_entities` and `customers`.
## Define the table
[Section titled “Define the table”](#define-the-table)
Create a schema file in your app. Import core tables from `@porulle/core/schema` to reference them in foreign key constraints.
src/schema/reviews.ts
```ts
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { sellableEntities, customers } from "@porulle/core/schema";
export const reviews = pgTable("reviews", {
id: uuid("id").defaultRandom().primaryKey(),
entityId: uuid("entity_id")
.notNull()
.references(() => sellableEntities.id, { onDelete: "cascade" }),
customerId: uuid("customer_id")
.references(() => customers.id, { onDelete: "set null" }),
rating: integer("rating").notNull(),
title: text("title"),
body: text("body"),
status: text("status", { enum: ["pending", "approved", "rejected"] })
.notNull()
.default("pending"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
```
Use `onDelete: "cascade"` when the row should be deleted with its parent. Use `onDelete: "set null"` when the row should survive parent deletion — the FK column must be nullable in that case.
## Register the table
[Section titled “Register the table”](#register-the-table)
Add the table to the `schema` array in `commerce.config.ts`:
commerce.config.ts
```ts
import { defineConfig } from "@porulle/core";
import { reviews } from "./src/schema/reviews.js";
export default defineConfig({
schema: [{ reviews }],
// ...
});
```
## Add to drizzle.config.ts
[Section titled “Add to drizzle.config.ts”](#add-to-drizzleconfigts)
Add the schema file path so `drizzle-kit push` and `drizzle-kit generate` include it:
drizzle.config.ts
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/plugin-*/src/schema.ts",
"./src/schema/reviews.ts",
],
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://localhost:5432/my_store",
},
});
```
Push the schema to apply the new table:
```bash
bunx drizzle-kit push --config drizzle.config.ts
```
For production, generate migration files instead:
```bash
bunx drizzle-kit generate --config drizzle.config.ts
bunx drizzle-kit migrate --config drizzle.config.ts
```
## Add routes
[Section titled “Add routes”](#add-routes)
Register route handlers via the `routes` field in `commerce.config.ts`. The callback receives the Hono app instance and the kernel:
src/routes/reviews.ts
```ts
import type { Hono } from "hono";
import { eq, desc } from "drizzle-orm";
import { reviews } from "../schema/reviews.js";
import { sellableEntities } from "@porulle/core/schema";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
function db(raw: unknown): PostgresJsDatabase> {
return raw as PostgresJsDatabase>;
}
export function reviewRoutes(app: Hono, kernel: unknown) {
const k = kernel as { database: { db: unknown } };
app.post("/api/reviews", async (c) => {
const body = await c.req.json();
const drizzle = db(k.database.db);
const [review] = await drizzle
.insert(reviews)
.values({
entityId: body.entityId,
customerId: body.customerId ?? null,
rating: body.rating,
title: body.title ?? null,
body: body.body ?? null,
})
.returning();
return c.json({ data: review }, 201);
});
app.get("/api/reviews/:entityId", async (c) => {
const entityId = c.req.param("entityId");
const drizzle = db(k.database.db);
const rows = await drizzle
.select({
review: reviews,
productSlug: sellableEntities.slug,
})
.from(reviews)
.innerJoin(sellableEntities, eq(reviews.entityId, sellableEntities.id))
.where(eq(reviews.entityId, entityId))
.orderBy(desc(reviews.createdAt));
return c.json({ data: rows });
});
}
```
Wire the routes in your config:
commerce.config.ts
```ts
import { defineConfig } from "@porulle/core";
import { reviews } from "./src/schema/reviews.js";
import { reviewRoutes } from "./src/routes/reviews.js";
export default defineConfig({
schema: [{ reviews }],
routes: (app, kernel) => {
reviewRoutes(app, kernel);
},
// ...
});
```
If you need routes from multiple files, call each registration function inside the same `routes` callback.
## Use the plugin system for reusable tables
[Section titled “Use the plugin system for reusable tables”](#use-the-plugin-system-for-reusable-tables)
If the same table will be used across multiple projects, package it as a plugin. Plugins define schema, hooks, and routes together, and are published as standalone packages. See [Build a Loyalty Plugin](/tutorials/build-a-plugin/) for a complete example.
## Related
[Section titled “Related”](#related)
* [Configuration Reference](/reference/configuration/) — all `defineConfig` options
* [Plugin Architecture](/concepts/plugin-architecture/) — why plugins are config transforms
* [TypeScript Patterns](/extending/typescript/) — type-safe DB access with `PluginDb`
# Use Hooks
> Add custom logic before or after any commerce operation.
Hooks intercept operations at two points: **before** (runs inside the database transaction, can modify incoming data or abort the operation) and **after** (runs outside the transaction, receives the result, cannot modify it).
For hook type signatures (`BeforeHook`, `AfterHook`), `HookContext` fields, and the complete list of available hook keys, see the [Hook System Reference](/reference/hooks/). For how checkout hooks execute in sequence and the compensation pattern, see [Hook Pipeline](/concepts/hook-pipeline/).
## Register hooks
[Section titled “Register hooks”](#register-hooks)
### Via the global hooks map
[Section titled “Via the global hooks map”](#via-the-global-hooks-map)
commerce.config.ts
```ts
import { defineConfig } from "@porulle/core";
import { sendOrderConfirmation } from "./src/hooks/order-confirmation.js";
import { validateMinimumAmount } from "./src/hooks/validate-minimum.js";
export default defineConfig({
hooks: {
"orders.afterCreate": [sendOrderConfirmation],
"checkout.beforeCreate": [validateMinimumAmount],
},
// ...
});
```
### Via module-specific config
[Section titled “Via module-specific config”](#via-module-specific-config)
commerce.config.ts
```ts
export default defineConfig({
orders: {
hooks: {
afterCreate: [sendOrderConfirmation],
beforeStatusChange: [blockFraudulentTransitions],
},
},
cart: {
hooks: {
beforeAddItem: [enforceMaxQuantity],
},
},
checkout: {
hooks: {
beforeCreate: [validateMinimumAmount],
},
},
});
```
### Via a plugin
[Section titled “Via a plugin”](#via-a-plugin)
src/plugins/my-plugin.ts
```ts
import { defineCommercePlugin } from "@porulle/core";
export const myPlugin = defineCommercePlugin({
id: "my-plugin",
version: "1.0.0",
hooks: () => [
{ key: "orders.afterCreate", handler: sendOrderConfirmation },
{ key: "checkout.beforeCreate", handler: validateMinimumAmount },
],
});
```
Plugin hooks run after core hooks, in registration order.
## Example: reject checkout below a minimum
[Section titled “Example: reject checkout below a minimum”](#example-reject-checkout-below-a-minimum)
Before hooks run inside the database transaction. Throwing aborts the operation and rolls back the transaction.
src/hooks/validate-minimum.ts
```ts
import type { BeforeHook } from "@porulle/core";
const MINIMUM_CENTS = 1000; // $10.00
export const validateMinimumAmount: BeforeHook = ({ data, context }) => {
const cart = data as { lineItems?: Array<{ quantity: number; unitPriceSnapshot: number }> };
const total = cart.lineItems?.reduce(
(sum, item) => sum + item.quantity * item.unitPriceSnapshot,
0,
) ?? 0;
if (total < MINIMUM_CENTS) {
context.logger.warn("Checkout rejected: below minimum", { total, minimum: MINIMUM_CENTS });
throw new Error(`Order total ${total} cents is below the $10.00 minimum`);
}
return data;
};
```
## Example: call an external webhook after an order is created
[Section titled “Example: call an external webhook after an order is created”](#example-call-an-external-webhook-after-an-order-is-created)
After hooks run outside the transaction. Failures are collected in `hookErrors` on the response but do not roll back the order.
src/hooks/order-webhook.ts
```ts
import type { AfterHook } from "@porulle/core";
const WEBHOOK_URL = process.env.ORDER_WEBHOOK_URL!;
export const sendOrderWebhook: AfterHook = async ({ result, context }) => {
const order = result as { id: string };
context.logger.info("Sending order webhook", { orderId: order.id });
await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event: "order.created",
orderId: order.id,
requestId: context.requestId,
timestamp: new Date().toISOString(),
}),
});
};
```
## What hooks can access
[Section titled “What hooks can access”](#what-hooks-can-access)
Every hook receives a `HookContext` object:
\| Field | Description | |-------|-------------| | `actor` | Authenticated user or API key that initiated the operation | | `tx` | Database transaction (before hooks only; `null` in after hooks) | | `logger` | Pino logger scoped to this request | | `services` | Full service container (catalog, orders, inventory, etc.) | | `context` | Mutable `Record` for passing data between hooks | | `requestId` | Unique ID for the current request, useful for tracing | | `origin` | `"rest"` or `"local"` — which interface triggered the operation |
The `context` bag is how before hooks pass data to after hooks. For example, `authorizePayment` stores the payment intent ID in `context.paymentIntentId`, and `completeCheckout` reads it.
# Payment Adapter
> Integrate a new payment provider by implementing the PaymentAdapter interface.
Porulle is payment-provider agnostic. Any provider that can authorize, capture, refund, and sign webhooks can be integrated by implementing the `PaymentAdapter` interface.
For the adopter contract (capture accuracy, webhook verification requirements, idempotency rules), see the [Payment Adapter Contract](/extending/payment-adapter-contract/).
## The interface
[Section titled “The interface”](#the-interface)
Every payment adapter implements five methods. All return `Result` — use `Ok()` for success, `Err()` for failure, never throw. For the full type definitions (`CreatePaymentIntentParams`, `PaymentIntent`, `PaymentCapture`, `PaymentRefund`, `PaymentWebhookEvent`), see the [Adapter Reference](/reference/adapters/).
```ts
interface PaymentAdapter {
providerId: string;
createPaymentIntent(params: CreatePaymentIntentParams): Promise>;
capturePayment(paymentIntentId: string, amount?: number): Promise>;
refundPayment(paymentId: string, amount: number, reason?: string): Promise>;
cancelPaymentIntent(paymentIntentId: string): Promise>;
verifyWebhook(request: Request): Promise>;
}
```
## Implement the adapter
[Section titled “Implement the adapter”](#implement-the-adapter)
This example implements Stripe:
src/payments/stripe-adapter.ts
```ts
import type {
PaymentAdapter,
PaymentIntent,
PaymentCapture,
PaymentRefund,
PaymentWebhookEvent,
} from "@porulle/core";
import { Ok, Err } from "@porulle/core";
import type { Result } from "@porulle/core";
import Stripe from "stripe";
interface StripeAdapterOptions {
secretKey: string;
webhookSecret: string;
}
export function stripeAdapter(options: StripeAdapterOptions): PaymentAdapter {
const stripe = new Stripe(options.secretKey);
return {
providerId: "stripe",
async createPaymentIntent(params): Promise> {
try {
const intent = await stripe.paymentIntents.create({
amount: params.amount,
currency: params.currency,
metadata: {
orderId: params.orderId,
...(params.customerId && { customerId: params.customerId }),
...params.metadata,
},
});
return Ok({
id: intent.id,
status: intent.status,
amount: intent.amount,
currency: intent.currency,
clientSecret: intent.client_secret,
});
} catch (error) {
return Err({
code: "PAYMENT_FAILED",
message: error instanceof Error ? error.message : "Failed to create payment intent",
});
}
},
async capturePayment(paymentIntentId, amount): Promise> {
try {
const intent = await stripe.paymentIntents.capture(paymentIntentId, {
...(amount != null && { amount_to_capture: amount }),
});
return Ok({
id: intent.id,
status: intent.status,
amountCaptured: intent.amount_received,
});
} catch (error) {
return Err({
code: "CAPTURE_FAILED",
message: error instanceof Error ? error.message : "Failed to capture payment",
});
}
},
async refundPayment(paymentId, amount, reason): Promise> {
try {
const refund = await stripe.refunds.create({
payment_intent: paymentId,
amount,
...(reason && { reason: "requested_by_customer" }),
});
return Ok({
id: refund.id,
status: refund.status ?? "succeeded",
amountRefunded: refund.amount,
});
} catch (error) {
return Err({
code: "REFUND_FAILED",
message: error instanceof Error ? error.message : "Failed to refund payment",
});
}
},
async cancelPaymentIntent(paymentIntentId): Promise> {
try {
await stripe.paymentIntents.cancel(paymentIntentId);
return Ok(undefined);
} catch (error) {
return Err({
code: "CANCEL_FAILED",
message: error instanceof Error ? error.message : "Failed to cancel payment intent",
});
}
},
async verifyWebhook(request): Promise> {
try {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return Err({ code: "WEBHOOK_FAILED", message: "Missing stripe-signature header" });
}
const event = stripe.webhooks.constructEvent(body, signature, options.webhookSecret);
return Ok({
id: event.id,
type: event.type,
data: event.data.object,
});
} catch (error) {
return Err({
code: "WEBHOOK_FAILED",
message: error instanceof Error ? error.message : "Webhook verification failed",
});
}
},
};
}
```
## Register the adapter
[Section titled “Register the adapter”](#register-the-adapter)
Add the adapter to the `payments` array in `commerce.config.ts`:
commerce.config.ts
```ts
import { defineConfig } from "@porulle/core";
import { stripeAdapter } from "./src/payments/stripe-adapter.js";
export default defineConfig({
payments: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
],
// ...
});
```
To support multiple payment providers (for example, Stripe online and a terminal provider for POS), add both to the array. The engine selects the adapter by `providerId` at checkout time.
## Error handling
[Section titled “Error handling”](#error-handling)
Never throw from adapter methods. All failures must return `Err({ code, message })`. The checkout pipeline treats a thrown exception from an adapter as an unrecoverable error — it cannot roll back cleanly or surface a user-facing message.
Codes are free-form strings you define. The engine does not interpret them; they are passed through to the error response so your frontend can act on them.
For the rationale behind `Result`, see the [Result Types explanation](/concepts/result-types/).
## Testing the adapter
[Section titled “Testing the adapter”](#testing-the-adapter)
Use the mock adapter pattern in tests to isolate checkout logic from the payment provider:
```ts
import { Ok, type PaymentAdapter } from "@porulle/core";
export const mockAdapter: PaymentAdapter = {
providerId: "mock",
async createPaymentIntent(p) {
return Ok({
id: `pi_${Date.now()}`,
status: "requires_capture",
amount: p.amount,
currency: p.currency,
clientSecret: `secret_${Date.now()}`,
});
},
async capturePayment(id, amount) {
return Ok({ id, status: "succeeded", amountCaptured: amount ?? 0 });
},
async refundPayment(_id, amount) {
return Ok({ id: `re_${Date.now()}`, status: "succeeded", amountRefunded: amount });
},
async cancelPaymentIntent() { return Ok(undefined); },
async verifyWebhook() {
return Ok({ id: "evt_mock", type: "payment.succeeded", data: {} });
},
};
```
## Related
[Section titled “Related”](#related)
* [Adapter Reference](/reference/adapters/) — full interface definitions and type signatures
* [Hook Pipeline](/concepts/hook-pipeline/) — how `authorizePayment` and `completeCheckout` call the adapter
* [Result Types](/concepts/result-types/) — why `Result` instead of exceptions
# Payment Adapter Contract
> The rules every payment adapter implementation must follow.
Rules for implementing a `PaymentAdapter` that integrates with UnifiedCommerce Engine’s payment orchestration. The interface is defined at `packages/core/src/modules/payments/adapter.ts`.
## Why this contract exists
[Section titled “Why this contract exists”](#why-this-contract-exists)
In commit `7cb06e3`, `payments.refund(paymentIntentId, order.grandTotal)` refunded the full order total instead of the captured amount. For partial captures (common in multi-shipment orders), this caused over-refunds. The fix added `amountCaptured` tracking on orders and a refund cap (`Math.min(grandTotal, amountCaptured)`) in `packages/core/src/modules/orders/service.ts:460--463`. Adapters that return inaccurate capture amounts defeat this defense.
## Interface walkthrough
[Section titled “Interface walkthrough”](#interface-walkthrough)
```ts
export interface PaymentAdapter {
readonly providerId: string;
createPaymentIntent(params: CreatePaymentIntentParams): Promise>;
capturePayment(paymentIntentId: string, amount?: number): Promise>;
refundPayment(paymentId: string, amount: number, reason?: string): Promise>;
cancelPaymentIntent(paymentIntentId: string): Promise>;
verifyWebhook(request: Request): Promise>;
}
```
### `createPaymentIntent`
[Section titled “createPaymentIntent”](#createpaymentintent)
Creates a payment intent and returns a `PaymentIntent` with `id`, `status`, `amount`, `currency`, and `clientSecret`. The `clientSecret` is sent to the storefront for client-side confirmation. If the provider requires a challenge (3DS, OTP), the returned `status` should indicate `requires_action` or equivalent, and the `clientSecret` must be available for the challenge flow.
### `capturePayment`
[Section titled “capturePayment”](#capturepayment)
Captures a previously authorized payment. Must return `PaymentCapture` with an accurate `amountCaptured` — this is the source of truth the framework stores on the order (`orders.amountCaptured`). If you return `amountCaptured: 0` on a successful capture, the refund cap will be zero and refunds will fail.
### `refundPayment`
[Section titled “refundPayment”](#refundpayment)
Initiates a refund. The framework already caps the refund at `amountCaptured` before calling this method. The adapter should also enforce its own cap as defense in depth — reject amounts exceeding what was captured. Return `PaymentRefund` with `id`, `status`, and `amountRefunded`.
### `cancelPaymentIntent`
[Section titled “cancelPaymentIntent”](#cancelpaymentintent)
Cancels an intent that has not been captured. For intents that are already captured, use `refundPayment` instead.
### `verifyWebhook`
[Section titled “verifyWebhook”](#verifywebhook)
Verifies the signature on an incoming webhook request. Must use timing-safe comparison to prevent timing attacks. In Node.js, use `crypto.timingSafeEqual`. The Stripe adapter delegates to `stripe.webhooks.constructEvent()` which handles this internally (`packages/adapters/adapter-stripe/src/index.ts:128`).
Return `PaymentWebhookEvent` with `id`, `type`, and `data`. The framework’s `processed_webhook_events` table prevents replay on the inbound side; the adapter prevents forgery.
## Idempotency
[Section titled “Idempotency”](#idempotency)
Every charge and refund call must accept and propagate an idempotency key. Stripe uses the `Idempotency-Key` header. Other providers have equivalent mechanisms.
Mock adapters used in testing must also honor idempotency keys. Idempotency is a correctness contract, not an optimization. If your mock does not deduplicate by key, integration tests will not catch double-charge bugs.
## Webhook signature verification
[Section titled “Webhook signature verification”](#webhook-signature-verification)
Required for every adapter. Two common schemes:
**Stripe signature scheme** — the adapter reads the `stripe-signature` header, which contains a timestamp and HMAC. The Stripe SDK’s `constructEvent()` handles verification. Reference: `packages/adapters/adapter-stripe/src/index.ts:110--141`.
**Generic HMAC scheme** — for providers without SDK support:
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
const expected = createHmac("sha256", webhookSecret)
.update(rawBody)
.digest();
const provided = Buffer.from(signatureHeader, "hex");
if (!timingSafeEqual(expected, provided)) {
return Err({ code: "WEBHOOK_VERIFICATION_FAILED", message: "Invalid signature" });
}
```
Never use `===` for signature comparison. It is vulnerable to timing attacks.
## Challenge / 3DS flow
[Section titled “Challenge / 3DS flow”](#challenge--3ds-flow)
When a payment requires cardholder authentication, `createPaymentIntent` should return a status indicating the challenge requirement (e.g., `requires_action`). The `clientSecret` is used by the storefront to initiate the challenge via the provider’s client SDK (Stripe.js Elements, Braintree Hosted Fields).
The framework does not have a first-class challenge flow type today. The workaround: return the challenge status and `clientSecret` through the standard `PaymentIntent` response. The storefront reads the status, handles the challenge client-side, and confirms via the provider’s SDK. This is how the Stripe adapter works — `client_secret` is always returned on the intent.
## Anti-patterns
[Section titled “Anti-patterns”](#anti-patterns)
**Returning `amountCaptured: 0` on a successful capture.** The framework reads this to cap refunds. Zero means zero refund capability. This was the root cause of the over-refund bug.
**Skipping signature verification “for testing.”** Every test environment should verify signatures. Use a known test secret. Skipping verification in any environment normalizes skipping it in production.
**Logging the full webhook payload.** PCI-DSS restricts storage of cardholder data. Stripe webhook payloads can contain PAN fragments. Strip sensitive fields before logging. The adapter itself receives the raw request body for signature verification — do not log it.
**Throwing instead of returning `Result`.** The `PaymentsService` (`packages/core/src/modules/payments/service.ts`) catches exceptions at the top level, but the contract is `Result`. Throwing forces callers into try/catch instead of the `if (result.ok)` pattern used everywhere else.
## Reference implementation
[Section titled “Reference implementation”](#reference-implementation)
The Stripe adapter at `packages/adapters/adapter-stripe/src/index.ts` is the canonical implementation. Key sections:
* `createPaymentIntent` (line 27) — intent creation with automatic payment methods
* `capturePayment` (line 57) — returns `amount_received` as `amountCaptured`
* `refundPayment` (line 73) — creates refund with optional reason
* `verifyWebhook` (line 110) — signature verification via Stripe SDK
# Plugin Contract
> The rules every plugin author must follow to inherit Porulle's security guarantees.
Rules every plugin author must follow to inherit the framework’s security guarantees. Violating these means your plugin is an attack surface, not an extension point.
## Why this contract exists
[Section titled “Why this contract exists”](#why-this-contract-exists)
In commit `7cb06e3` the plugin-reviews service accepted `customerId` directly from the request body. Any authenticated customer could submit a review under a different customer’s identity by changing one field. This is the same IDOR class that affected carts before it: if every plugin trusts body-supplied `customerId`, every plugin is a cross-customer hijack vector. The contract below codifies the fix pattern so adopters don’t rebuild the same bug.
## Required signature shape
[Section titled “Required signature shape”](#required-signature-shape)
Every plugin service method that mutates data accepts `actor: Actor | null` as a required parameter — not optional, not defaulted to `null`.
```ts
// Correct
async submit(orgId: string, input: SubmitInput, actor: Actor | null): Promise>
// Wrong -- no actor, no accountability
async submit(orgId: string, input: SubmitInput): Promise>
```
Reject anonymous callers on mutations:
```ts
if (!actor?.userId) {
return Err("Authentication required");
}
```
For end-user (customer) roles, resolve the customer profile UUID server-side via `customers.getByUserId(actor.userId, actor)` and use that as the `customerId`. Never trust `input.customerId` for customer-role actors.
## Staff/admin override
[Section titled “Staff/admin override”](#staffadmin-override)
These roles are trusted to supply `customerId` via input (POS terminal, agent assist, admin panel):
* `staff`
* `admin`
* `owner`
* `ai_agent`
* `service`
```ts
const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]);
const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
let resolvedCustomerId: string | null = null;
if (isStaff) {
resolvedCustomerId = input.customerId ?? null;
} else {
// Resolve from session -- never from input
const profile = await customers.getByUserId(actor.userId, actor);
if (!profile.ok) return Err("Customer profile not found");
resolvedCustomerId = profile.value.id;
}
```
Reference: `packages/plugins/plugin-reviews/src/services/review-service.ts:41--57`.
## Result contract
[Section titled “Result contract”](#result-contract)
Services never throw across module boundaries. Return `Result` via `Ok` / `Err` from `@unifiedcommerce/core`.
For plugin services, use `PluginResult` — the simplified `{ ok: true; value: T } | { ok: false; error: string }` form. See `packages/core/src/kernel/result.ts`.
Core services use the richer `Result` with typed error classes from `packages/core/src/kernel/errors.ts`:
* `CommerceForbiddenError` — authorization failure
* `CommerceNotFoundError` — resource does not exist
* `CommerceValidationError` — input constraint violation
* `CommerceConflictError` — concurrent state conflict
* `CommerceInvalidTransitionError` — state machine guard
* `OrgResolutionError` — tenant resolution failure
## Org scoping
[Section titled “Org scoping”](#org-scoping)
Every Drizzle query on a tenant-scoped table must include `eq(table.organizationId, orgId)` in the WHERE clause. The framework resolves `orgId` via `resolveOrgId(actor)` (`packages/core/src/auth/org.ts`).
Tenant-scoped tables:
* `carts`, `cart_line_items`
* `orders`, `order_line_items`, `order_status_history`
* `customers`, `customer_addresses`, `customer_groups`, `customer_group_members`
* `inventory_levels`, `inventory_movements`, `warehouses`
* `prices`, `price_modifiers`
* `promotions`, `promotion_usages`
* `webhook_endpoints`, `processed_webhook_events`
* `media_assets`
* `sellable_entities`, `sellable_attributes`, `sellable_custom_fields`, `variants`, `option_types`, `option_values`
* `categories`, `brands`
* `commerce_audit_log`
Omitting the org filter on any of these tables leaks cross-tenant data. The webhook cross-tenant bug (`webhooks/service.ts`) and the media cross-tenant bug (`media/service.ts`) were both caused by missing org filters.
## Hook ordering
[Section titled “Hook ordering”](#hook-ordering)
Hooks are resolved positionally: `[...prepended, ...configured, ...appended]`. Two plugins both prepending to `orders.afterCreate` will execute in registration order. There is no `runs.before` / `runs.after` declarative ordering. Test your plugin composition, especially when multiple plugins touch the same hook.
Reference: `packages/core/src/kernel/hooks/registry.ts`.
## Test contract
[Section titled “Test contract”](#test-contract)
Every plugin ships at least one regression test per security-relevant code path. The minimum:
* Customer-role actor cannot spoof `customerId`.
* Anonymous callers are rejected on mutations.
* Org isolation: a different org sees zero records.
Reference: `packages/plugins/plugin-reviews/test/reviews.test.ts`, specifically the `"ignores spoofed customerId for customer-role actor"` and `"org isolation"` test cases.
## Anti-patterns to avoid
[Section titled “Anti-patterns to avoid”](#anti-patterns-to-avoid)
**Trust `input.customerId` from end-users.** Allows cross-customer identity hijack. Always resolve server-side for customer roles.
**Throw across module boundaries.** Breaks the `Result` contract. Callers cannot distinguish business errors from runtime crashes.
**Skip the org filter on a “convenience” query.** Cross-tenant data leak. Every query on a tenant-scoped table needs the filter.
**Catch and swallow errors.** Silent failures hide bugs and mask attacks. Propagate via `Err()`.
**Use `as any` to bypass type errors.** The type system is a security boundary here. If the types don’t line up, fix the types.
## Example diff — the plugin-reviews IDOR fix
[Section titled “Example diff — the plugin-reviews IDOR fix”](#example-diff--the-plugin-reviews-idor-fix)
Before (vulnerable):
```ts
// review-service.ts -- BEFORE
async submit(orgId: string, input: {
customerId: string; // <-- trusted directly from request body
entityId: string;
rating: number;
title?: string;
body?: string;
}): Promise> {
const rows = await this.db.insert(customerReviews).values({
organizationId: orgId,
customerId: input.customerId, // <-- spoofed value persisted
entityId: input.entityId,
rating: input.rating,
// ...
}).returning();
return Ok(rows[0]!);
}
```
After (fixed):
```ts
// review-service.ts -- AFTER
async submit(orgId: string, input: {
customerId?: string; // <-- optional, ignored for customer roles
entityId: string;
rating: number;
title?: string;
body?: string;
}, actor: Actor | null): Promise> { // <-- actor required
if (!actor?.userId) return Err("Authentication required");
const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]);
const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
let resolvedCustomerId: string | null = null;
if (isStaff) {
resolvedCustomerId = input.customerId ?? null;
} else {
const profile = await customers.getByUserId(actor.userId, actor);
if (!profile.ok) return Err("Customer profile not found");
resolvedCustomerId = profile.value.id; // <-- server-side resolution
}
const rows = await this.db.insert(customerReviews).values({
organizationId: orgId,
customerId: resolvedCustomerId, // <-- never from input for customers
entityId: input.entityId,
rating: input.rating,
// ...
}).returning();
return Ok(rows[0]!);
}
```
This is the canonical pattern. Apply it to every plugin service method that accepts an identity field.
## Contributing API-key scopes
[Section titled “Contributing API-key scopes”](#contributing-api-key-scopes)
A plugin that mints its own credentials must register an API-key scope through the manifest `apiKeyScopes` slot — never reach into `config.auth` directly. The slot is how a plugin declares a short-lived, narrowly-scoped key configuration that the framework merges into `config.auth.apiKeyScopes` at boot (user config of the same name wins). Reaching around it bypasses that merge and the guarantees that come with it.
Minting and verifying at runtime goes through `PluginContext.auth`, the Better Auth instance. It may be `undefined` in bare-kernel setups that do not run `createServer`, so every mint path must fail gracefully with a clear error rather than dereferencing `undefined`.
plugin-pos is the worked example: it registers a `pos` scope with `keyExpiration.minExpiresIn = 1/96` (15 minutes, in days) and mints a per-shift key at PIN login. The scope caps what the key can do (`permissions: { pos: ["operate"] }`) and how long it lives, so a leaked shift key expires on its own and can never exceed the operate scope.
# Store Connector
> Add a new store platform (Shopify, WooCommerce, BigCommerce, …) by implementing the ChannelConnector interface.
Porulle is store-platform agnostic. Any platform that can list a catalog, report stock, accept an order, and sign webhooks can be integrated by implementing the `ChannelConnector` interface. The interface is defined in `@porulle/core` at `packages/core/src/modules/channels/adapter.ts`; the durable machinery (schema, jobs, sync, injection, reconciliation, REST) lives once in `@porulle/plugin-channel-connector`, so a connector is just the \~6 platform-specific methods — you inherit everything else.
## The shape
[Section titled “The shape”](#the-shape)
A connector is a factory returning the interface — mirror `@porulle/adapter-shopify`. Take an injectable `fetchImpl` so tests can run offline (see [Testing](#testing)).
packages/adapters/adapter-bigcommerce/src/index.ts
```ts
import { defineChannelConnector, Ok, Err, type ChannelConnector } from "@porulle/core";
export interface BigCommerceOptions {
fetchImpl?: typeof fetch;
}
export function bigcommerceConnector(options: BigCommerceOptions = {}): ChannelConnector {
const fetchImpl = options.fetchImpl ?? fetch;
return defineChannelConnector({
providerId: "bigcommerce",
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
async importCatalog(store, cursor) { /* … */ },
async fetchInventory(store, ids) { /* … */ },
async pushOrder(store, slice) { /* … */ },
async fetchOrderStatus(store, remoteId) { /* … */ },
async verifyWebhook(store, request) { /* … */ },
async refundExecute(store, slice, amount) { /* … */ },
});
}
```
Every method returns `Result` (`Ok(value)` / `Err({ code, message, retriable? })`). The `store` argument is the connected-store record: `{ id, organizationId, provider, credentials, storeDomain, status, webhookSecret }`. Read the platform’s auth material out of `store.credentials` (e.g. `{ accessToken }` for Shopify, `{ consumerKey, consumerSecret }` for WooCommerce) — the framework redacts it on every API read.
## Interface walkthrough
[Section titled “Interface walkthrough”](#interface-walkthrough)
### `importCatalog(store, cursor?)`
[Section titled “importCatalog(store, cursor?)”](#importcatalogstore-cursor)
Return one **page** of the store’s catalog as `ChannelCatalogPage` — `{ items: ChannelCatalogItem[], nextCursor?: string | null }`. Each item carries `externalId`, `slug`, `title`, and `variants[]` (each with `externalId`, `sku?`, `barcode?`). The engine walks pages via `nextCursor`, upserts by external id (idempotent), and stamps provenance. Use the platform’s `updated_at` / cursor for incremental pulls. **Do not** attempt bulk-export tricks here — pagination is the contract.
### `fetchInventory(store, ids?)`
[Section titled “fetchInventory(store, ids?)”](#fetchinventorystore-ids)
Return `ChannelInventoryLevel[]` — `{ externalId, available }`. The engine level-sets these into Porulle inventory in external-owns-stock mode. Return the platform’s *available* number (already net of the store’s own commitments).
### `pushOrder(store, slice)`
[Section titled “pushOrder(store, slice)”](#pushorderstore-slice)
The heart of injection. Create the order in the merchant’s store as **already paid**, from the `ChannelOrderSlice` (`{ orderId, currency, grandTotal, lines[], customer: { name, email, shippingAddress } }`). Two money rules:
* Carry the **exact amounts** from the slice — the platform already captured payment. Mark the order paid (`financial_status: "paid"` / `set_paid: true`) but **never re-capture**.
* Map each `line.externalVariantId` to the platform’s line format.
Return `ChannelPushOrderResult` — `{ remoteOrderId, remoteUrl? }`. On failure, return `Err({ code, message, retriable })`; set `retriable: true` for transient errors (timeouts, 5xx) and `false` for definitive ones (variant not found, auth revoked) — the SLA reaper uses this to decide how long to wait before abandoning + auto-refunding.
### `fetchOrderStatus(store, remoteId)`
[Section titled “fetchOrderStatus(store, remoteId)”](#fetchorderstatusstore-remoteid)
Return `ChannelOrderStatus` — `{ status: "pending" | "confirmed" | "failed" | "cancelled" | "fulfilled" }`. Map the platform’s order state onto this union. The engine uses `confirmed` to advance the export, `failed`/`cancelled` to mark it failed.
### `verifyWebhook(store, request)`
[Section titled “verifyWebhook(store, request)”](#verifywebhookstore-request)
Verify the signature on an inbound per-store webhook and return `ChannelWebhookEvent` — `{ id, type, data }`. Compute the HMAC over the **raw request body** (`await request.text()`, never a re-serialized object) and compare with `crypto.timingSafeEqual` against the platform header. **The signing key is platform-specific:** Shopify signs every webhook for an app with the **app client secret** (there is no per-store secret — verify against the connector’s configured `clientSecret`, `x-shopify-hmac-sha256`); WooCommerce uses the **per-webhook secret** set at registration (`store.webhookSecret`, `x-wc-webhook-signature`). Both are base64 HMAC-SHA256; timing-safe comparison is required. The engine’s `processed_webhook_events` table prevents replay on the inbound side; your adapter prevents forgery.
### `verifyAppWebhook?(request)` (optional)
[Section titled “verifyAppWebhook?(request) (optional)”](#verifyappwebhookrequest-optional)
Verify an **app-level** compliance webhook (e.g. Shopify’s mandatory GDPR webhooks) and return `{ topic, shopDomain, data }`. These webhooks are delivered to a single app URL, signed with the **app client secret** (not a per-store `webhookSecret`), and identify the target store by `shop_domain` in the payload. The engine exposes `POST /api/channels/compliance/{provider}` as the unauthenticated ingress. Return `Err({ code, message, retriable: false })` on invalid HMAC so the engine can reply with `401` (Shopify requires this). Shopify adapters implement this; WooCommerce has no app-store equivalent and omits it.
### `refundExecute(store, slice, amount)`
[Section titled “refundExecute(store, slice, amount)”](#refundexecutestore-slice-amount)
Optional outbound leg for cross-boundary refunds — reflect a refund into the merchant’s store. Return `ChannelRefundResult` — `{ remoteRefundId, status }`. The **money-moving refund on Porulle’s side goes through the order refund engine** (`orders.service.refundLines`), never a direct adapter call — this method only mirrors it to the store.
### `registerWebhooks?(store, topics, callbackUrl)` (optional)
[Section titled “registerWebhooks?(store, topics, callbackUrl) (optional)”](#registerwebhooksstore-topics-callbackurl-optional)
Subscribe the store’s webhooks on connect. The engine calls this from the connect flow with the topics it needs and the callback URL (`/api/channels/webhooks/{storeId}`). Implement it via the platform’s webhook API (Shopify `POST /admin/api/{v}/webhooks.json`, WooCommerce `POST /wp-json/wc/v3/webhooks`).
### `reserve?(store, lines)` (optional)
[Section titled “reserve?(store, lines) (optional)”](#reservestore-lines-optional)
Reserve stock on the merchant store before payment. Off by default — Porulle’s default oversell defense is a pre-payment live read (`fetchInventory`), not a reserve. Implement only if a platform benefits from hard reservations.
## Register it
[Section titled “Register it”](#register-it)
Drop the connector into the plugin’s `connectors` array — no core changes:
commerce.config.ts
```ts
channelConnectorPlugin({
connectors: [shopifyConnector(), wooConnector(), bigcommerceConnector()],
});
```
The store row’s `provider` selects your connector by `providerId`.
## OAuth onboarding
[Section titled “OAuth onboarding”](#oauth-onboarding)
Credential paste and one-click OAuth are two front doors to the same `connectStore` pipeline. A connector that supports one-click onboarding adds these optional methods:
```ts
buildAuthUrl(params: {
storeDomain: string;
state: string;
redirectUri: string;
callbackUri: string;
scopes: string[];
}): Result;
completeAuth(
request: Request,
ctx: { storeDomain: string },
): Promise;
storeDomain: string;
}, ChannelConnectorError>>;
```
The engine exposes generic `GET /api/channels/oauth/{provider}/start` and `/callback` routes. The start route requires `channels:manage`, signs a five-minute state containing the organization and store domain, and redirects to the provider. The unauthenticated callback verifies the signature and single-use state, calls `completeAuth`, then reuses `connectStore` to persist credentials, register webhooks, and enqueue catalog import. OAuth credentials have the same downstream shape as paste credentials, such as `{ accessToken }` or `{ consumerKey, consumerSecret }`.
Enable the routes on the engine plugin with a state secret and a fixed post-connect destination:
```ts
channelConnectorPlugin({
connectors: [shopifyConnector({
clientId: process.env.SHOPIFY_CLIENT_ID,
clientSecret: process.env.SHOPIFY_CLIENT_SECRET,
appUrl: "https://platform.example",
}), wooConnector()],
oauth: {
stateSecret: process.env.CHANNEL_OAUTH_SECRET!,
postConnectRedirect: "https://platform.example/stores",
},
});
```
Shopify uses its authorization and code-exchange endpoints. WooCommerce uses `/wc-auth/v1/authorize`: its server posts keys to `callback_url`, while the browser returns through `return_url`. The signed state is carried on both Woo URLs; the browser landing redirects to the configured destination while the dashboard observes the connected store.
## Testing
[Section titled “Testing”](#testing)
Everything durable (jobs, id-map, injection, reconciliation, the export state machine, retries, drift report) is inherited from the plugin and covered by its tests — so a connector’s tests only exercise the \~6 methods, **offline**, by injecting a fake `fetchImpl` that returns `new Response(...)` and records requests:
```ts
const requests: { url: string; method: string }[] = [];
const connector = bigcommerceConnector({
fetchImpl: async (input, init) => {
requests.push({ url: String(input), method: init?.method ?? "GET" });
return new Response(JSON.stringify({ data: [] }), { status: 200 });
},
});
```
The **mock connector** (`@porulle/plugin-channel-connector` `mockChannelConnector`) implements the whole interface in memory and is the integration-test backbone — use it to exercise the engine end-to-end (connect → import → inject → confirm) without any live store. Assert on behavior: a correctly-signed webhook is accepted and a tampered one rejected; `pushOrder` sends the paid amounts; errors carry `retriable`.
See the [Store Connector feature docs](/building/channel-connectors) for how the pieces fit together at runtime.
# Testing
> Plugin route tests, PGlite, integration test patterns, and load testing.
## Test layers
[Section titled “Test layers”](#test-layers)
\| Layer | Tool | What it tests | |-------|------|---------------| | Plugin unit tests | Vitest + PGlite | Plugin routes, hooks, permissions — in-memory, no external DB | | Integration tests | Vitest + live PostgreSQL | Full API flows against a running server | | Load tests | k6 | Throughput and latency at 100 concurrent users |
## Plugin route tests with `createPluginTestApp`
[Section titled “Plugin route tests with createPluginTestApp”](#plugin-route-tests-with-createplugintestapp)
`createPluginTestApp` from `@porulle/core` boots an in-memory PGlite database, pushes the full schema (core tables and your plugin tables), and wires the actor middleware onto a Hono instance that matches the production server. No running server, no Docker, no hand-written DDL.
test/reviews.test.ts
```typescript
import { describe, expect, it, beforeAll } from "vitest";
import {
createPluginTestApp,
jsonHeaders,
testAdminActor,
testCustomerActor,
testNoPermActor,
} from "@porulle/core";
import { myPlugin } from "../src";
describe("my plugin routes", () => {
let app: Awaited>["app"];
beforeAll(async () => {
const result = await createPluginTestApp(myPlugin());
app = result.app;
});
it("creates a resource", async () => {
const res = await app.request("http://localhost/api/my-plugin/resources", {
method: "POST",
headers: jsonHeaders(testAdminActor),
body: JSON.stringify({ name: "Test Resource" }),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.data.name).toBe("Test Resource");
});
it("returns 401 without authentication", async () => {
const res = await app.request("http://localhost/api/my-plugin/resources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "No Auth" }),
});
expect(res.status).toBe(401);
});
it("returns 403 without required permission", async () => {
const res = await app.request("http://localhost/api/my-plugin/resources", {
method: "POST",
headers: jsonHeaders(testNoPermActor),
body: JSON.stringify({ name: "No Perm" }),
});
expect(res.status).toBe(403);
});
it("returns 400 or 422 for invalid input", async () => {
const res = await app.request("http://localhost/api/my-plugin/resources", {
method: "POST",
headers: jsonHeaders(testAdminActor),
body: JSON.stringify({}),
});
expect([400, 422]).toContain(res.status);
});
});
```
### What `createPluginTestApp` does
[Section titled “What createPluginTestApp does”](#what-createplugintestapp-does)
1. Builds a `CommerceConfig` with your plugin applied (PGlite auto-provisioned).
2. Boots the kernel (core services, hook registry).
3. Merges core and plugin schemas via `buildSchema(config)`.
4. Pushes the merged schema to PGlite using `drizzle-kit/api`’s programmatic `pushSchema` — no migration files, no hand-written DDL.
5. Creates a Hono instance with the `x-test-actor` middleware.
6. Registers all plugin routes via `config.routes(app, kernel)`.
### Shared test actors
[Section titled “Shared test actors”](#shared-test-actors)
\| Actor | Role | Permissions | Use case | |-------|------|-------------|----------| | `testAdminActor` | admin | `*:*` | Setup operations, admin-only routes | | `testStaffActor` | staff | catalog, inventory, orders | Operational routes | | `testCustomerActor` | customer | catalog:read, cart, orders:read:own | Customer-facing routes | | `testNoPermActor` | customer | (none) | Negative permission tests (expect 403) |
### Using a real PostgreSQL instance
[Section titled “Using a real PostgreSQL instance”](#using-a-real-postgresql-instance)
PGlite is single-connection, so `SELECT ... FOR UPDATE` does not exercise real lock contention. Use a real PostgreSQL instance when testing concurrent transactions:
```typescript
import { postgresAdapter } from "@porulle/adapter-postgres";
const { app } = await createPluginTestApp(myPlugin(), {
databaseAdapter: postgresAdapter({
connectionString: process.env.TEST_DATABASE_URL ?? "postgres://localhost:5432/uc_test",
}),
});
```
## Integration tests (live server)
[Section titled “Integration tests (live server)”](#integration-tests-live-server)
For tests that exercise the full middleware stack — auth middleware, rate limiting, CORS, and all core routes — use the `api()` helper pattern:
```typescript
const BASE = "http://localhost:4000";
const DEV_KEY = "dev-staff-key";
async function api(method: string, path: string, body?: unknown, opts?: { noAuth?: boolean }) {
const headers: Record = {
"content-type": "application/json",
"origin": BASE,
};
if (!opts?.noAuth) headers["x-api-key"] = DEV_KEY;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
return { status: res.status, data };
}
```
This requires a running server and seeded database. Start the server separately and run tests in another terminal.
## CI integration
[Section titled “CI integration”](#ci-integration)
.github/workflows/test.yml
```yaml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: cd packages/core && bun test
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: my_store
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: DATABASE_URL=postgres://postgres:postgres@localhost/my_store bunx drizzle-kit push
- run: bun run src/server.ts &
- run: sleep 5
- run: bun test
```
## Load tests with k6
[Section titled “Load tests with k6”](#load-tests-with-k6)
Install k6:
```bash
brew install k6 # macOS
```
A k6 script that exercises realistic ecommerce traffic:
test/load/k6-load-test.js
```js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "15s", target: 20 },
{ duration: "60s", target: 50 },
{ duration: "30s", target: 100 },
{ duration: "30s", target: 0 },
],
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"],
http_req_failed: ["rate<0.10"],
},
};
const BASE = "http://localhost:4000";
const HEADERS = { "x-api-key": "dev-staff-key", "Content-Type": "application/json" };
export default function () {
const res = http.get(`${BASE}/api/catalog/entities`, { headers: HEADERS });
check(res, { "status 200": (r) => r.status === 200 });
sleep(1);
}
```
**Expected baseline on developer hardware (M-series Mac, local PostgreSQL):**
\| Metric | Value | |--------|-------| | Median latency | \~9ms | | p95 latency | \~164ms | | p99 latency | \~470ms | | Server errors | 0% |
**Rate limiting note:** Load tests run all requests from `localhost`. Set `config.rateLimits` values high (e.g., 100000) in your test config to avoid 429 responses masking throughput results.
## Related
[Section titled “Related”](#related)
* [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — end-to-end example including `createPluginTestApp` usage
* [TypeScript Patterns](/extending/typescript/) — type-safe test setup
* [Plugin API Reference](/reference/plugins/) — `createPluginTestApp` options
# TypeScript Patterns
> Type-safe DB access, PluginDb, and avoiding double-casts in plugin and hook code.
## Use `PluginDb` for database access in plugins
[Section titled “Use PluginDb for database access in plugins”](#use-plugindb-for-database-access-in-plugins)
Plugin routes and services receive a typed Drizzle database instance. Import `PluginDb` from core — never define your own `PgDatabase` type.
src/plugins/loyalty-plugin.ts
```ts
import type { PluginDb } from "@porulle/core";
class LoyaltyService {
constructor(
private db: PluginDb,
private tierThresholds: { silver: number; gold: number; platinum: number },
) {}
async getPoints(orgId: string, customerId: string) {
return this.db
.select()
.from(loyaltyPoints)
.where(and(eq(loyaltyPoints.orgId, orgId), eq(loyaltyPoints.customerId, customerId)));
}
}
```
In a plugin’s `routes` callback, `ctx.database.db` is already typed as `PluginDb`. Use it directly:
```ts
routes: (ctx) => {
const db = ctx.database.db; // typed as PluginDb — no cast needed
const service = new LoyaltyService(db, options.tierThresholds);
return buildRoutes(service, ctx);
},
```
If you see `ctx.database.db as unknown as Db` anywhere, remove it.
## Use `PluginTxFn` for transactions
[Section titled “Use PluginTxFn for transactions”](#use-plugintxfn-for-transactions)
If your service needs transactions, accept `PluginTxFn`:
```ts
import type { PluginDb, PluginTxFn } from "@porulle/core";
class GiftCardService {
constructor(
private db: PluginDb,
private txFn: PluginTxFn,
) {}
async debitWithLock(orgId: string, code: string, amount: number) {
return this.txFn(async (tx) => {
// tx is typed as PluginDb — use it directly
await tx
.update(giftCards)
.set({ balance: sql`balance - ${amount}` })
.where(and(eq(giftCards.orgId, orgId), eq(giftCards.code, code)));
});
}
}
```
## Two DB types — know which to use
[Section titled “Two DB types — know which to use”](#two-db-types--know-which-to-use)
\| Type | Schema | Use in | |------|--------|--------| | `DrizzleDatabase` | `PgDatabase` — full core schema | Core repositories, kernel services | | `PluginDb` | `PgDatabase>` — opaque | Plugins, hooks, jobs |
Core repositories need the concrete schema type for relational queries (`db.query.sellableEntities`). Plugins don’t — they import their own table definitions and use `db.select().from(myTable)`.
The single cast from `PluginDb` to `DrizzleDatabase` happens once in the kernel at boot time — not in service or plugin code.
## Avoid `as unknown as`
[Section titled “Avoid as unknown as”](#avoid-as-unknown-as)
The codebase follows a rule borrowed from tRPC and oRPC: **double-casts are only permitted at third-party SDK boundaries**, never in internal type pipelines.
**Permitted** (third-party boundary):
```ts
// Better Auth's return type is a generic plugin union we can't express.
return auth as unknown as AuthInstance;
```
**Not permitted** (internal code):
```ts
// BAD — ctx.database.db is already PluginDb
const db = ctx.database.db as unknown as Db;
// GOOD — use directly
const db = ctx.database.db;
```
If TypeScript forces a double-cast on code you own, the type design is wrong. Fix the interface, don’t add a cast. When you must double-cast at a third-party boundary, add a comment identifying which upstream gap requires it.
## Proxy for lazy initialization
[Section titled “Proxy for lazy initialization”](#proxy-for-lazy-initialization)
If a service isn’t available at registration time (hooks register before routes), use a Proxy with `Reflect.get`:
```ts
const lazyService = new Proxy({} as GiftCardService, {
get(_target, prop, receiver) {
if (!serviceRef.current) {
throw new Error("Service not initialized — hooks ran before routes()");
}
return Reflect.get(serviceRef.current, prop, receiver);
},
});
```
This avoids `as unknown as Record` when accessing dynamic properties.
## Handle Drizzle `null` vs `undefined`
[Section titled “Handle Drizzle null vs undefined”](#handle-drizzle-null-vs-undefined)
Drizzle returns `null` for nullable columns, never `undefined`. Use loose equality (`!= null`) to catch both:
```ts
// GOOD — catches both null and undefined
if (variantId != null) {
query = query.where(eq(inventoryLevels.variantId, variantId));
} else {
query = query.where(isNull(inventoryLevels.variantId));
}
```
```ts
// BAD — null passes this check; eq(col, null) generates `col = NULL` (always 0 rows)
if (variantId !== undefined) {
query = query.where(eq(inventoryLevels.variantId, variantId));
}
```
Never pass `null` to Drizzle’s `eq()`. Use `isNull(column)` for IS NULL checks.
## Type hook context
[Section titled “Type hook context”](#type-hook-context)
When building a `HookContext`, pass structured fields instead of casting the kernel:
```ts
// GOOD — pass only the fields the function needs
const context = createHookContext({
actor,
logger: kernel.logger,
services: kernel.services as ServiceContainer,
context: { moduleName: "checkout" },
origin: "rest",
kernel: { database: { db: kernel.database.db } },
});
```
```ts
// BAD — casts entire kernel through unknown
kernel: kernel as unknown as { database: { db: PluginDb } },
```
## Run type checks
[Section titled “Run type checks”](#run-type-checks)
```bash
# Full project
bun run check-types
# Single package
cd packages/core && npx tsc --noEmit
```
The type-check catches casts that are no longer necessary. If TypeScript reports “Conversion of type X to type Y may be a mistake”, the types are already compatible — remove the `as unknown as` and use a direct `as` or no cast at all.
## Related
[Section titled “Related”](#related)
* [Plugin Architecture](/concepts/plugin-architecture/) — plugin type model and `defineCommercePlugin`
* [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — end-to-end plugin with typed DB access
* [Hook System Reference](/reference/hooks/) — `HookContext`, `BeforeHook`, `AfterHook` types
# Frontend Integration
> Wiring Porulle to your storefront — Next.js, TanStack Start, the typed SDK client, OpenAPI types.
Porulle is a backend framework. Your storefront calls its REST API. This section covers the established patterns for wiring the API into a TypeScript frontend with full type safety end-to-end.
[Next.js ](/frontend/nextjs/)Mount Porulle as a Hono app inside a Next.js route handler, share types between server components and client components.
[TanStack Start ](/frontend/tanstack-start/)Server functions calling Porulle's LocalAPI in-process — zero HTTP overhead.
[Typed SDK Client ](/frontend/sdk/)@porulle/sdk plus React Query hooks. Compile-time validated requests and responses against your OpenAPI spec.
## The general pattern
[Section titled “The general pattern”](#the-general-pattern)
Porulle exposes its API at `/api/*` (mounted by `createServer`). Every route is documented in the OpenAPI spec served at `/api/doc`. Generate TypeScript types from that spec, then call the API through `@porulle/sdk` for end-to-end type safety:
```bash
bunx openapi-typescript http://localhost:4000/api/doc -o src/generated/api-types.ts
```
```ts
import { createClient } from "@porulle/sdk";
import type { paths } from "./generated/api-types";
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
});
const { data } = await client.GET("/api/catalog/entities", {
params: { query: { type: "product", limit: 10 } },
});
```
## Where to next
[Section titled “Where to next”](#where-to-next)
* [**Reference → REST API**](/reference/rest-api/) — every endpoint
* [**Building a Store**](/building/) — task-focused server-side guides
* [**Concepts → Architecture**](/concepts/architecture/) — why the kernel is interface-agnostic
# Next.js
> Mount Porulle inside a Next.js App Router project.
This guide mounts the Porulle Hono app inside a Next.js App Router catch-all route. The result: your Next.js frontend and the commerce API run in the same process, with no separate server, proxy, or CORS configuration.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/core @porulle/adapter-postgres hono postgres drizzle-orm
bun add -d drizzle-kit
```
## Create the commerce config
[Section titled “Create the commerce config”](#create-the-commerce-config)
commerce.config.ts
```ts
import { defineConfig, Ok, type PaymentAdapter } from "@porulle/core";
import { postgresAdapter } from "@porulle/adapter-postgres";
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://localhost:5432/my_store";
const mockPayments: PaymentAdapter = {
providerId: "mock-payments",
async createPaymentIntent(p) {
return Ok({ id: `pi_${Date.now()}`, status: "requires_capture", amount: p.amount, currency: p.currency, clientSecret: `secret_${Date.now()}` });
},
async capturePayment(id, amount) { return Ok({ id, status: "succeeded", amountCaptured: amount ?? 0 }); },
async refundPayment(_id, amount) { return Ok({ id: `re_${Date.now()}`, status: "succeeded", amountRefunded: amount }); },
async cancelPaymentIntent() { return Ok(undefined); },
async verifyWebhook() { return Ok({ id: "evt_mock", type: "payment.succeeded", data: {} }); },
};
export default defineConfig({
storeName: "My Store",
database: { provider: "postgresql" },
databaseAdapter: postgresAdapter({ connectionString: DATABASE_URL }),
auth: {
requireEmailVerification: false,
apiKeys: { enabled: true },
trustedOrigins: ["http://localhost:3000"],
},
payments: [mockPayments],
});
```
Replace `mockPayments` with a real adapter before production. See the [Payment Adapter guide](/extending/payment-adapter/).
## Mount as a Next.js API route
[Section titled “Mount as a Next.js API route”](#mount-as-a-nextjs-api-route)
Create a catch-all route that delegates all `/api/*` requests to the Hono app:
src/app/api/\[\[...route]]/route.ts
```ts
import { handle } from "hono/vercel";
import { createServer } from "@porulle/core";
import config from "../../../../commerce.config";
const { app } = await createServer(config);
export const GET = handle(app);
export const POST = handle(app);
export const PUT = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
```
All Porulle routes are now available at `/api/*` inside your Next.js app.
## Add drizzle.config.ts
[Section titled “Add drizzle.config.ts”](#add-drizzleconfigts)
drizzle.config.ts
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://localhost:5432/my_store",
},
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/plugin-*/src/schema.ts",
],
});
```
## Push the database schema
[Section titled “Push the database schema”](#push-the-database-schema)
```bash
createdb my_store
bunx drizzle-kit push --config drizzle.config.ts
```
## Verify
[Section titled “Verify”](#verify)
```bash
bun run dev
curl http://localhost:3000/api/catalog/entities
```
Expected: `{ "success": true, "data": [] }`. The OpenAPI spec is at `http://localhost:3000/api/doc`.
## Exclude native binaries from Turbopack
[Section titled “Exclude native binaries from Turbopack”](#exclude-native-binaries-from-turbopack)
If Turbopack fails to bundle `postgres` or other native packages:
next.config.ts
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
"@porulle/core",
"drizzle-orm",
"postgres",
],
};
export default nextConfig;
```
## Add the typed SDK
[Section titled “Add the typed SDK”](#add-the-typed-sdk)
With the dev server running, generate types from your OpenAPI spec:
```bash
bun add @porulle/sdk @tanstack/react-query openapi-react-query
bun add -d openapi-typescript
bunx @porulle/sdk generate --url http://localhost:3000/api/doc --output src/generated/api-types.ts
```
Create a typed client. Use an empty `baseUrl` so SDK calls route through the same Next.js process:
src/lib/commerce.ts
```ts
import { createClient } from "@porulle/sdk";
import { createCommerceHooks } from "@porulle/sdk/react";
import { QueryClient } from "@tanstack/react-query";
import type { paths } from "@/generated/api-types";
export const client = createClient({
baseUrl: "",
auth: { type: "cookie" },
});
export const commerce = createCommerceHooks(client);
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
```
Add a script to `package.json` for regenerating types:
```json
{
"scripts": {
"db:push": "bunx drizzle-kit push --config drizzle.config.ts",
"sdk:generate": "bunx @porulle/sdk generate --url http://localhost:3000/api/doc --output src/generated/api-types.ts"
}
}
```
## Deploy to Vercel
[Section titled “Deploy to Vercel”](#deploy-to-vercel)
The Hono Vercel preset deploys the catch-all route as a serverless function automatically. No additional configuration is needed beyond setting environment variables:
* `DATABASE_URL` — PostgreSQL connection string (Neon, Supabase, etc.)
* `BETTER_AUTH_SECRET` — generate with `openssl rand -hex 32`
* `BETTER_AUTH_URL` — your app’s public URL
See the [Deployment guide](/production/deployment/) for Vercel-specific project settings and the conditional exports requirement.
## Related
[Section titled “Related”](#related)
* [Typed SDK Client guide](/frontend/sdk/) — full SDK usage including React hooks
* [Authentication guide](/building/authentication/) — session cookies and trusted origins for browser requests
* [Deployment guide](/production/deployment/) — Vercel, Cloudflare Workers, Docker
# Typed SDK Client
> Generate types from your OpenAPI spec and make type-safe API calls from TypeScript.
The SDK does not ship pre-generated types. You generate types from your own server’s OpenAPI spec, so the types match your exact configuration — including plugin routes.
```plaintext
Your server → GET /api/doc → openapi-typescript → src/generated/api-types.ts
```
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/sdk
bun add @tanstack/react-query openapi-react-query # for React hooks
bun add -d openapi-typescript # for codegen
```
## Generate types
[Section titled “Generate types”](#generate-types)
Start your server, then run the codegen CLI:
4000/api/doc
```bash
bunx @porulle/sdk generate
# Custom URL
bunx @porulle/sdk generate --url http://localhost:4000/api/doc
# Custom output path
bunx @porulle/sdk generate --output src/generated/api-types.ts
```
This produces a `paths` type covering every route — core and installed plugins. Commit the generated file and regenerate when you add plugins or change routes.
## Create a typed client
[Section titled “Create a typed client”](#create-a-typed-client)
src/lib/commerce.ts
```typescript
import { createClient } from "@porulle/sdk";
import type { paths } from "./generated/api-types";
// API key auth (server-to-server, CI, scripts)
const client = createClient({
baseUrl: "http://localhost:4000",
auth: { type: "api_key", key: "dev-staff-key" },
});
// Bearer token auth (mobile apps, SPAs)
const client = createClient({
baseUrl: "http://localhost:4000",
auth: { type: "bearer", token: sessionToken },
});
```
## Make typed requests
[Section titled “Make typed requests”](#make-typed-requests)
Every path, method, body, query parameter, and response is compile-time validated:
```typescript
// Catalog
const { data } = await client.GET("/api/catalog/entities", {
params: { query: { type: "product", limit: "20" } },
});
// Cart
const { data: cart } = await client.POST("/api/carts", {
body: { currency: "USD" },
});
await client.POST("/api/carts/{id}/items", {
params: { path: { id: cart.data.id } },
body: { entityId: "entity-uuid", quantity: 2 },
});
// Checkout
const { data: order } = await client.POST("/api/checkout", {
body: {
cartId: cart.data.id,
paymentMethodId: "stripe",
currency: "USD",
shippingAddress: {
line1: "123 Main St",
city: "New York",
country: "US",
firstName: "Jane",
lastName: "Doe",
},
},
});
// Plugin routes are fully typed too
const { data: points } = await client.GET("/api/loyalty/points/{customerId}", {
params: { path: { customerId: "..." } },
});
```
If you pass a wrong field name, TypeScript reports it at compile time:
```typescript
await client.POST("/api/catalog/entities", { body: { typo: "product" } });
// ^^^^ TS error
```
## Handle errors
[Section titled “Handle errors”](#handle-errors)
Every method returns `{ data, error, response }`. Only one of `data` or `error` is populated:
```typescript
const { data, error } = await client.GET("/api/catalog/entities/{idOrSlug}", {
params: { path: { idOrSlug: "nonexistent" } },
});
if (error) {
console.log(error.error.code); // "NOT_FOUND"
console.log(error.error.message); // "Entity not found"
return;
}
// data is guaranteed non-null here
console.log(data.data.id);
```
## React hooks
[Section titled “React hooks”](#react-hooks)
src/lib/commerce.ts
```typescript
import { createClient } from "@porulle/sdk";
import { createCommerceHooks } from "@porulle/sdk/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { paths } from "./generated/api-types";
const client = createClient({ baseUrl: "", auth: { type: "cookie" } });
const commerce = createCommerceHooks(client);
const queryClient = new QueryClient();
function App() {
return (
);
}
```
Use `commerce.useQuery` for reads and `commerce.useMutation` for writes:
```typescript
function ProductList() {
const { data, isLoading } = commerce.useQuery("get", "/api/catalog/entities", {
params: { query: { type: "product", limit: "20" } },
});
if (isLoading) return
Loading...
;
return (
{data?.data.map((product) => (
{product.slug}
))}
);
}
function AddToCartButton({ cartId, entityId }: { cartId: string; entityId: string }) {
const addItem = commerce.useMutation("post", "/api/carts/{id}/items");
return (
);
}
```
## Next.js in-process mount
[Section titled “Next.js in-process mount”](#nextjs-in-process-mount)
When the Hono app is mounted inside a Next.js catch-all route, use an empty `baseUrl` so SDK calls route through the same process:
```typescript
const client = createClient({ baseUrl: "", auth: { type: "cookie" } });
```
API calls from client components go to `/api/...` which Next.js routes to your Hono handler. No CORS, no proxy, no separate port.
## Untyped convenience wrapper
[Section titled “Untyped convenience wrapper”](#untyped-convenience-wrapper)
For one-off scripts where you don’t need compile-time validation, `createSDK()` provides domain namespaces without codegen:
```typescript
import { createSDK } from "@porulle/sdk";
const sdk = createSDK({ baseUrl: "http://localhost:4000", auth: { type: "api_key", key: "..." } });
await sdk.catalog.list({ type: "product" });
await sdk.cart.create({ currency: "USD" });
```
Bodies and responses are untyped. For production code, always use `createClient()` with generated types.
## Regenerate types
[Section titled “Regenerate types”](#regenerate-types)
After adding, removing, or modifying routes:
```bash
bun run dev # start server
bunx @porulle/sdk generate # regenerate types
git add src/generated/api-types.ts # commit
```
Add a `sdk:generate` script to `package.json` to make this repeatable:
```json
{
"scripts": {
"sdk:generate": "bunx @porulle/sdk generate --url http://localhost:4000/api/doc --output src/generated/api-types.ts"
}
}
```
## Offline queue
[Section titled “Offline queue”](#offline-queue)
POS and field clients often lose connectivity mid-shift. `OfflineQueue` is an offline-first operation queue: you enqueue write operations (like a sale) while offline, and they replay FIFO on reconnect. Each op is stamped with an idempotency key that pairs with core’s order/checkout replay, so a queued sale lands exactly once — even if it was partially sent before the connection dropped.
### Create a queue
[Section titled “Create a queue”](#create-a-queue)
```typescript
import { OfflineQueue, webStorage } from "@porulle/sdk";
const queue = new OfflineQueue({
baseUrl: "http://localhost:4000",
headers: { "x-api-key": "dev-staff-key" },
storage: webStorage(localStorage),
});
```
`storage` defaults to `memoryStorage()` (volatile); pass `webStorage(localStorage)` to persist queued ops across restarts. Other options: `maxAttempts` (default 5), `backoffMs` (default 1000), `autoFlush` (default true — flushes on the window `online` event when `addEventListener` exists), and `idempotencyField` (default `"idempotencyKey"`, the body field the op id is stamped into).
### Enqueue while offline, flush on reconnect
[Section titled “Enqueue while offline, flush on reconnect”](#enqueue-while-offline-flush-on-reconnect)
`enqueue` persists the op — it does not send. Delivery happens on `flush()`, the `online` event, or the backoff timer:
```typescript
// Persist a sale while offline
await queue.enqueue("/api/checkout", { cartId, paymentMethodId, currency });
// Watch queue changes (pending count, failures)
const unsubscribe = queue.onChange((s) => {
console.log(`${s.pending} pending, ${s.failed} failed`);
});
// Drain pending ops FIFO when back online
await queue.flush();
```
The op id (your `idempotencyKey` or an auto-generated one) is written into the body under `idempotencyField`, so a replay never double-charges. `enqueue` accepts an optional `{ method, idempotencyKey }` — `method` defaults to `"POST"`.
### Retry, backoff, and failures
[Section titled “Retry, backoff, and failures”](#retry-backoff-and-failures)
On a 2xx the op is removed from the queue. A network throw stops the drain and schedules a backoff retry — `min(backoffMs * 2 ** (attempts-1), 60000)`. Retryable HTTP statuses (>=500, 408, 429) retry with exponential backoff up to `maxAttempts`, then mark the op `failed`; all other 4xx go straight to `failed` with the server error body captured in `lastError`. There are only two states — `pending` and `failed` — since successful ops are deleted, not marked done. Use `retry(id)` to re-queue a failed op (resets attempts and triggers a flush) or `drop(id)` to remove it. Call `dispose()` to clear timers and detach the online listener.
Queued ops only survive a restart when you pass `webStorage` — `memoryStorage()` is volatile and clears on reload.
## Related
[Section titled “Related”](#related)
* [Point of Sale](/building/pos/) — the in-store surface the queue backs
* [Next.js guide](/frontend/nextjs/) — in-process mounting and SDK setup
* [TanStack Start guide](/frontend/tanstack-start/) — same pattern for TanStack Start
* [REST API Reference](/reference/rest-api/) — all endpoints and response shapes
# TanStack Start
> Mount Porulle inside a TanStack Start project.
This guide mounts the Porulle Hono app inside a TanStack Start project. TanStack Start uses Vinxi (Vite + server-side rendering) with file-based routing. The pattern is similar to the Next.js integration but uses TanStack’s API route convention.
## Install
[Section titled “Install”](#install)
```bash
bun add @porulle/core @porulle/adapter-postgres hono postgres drizzle-orm
bun add -d drizzle-kit
```
## Create the commerce config
[Section titled “Create the commerce config”](#create-the-commerce-config)
commerce.config.ts
```ts
import { defineConfig, Ok, type PaymentAdapter } from "@porulle/core";
import { postgresAdapter } from "@porulle/adapter-postgres";
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://localhost:5432/my_store";
const mockPayments: PaymentAdapter = {
providerId: "mock-payments",
async createPaymentIntent(p) {
return Ok({ id: `pi_${Date.now()}`, status: "requires_capture", amount: p.amount, currency: p.currency, clientSecret: `secret_${Date.now()}` });
},
async capturePayment(id, amount) { return Ok({ id, status: "succeeded", amountCaptured: amount ?? 0 }); },
async refundPayment(_id, amount) { return Ok({ id: `re_${Date.now()}`, status: "succeeded", amountRefunded: amount }); },
async cancelPaymentIntent() { return Ok(undefined); },
async verifyWebhook() { return Ok({ id: "evt_mock", type: "payment.succeeded", data: {} }); },
};
export default defineConfig({
storeName: "My Store",
database: { provider: "postgresql" },
databaseAdapter: postgresAdapter({ connectionString: DATABASE_URL }),
auth: {
requireEmailVerification: false,
apiKeys: { enabled: true },
trustedOrigins: ["http://localhost:3000"],
},
payments: [mockPayments],
});
```
Replace `mockPayments` with a real adapter before production. See the [Payment Adapter guide](/extending/payment-adapter/).
## Mount as a TanStack Start API route
[Section titled “Mount as a TanStack Start API route”](#mount-as-a-tanstack-start-api-route)
TanStack Start uses `app/routes/api/` for server-side API handlers. Create a wildcard route to delegate all `/api/*` requests to the Hono app:
app/routes/api/$.ts
```ts
import { createAPIFileRoute } from "@tanstack/start/api";
import { createServer } from "@porulle/core";
import config from "../../../commerce.config";
const { app } = await createServer(config);
export const APIRoute = createAPIFileRoute("/api/$")({
GET: ({ request }) => app.fetch(request),
POST: ({ request }) => app.fetch(request),
PUT: ({ request }) => app.fetch(request),
PATCH: ({ request }) => app.fetch(request),
DELETE: ({ request }) => app.fetch(request),
});
```
All Porulle routes are now available at `/api/*` inside your TanStack Start app.
## Add drizzle.config.ts
[Section titled “Add drizzle.config.ts”](#add-drizzleconfigts)
drizzle.config.ts
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://localhost:5432/my_store",
},
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/plugin-*/src/schema.ts",
],
});
```
## Push the database schema
[Section titled “Push the database schema”](#push-the-database-schema)
```bash
createdb my_store
bunx drizzle-kit push --config drizzle.config.ts
```
## Verify
[Section titled “Verify”](#verify)
```bash
bun run dev
curl http://localhost:3000/api/catalog/entities
```
Expected: `{ "success": true, "data": [] }`. The OpenAPI spec is at `http://localhost:3000/api/doc`.
## Add the typed SDK
[Section titled “Add the typed SDK”](#add-the-typed-sdk)
Generate types from your OpenAPI spec with the dev server running:
```bash
bun add @porulle/sdk @tanstack/react-query openapi-react-query
bun add -d openapi-typescript
bunx @porulle/sdk generate --url http://localhost:3000/api/doc --output src/generated/api-types.ts
```
Create a typed client. Use an empty `baseUrl` so SDK calls route through the same TanStack Start process:
src/lib/commerce.ts
```ts
import { createClient } from "@porulle/sdk";
import { createCommerceHooks } from "@porulle/sdk/react";
import { QueryClient } from "@tanstack/react-query";
import type { paths } from "@/generated/api-types";
export const client = createClient({
baseUrl: "",
auth: { type: "cookie" },
});
export const commerce = createCommerceHooks(client);
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
```
## Related
[Section titled “Related”](#related)
* [Next.js guide](/frontend/nextjs/) — same pattern for Next.js App Router
* [Typed SDK Client guide](/frontend/sdk/) — full SDK usage including React hooks
* [Deployment guide](/production/deployment/) — production deployment options
# Install
> Prerequisites, package installation, database setup, and verification.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* **Bun 1.3+** or Node.js 20+
* **PostgreSQL 15+** running locally or remotely
* A package manager: Bun (recommended), npm, pnpm, or yarn
PostgreSQL must be reachable before running `db:push`. Local setup: `brew install postgresql@16 && brew services start postgresql@16`.
## Install packages
[Section titled “Install packages”](#install-packages)
At minimum you need the core engine and a database adapter:
```bash
bun add @porulle/core @porulle/adapter-postgres
```
For a full store with payments, file storage, and search:
```bash
bun add @porulle/core \
@porulle/adapter-postgres \
@porulle/adapter-stripe \
@porulle/adapter-local-storage \
@porulle/adapter-pg-search
```
## Available packages
[Section titled “Available packages”](#available-packages)
| Package | Purpose |
| -------------------------------- | ------------------------------------------------------ |
| `@porulle/core` | Kernel: services, hooks, state machines, auth, runtime |
| `@porulle/cli` | `init`, `dev`, `migrate`, `api-key`, `doctor` commands |
| `@porulle/sdk` | Typed TypeScript client + React Query bindings |
| `@porulle/adapter-postgres` | PostgreSQL database adapter (required) |
| `@porulle/adapter-stripe` | Stripe payment adapter |
| `@porulle/adapter-local-storage` | Local filesystem media storage |
| `@porulle/adapter-s3` | AWS S3 media storage |
| `@porulle/adapter-r2` | Cloudflare R2 media storage |
| `@porulle/adapter-meilisearch` | Meilisearch full-text search |
| `@porulle/adapter-pg-search` | PostgreSQL full-text search (no external service) |
| `@porulle/adapter-resend` | Resend transactional email |
| `@porulle/adapter-ses` | AWS SES transactional email |
| `@porulle/adapter-taxjar` | TaxJar tax calculation |
| `@porulle/adapter-tax-manual` | Flat-rate / manual tax |
| `@porulle/plugin-marketplace` | Multi-vendor marketplace |
| `@porulle/plugin-loyalty` | Points and tiers |
| `@porulle/plugin-reviews` | Product reviews |
| `@porulle/plugin-gift-cards` | Gift card management |
| `@porulle/plugin-pos` | Point-of-sale terminals, shifts, Z-reports |
| `@porulle/plugin-appointments` | Appointment scheduling |
## Database setup
[Section titled “Database setup”](#database-setup)
Create a PostgreSQL database:
```bash
createdb porulle_dev
export DATABASE_URL="postgres://localhost:5432/porulle_dev"
```
Porulle uses [Drizzle ORM](https://orm.drizzle.team/) for schema management. After creating your `commerce.config.ts` (see [Quickstart](/get-started/quickstart/)), create a `drizzle.config.ts`:
drizzle.config.ts
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://localhost:5432/porulle_dev",
},
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-*/src/schema.ts",
],
});
```
Push the schema:
```bash
bunx drizzle-kit push --config drizzle.config.ts
```
This creates all core tables (catalog, inventory, cart, orders, customers, pricing, promotions, fulfillment, media, webhooks, audit, jobs), Better Auth tables (user, session, account, organization, member), and any plugin tables you have installed.
Plugin schemas are discovered automatically via the glob pattern. Adding a new plugin and re-running `db:push` picks up its tables.
## Verify
[Section titled “Verify”](#verify)
Start the server and check the health endpoint:
```bash
bun run dev
curl http://localhost:4000/health
```
```json
{ "status": "ok", "store": "My Store" }
```
The OpenAPI spec is at `GET /api/doc`. The interactive Scalar explorer is at `GET /api/reference`.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Quickstart](/get-started/quickstart/) — create a working store in five minutes
* [Your First Store tutorial](/tutorials/first-store/) — a complete walkthrough with seed data
# Introduction
> What Porulle is, what it provides, and who it is for.
Porulle is a TypeScript-first headless commerce framework. You install it into a TypeScript project the way you install Payload, Inngest, or Temporal — as a library, not a hosted service. It gives you a complete commerce backend wired together through a single `defineConfig`, exposed as a hardened REST API, and extensible through a typed plugin system.
The Tamil root *porul* (பொருள் — *thing / substance / merchandise / meaning*) is embedded in the name.
## What it provides
[Section titled “What it provides”](#what-it-provides)
**Kernel:** catalog, cart, checkout, orders, inventory, pricing, promotions, fulfillment, customers, search, tax, shipping — all coordinated through a single service layer. No orphaned services, no duplicated business logic across routes.
**Adapter pattern:** swap PostgreSQL variants, payment processors (Stripe and others), file storage (local, S3, R2), search engines (Meilisearch, built-in PostgreSQL full-text), tax providers (TaxJar, manual), and email (Resend, SES) behind clean interfaces. The kernel never imports a vendor SDK directly.
**Plugin system:** plugins are config transforms — plain functions that receive `CommerceConfig` and return a modified `CommerceConfig`. They contribute schema tables, hook handlers, REST routes, and analytics models without forking core. First-party plugins: marketplace, loyalty, reviews, gift cards, POS, appointments, and more.
**Hook pipeline:** intercept any operation (catalog create, cart update, checkout, order status change) with typed before/after handlers. Before hooks run inside the database transaction; after hooks run outside it. The checkout pipeline uses a compensation chain so external side-effects (payment capture, inventory decrement, email) can be safely reversed on failure.
**Multi-tenancy:** every row carries `organizationId`. A single-store deployment uses `org_default` silently. A multi-store SaaS configures `storeResolver` to map requests to org IDs. The data isolation test suite (`packages/core/test/multi-org-isolation.test.ts`) verifies cross-org access fails for every operation.
**Security posture:** five-round adversarial audit before extraction. SSRF guards, CSRF, body limits, per-IP and per-account rate limits, `__Secure-` cookies, CSP hook, magic-byte MIME validation, timing-safe webhook verification. Every mutation lands in the audit log.
**OpenAPI:** `GET /api/doc` emits a JSON spec, `GET /api/reference` serves the interactive Scalar explorer. Both include plugin routes automatically.
## Who it is for
[Section titled “Who it is for”](#who-it-is-for)
**Application developers** building storefronts, admin dashboards, POS systems, or marketplaces who want a commerce backend they own and can read.
**Platform engineers** who need a commerce kernel they can extend with project-specific plugins without forking the engine.
**AI engineers** wrapping the REST API in an agent layer. Porulle is REST-only by design; MCP/UCP/ACP shims live above it.
## What it is not
[Section titled “What it is not”](#what-it-is-not)
Porulle is not a hosted service, not a SaaS, and not a no-code platform. You run PostgreSQL. You deploy the server. You write plugins in TypeScript.
It is also not batteries-for-all-use-cases included by default. A tea shop doesn’t need the marketplace plugin. A marketplace doesn’t need kitchen display tickets. Start with `@porulle/core` and add plugins as your use case demands them.
## Stack
[Section titled “Stack”](#stack)
| Layer | Choice |
| -------- | ------------------------------------------------------ |
| Language | TypeScript 5.9, Bun 1.3, Node ≥18 |
| HTTP | Hono (runtime-agnostic: Bun, Node, Cloudflare Workers) |
| Database | PostgreSQL 15+ via Drizzle ORM |
| Auth | Better Auth with organization plugin |
| ORM | Drizzle (schema-as-code, SQL-like query builder) |
| Testing | Vitest + PGlite (real PostgreSQL, in-process) |
## Adopter contracts
[Section titled “Adopter contracts”](#adopter-contracts)
Three documents codify the rules for extending Porulle. Read them before writing a plugin or payment adapter.
* [Plugin Contract](/extending/plugin-contract/) — actor resolution, org scoping, Result contract
* [Payment Adapter Contract](/extending/payment-adapter-contract/) — capture accuracy, webhook verification, idempotency
* [Security Model](/production/security-model/) — threat model, rate limits, cookie hygiene, Phase 2 gaps
## Status
[Section titled “Status”](#status)
v0.1.0 alpha. Stable surface: REST API, multi-tenant kernel, plugin contract, adapter contracts, security model. Unstable / Phase 2: agent-native principal model, multi-protocol gateway (MCP, UCP, ACP), conversation layer, per-region data residency.
This framework was extracted from a production commerce engine after a five-round adversarial security review. Every cross-tenant leak, race condition, IDOR, and information-disclosure surface caught was fixed and pinned with a regression test before this release.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Install](/get-started/install/) — set up the packages and database
* [Quickstart](/get-started/quickstart/) — a working API in five minutes
* [Your First Store tutorial](/tutorials/first-store/) — a complete walkthrough with products, inventory, and checkout
# Quickstart
> A working commerce API in five minutes — three files, two commands, five curl requests.
This guide gets you from zero to a running commerce API with products, cart, and checkout. You will create three files, run two commands, and make five API calls.
For a full walkthrough with seed data, inventory, and multiple entity types, see [Your First Store](/tutorials/first-store/).
## 1. Create the config
[Section titled “1. Create the config”](#1-create-the-config)
Every Porulle app starts with `commerce.config.ts`. This file declares your store’s entity types, adapters, auth, shipping, and plugins.
commerce.config.ts
```ts
import { defineConfig, Ok, type PaymentAdapter } from "@porulle/core";
import { postgresAdapter } from "@porulle/adapter-postgres";
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://localhost:5432/porulle_dev";
const mockPayments: PaymentAdapter = {
providerId: "mock-payments",
async createPaymentIntent(params) {
return Ok({
id: `pi_${Date.now()}`,
status: "requires_capture",
amount: params.amount,
currency: params.currency,
clientSecret: `secret_${Date.now()}`,
});
},
async capturePayment(id, amount) {
return Ok({ id, status: "succeeded", amountCaptured: amount ?? 0 });
},
async refundPayment(_id, amount) {
return Ok({ id: `re_${Date.now()}`, status: "succeeded", amountRefunded: amount });
},
async cancelPaymentIntent() { return Ok(undefined); },
async verifyWebhook() {
return Ok({ id: "evt_mock", type: "payment.succeeded", data: {} });
},
};
export default defineConfig({
storeName: "My Store",
databaseAdapter: postgresAdapter({ connectionString: DATABASE_URL }),
auth: {
requireEmailVerification: false,
apiKeys: { enabled: true },
trustedOrigins: ["http://localhost:4000"],
roles: {
admin: { permissions: ["*:*"] },
customer: {
permissions: [
"catalog:read",
"cart:create", "cart:read", "cart:update",
"orders:create", "orders:read:own",
],
},
},
},
entities: {
product: {
fields: [{ name: "weight", type: "number", unit: "grams" }],
variants: { enabled: true, optionTypes: ["size", "color"] },
fulfillment: "physical",
},
},
shipping: {
type: "flat",
flatRate: 500,
freeShippingThreshold: 10000,
brackets: [],
fallbackCost: 500,
},
payments: [mockPayments],
});
```
## 2. Create the server
[Section titled “2. Create the server”](#2-create-the-server)
src/server.ts
```ts
import { serve } from "@hono/node-server";
import { createServer } from "@porulle/core";
import config from "../commerce.config.js";
const PORT = Number(process.env.PORT ?? 4000);
const app = createServer(await config);
app.get("/health", (c) => c.json({ status: "ok" }));
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`Store running at http://localhost:${info.port}`);
});
```
## 3. Create the Drizzle config
[Section titled “3. Create the Drizzle config”](#3-create-the-drizzle-config)
drizzle.config.ts
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://localhost:5432/porulle_dev",
},
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
],
});
```
## 4. Push schema and start
[Section titled “4. Push schema and start”](#4-push-schema-and-start)
```bash
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts
```
You should see `Store running at http://localhost:4000`.
## 5. Try it
[Section titled “5. Try it”](#5-try-it)
Run these in a new terminal. The `x-api-key` header authenticates as staff using the built-in development key.
```bash
# Create a product
ENTITY=$(curl -s -X POST http://localhost:4000/api/catalog/entities \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"type":"product","slug":"classic-tee","status":"active","metadata":{}}')
ENTITY_ID=$(echo $ENTITY | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo "Entity: $ENTITY_ID"
# Create a cart
CART=$(curl -s -X POST http://localhost:4000/api/carts \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{"currency":"USD"}')
CART_ID=$(echo $CART | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo "Cart: $CART_ID"
# Add an item (price in cents: 2999 = $29.99)
curl -s -X POST "http://localhost:4000/api/carts/$CART_ID/items" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d "{\"entityId\":\"$ENTITY_ID\",\"quantity\":1,\"unitPriceSnapshot\":2999}"
# Checkout
curl -s -X POST http://localhost:4000/api/checkout \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d "{
\"cartId\":\"$CART_ID\",
\"paymentMethodId\":\"mock-payments\",
\"currency\":\"USD\",
\"shippingAddress\":{
\"country\":\"US\",\"postalCode\":\"90210\",
\"city\":\"Beverly Hills\",\"line1\":\"1 Commerce Ave\"
}
}"
```
The checkout response includes an `orderNumber` (e.g., `ORD-2026-000001`), a calculated `grandTotal`, and a `status` of `pending`.
> **Note on the dev API key:** `dev-staff-key` is available only when `NODE_ENV !== "production"`. Production deployments require scoped API keys generated with `bunx @porulle/cli api-key create`. See [Authentication](/building/authentication/).
## What just happened
[Section titled “What just happened”](#what-just-happened)
The checkout pipeline ran eight steps in order: validated the cart, resolved prices, checked inventory (0 stock is fine in this quickstart), applied promotions, calculated tax, calculated shipping ($5 flat rate), authorized payment via the mock adapter, and created the order. Each step is a hook — you can intercept any of them.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Your First Store tutorial](/tutorials/first-store/) — a complete walkthrough with real inventory, seed data, and multiple entity types
* [Build a Loyalty Plugin tutorial](/tutorials/build-a-plugin/) — extend the checkout pipeline with custom logic
* [Configuration reference](/reference/configuration/) — every `defineConfig` option documented
# Running in Production
> Deploy, multi-tenancy, security, observability — the operator's guides.
What an operator needs once the code is written. Porulle is hardened by audit (five-round adversarial security review, all criticals closed) but the wiring depends on you.
[Deploy ](/production/deployment/)Bun, Node.js, Cloudflare Workers, Fly.io. Migration strategy, secrets, health checks.
[Multi-Tenancy ](/production/multi-tenancy/)Org resolution profiles (B2C single-storefront vs B2B multi-tenant), strict mode, store resolver.
[Webhooks and Audit ](/production/webhooks-and-audit/)Outbound webhook delivery, signature verification, audit log, processed-events idempotency.
## Security model
[Section titled “Security model”](#security-model)
Adopter-facing security documentation lives in the [Security Model](/production/security-model/) (mirrored from the canonical [`SECURITY.md`](https://github.com/asyncdotengineering/porulle/blob/main/SECURITY.md) at the repository root) — threat model, what the framework defends against, what it does not, the rate-limit layers, cookie hygiene, CSP recommendations, SSRF guards, audit log scope.
That document is the one to send to a buyer’s security review team.
## Where to next
[Section titled “Where to next”](#where-to-next)
* [**Reference → Configuration**](/reference/configuration/) — every config field
* [**Reference → Job Queue**](/reference/jobs/) — durable claim-based queue, the substrate behind webhook delivery and async work
* [**Concepts → Identity Model**](/concepts/identity-model/) — actor, principal, store resolution
# Deploy to Production
> Run Porulle on Bun, Node.js, Vercel, Cloudflare Workers, or inside a Next.js app.
`createServer(config)` returns a Hono app. Hono is runtime-agnostic — the same app object runs on Bun, Node.js, Vercel, and Cloudflare Workers without deployment-specific packages in your business logic.
## Environment variables
[Section titled “Environment variables”](#environment-variables)
Set these for every deployment target:
.env
```bash
DATABASE_URL=postgres://user:password@host:5432/my_store
BETTER_AUTH_SECRET=