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) => `

Order Confirmed

Hi! Your order #${data.orderId} is confirmed.

Total: ${data.currency} ${(Number(data.total) / 100).toFixed(2)}

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= BETTER_AUTH_URL=https://your-app.example.com PORT=4000 ``` `BETTER_AUTH_SECRET` signs session tokens. `BETTER_AUTH_URL` is the public URL of your app — Better Auth uses it to validate CSRF tokens and configure cookie domains. Never commit either to git. ## Run migrations before deploying [Section titled “Run migrations before deploying”](#run-migrations-before-deploying) Reserve `drizzle-kit push` for development. In production, use generated migration files: ```bash bunx drizzle-kit generate --config drizzle.config.ts bunx drizzle-kit migrate --config drizzle.config.ts ``` Run this step in your CI/CD pipeline before your app starts, or as a one-time pre-deploy command. ## Production auth config [Section titled “Production auth config”](#production-auth-config) Disable the dev API key and lock down trusted origins before going live: commerce.config.ts ```ts export default defineConfig({ auth: { enableDevKey: false, requireEmailVerification: true, trustedOrigins: ["https://mystore.com"], apiKeys: { enabled: true }, roles: { admin: { permissions: ["*:*"] }, customer: { permissions: [ "catalog:read", "cart:create", "cart:read", "cart:update", "orders:create", "orders:read:own", ], }, }, }, }); ``` ## Bun [Section titled “Bun”](#bun) No adapter needed. Hono’s `app.fetch` works directly with Bun’s built-in server: src/server.ts ```ts import { createServer } from "@porulle/core"; import config from "./commerce.config"; const { app } = await createServer(config); export default { port: Number(process.env.PORT ?? 4000), fetch: app.fetch, }; ``` ```bash bun run src/server.ts ``` ## Node.js [Section titled “Node.js”](#nodejs) Install `@hono/node-server` to bridge Hono to Node’s HTTP server: ```bash bun add @hono/node-server ``` src/server.ts ```ts import { serve } from "@hono/node-server"; import { createServer } from "@porulle/core"; import config from "./commerce.config"; const { app } = await createServer(config); serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 4000) }, (info) => { console.log(`Store running at http://localhost:${info.port}`); }); ``` ## Vercel [Section titled “Vercel”](#vercel) Vercel supports Hono natively with zero configuration. Export the app as the default export and set Framework Preset to **Hono** in the Vercel dashboard: src/index.ts ```ts import { createServer } from "@porulle/core"; import config from "../commerce.config.js"; const { app } = await createServer(config); export default app; ``` No `vercel.json`, no `handle()` wrapper, no named HTTP exports needed. **Vercel project settings:** \| Setting | Value | |---------|-------| | Framework Preset | Hono | | Root Directory | `apps/your-app` | | Install Command | `bun install` | | Build Command | auto-detected via Turbo | **Required environment variables:** \| Variable | Description | |----------|-------------| | `DATABASE_URL` | PostgreSQL connection string (Neon, Supabase, etc.) | | `BETTER_AUTH_SECRET` | Signs session tokens. Generate: `openssl rand -hex 32` | | `BETTER_AUTH_URL` | Your app’s public URL (e.g., `https://your-app.vercel.app`) | All workspace packages must have conditional exports pointing `"import"` to compiled `dist/*.js` files, not TypeScript source. Vercel’s bundler cannot process raw TypeScript from `node_modules`. ## Next.js App Router [Section titled “Next.js App Router”](#nextjs-app-router) Create a catch-all API route that delegates all `/api/*` requests to the Hono app: 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); ``` Your Next.js frontend and the commerce API run in the same process. No separate server, no proxy, no CORS. See the [Next.js guide](/frontend/nextjs/) for the full setup including `serverExternalPackages`. ## Cloudflare Workers [Section titled “Cloudflare Workers”](#cloudflare-workers) Workers require a database adapter that works in the Workers runtime (no Node.js `net` module). Use Hyperdrive (TCP pooling at edge) with `postgres`, or the Neon WebSocket driver as a fallback: src/worker.ts ```ts import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { createServer, defineConfig, type DatabaseAdapter } from "@porulle/core"; type Env = { DATABASE_URL: string; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL: string; ORG_ID?: string; HYPERDRIVE?: { connectionString: string }; }; let singleton: { app: { fetch: typeof fetch } } | null = null; export default { async fetch(request: Request, env: Env): Promise { if (!singleton) { const connStr = env.HYPERDRIVE?.connectionString ?? env.DATABASE_URL; const client = postgres(connStr, { prepare: false }); const db = drizzle({ client }); const databaseAdapter: DatabaseAdapter = { provider: "postgresql", db, async transaction(fn: (tx: unknown) => Promise): Promise { return db.transaction(async (tx) => fn(tx)); }, }; const config = await defineConfig({ storeName: "My Store", database: { provider: "postgresql" }, databaseAdapter, auth: { ...(env.ORG_ID ? { defaultOrganizationId: env.ORG_ID } : {}), requireEmailVerification: false, }, }); singleton = await createServer(config); } return singleton.app.fetch(request); }, }; ``` wrangler.toml ```toml name = "my-store" main = "src/worker.ts" compatibility_flags = ["nodejs_compat"] compatibility_date = "2026-04-01" [[hyperdrive]] binding = "HYPERDRIVE" id = "" ``` ```bash npx wrangler hyperdrive create my-store-db \ --connection-string="postgres://user:pass@host:5432/dbname" npx wrangler secret put DATABASE_URL npx wrangler secret put BETTER_AUTH_SECRET npx wrangler secret put BETTER_AUTH_URL npx wrangler deploy ``` ## Docker [Section titled “Docker”](#docker) Dockerfile ```dockerfile FROM oven/bun:1 WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bunx drizzle-kit migrate --config drizzle.config.ts EXPOSE 4000 CMD ["bun", "run", "src/server.ts"] ``` ```bash docker build -t mystore . docker run -p 4000:4000 -e DATABASE_URL=postgres://... mystore ``` ## Related [Section titled “Related”](#related) * [Authentication guide](/building/authentication/) — production auth config in detail * [Multi-Tenancy guide](/production/multi-tenancy/) — `storeResolver` for SaaS deployments * [Next.js guide](/frontend/nextjs/) — full Next.js App Router setup # Multi-Tenancy > Run multiple isolated stores on one Porulle instance using organization-scoped data. Every Porulle instance starts with one organization. For single-store deployments, you never need to think about organizations beyond the initial seed. For multi-tenant SaaS, organizations are the isolation boundary — each organization is a fully independent store sharing one database and one running process. For the conceptual design — why organizations map to stores, how shared tables work, and composite unique constraints — see [Identity and Store Resolution](/concepts/identity-model/). ## Single-store setup [Section titled “Single-store setup”](#single-store-setup) Create the organization in your seed script and store the ID in your config: scripts/seed.ts ```ts const org = await commerce.auth.api.createOrganization({ body: { name: "My Store", slug: "my-store", userId: adminUserId, }, }); // Write to .env: PORULLE_ORG_ID= ``` commerce.config.ts ```ts auth: { defaultOrganizationId: process.env.PORULLE_ORG_ID, } ``` ## Create additional organizations [Section titled “Create additional organizations”](#create-additional-organizations) To add more stores, insert organization rows. Better Auth provides a REST endpoint: ```bash curl -X POST http://localhost:4000/api/auth/organization/create \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"name": "Alpha Streetwear", "slug": "alpha-streetwear"}' ``` Or insert directly via SQL: src/scripts/create-orgs.ts ```ts import { sql } from "drizzle-orm"; // db = kernel.database.db await db.execute(sql` INSERT INTO organization (id, name, slug, created_at) VALUES ('org_alpha', 'Alpha Streetwear', 'alpha-streetwear', NOW()) ON CONFLICT (id) DO NOTHING `); ``` ## Scope actors to an organization [Section titled “Scope actors to an organization”](#scope-actors-to-an-organization) Every API request carries an actor. The actor’s `organizationId` determines which store’s data is visible. All service calls resolve the org from the actor internally — you never pass `orgId` as a separate parameter. ```ts const alphaAdmin = { type: "user" as const, userId: "alpha-admin", organizationId: "org_alpha", role: "admin", permissions: ["*:*"], email: "admin@alpha.com", name: "Alpha Admin", vendorId: null, }; await kernel.services.catalog.create( { type: "product", slug: "summer-tee", metadata: {} }, alphaAdmin, ); ``` ## Composite unique constraints [Section titled “Composite unique constraints”](#composite-unique-constraints) Unique constraints on slug, code, and email are composite with `organizationId`. Two organizations can have the same product slug or warehouse code: ```ts // Both succeed — composite unique (organizationId, slug) await kernel.services.catalog.create( { type: "product", slug: "summer-special", metadata: {} }, alphaAdmin, ); await kernel.services.catalog.create( { type: "product", slug: "summer-special", metadata: {} }, betaAdmin, ); ``` ## Store resolution for SaaS [Section titled “Store resolution for SaaS”](#store-resolution-for-saas) For multi-tenant SaaS where each subdomain or header maps to a different store, configure `storeResolver`: commerce.config.ts ```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. This prevents accidental cross-store data access when the resolver can’t determine the store. ## Define plugin tables with `defineTable` [Section titled “Define plugin tables with defineTable”](#define-plugin-tables-with-definetable) Use `defineTable` from `@porulle/db` for plugin-owned tables that need multi-tenant scoping. It auto-injects `id`, `organizationId`, `createdAt`, `updatedAt`, org index, and composite unique constraints: src/schema.ts ```ts import { defineTable, column } from "@porulle/db"; export const giftCards = defineTable("gift_cards", { code: column.text({ unique: true }), balance: column.integer(), currency: column.text(), }); export const giftCardTransactions = defineTable("gift_card_transactions", { giftCardId: column.uuid({ references: giftCards }), amount: column.integer(), type: column.text({ enum: ["debit", "credit", "refund"] }), }); ``` Child tables whose parent has `organizationId` do not get a second `organizationId` — the FK relationship provides the scoping implicitly. ## Write plugin routes with the scoped DB [Section titled “Write plugin routes with the scoped DB”](#write-plugin-routes-with-the-scoped-db) Route handlers receive a `db` that auto-stamps `organizationId` on INSERT operations: ```ts r.post("/") .permission("my-plugin:admin") .handler(async ({ db, input, actor }) => { const [card] = await db.insert(giftCards) .values({ code: input.code, balance: input.amount }) .returning(); return card; }); ``` ## Verify data isolation [Section titled “Verify data isolation”](#verify-data-isolation) Query the database to confirm products are scoped to their organization: ```sql SELECT o.name, COUNT(e.id) AS products FROM organization o LEFT JOIN sellable_entities e ON e.organization_id = o.id GROUP BY o.name; ``` ## Related [Section titled “Related”](#related) * [Identity and Store Resolution](/concepts/identity-model/) — conceptual design of org isolation * [Authentication guide](/building/authentication/) — `storeResolver` and `strictOrgResolution` in detail * [Plugin API Reference](/reference/plugins/) — `defineTable` API and `@porulle/db` exports # Security Model > Threat model, what Porulle defends against, configuration profiles, audit log scope. Canonical source This page mirrors [`SECURITY.md`](https://github.com/asyncdotengineering/porulle/blob/main/SECURITY.md) at the repository root. The root file is canonical for vulnerability disclosure (GitHub surfaces it in the Security tab); this page is the searchable mirror for adopter security review teams. Adopter-facing security documentation for Porulle. This document describes what the framework defends against, how it is configured, and what it does not cover. ## Threat model [Section titled “Threat model”](#threat-model) The framework defends against: * OWASP Top 10 (injection, broken auth, sensitive data exposure, XXE, broken access control, misconfig, XSS, deserialization, known-vuln components, logging gaps) * OWASP Business Logic Top 10 (mass assignment, race conditions, IDOR, privilege escalation) * Commerce-specific severity classes: cross-tenant data leak, cross-customer IDOR, payment over-refund, inventory double-release, order status race, webhook forgery The framework does **not** defend against: * **Magecart at adopter checkout pages.** Requires CSP configuration by the adopter. The framework provides the hook (`config.security.csp`); the adopter must use it. * **Data residency.** Single-region database today. No per-org region routing. Multi-region data sovereignty is Phase 2. * **Agent identity verification.** The `Actor` type has no agent principal. API keys are the only non-human identity. Agent attestation (Web Bot Auth, KYA) is Phase 2. ## Org resolution profiles [Section titled “Org resolution profiles”](#org-resolution-profiles) The framework supports two tenant resolution modes. ### B2C single-storefront [Section titled “B2C single-storefront”](#b2c-single-storefront) ```ts auth: { defaultOrganizationId: "org_default", } ``` Customers without explicit org membership fall through to the default org. All data is scoped to one tenant. Used in `apps/store-example/commerce.config.ts`. This is the right mode for single-store deployments. ### B2B multi-tenant [Section titled “B2B multi-tenant”](#b2b-multi-tenant) ```ts auth: { strictOrgResolution: true, storeResolver: async (request) => { const storeId = request.headers.get("x-store-id"); return storeId ?? null; }, } ``` No fallback. Customers must be members of an org via Better Auth’s organization plugin. The `storeResolver` callback maps incoming requests to org IDs (header-based, domain-based, or path-based). If resolution fails and `strictOrgResolution` is true, the request is rejected with HTTP 503. Production B2B deployments must configure `storeResolver`. Without it, all requests fall into the same default org and cross-tenant isolation breaks. Reference: `packages/core/src/auth/org.ts`. ## Rate limit layers [Section titled “Rate limit layers”](#rate-limit-layers) Four rate limiters are applied in sequence. All are per-IP unless noted. Defaults can be overridden via `config.rateLimits`. \| Layer | Scope | Default | Config key | |---|---|---|---| | `/api/auth/*` | Per-IP | 10/min | `rateLimits.auth` | | `/api/auth/sign-in/email` | Per-email (SHA-256 keyed) | 10/15min | `rateLimits.signInPerEmail` | | `/api/checkout` | Per-IP | 5/min | `rateLimits.checkout` | | `/api/*` | Per-IP | 100/min | `rateLimits.api` | The per-email limiter hashes the email with SHA-256 before using it as the rate limit key, so raw emails are not stored in the rate limit state. Reference: `packages/core/src/runtime/server.ts:47--51`. **Limitation:** Rate limit storage is in-memory. Limits are per-process and do not hold across multiple instances. For multi-instance deployments (ECS, Cloud Run, multiple Fly machines), a `RateLimitStoreAdapter` backed by Redis or Durable Objects is needed. This is not yet implemented. See agent-native audit Gap F4. ## Cookie hygiene [Section titled “Cookie hygiene”](#cookie-hygiene) Session cookies are configured with: * `__Secure-` prefix in production (via `useSecureCookies: true` when `NODE_ENV === "production"`) * `HttpOnly` (managed by Better Auth) * `Secure` flag in production * `SameSite: lax` — blocks CSRF on POST/PUT/DELETE while allowing top-level GET navigation (needed for OAuth redirects) Reference: `packages/core/src/auth/setup.ts:157--166`. ## CSP recommendations [Section titled “CSP recommendations”](#csp-recommendations) The framework exposes `config.security.csp` for Content-Security-Policy header injection. ```ts security: { csp: { default: "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", perRoute: { "/api/checkout": "default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }, }, }, ``` The `/api/checkout` route should have a strict CSP when integrating Stripe Elements or Braintree Hosted Fields. The policy above allows only the provider’s script and frame origins. Adjust for your payment provider. Reference: `packages/core/src/runtime/server.ts:252--265`. ## Trusted origins and CSRF [Section titled “Trusted origins and CSRF”](#trusted-origins-and-csrf) `config.auth.trustedOrigins` sets the allowed origins for CORS and CSRF protection. In development, `http://localhost:*` is allowed by default. In production, an empty `trustedOrigins` array blocks all cross-origin requests. CSRF middleware is scoped to `/api/*` via Hono’s `csrf()` middleware. Better Auth handles CSRF on `/api/auth/*` routes separately. Reference: `packages/core/src/runtime/server.ts:168--184`. ## Body limit [Section titled “Body limit”](#body-limit) 1 MB default via Hono’s `bodyLimit` middleware. Applied globally. ```ts app.use("*", bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.json({ error: { code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1MB limit." } }, 413), })); ``` Override for media uploads is not yet a first-class config option. The media module has `config.media.allowedMimeTypes` and `config.media.allowSvg` for MIME validation, but no separate body size override. Reference: `packages/core/src/runtime/server.ts:187--192`. ## SSRF guards [Section titled “SSRF guards”](#ssrf-guards) Webhook URL registration validates against private, loopback, and metadata IPs: * Loopback: `127.x.x.x`, `::1`, `localhost` * Link-local: `169.254.x.x`, `fe80:` * Private (RFC 1918): `10.x`, `172.16--31.x`, `192.168.x` * Cloud metadata: `169.254.169.254`, `metadata.google.internal` * Invalid URLs are blocked (returns `true`) Reference: `packages/core/src/modules/webhooks/service.ts:13--48` (`isPrivateUrl`). ## Audit log [Section titled “Audit log”](#audit-log) Every mutation writes a row to `commerce_audit_log` with `organizationId`, `entityType`, `entityId`, `event`, `payload`, `actorId`, `actorType`, and `requestId`. Audit entries are written automatically via audit hooks registered during kernel boot. Services do not call `audit.record()` directly. Schema: `packages/core/src/modules/audit/schema.ts`. Hook registration: `packages/core/src/modules/audit/hooks.ts`. ## What is coming in Phase 2 [Section titled “What is coming in Phase 2”](#what-is-coming-in-phase-2) These are roadmap items, not current capabilities: * **Agent principal model.** `Actor` will be replaced with a `Principal` discriminated union supporting `User | ApiKey | BuyerAgent | SellerAgent | System`. Authorization grants with scope, expiry, and amount caps. * **UCP/ACP protocols.** Beyond MCP. A `/.well-known/commerce-capabilities` manifest. Multi-protocol gateway. * **Conversation layer.** `Conversation` entity with `ChannelAdapter` for WhatsApp/SMS/web-chat. * **Returns-as-entity.** Returns promoted from marketplace-plugin-local to a core entity with its own state machine. * **Data residency.** Per-org region routing in the database adapter. Data classification tags. Cross-border transfer audit log. * **Rate limit store adapter.** External store (Redis, Durable Objects) for cross-instance rate limiting. Reference: `.audits/agent-native-audit-unified-commerce-engine-2026-05-10.md`, Migration Path. ## Known gaps [Section titled “Known gaps”](#known-gaps) These are documented limitations from the security audit (commit `5d18ce6` and follow-up closures): 1. **Coupon race condition.** The promotion apply endpoint has not been load-tested at the database level. A unique index on `(promotion_id, customer_id, order_id)` and a parallel-apply regression test are needed. Reference: `SECURITY-AUDIT-V2-SYNTHESIS.md`. 2. **Outbound webhook signature replay.** The inbound side has `processed_webhook_events` for replay protection. The outbound side signs payloads but does not track delivery receipts. Replay protection is the receiver’s responsibility. Document this in your webhook consumer. 3. **HTTP request smuggling.** Not tested at the proxy boundary. Fly.io fronts the deployment with their own proxy. If you deploy behind a different reverse proxy, test for request smuggling with `smuggler` or equivalent. 4. **In-memory rate limiting.** Does not hold across multiple instances. See Rate limit layers above. 5. **`requireEmailVerification: false` in production.** The framework logs a warning at boot. If you run with email verification disabled in production, anyone can sign up with any email and access the account immediately. Enable it and configure `config.email.send` for production deployments. # Webhooks and Audit > Register webhook endpoints, verify signatures, and query the audit log. ## Register a webhook endpoint [Section titled “Register a webhook endpoint”](#register-a-webhook-endpoint) Send a `POST` to `/api/webhooks`. Requires the `webhooks:manage` permission. ```bash curl -X POST http://localhost:4000/api/webhooks \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{ "url": "https://your-service.example.com/hooks/commerce", "secret": "whsec_your_random_secret_string", "events": ["orders.create", "orders.statusChange", "inventory.afterAdjust"] }' ``` \| Field | Type | Description | |-------|------|-------------| | `url` | `string` | The HTTPS URL that receives webhook payloads. Must be HTTPS in production. | | `secret` | `string` | A shared secret used to sign payloads. Store this securely on your receiving server. | | `events` | `string[]` | Event types to subscribe to. | The response returns the created webhook record including its `id`. Delete a webhook with `DELETE /api/webhooks/:id`. List all webhooks with `GET /api/webhooks`. ## Event types [Section titled “Event types”](#event-types) 14 event types are available: `orders.create`, `orders.statusChange`, `catalog.create`, `catalog.update`, `catalog.delete`, `inventory.afterAdjust`, `customers.create`, `customers.update`, `pricing.create`, `pricing.update`, `promotions.create`, `promotions.update`, `fulfillment.create`, `cart.afterAddItem` Webhook payloads are delivered asynchronously through the background job queue. Failed deliveries are retried up to 5 times with exponential backoff. The job queue must be running (`jobs.autorun.enabled: true`) for webhooks to be delivered. ## Verify webhook signatures [Section titled “Verify webhook signatures”](#verify-webhook-signatures) Every webhook delivery includes an `x-commerce-signature` header containing an HMAC-SHA256 signature of the request body, computed using the shared secret. webhook-handler.js ```javascript const crypto = require("crypto"); function verifyWebhookSignature(body, signature, secret) { const expected = crypto .createHmac("sha256", secret) .update(body, "utf8") .digest("hex"); return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") ); } app.post("/hooks/commerce", (req, res) => { const rawBody = req.rawBody; const signature = req.headers["x-commerce-signature"]; if (!verifyWebhookSignature(rawBody, signature, "whsec_your_secret")) { return res.status(401).send("Invalid signature"); } const event = JSON.parse(rawBody); // process event... res.status(200).send("OK"); }); ``` Use `crypto.timingSafeEqual` to prevent timing attacks. Compare raw hex strings — not base64 or other encodings. ### Payload structure [Section titled “Payload structure”](#payload-structure) ```json { "id": "evt_01HQ...", "type": "orders.create", "timestamp": "2026-03-15T10:30:00.000Z", "data": { "id": "order-uuid", "orderNumber": "ORD-2026-000042", "status": "pending", "grandTotal": 4999, "currency": "USD" } } ``` ## Audit logging [Section titled “Audit logging”](#audit-logging) The engine records an audit entry for every state-changing operation across all modules (orders, catalog, inventory, customers, pricing, promotions, fulfillment, cart, webhooks). Audit logging is enabled by default and requires no configuration. ## Query audit logs [Section titled “Query audit logs”](#query-audit-logs) Both audit endpoints require the `audit:read` permission. ### List all audit entries [Section titled “List all audit entries”](#list-all-audit-entries) ```bash curl "http://localhost:4000/api/audit?entityType=order&from=2026-03-01&limit=50" \ -H "x-api-key: dev-staff-key" ``` Query parameters: \| Parameter | Description | |-----------|-------------| | `entityType` | Filter by entity type (e.g., `order`, `catalog`, `customer`) | | `entityId` | Filter to a specific entity UUID | | `event` | Filter by event name (e.g., `orders.create`) | | `actorId` | Filter by actor who triggered the event | | `from` | Start of time range (ISO 8601) | | `to` | End of time range (ISO 8601) | | `limit` | Max results (default 50, max 100) | ### Audit history for a specific entity [Section titled “Audit history for a specific entity”](#audit-history-for-a-specific-entity) ```bash curl "http://localhost:4000/api/audit/order/550e8400-e29b-41d4-a716-446655440000" \ -H "x-api-key: dev-staff-key" ``` Returns all audit entries for the specified entity, ordered by timestamp descending. Accepts the same `from`, `to`, and `limit` parameters. ## Related [Section titled “Related”](#related) * [Database Schema Reference](/reference/database-schema/) — audit entry schema fields * [REST API Reference](/reference/rest-api/) — full webhook endpoint details * [Authentication guide](/building/authentication/) — `webhooks:manage` and `audit:read` permissions # Reference > Technical specifications for all Porulle APIs, types, and interfaces. Reference documentation is information-oriented. It describes the machinery precisely and completely. Use it to look up a specific config option, API endpoint, or type signature. \| Reference | Contents | |-----------|----------| | [Configuration](/reference/configuration/) | Every field in `CommerceConfig` | | [REST API](/reference/rest-api/) | All endpoints, methods, request/response shapes | | [Plugin API](/reference/plugins/) | `defineCommercePlugin`, manifest, `PluginContext`, `router()` builder | | [Hook System](/reference/hooks/) | Hook types, context, available keys, execution order | | [Database Schema](/reference/database-schema/) | All Drizzle tables and their columns | | [Adapter Interfaces](/reference/adapters/) | Database, payment, storage, search, tax, email adapters | | [Analytics](/reference/analytics/) | Models, measures, dimensions, query format, filter operators, scope rules | | [Job Queue](/reference/jobs/) | Task definitions, job lifecycle, runner strategies, retry policies | # Adapter Interfaces > Database, payment, storage, search, tax, and email adapter specifications. All adapter methods that perform fallible operations return `Result`: ```ts type Result = | { ok: true; value: T; meta?: Record } | { ok: false; error: E }; ``` Use `Ok(value)` for success and `Err({ code, message })` for failure. Never throw from adapter methods. See [Result Types](/concepts/result-types/) for the rationale. *** ## DatabaseAdapter [Section titled “DatabaseAdapter”](#databaseadapter) ```ts interface DatabaseAdapter { provider: string; db: TDatabase; transaction(fn: (tx: TTransaction) => Promise): Promise; } ``` \| Member | Type | Description | | ------------- | --------------------------------------------------------- | -------------------------------------------------- | | `provider` | `string` | Database backend identifier (e.g., `"postgresql"`) | | `db` | `TDatabase` | The underlying Drizzle database instance | | `transaction` | `(fn: (tx: TTransaction) => Promise) => Promise` | Executes `fn` within a database transaction | **Implementations:** `@porulle/adapter-postgres` (Drizzle on postgres-js) for long-lived Node servers, and `@porulle/adapter-neon` for serverless/Workers. `neonAdapter({ connectionString, hyperdrive? })` uses the Neon HTTP driver for plain queries and opens a fresh WebSocket pool per `transaction()` (ended after each), sidestepping isolate-shared-pool flakiness; pass a Cloudflare `hyperdrive` binding to route the transaction pool through it. *** ## PaymentAdapter [Section titled “PaymentAdapter”](#paymentadapter) ```ts 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>; } ``` ### CreatePaymentIntentParams [Section titled “CreatePaymentIntentParams”](#createpaymentintentparams) \| Field | Type | Required | | ------------ | ------------------------ | -------- | | `amount` | `number` | Yes | | `currency` | `string` | Yes | | `orderId` | `string` | Yes | | `customerId` | `string` | No | | `metadata` | `Record` | No | | `terminalId` | `string` | No | ### PaymentIntent [Section titled “PaymentIntent”](#paymentintent) \| Field | Type | | -------------- | ---------------- | | `id` | `string` | | `status` | `string` | | `amount` | `number` | | `currency` | `string` | | `clientSecret` | `string \| null` | ### PaymentCapture [Section titled “PaymentCapture”](#paymentcapture) \| Field | Type | | ---------------- | -------- | | `id` | `string` | | `status` | `string` | | `amountCaptured` | `number` | ### PaymentRefund [Section titled “PaymentRefund”](#paymentrefund) \| Field | Type | | ---------------- | -------- | | `id` | `string` | | `status` | `string` | | `amountRefunded` | `number` | ### PaymentWebhookEvent [Section titled “PaymentWebhookEvent”](#paymentwebhookevent) \| Field | Type | | ------ | --------- | | `id` | `string` | | `type` | `string` | | `data` | `unknown` | For implementation guidance, see the [Payment Adapter Contract](/extending/payment-adapter-contract/) and the [Payment Adapter guide](/extending/payment-adapter/). *** ## StorageAdapter [Section titled “StorageAdapter”](#storageadapter) ```ts 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>; } ``` ### StoredFile [Section titled “StoredFile”](#storedfile) \| Field | Type | Required | | ------------- | -------- | -------- | | `key` | `string` | Yes | | `url` | `string` | Yes | | `contentType` | `string` | Yes | | `size` | `number` | No | *** ## SearchAdapter [Section titled “SearchAdapter”](#searchadapter) ```ts interface SearchAdapter { readonly providerId: string; index(documents: SearchDocument[]): Promise>; remove(ids: string[]): Promise>; search(params: SearchQueryParams): Promise>; suggest(params: SearchSuggestParams): Promise>; } ``` ### SearchDocument [Section titled “SearchDocument”](#searchdocument) \| Field | Type | Required | | ------------- | ------------------------- | -------- | | `id` | `string` | Yes | | `type` | `string` | Yes | | `slug` | `string` | Yes | | `title` | `string` | Yes | | `description` | `string` | No | | `status` | `string` | No | | `categories` | `string[]` | Yes | | `brands` | `string[]` | Yes | | `text` | `string` | Yes | | `payload` | `Record` | No | ### SearchQueryParams [Section titled “SearchQueryParams”](#searchqueryparams) \| Field | Type | Required | | --------- | --------------------------------------- | -------- | | `query` | `string` | Yes | | `page` | `number` | No | | `limit` | `number` | No | | `filters` | `{ type?, category?, brand?, status? }` | No | | `facets` | `string[]` | No | ### SearchQueryResult [Section titled “SearchQueryResult”](#searchqueryresult) \| Field | Type | | -------- | ---------------------------------------- | | `hits` | `SearchHit[]` | | `total` | `number` | | `page` | `number` | | `limit` | `number` | | `facets` | `Record>` | *** ## TaxAdapter [Section titled “TaxAdapter”](#taxadapter) ```ts interface TaxAdapter { readonly providerId: string; calculateTax( params: TaxCalculationParams, ): Promise>; reportTransaction( params: TaxReportParams, ): Promise>; voidTransaction( params: TaxVoidParams, ): Promise>; } ``` ### TaxCalculationParams [Section titled “TaxCalculationParams”](#taxcalculationparams) \| Field | Type | Required | | ---------------- | --------------- | -------- | | `currency` | `string` | Yes | | `customerId` | `string` | No | | `orderId` | `string` | No | | `fromAddress` | `TaxAddress` | No | | `toAddress` | `TaxAddress` | No | | `shippingAmount` | `number` | Yes | | `lineItems` | `TaxLineItem[]` | Yes | ### TaxCalculationResult [Section titled “TaxCalculationResult”](#taxcalculationresult) \| Field | Type | | ----------------- | ------------------------- | | `amountToCollect` | `number` | | `taxableAmount` | `number` | | `rate` | `number` | | `breakdown` | `Record` | ### TaxAddress [Section titled “TaxAddress”](#taxaddress) \| Field | Type | Required | | ------------ | -------- | -------- | | `country` | `string` | Yes | | `postalCode` | `string` | Yes | | `state` | `string` | No | | `city` | `string` | No | | `line1` | `string` | No | *** ## EmailAdapter [Section titled “EmailAdapter”](#emailadapter) ```ts interface EmailAdapter { send(input: { template: string; to: string; data?: Record; }): Promise; } ``` Any object with a `send` method matching this signature works. See the [Email Notifications guide](/building/email/) for setup instructions. *** ## Job execution engines [Section titled “Job execution engines”](#job-execution-engines) Task authors depend on the enqueue-only `JobsAdapter` surface exposed through hooks. Runtime configuration accepts the full `ExecutionEngine`, which registers the merged `TaskDefinition` map and declares how execution is driven: ```ts interface ExecutionEngine extends JobsAdapter { readonly execution: | { mode: "pull"; run(options?: { queue?: string; limit?: number; }): Promise; } | { mode: "push" }; register(setup: ExecutionEngineSetup): void; } ``` The built-in `DrizzleJobsAdapter` is the default pull engine. It owns enqueue, claim, concurrency enforcement, retries, and stale-job recovery. A configured engine replaces it: commerce.config.ts ```ts import { PgBossExecutionEngine } from "@porulle/jobs-pg-boss"; const jobs = new PgBossExecutionEngine({ connectionString: process.env.DATABASE_URL!, }); export default defineConfig({ jobs: { adapter: jobs, tasks: [importCatalogTask], }, }); await jobs.start(); ``` \| Package | Driver | Concurrency and scheduling | Use when | | ----------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Built-in `DrizzleJobsAdapter` | Pull | Postgres claim transaction, exclusive keys, `delayMs`, configurable `processingOrder` | Default; no extra infrastructure | | `@porulle/jobs-pg-boss` | Push worker | One singleton-policy queue preserves global key exclusivity; logical queue labels remain in job data. Includes retries, delayed jobs, and pending upsert. | You already operate Postgres and want pg-boss tooling | | `@porulle/jobs-inngest` | Push | Inngest concurrency CEL key, retries, future event timestamps; supersession uses trailing debounce | Managed durable functions and Inngest observability | | `@porulle/jobs-trigger` | Push | Trigger.dev queue concurrency plus `concurrencyKey`, retries, delayed triggers; supersession uses trailing debounce | Managed Trigger.dev task execution | | `@porulle/jobs-cloudflare` | Push | Workflow step retries and sleeps. Exclusive/superseding keys require a durable `CloudflareConcurrencyCoordinator`. | A Workers-native deployment that can supply a Durable Object coordinator | For Inngest, pass `engine.functions` to the framework’s `serve()` integration. Its retry and debounce policies are fixed when functions register, so put `retries` and `supersedes` on `TaskDefinition` rather than overriding them per enqueue. Trigger.dev indexes the task objects in `engine.tasks`. Cloudflare invokes `engine.run(event.payload, step)` from a `WorkflowEntrypoint`. These push engines must not use Porulle’s cron endpoint or `jobs.autorun`. *** ## Available implementations [Section titled “Available implementations”](#available-implementations) \| Package | Adapter | Interface | | -------------------------------- | --------------------------- | ----------------- | | `@porulle/adapter-postgres` | PostgreSQL (Drizzle) | `DatabaseAdapter` | | `@porulle/adapter-local-storage` | Local filesystem | `StorageAdapter` | | `@porulle/adapter-s3` | AWS S3 | `StorageAdapter` | | `@porulle/adapter-r2` | Cloudflare R2 | `StorageAdapter` | | `@porulle/adapter-stripe` | Stripe | `PaymentAdapter` | | `@porulle/adapter-resend` | Resend | `EmailAdapter` | | `@porulle/adapter-ses` | AWS SES v2 | `EmailAdapter` | | `consoleEmailAdapter()` | Console (dev) | `EmailAdapter` | | `@porulle/adapter-meilisearch` | Meilisearch | `SearchAdapter` | | `@porulle/adapter-pg-search` | PostgreSQL full-text search | `SearchAdapter` | | `@porulle/adapter-taxjar` | TaxJar | `TaxAdapter` | | `@porulle/adapter-tax-manual` | Manual / flat-rate tax | `TaxAdapter` | | `@porulle/jobs-pg-boss` | pg-boss | `ExecutionEngine` | | `@porulle/jobs-inngest` | Inngest | `ExecutionEngine` | | `@porulle/jobs-trigger` | Trigger.dev | `ExecutionEngine` | | `@porulle/jobs-cloudflare` | Cloudflare Workflows | `ExecutionEngine` | # Analytics > Built-in analytics models, measures, dimensions, query format, filter operators, and scope rules. The analytics subsystem compiles semantic queries into SQL and runs them against PostgreSQL via the `DrizzleAnalyticsAdapter`. No external service is required. For query setup and custom model registration, see the [Analytics guide](/building/analytics/). *** ## Query format [Section titled “Query format”](#query-format) ```ts interface AnalyticsQueryParams { measures: string[]; dimensions?: string[]; timeDimensions?: AnalyticsTimeDimension[]; filters?: AnalyticsFilter[]; order?: Record; limit?: number; } ``` \| Property | Required | Description | |----------|----------|-------------| | `measures` | Yes | Measure names in `ModelName.measureName` format | | `dimensions` | No | Dimension names for GROUP BY | | `timeDimensions` | No | Time-based grouping and filtering | | `filters` | No | Array of filter objects | | `order` | No | Sort order by measure or dimension | | `limit` | No | Row limit (default: 100) | ### TimeDimension [Section titled “TimeDimension”](#timedimension) ```ts { dimension: "Orders.placedAt", granularity: "month", // day | week | month | year dateRange: ["2025-01-01", "2025-12-31"] // or preset string like "last 6 months" } ``` ### Filter [Section titled “Filter”](#filter) ```ts { member: "Orders.status", operator: "equals", values: ["confirmed", "processing"] } ``` ### Filter operators [Section titled “Filter operators”](#filter-operators) \| Operator | Applies to | Description | |----------|-----------|-------------| | `equals` | string, number, time | Exact match (supports multiple values) | | `notEquals` | string, number, time | Excludes exact matches | | `contains` | string | Case-insensitive substring match | | `in` | string, number | Value is in the provided list | | `notIn` | string, number | Value is not in the provided list | | `gt` / `gte` | number | Greater than / greater than or equal | | `lt` / `lte` | number | Less than / less than or equal | | `beforeDate` | time | Before a timestamp | | `afterDate` | time | After a timestamp | | `inDateRange` | time | Between two timestamps | ### Boolean logic [Section titled “Boolean logic”](#boolean-logic) Combine filters with `and` / `or`: ```json { "filters": [ { "or": [ { "member": "Orders.status", "operator": "equals", "values": ["confirmed"] }, { "member": "Orders.status", "operator": "equals", "values": ["processing"] } ] } ] } ``` *** ## Built-in models [Section titled “Built-in models”](#built-in-models) ### Orders [Section titled “Orders”](#orders) Table: `orders` **Measures:** \| Name | Type | Description | |------|------|-------------| | `Orders.count` | count | Number of orders | | `Orders.revenue` | sum | Sum of grand totals | | `Orders.averageOrderValue` | avg | Average grand total per order | | `Orders.subtotalRevenue` | sum | Sum of subtotals (before tax/shipping) | | `Orders.taxCollected` | sum | Sum of tax amounts | | `Orders.shippingRevenue` | sum | Sum of shipping charges | | `Orders.discountsGiven` | sum | Sum of discounts applied | | `Orders.uniqueCustomers` | countDistinct | Unique customers who ordered | **Dimensions:** \| Name | Type | Description | |------|------|-------------| | `Orders.id` | string | Order UUID | | `Orders.orderNumber` | string | Human-readable order number | | `Orders.status` | string | Order status | | `Orders.currency` | string | Currency code | | `Orders.placedAt` | time | Order creation timestamp | ### OrderLineItems [Section titled “OrderLineItems”](#orderlineitems) Table: `order_line_items` (left join to `orders`) **Measures:** \| Name | Type | Description | |------|------|-------------| | `OrderLineItems.count` | count | Number of line items | | `OrderLineItems.itemsSold` | sum | Total quantity of items sold | | `OrderLineItems.lineItemRevenue` | sum | Sum of line item total prices | | `OrderLineItems.averageUnitPrice` | avg | Average unit price across line items | **Dimensions:** \| Name | Type | Description | |------|------|-------------| | `OrderLineItems.entityType` | string | Entity type (product, service, etc.) | | `OrderLineItems.sku` | string | SKU at time of purchase | | `OrderLineItems.title` | string | Product title at time of purchase | | `OrderLineItems.fulfillmentStatus` | string | Line item fulfillment status | ### Inventory [Section titled “Inventory”](#inventory) Table: `inventory_levels` **Measures:** \| Name | Type | Description | |------|------|-------------| | `Inventory.totalOnHand` | sum | Total on-hand quantity | | `Inventory.totalReserved` | sum | Total reserved quantity | | `Inventory.totalAvailable` | sum | Total available (on hand minus reserved) | | `Inventory.inventoryValue` | sum | Total inventory value at cost | | `Inventory.lowStockCount` | count | Items below reorder threshold | **Dimensions:** \| Name | Type | Description | |------|------|-------------| | `Inventory.entityId` | string | Catalog entity UUID | | `Inventory.warehouseId` | string | Warehouse UUID | | `Inventory.lastRestockedAt` | time | Last restock timestamp | **Segments:** \| Name | Description | |------|-------------| | `Inventory.lowStock` | Items at or below reorder threshold | ### Customers [Section titled “Customers”](#customers) Table: `customers` **Measures:** \| Name | Type | Description | |------|------|-------------| | `Customers.customerCount` | count | Total customer profiles | | `Customers.newCustomers` | count | New customer profiles | | `Customers.returningCustomers` | count | Customers with more than one order | **Dimensions:** \| Name | Type | Description | |------|------|-------------| | `Customers.createdAt` | time | Profile creation timestamp | | `Customers.customerGroup` | string | Customer group (from metadata) | *** ## Plugin models [Section titled “Plugin models”](#plugin-models) ### VendorOrders (Marketplace Plugin) [Section titled “VendorOrders (Marketplace Plugin)”](#vendororders-marketplace-plugin) Table: `marketplace_vendor_sub_orders` \| Measure | Description | |---------|-------------| | `VendorOrders.count` | Number of vendor sub-orders | | `VendorOrders.revenue` | Vendor-attributed revenue | | `VendorOrders.commissionPaid` | Total commissions paid | | `VendorOrders.netPayout` | Net payout after commission | ### VendorBalance (Marketplace Plugin) [Section titled “VendorBalance (Marketplace Plugin)”](#vendorbalance-marketplace-plugin) Table: `marketplace_vendor_balances` \| Measure | Description | |---------|-------------| | `VendorBalance.totalCredits` | Total credits (earnings) | | `VendorBalance.totalDebits` | Total debits (payouts) | | `VendorBalance.netBalance` | Net balance (credits minus debits) | ### VendorReviews (Marketplace Plugin) [Section titled “VendorReviews (Marketplace Plugin)”](#vendorreviews-marketplace-plugin) \| Measure | Description | |---------|-------------| | `VendorReviews.count` | Number of reviews | | `VendorReviews.averageRating` | Mean review rating | *** ## Scoped analytics [Section titled “Scoped analytics”](#scoped-analytics) All queries run within an `AnalyticsScope` that restricts data visibility based on the caller’s role. Scopes are constructed exclusively through `buildAnalyticsScope(actor)`: ```ts import { buildAnalyticsScope } from "@porulle/core"; const scope = buildAnalyticsScope(actor); const result = await kernel.analytics.query(params, scope); ``` ### Role-to-scope mapping [Section titled “Role-to-scope mapping”](#role-to-scope-mapping) \| Actor role | Data access | |------------|-------------| | `admin`, `owner`, `staff` | All models, all rows | | Vendor (actor has `vendorId`) | Vendor models only, filtered by `vendorId` | | `customer` | Customer models only, filtered by `customerId` | | Unauthenticated | All queries blocked | *** ## Custom models [Section titled “Custom models”](#custom-models) Contribute models from a plugin via `analyticsModels`: ```ts import { defineCommercePlugin } from "@porulle/core"; import type { AnalyticsModel } from "@porulle/core"; const model: AnalyticsModel = { name: "Subscriptions", table: "subscriptions", measures: { count: { type: "count" }, mrr: { sql: "monthly_amount", type: "sum" }, }, dimensions: { status: { sql: "status", type: "string" }, startedAt: { sql: "started_at", type: "time" }, }, }; export const myPlugin = defineCommercePlugin({ id: "my-plugin", version: "1.0.0", analyticsModels: () => [model], }); ``` Models appear in `GET /api/analytics/meta` and can be queried via `GET /api/analytics/query` like any built-in model. # Configuration > Complete reference for every field in CommerceConfig. The root configuration object passed to `defineConfig()`. ```ts import { defineConfig } from "@porulle/core"; export default defineConfig({ /* CommerceConfig */ }); ``` ## Top-level fields [Section titled “Top-level fields”](#top-level-fields) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `storeName` | `string` | — | Display name of the store | | `version` | `string` | `"0.0.1"` | Semantic version of the store configuration | | `database` | [`DatabaseConfig`](#databaseconfig) | **required** | Primary database connection | | `databaseAdapter` | `DatabaseAdapter` | — | Pre-built database adapter. When provided, bypasses `database` config. | | `auth` | [`AuthConfig`](#authconfig) | See defaults | Authentication and authorization settings | | `entities` | `Record` | — | Custom entity type definitions keyed by entity slug | | `cart` | [`CartConfig`](#cartconfig) | See defaults | Cart behavior and hooks | | `checkout` | [`CheckoutConfig`](#checkoutconfig) | See defaults | Checkout hooks | | `orders` | [`OrdersConfig`](#ordersconfig) | See defaults | Order lifecycle hooks | | `inventory` | [`InventoryConfig`](#inventoryconfig) | See defaults | Inventory hooks | | `shipping` | [`ShippingConfig`](#shippingconfig) | — | Shipping calculation strategy | | `payments` | `PaymentAdapter[]` | — | Payment gateway adapters | | `storage` | `StorageAdapter` | — | File/media storage adapter | | `email` | `EmailAdapter` | — | Transactional email sender | | `tax` | [`TaxConfig`](#taxconfig) | — | Tax calculation adapter and origin address | | `analytics` | [`AnalyticsConfig`](#analyticsconfig) | — | Analytics and reporting settings | | `search` | [`SearchConfig`](#searchconfig) | — | Search adapter and facet defaults | | `jobs` | [`JobsConfig`](#jobsconfig) | — | Background job processing | | `schema` | `Array>` | — | App-level Drizzle table definitions merged at boot. Must also add file paths to `drizzle.config.ts`. | | `hooks` | `Record>` | — | Global hook functions keyed by hook name (e.g., `"orders.afterCreate"`) | | `plugins` | `CommercePlugin[]` | — | Plugin config-transform functions, applied in array order | | `logLevel` | `"fatal" \| "error" \| "warn" \| "info" \| "debug" \| "trace"` | `"info"` | Pino log level | | `rateLimits` | [`RateLimitsConfig`](#ratelimitsconfig) | See defaults | Per-tier rate limiting thresholds (requests per minute) | | `middleware` | `MiddlewareHandler[]` | — | Hono middleware applied to all routes | | `routes` | `(app: Hono, kernel: unknown) => void` | — | Callback to register custom Hono routes. Cast `kernel` to `Kernel` to access services and database. | *** ## DatabaseConfig [Section titled “DatabaseConfig”](#databaseconfig) ```ts database: { provider: "postgresql"; options?: Record; } ``` \| Field | Type | Default | Description | |-------|------|---------|-------------| | `provider` | `"postgresql"` | **required** | Database provider. PostgreSQL is the only supported database. | | `options` | `Record` | — | Provider-specific connection options | *** ## RateLimitsConfig [Section titled “RateLimitsConfig”](#ratelimitsconfig) ```ts rateLimits: { api?: number; auth?: number; checkout?: number; } ``` \| Field | Type | Default | Description | |-------|------|---------|-------------| | `api` | `number` | `100` | Max requests per minute for general API routes | | `auth` | `number` | `10` | Max requests per minute for authentication endpoints | | `checkout` | `number` | `5` | Max requests per minute for checkout creation | When a client exceeds the limit, the server responds with `429 Too Many Requests`. *** ## AuthConfig [Section titled “AuthConfig”](#authconfig) ```ts interface AuthConfig { defaultOrganizationId?: string; strictOrgResolution?: boolean; storeResolver?: (request: Request) => string | null | Promise; requireEmailVerification?: boolean; sessionDuration?: number; socialProviders?: Record; twoFactor?: { enabled: boolean; requiredForRoles?: string[] }; apiKeys?: { enabled: boolean; defaultPermissions?: string[] }; roles?: Record; trustedOrigins?: string[]; enableDevKey?: boolean; } ``` \| Field | Type | Default | Description | |-------|------|---------|-------------| | `defaultOrganizationId` | `string` | — | Organization ID for single-store deployments. See [Identity and Store Resolution](/concepts/identity-model/). | | `strictOrgResolution` | `boolean` | `false` | When `true`, requests where `storeResolver` returns `null` are rejected with HTTP 503. | | `storeResolver` | `(request: Request) => string \| null \| Promise` | — | Resolves which organization a request belongs to in multi-store SaaS deployments. | | `requireEmailVerification` | `boolean` | `true` | Require email verification before account activation | | `sessionDuration` | `number` | `604800` (7 days, in seconds) | Session lifetime in seconds | | `socialProviders` | `Record` | — | OAuth provider credentials keyed by provider name | | `twoFactor` | `{ enabled: boolean; requiredForRoles?: string[] }` | `{ enabled: false }` | Two-factor authentication settings | | `apiKeys` | `{ enabled: boolean; defaultPermissions?: string[] }` | `{ enabled: false }` | Enable API key authentication | | `roles` | `Record` | See below | Role definitions mapping role names to permission sets | | `trustedOrigins` | `string[]` | — | Origins allowed for CSRF protection | | `enableDevKey` | `boolean` | `true` in dev, `false` in prod | Enable the `dev-staff-key` API key. Set to `false` in production. | ### Default roles [Section titled “Default roles”](#default-roles) \| Role | Permissions | |------|-------------| | `owner` | `*:*` | | `admin` | `*:*` | | `manager` | catalog CRUD, inventory, orders, cart | | `customer` | catalog:read, cart, orders:read:own, customers:read:self, customers:update:self | ### RoleDefinition [Section titled “RoleDefinition”](#roledefinition) ```ts interface RoleDefinition { permissions: string[]; } ``` Permissions use `"module:action"` or `"module:action:scope"` format. `"*:*"` grants all. *** ## EntityConfig [Section titled “EntityConfig”](#entityconfig) ```ts interface EntityConfig { fields: EntityFieldDefinition[]; variants: EntityVariantConfig; fulfillment: string; hooks?: EntityHooks; } ``` \| Field | Type | Description | |-------|------|-------------| | `fields` | `EntityFieldDefinition[]` | Custom field definitions. The catalog service validates entity `metadata` against these on create/update. | | `variants` | `EntityVariantConfig` | Variant support configuration | | `fulfillment` | `string` | Fulfillment strategy: `"physical"`, `"digital"`, `"digital-download"`, `"digital-access"` | | `hooks` | `EntityHooks` | Lifecycle hooks scoped to this entity type | ### EntityFieldDefinition [Section titled “EntityFieldDefinition”](#entityfielddefinition) \| Field | Type | Description | |-------|------|-------------| | `name` | `string` | Field identifier | | `type` | `"text" \| "number" \| "boolean" \| "date" \| "json" \| "relation" \| "select"` | Data type | | `unit` | `string` | Unit of measurement (for `"number"` fields, e.g., `"g"`, `"ml"`) | | `schema` | `unknown` | Validation schema for the field value | | `target` | `string` | Target entity slug (for `"relation"` fields) | | `options` | `string[]` | Allowed values (for `"select"` fields) | ### EntityVariantConfig [Section titled “EntityVariantConfig”](#entityvariantconfig) \| Field | Type | Description | |-------|------|-------------| | `enabled` | `boolean` | Whether this entity supports variants | | `optionTypes` | `string[]` | Variant option type names (e.g., `["size", "color"]`) | *** ## CartConfig [Section titled “CartConfig”](#cartconfig) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `ttlMinutes` | `number` | `10080` (7 days) | Cart time-to-live in minutes | | `hooks.beforeAddItem` | `BeforeHook[]` | `[]` | Before adding an item | | `hooks.afterAddItem` | `AfterHook[]` | `[]` | After adding an item | | `hooks.beforeRemoveItem` | `BeforeHook[]` | `[]` | Before removing an item | | `hooks.afterRemoveItem` | `AfterHook[]` | `[]` | After removing an item | | `hooks.beforeUpdateQuantity` | `BeforeHook[]` | `[]` | Before updating item quantity | | `hooks.afterUpdateQuantity` | `AfterHook[]` | `[]` | After updating item quantity | *** ## CheckoutConfig [Section titled “CheckoutConfig”](#checkoutconfig) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `hooks.beforeCreate` | `BeforeHook[]` | `[]` | Before checkout is created. May mutate checkout data. | | `hooks.afterCreate` | `AfterHook[]` | `[]` | After checkout completes and order is created | *** ## OrdersConfig [Section titled “OrdersConfig”](#ordersconfig) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `hooks.beforeCreate` | `BeforeHook[]` | `[]` | Before order creation | | `hooks.afterCreate` | `AfterHook[]` | `[]` | After order creation | | `hooks.beforeStatusChange` | `BeforeHook[]` | `[]` | Before order status transition | | `hooks.afterStatusChange` | `AfterHook[]` | `[]` | After order status transition | | `hooks.beforeDelete` | `BeforeHook[]` | `[]` | Before order deletion | *** ## InventoryConfig [Section titled “InventoryConfig”](#inventoryconfig) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `hooks.afterAdjust` | `AfterHook[]` | `[]` | After an inventory level adjustment | *** ## ShippingConfig [Section titled “ShippingConfig”](#shippingconfig) ```ts interface ShippingConfig { type: "flat" | "weight_based"; flatRate: number; freeShippingThreshold?: number; brackets: Array<{ upToGrams: number; cost: number }>; fallbackCost: number; } ``` \| Field | Type | Description | |-------|------|-------------| | `type` | `"flat" \| "weight_based"` | Shipping calculation strategy | | `flatRate` | `number` | Fixed shipping cost. Used when `type` is `"flat"`. | | `freeShippingThreshold` | `number` | Order subtotal above which shipping is free | | `brackets` | `Array<{ upToGrams: number; cost: number }>` | Weight-based cost brackets. Used when `type` is `"weight_based"`. | | `fallbackCost` | `number` | Shipping cost when no bracket matches | *** ## TaxConfig [Section titled “TaxConfig”](#taxconfig) \| Field | Type | Description | |-------|------|-------------| | `adapter` | `TaxAdapter` | Tax calculation adapter instance | | `defaultFromAddress.country` | `string` | ISO country code | | `defaultFromAddress.postalCode` | `string` | Postal/ZIP code | | `defaultFromAddress.state` | `string` | State or province code | | `defaultFromAddress.city` | `string` | City name | | `defaultFromAddress.line1` | `string` | Street address | *** ## AnalyticsConfig [Section titled “AnalyticsConfig”](#analyticsconfig) \| Field | Type | Description | |-------|------|-------------| | `models` | `AnalyticsModel[]` | Plugin-contributed analytics model definitions | | `customSchemaPath` | `string` | Path to a directory containing custom `.js` analytics model files | *** ## SearchConfig [Section titled “SearchConfig”](#searchconfig) \| Field | Type | Description | |-------|------|-------------| | `adapter` | `SearchAdapter` | Search engine adapter instance | | `defaultFacets` | `string[]` | Facet field names returned by default in search results | *** ## JobsConfig [Section titled “JobsConfig”](#jobsconfig) \| Field | Type | Default | Description | |-------|------|---------|-------------| | `adapter` | `JobsAdapter` | — | Background job queue adapter. When omitted, uses in-memory queue (dev only). | | `tasks` | `TaskDefinition[]` | — | Task definitions registered at startup. Each specifies a `slug` and `handler`. | | `autorun.enabled` | `boolean` | — | Enable automatic job polling | | `autorun.intervalMs` | `number` | `5000` | Polling interval in milliseconds | # Database Schema > All Drizzle ORM tables, columns, and relationships. All tables use PostgreSQL via Drizzle ORM. Integer monetary amounts are stored in the smallest currency unit (cents). Timestamps use `timestamptz`. UUIDs are auto-generated with `gen_random_uuid()` unless noted. *** ## Catalog [Section titled “Catalog”](#catalog) ### sellable\_entities [Section titled “sellable\_entities”](#sellable_entities) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK, default random | | `type` | `text` | NOT NULL | | `slug` | `text` | NOT NULL, UNIQUE | | `status` | `text` | NOT NULL, default `'draft'`. Enum: `draft`, `active`, `archived`, `discontinued` | | `is_visible` | `boolean` | NOT NULL, default `false` | | `tax_class` | `text` | — Maps to `tax_classes.name` at checkout. Null = the org’s default class | | `metadata` | `jsonb` | default `{}` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | | `published_at` | `timestamptz` | — | Indexes: `type`, `status`, `slug`. ### sellable\_attributes [Section titled “sellable\_attributes”](#sellable_attributes) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_id` | `uuid` | NOT NULL, FK `sellable_entities.id` ON DELETE CASCADE | | `locale` | `text` | NOT NULL, default `'en'` | | `title` | `text` | NOT NULL | | `subtitle` | `text` | — | | `description` | `text` | — | | `rich_description` | `jsonb` | — | | `seo_title` | `text` | — | | `seo_description` | `text` | — | Index: `(entity_id, locale)`. ### variants [Section titled “variants”](#variants) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_id` | `uuid` | NOT NULL, FK `sellable_entities.id` ON DELETE CASCADE | | `sku` | `text` | UNIQUE | | `barcode` | `text` | — | | `status` | `text` | NOT NULL, default `'active'`. Enum: `active`, `discontinued` | | `sort_order` | `integer` | NOT NULL, default `0` | | `tax_class` | `text` | — Overrides the entity’s `tax_class` when set | | `metadata` | `jsonb` | default `{}` | ### categories [Section titled “categories”](#categories) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `parent_id` | `uuid` | FK `categories.id` ON DELETE SET NULL | | `slug` | `text` | NOT NULL, UNIQUE | | `sort_order` | `integer` | NOT NULL, default `0` | | `metadata` | `jsonb` | default `{}` | *** ## Inventory [Section titled “Inventory”](#inventory) ### warehouses [Section titled “warehouses”](#warehouses) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `name` | `text` | NOT NULL | | `code` | `text` | NOT NULL, UNIQUE | | `address` | `jsonb` | — | | `is_active` | `boolean` | NOT NULL, default `true` | | `priority` | `integer` | NOT NULL, default `0` | ### inventory\_levels [Section titled “inventory\_levels”](#inventory_levels) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_id` | `uuid` | NOT NULL, FK `sellable_entities.id` ON DELETE CASCADE | | `variant_id` | `uuid` | FK `variants.id` ON DELETE CASCADE | | `warehouse_id` | `uuid` | NOT NULL, FK `warehouses.id` | | `quantity_on_hand` | `integer` | NOT NULL, default `0` | | `quantity_reserved` | `integer` | NOT NULL, default `0` | | `quantity_incoming` | `integer` | NOT NULL, default `0` | | `unit_cost` | `integer` | — | | `reorder_threshold` | `integer` | — | | `reorder_quantity` | `integer` | — | | `version` | `integer` | NOT NULL, default `0`. Used for optimistic locking. | | `last_restocked_at` | `timestamptz` | — | Index: `(entity_id, variant_id, warehouse_id)`. ### inventory\_movements [Section titled “inventory\_movements”](#inventory_movements) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_id` | `uuid` | NOT NULL | | `variant_id` | `uuid` | — | | `warehouse_id` | `uuid` | NOT NULL | | `type` | `text` | Enum: `receipt`, `sale`, `return`, `adjustment`, `transfer`, `reservation`, `release` | | `quantity` | `integer` | NOT NULL | | `reference_type` | `text` | — | | `reference_id` | `text` | — | | `reason` | `text` | — | | `performed_by` | `text` | NOT NULL | | `performed_at` | `timestamptz` | NOT NULL, default `now()` | *** ## Cart [Section titled “Cart”](#cart) ### carts [Section titled “carts”](#carts) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `customer_id` | `uuid` | — | | `status` | `text` | Enum: `active`, `merged`, `checked_out`, `abandoned` | | `currency` | `text` | NOT NULL, default `'USD'` | | `secret` | `text` | — | | `expires_at` | `timestamptz` | NOT NULL | ### cart\_line\_items [Section titled “cart\_line\_items”](#cart_line_items) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `cart_id` | `uuid` | NOT NULL, FK `carts.id` ON DELETE CASCADE | | `entity_id` | `uuid` | NOT NULL, FK `sellable_entities.id` | | `variant_id` | `uuid` | FK `variants.id` | | `quantity` | `integer` | NOT NULL, default `1` | | `unit_price_snapshot` | `integer` | NOT NULL | | `currency` | `text` | NOT NULL | | `notes` | `text` | Free-text notes (POS: “no ice”, “gift wrap”) | *** ## Orders [Section titled “Orders”](#orders) ### orders [Section titled “orders”](#orders-1) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `order_number` | `text` | NOT NULL, UNIQUE | | `customer_id` | `uuid` | — | | `status` | `text` | NOT NULL, default `'pending'` | | `currency` | `text` | NOT NULL | | `subtotal` | `integer` | NOT NULL | | `tax_total` | `integer` | NOT NULL | | `shipping_total` | `integer` | NOT NULL | | `discount_total` | `integer` | NOT NULL, default `0` | | `grand_total` | `integer` | NOT NULL | | `payment_intent_id` | `text` | Payment processor transaction ID | | `payment_method_id` | `text` | Payment method used | | `placed_at` | `timestamptz` | NOT NULL, default `now()` | | `fulfilled_at` | `timestamptz` | — | | `cancelled_at` | `timestamptz` | — | **Order state machine:** `pending → confirmed → processing → [partially_fulfilled | fulfilled] → refunded`. Cancel is allowed from `pending`, `confirmed`, `processing`, `partially_fulfilled`. ### order\_line\_items [Section titled “order\_line\_items”](#order_line_items) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `entity_id` | `uuid` | NOT NULL | | `entity_type` | `text` | NOT NULL | | `variant_id` | `uuid` | — | | `sku` | `text` | — | | `title` | `text` | NOT NULL | | `quantity` | `integer` | NOT NULL | | `unit_price` | `integer` | NOT NULL | | `total_price` | `integer` | NOT NULL | | `tax_amount` | `integer` | NOT NULL, default `0`. Per-line tax written by checkout (tax classes) | | `discount_amount` | `integer` | NOT NULL, default `0` | | `refunded_quantity` | `integer` | NOT NULL, default `0`. Units already refunded on this line | | `fulfillment_status` | `text` | NOT NULL, default `'unfulfilled'` | ### order\_status\_history [Section titled “order\_status\_history”](#order_status_history) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `from_status` | `text` | NOT NULL | | `to_status` | `text` | NOT NULL | | `reason` | `text` | — | | `changed_by` | `text` | NOT NULL | | `changed_at` | `timestamptz` | NOT NULL | ### order\_refunds [Section titled “order\_refunds”](#order_refunds) Line-level refund ledger. See [Refunds & Exchanges](/building/refunds-and-exchanges/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `amount` | `integer` | NOT NULL. Refund total in minor units | | `reason` | `text` | — | | `lines` | `jsonb` | NOT NULL. `[{ lineItemId, quantity, amount }]` | | `performed_by` | `text` | NOT NULL. Operator who issued the refund | | `status` | `text` | NOT NULL, default `'completed'`. Enum: `completed`, `undone` | | `undone_at` | `timestamptz` | — | | `undone_by` | `text` | — | | `created_at` | `timestamptz` | NOT NULL, default `now()` | Indexes: `(order_id)`, `(organization_id, performed_by, created_at)` — the second backs the per-operator daily cap. ### order\_notes [Section titled “order\_notes”](#order_notes) Free-text notes on an order; surfaced in the order timeline. See [order notes and timeline](/building/pos/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `author` | `text` | NOT NULL. Set server-side to the acting user | | `body` | `text` | NOT NULL | | `pinned` | `boolean` | NOT NULL, default `false` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | Index: `(order_id)`. Listing is pinned-first, then newest-first. *** ## Customers [Section titled “Customers”](#customers) ### customers [Section titled “customers”](#customers-1) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `user_id` | `text` | NOT NULL, UNIQUE | | `email` | `text` | UNIQUE | | `phone` | `text` | — | | `first_name` | `text` | — | | `last_name` | `text` | — | | `metadata` | `jsonb` | default `{}` | ### customer\_addresses [Section titled “customer\_addresses”](#customer_addresses) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `customer_id` | `uuid` | NOT NULL, FK `customers.id` ON DELETE CASCADE | | `type` | `text` | NOT NULL. Enum: `shipping`, `billing` | | `is_default` | `boolean` | NOT NULL, default `false` | | `first_name` | `text` | NOT NULL | | `last_name` | `text` | NOT NULL | | `line1` | `text` | NOT NULL | | `line2` | `text` | — | | `city` | `text` | NOT NULL | | `state` | `text` | — | | `postal_code` | `text` | — | | `country` | `text` | NOT NULL | *** ## Pricing [Section titled “Pricing”](#pricing) ### prices [Section titled “prices”](#prices) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_id` | `uuid` | NOT NULL, FK `sellable_entities.id` ON DELETE CASCADE | | `variant_id` | `uuid` | FK `variants.id` ON DELETE CASCADE | | `currency` | `text` | NOT NULL | | `amount` | `integer` | NOT NULL | | `customer_group_id` | `text` | — | | `min_quantity` | `integer` | — | | `max_quantity` | `integer` | — | | `valid_from` | `timestamptz` | — | | `valid_until` | `timestamptz` | — | Indexes: `(entity_id, variant_id, currency)`, `(valid_from, valid_until)`. *** ## Promotions [Section titled “Promotions”](#promotions) ### promotions [Section titled “promotions”](#promotions-1) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `code` | `text` | UNIQUE | | `name` | `text` | NOT NULL | | `type` | `text` | Enum: `percentage_off_order`, `fixed_off_order`, `percentage_off_item`, `fixed_off_item`, `free_shipping`, `buy_x_get_y` | | `value` | `integer` | NOT NULL, default `0` | | `is_automatic` | `boolean` | NOT NULL, default `false` | | `is_active` | `boolean` | NOT NULL, default `true` | | `priority` | `integer` | NOT NULL, default `100` | | `usage_limit_total` | `integer` | — | | `usage_limit_per_customer` | `integer` | — | | `valid_from` | `timestamptz` | — | | `valid_until` | `timestamptz` | — | *** ## Tax [Section titled “Tax”](#tax) ### tax\_classes [Section titled “tax\_classes”](#tax_classes) Named product tax rates. See [Tax Classes](/building/tax/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `name` | `text` | NOT NULL. Lowercase slug; UNIQUE per org | | `rate_bps` | `integer` | NOT NULL. Basis points (`1800` = 18%) | | `is_default` | `boolean` | NOT NULL, default `false`. Applied to lines with no class | | `is_active` | `boolean` | NOT NULL, default `true` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | Unique: `(organization_id, name)`. *** ## Settings & Documents [Section titled “Settings & Documents”](#settings--documents) ### store\_settings [Section titled “store\_settings”](#store_settings) Org-scoped typed settings groups. See [Store Settings](/building/settings/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `group` | `text` | NOT NULL. e.g. `general`, `branding`, `policies` | | `value` | `jsonb` | NOT NULL, default `{}` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | Unique: `(organization_id, group)` — one row per group. ### invoice\_sequences [Section titled “invoice\_sequences”](#invoice_sequences) Per-org fiscal counter for invoice numbers. See [Receipts & Invoices](/building/receipts-and-invoices/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `series` | `text` | NOT NULL, default `'default'` | | `next_value` | `integer` | NOT NULL, default `1`. Next number to allocate | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | Unique: `(organization_id, series)`. Allocation is a single atomic upsert-returning. ### order\_documents [Section titled “order\_documents”](#order_documents) One issued document per (org, order, type) — the idempotency key for fiscal numbers. \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL, FK `organization.id` ON DELETE CASCADE | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `type` | `text` | NOT NULL. `invoice` | `receipt` | | `document_number` | `text` | NOT NULL | | `created_at` | `timestamptz` | NOT NULL, default `now()` | Unique: `(organization_id, order_id, type)`. *** ## Audit [Section titled “Audit”](#audit) ### commerce\_audit\_log [Section titled “commerce\_audit\_log”](#commerce_audit_log) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `entity_type` | `text` | NOT NULL | | `entity_id` | `text` | NOT NULL | | `event` | `text` | NOT NULL | | `payload` | `jsonb` | NOT NULL, default `'{}'` | | `actor_id` | `text` | — | | `actor_type` | `text` | — | | `request_id` | `text` | — | | `created_at` | `timestamptz` | NOT NULL, default `now()` | Index: `(entity_type, entity_id)`. *** ## Webhooks [Section titled “Webhooks”](#webhooks) ### webhook\_endpoints [Section titled “webhook\_endpoints”](#webhook_endpoints) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `url` | `text` | NOT NULL | | `secret` | `text` | NOT NULL | | `events` | `jsonb` | NOT NULL | | `is_active` | `boolean` | NOT NULL, default `true` | ### webhook\_deliveries [Section titled “webhook\_deliveries”](#webhook_deliveries) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `endpoint_id` | `uuid` | NOT NULL, FK `webhook_endpoints.id` | | `event_name` | `text` | NOT NULL | | `payload` | `jsonb` | NOT NULL | | `status_code` | `integer` | — | | `attempt_count` | `integer` | NOT NULL, default `0` | | `next_retry_at` | `timestamptz` | — | | `delivered_at` | `timestamptz` | — | | `failed_at` | `timestamptz` | — | *** ## Jobs [Section titled “Jobs”](#jobs) ### commerce\_jobs [Section titled “commerce\_jobs”](#commerce_jobs) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `queue` | `text` | NOT NULL, default `'default'` | | `task_slug` | `text` | NOT NULL | | `input` | `jsonb` | NOT NULL, default `'{}'` | | `output` | `jsonb` | — | | `status` | `text` | Enum: `pending`, `processing`, `succeeded`, `failed` | | `attempts` | `integer` | NOT NULL, default `0` | | `max_attempts` | `integer` | NOT NULL, default `5` | | `error` | `text` | — | | `wait_until` | `timestamptz` | — | | `concurrency_key` | `text` | — | | `processing_started_at` | `timestamptz` | — | | `completed_at` | `timestamptz` | — | *** ## Fulfillment [Section titled “Fulfillment”](#fulfillment) ### fulfillment\_records [Section titled “fulfillment\_records”](#fulfillment_records) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `order_id` | `uuid` | NOT NULL, FK `orders.id` ON DELETE CASCADE | | `type` | `text` | NOT NULL | | `status` | `text` | NOT NULL, default `'pending'` | | `carrier` | `text` | — | | `tracking_number` | `text` | — | | `tracking_url` | `text` | — | | `estimated_delivery` | `timestamptz` | — | | `shipped_at` | `timestamptz` | — | | `delivered_at` | `timestamptz` | — | | `download_url` | `text` | For digital downloads | | `download_expires_at` | `timestamptz` | — | | `max_downloads` | `integer` | — | | `download_count` | `integer` | NOT NULL, default `0` | *** ## POS Plugin [Section titled “POS Plugin”](#pos-plugin) Added by `@porulle/plugin-pos`. ### pos\_terminals [Section titled “pos\_terminals”](#pos_terminals) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `name` | `text` | NOT NULL | | `code` | `text` | NOT NULL | | `type` | `text` | Enum: `register`, `tablet`, `mobile`, `kiosk` | | `is_active` | `boolean` | NOT NULL, default `true` | Unique: `(organization_id, code)`. ### pos\_shifts [Section titled “pos\_shifts”](#pos_shifts) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `terminal_id` | `uuid` | NOT NULL, FK `pos_terminals.id` ON DELETE CASCADE | | `operator_id` | `text` | NOT NULL | | `status` | `text` | Enum: `open`, `closed` | | `opening_float` | `integer` | NOT NULL, default `0` | | `closing_count` | `integer` | — | | `expected_cash` | `integer` | — | | `cash_variance` | `integer` | — | | `sales_count` | `integer` | NOT NULL, default `0` | | `sales_total` | `integer` | NOT NULL, default `0` | ### pos\_transactions [Section titled “pos\_transactions”](#pos_transactions) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `shift_id` | `uuid` | NOT NULL, FK `pos_shifts.id` ON DELETE CASCADE | | `terminal_id` | `uuid` | NOT NULL, FK `pos_terminals.id` | | `cart_id` | `uuid` | NOT NULL | | `order_id` | `uuid` | Set after checkout completes | | `type` | `text` | Enum: `sale`, `return`, `exchange` | | `status` | `text` | Enum: `open`, `held`, `completed`, `voided` | | `receipt_number` | `text` | Sequential per terminal per day (e.g., `MC1-0001`) | | `total` | `integer` | NOT NULL, default `0` | | `void_reason` | `text` | Required when voiding | ### pos\_payments [Section titled “pos\_payments”](#pos_payments) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `transaction_id` | `uuid` | NOT NULL, FK `pos_transactions.id` ON DELETE CASCADE | | `method` | `text` | Enum: `cash`, `card`, `gift_card`, `store_credit`, `other` | | `amount` | `integer` | NOT NULL | | `change_given` | `integer` | NOT NULL, default `0` | | `reference` | `text` | Card last 4, gift card code, auth code | | `status` | `text` | Enum: `collected`, `refunded` | ### pos\_operator\_pins [Section titled “pos\_operator\_pins”](#pos_operator_pins) Cashier PINs for terminal login. See [PIN authentication](/building/pos/). \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL | | `operator_id` | `text` | NOT NULL. UNIQUE per org | | `pin_hash` | `text` | NOT NULL. PBKDF2-SHA256 — never plaintext | | `can_override` | `boolean` | NOT NULL, default `false` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | Unique: `(organization_id, operator_id)`. *** ## Layaway Plugin [Section titled “Layaway Plugin”](#layaway-plugin) From `@porulle/plugin-layaway`. See [Layaway](/building/layaway/). ### layaways [Section titled “layaways”](#layaways) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `organization_id` | `text` | NOT NULL | | `customer_id` | `uuid` | — | | `status` | `text` | NOT NULL, default `'active'`. Enum: `active`, `completed`, `forfeited`, `cancelled` | | `currency` | `text` | NOT NULL | | `items` | `jsonb` | NOT NULL. `[{ entityId, variantId?, sku?, title, quantity, unitPrice }]` | | `total` | `integer` | NOT NULL | | `deposit_amount` | `integer` | NOT NULL, default `0` | | `paid_total` | `integer` | NOT NULL, default `0` | | `order_id` | `uuid` | — Core order created at completion | | `expires_at` | `timestamptz` | — | | `forfeited_at` | `timestamptz` | — | | `forfeit_reason` | `text` | — | | `created_by` | `text` | NOT NULL | | `metadata` | `jsonb` | default `{}` | | `created_at` | `timestamptz` | NOT NULL, default `now()` | | `updated_at` | `timestamptz` | NOT NULL, default `now()` | Indexes: `(organization_id)`, `(status)`, `(customer_id)`. ### layaway\_payments [Section titled “layaway\_payments”](#layaway_payments) \| Column | Type | Constraints | |--------|------|-------------| | `id` | `uuid` | PK | | `layaway_id` | `uuid` | NOT NULL, FK `layaways.id` ON DELETE CASCADE | | `amount` | `integer` | NOT NULL | | `method` | `text` | NOT NULL | | `reference` | `text` | — | | `performed_by` | `text` | NOT NULL | | `created_at` | `timestamptz` | NOT NULL, default `now()` | Index: `(layaway_id)`. # Hook System > Hook types, context object, available hook keys, and execution order. For usage patterns and examples, see the [Use Hooks guide](/extending/hooks/). For checkout pipeline details, see the [Hook Pipeline explanation](/concepts/hook-pipeline/). ## Hook types [Section titled “Hook types”](#hook-types) ### BeforeHook [Section titled “BeforeHook”](#beforehook) ```ts type BeforeHook = (args: { data: TData; operation: HookOperation; context: HookContext; }) => Promise | TData; ``` Before hooks receive the incoming data, may mutate or replace it, and return the (possibly modified) data. Throwing aborts the operation. ### AfterHook [Section titled “AfterHook”](#afterhook) ```ts type AfterHook = (args: { data: TData | null; result: TData; operation: HookOperation; context: HookContext; }) => Promise | void; ``` After hooks receive both the original input (`data`) and the operation result. They cannot alter the result. Throwing does not roll back the operation unless executed inside a transaction. ### HookOperation [Section titled “HookOperation”](#hookoperation) ```ts type HookOperation = | "create" | "update" | "delete" | "read" | "list" | "statusChange" | "addItem" | "removeItem" | "custom"; ``` ### HookOrigin [Section titled “HookOrigin”](#hookorigin) ```ts type HookOrigin = "rest" | "local"; ``` *** ## HookContext [Section titled “HookContext”](#hookcontext) ```ts interface HookContext { actor: Actor | null; tx: unknown; logger: Logger; services: ServiceContainer; context: Record; requestId: string; origin: HookOrigin; jobs: JobsAdapter; db: PluginDb; kernel: unknown; } ``` \| Field | Type | Description | |-------|------|-------------| | `actor` | `Actor \| null` | The authenticated user or API key. `null` for anonymous operations. | | `tx` | `unknown` | Current database transaction handle. Cast to `TxContext` at usage sites. | | `logger` | `Logger` | Scoped Pino logger with `info`, `warn`, `error` methods. | | `services` | `ServiceContainer` | All registered service instances (catalog, orders, inventory, etc.). | | `context` | `Record` | Mutable bag for passing data between hooks within the same operation. | | `requestId` | `string` | Unique identifier for the current request. Same value across all hooks in one request. | | `origin` | `HookOrigin` | `"rest"` (HTTP request) or `"local"` (direct kernel call). | | `jobs` | `JobsAdapter` | Adapter for enqueueing background jobs. | | `db` | `PluginDb` | Drizzle database instance. Typed — use directly without casting. | | `kernel` | `unknown` | The kernel singleton. Cast to `Kernel` to access `kernel.services`, `kernel.database.db`, `kernel.hooks`. | ### Actor [Section titled “Actor”](#actor) ```ts interface Actor { type: "user" | "api_key"; userId: string; email: string | null; name: string; vendorId: string | null; organizationId: string | null; role: string; permissions: string[]; } ``` *** ## Available hook keys [Section titled “Available hook keys”](#available-hook-keys) ### Entity hooks [Section titled “Entity hooks”](#entity-hooks) Registered in `config.entities[slug].hooks`. \| Key | Type | Operation | |-----|------|-----------| | `beforeCreate` | `BeforeHook` | `create` | | `afterCreate` | `AfterHook` | `create` | | `beforeUpdate` | `BeforeHook` | `update` | | `afterUpdate` | `AfterHook` | `update` | | `beforeDelete` | `BeforeHook` | `delete` | | `afterDelete` | `AfterHook` | `delete` | | `beforeRead` | `BeforeHook` | `read` | | `afterRead` | `AfterHook` | `read` | | `beforeList` | `BeforeHook` | `list` | | `afterList` | `AfterHook` | `list` | ### Cart hooks [Section titled “Cart hooks”](#cart-hooks) Registered in `config.cart.hooks`. \| Key | Type | Operation | |-----|------|-----------| | `beforeAddItem` | `BeforeHook` | `addItem` | | `afterAddItem` | `AfterHook` | `addItem` | | `beforeRemoveItem` | `BeforeHook` | `removeItem` | | `afterRemoveItem` | `AfterHook` | `removeItem` | | `beforeUpdateQuantity` | `BeforeHook` | `update` | | `afterUpdateQuantity` | `AfterHook` | `update` | ### Checkout hooks [Section titled “Checkout hooks”](#checkout-hooks) Registered in `config.checkout.hooks` or via plugin hooks with key prefix `checkout.`. \| Key | Type | Pipeline position | |-----|------|-------------------| | `beforePayment` | `BeforeHook` | After `calculateShipping`, before `validatePaymentMethod`. Apply gift card deductions, loyalty redemptions, or other balance-based reductions here. | | `beforeCreate` | `BeforeHook` | After `authorizePayment`, before order creation | | `afterCreate` | `AfterHook` | After order creation | ### Order hooks [Section titled “Order hooks”](#order-hooks) Registered in `config.orders.hooks`. \| Key | Type | Operation | |-----|------|-----------| | `beforeCreate` | `BeforeHook` | `create` | | `afterCreate` | `AfterHook` | `create` | | `beforeStatusChange` | `BeforeHook` | `statusChange` | | `afterStatusChange` | `AfterHook` | `statusChange` | | `beforeDelete` | `BeforeHook` | `delete` | ### Inventory hooks [Section titled “Inventory hooks”](#inventory-hooks) Registered in `config.inventory.hooks`. \| Key | Type | Operation | |-----|------|-----------| | `afterAdjust` | `AfterHook` | `custom` | ### System-registered hooks (read-only) [Section titled “System-registered hooks (read-only)”](#system-registered-hooks-read-only) These are registered by the kernel in the `appended` slot at boot. They always run after user-defined and plugin hooks and cannot be disabled or reordered. * **Webhook delivery** — fires on 14 after-hooks; enqueues async delivery jobs * **Audit logging** — fires on 11 after-hooks; writes to `commerce_audit_log` * **Search index sync** — fires on `catalog.afterCreate` and `catalog.afterUpdate` * **Order status email** — fires on `orders.afterStatusChange` *** ## Checkout pipeline execution order [Section titled “Checkout pipeline execution order”](#checkout-pipeline-execution-order) ### Before hooks (pre-order creation) [Section titled “Before hooks (pre-order creation)”](#before-hooks-pre-order-creation) Executed sequentially on `CheckoutData`: \| Step | Function | Purpose | |------|----------|---------| | 1 | `validateCartNotEmpty` | Loads cart, verifies line items, enriches with entity title and type | | 2 | `resolveCurrentPrices` | Resolves live prices and computes subtotal | | 3 | `checkInventoryAvailability` | Verifies sufficient stock for every line item | | 4 | `applyPromotionCodes` | Applies automatic and code-based promotions | | 5 | `calculateTax` | Computes tax via the tax adapter | | 6 | `calculateShipping` | Computes shipping cost | | 7 | *Plugin `checkout.beforePayment` hooks* | Gift cards, loyalty redemptions, balance-based reductions | | 8 | `validatePaymentMethod` | Asserts a payment method ID is present | | 9 | `authorizePayment` | Authorizes payment. Stores `paymentIntentId` in `context`. | ### After hooks (post-order creation, compensation chain) [Section titled “After hooks (post-order creation, compensation chain)”](#after-hooks-post-order-creation-compensation-chain) \| Step | Function | Compensatable | Purpose | |------|----------|---------------|---------| | 1 | `reserveInventoryStep` | Yes | Reserves inventory. Released if a later step fails. | | 2 | `capturePaymentStep` | Yes | Captures the authorized payment. Reversed if a later step fails. | | 3 | `initiateFulfillmentStep` | No (best-effort) | Triggers fulfillment | | 4 | `sendConfirmationStep` | No (best-effort) | Sends order confirmation email | If the compensation chain fails, order status is set to `cancelled`. *** ## Three-slot execution order [Section titled “Three-slot execution order”](#three-slot-execution-order) Every hook key has three execution slots. When a hook fires, all handlers run sequentially: ```plaintext prepended → configured → appended ``` \| Slot | Written by | Purpose | |------|-----------|---------| | `prepended` | System internals, plugins | Validation guards, system checks — run before user hooks | | `configured` | User config (`commerce.config.ts`) | Your hooks | | `appended` | System internals, plugins | Webhook delivery, audit logging, search sync — run after user hooks | If any handler throws, execution stops and the error propagates. *** ## Registration methods [Section titled “Registration methods”](#registration-methods) Three mechanisms register hooks: 1. **Config hooks** — directly in the config object under the relevant module key (e.g., `config.checkout.hooks.beforeCreate`) 2. **Entity hooks** — per entity type in `config.entities[slug].hooks` 3. **Plugin hooks** — via `defineCommercePlugin({ hooks: () => [...] })`, merged into `config.hooks` See the [Use Hooks guide](/extending/hooks/) for code examples of all three methods. # Job Queue > Task definitions, job lifecycle, runner strategies, retry policies, and the commerce_jobs schema. The default execution engine stores background work in the `commerce_jobs` table and processes it with the built-in Postgres runner. Jobs are durable: they survive server restarts and can be retried on failure. Set `jobs.adapter` to replace the engine without changing task definitions. With the built-in engine, jobs accumulate in `pending` status until the cron endpoint, `server.runJobs()`, or `jobs.autorun` drives a runner cycle. Push engines are driven by their native runtime instead. *** ## Task definition [Section titled “Task definition”](#task-definition) A task is a named handler with typed input, output, retry policy, and optional concurrency control: ```ts import type { TaskDefinition } from "@porulle/core"; const sendEmailTask: TaskDefinition< { to: string; template: string; data: Record }, { messageId: string } > = { slug: "email/send", handler: async ({ input, ctx }) => { await ctx.services.email.send({ template: input.template, to: input.to, data: input.data, }); return { output: { messageId: "ok" } }; }, retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 }, }, }; ``` ### TaskDefinition fields [Section titled “TaskDefinition fields”](#taskdefinition-fields) \| Field | Type | Required | Description | | ------------- | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `slug` | `string` | Yes | Unique identifier. Convention: `domain/action` (e.g., `email/send`, `webhooks/deliver`) | | `handler` | `(args) => Promise<{ output }>` | Yes | Function that runs when the job is processed. Receives `{ input, ctx }`. | | `retries` | `{ attempts, backoff? }` | No | Retry policy. `backoff.type` is `"fixed"` or `"exponential"`. `backoff.delay` is milliseconds. | | `concurrency` | `{ key, exclusive?, supersedes? }` | No | `key(input)` identifies related jobs. `exclusive` defaults to `true`; `supersedes` replaces an older pending job for the same task, organization, and key. | ### Handler context [Section titled “Handler context”](#handler-context) \| Field | Type | Description | | -------------- | ------------------ | ------------------------------------------------------ | | `input` | `TInput` | The job’s input payload (typed by the task definition) | | `ctx.db` | `PluginDb` | Database instance for direct queries | | `ctx.logger` | `Logger` | Structured logger (Pino) | | `ctx.services` | `ServiceContainer` | Kernel services (inventory, orders, catalog, etc.) | *** ## Enqueueing jobs [Section titled “Enqueueing jobs”](#enqueueing-jobs) Jobs are enqueued via the jobs adapter on the service container or from `HookContext.jobs`: ```ts // From a hook handler await context.jobs.enqueue( "email/send", { to: "customer@example.com", template: "order-confirmation", data: { orderNumber: "ORD-001", total: "$99.00" }, }, { organizationId: context.actor!.organizationId }, ); // With delayed execution await context.jobs.enqueue( "appointment/reminder", { bookingId: "abc-123" }, { organizationId: context.actor!.organizationId, delayMs: 24 * 60 * 60 * 1000, queue: "reminders", }, ); ``` ### Enqueue options [Section titled “Enqueue options”](#enqueue-options) \| Option | Type | Default | Description | | ---------------- | --------- | ----------------------------- | ---------------------------------------------------------------- | | `organizationId` | `string` | — | Required tenant boundary for the job. Blank values are rejected. | | `queue` | `string` | `"default"` | Queue name. Runners can process specific queues. | | `delayMs` | `number` | — | Delay before the job becomes eligible. | | `maxAttempts` | `number` | Task policy or `1` | Total attempts, including the first execution. | | `concurrencyKey` | `string` | Task `concurrency.key(input)` | Overrides the task-derived key. | | `supersedes` | `boolean` | Task policy | Overrides pending-job supersession. | *** ## Job lifecycle [Section titled “Job lifecycle”](#job-lifecycle) ```plaintext pending ──→ processing ──→ succeeded │ ├──→ pending (retry with backoff) │ └──→ failed (max attempts reached) ``` \| Status | Meaning | | ------------ | --------------------------------------------------------------- | | `pending` | Waiting to be claimed by a runner. Respects `waitUntil` if set. | | `processing` | Claimed by a runner, handler is executing | | `succeeded` | Handler returned successfully. Output stored. | | `failed` | Handler threw and max attempts reached. Error stored. | *** ## Runner strategies [Section titled “Runner strategies”](#runner-strategies) ### 1. Built-in cron endpoint (serverless) [Section titled “1. Built-in cron endpoint (serverless)”](#1-built-in-cron-endpoint-serverless) UC exposes `GET /api/jobs/run` automatically. Point your cron service at it: vercel.json ```json { "crons": [ { "path": "/api/jobs/run", "schedule": "* * * * *" }, { "path": "/api/jobs/run?queue=reminders", "schedule": "*/5 * * * *" } ] } ``` Query parameters: \| Param | Default | Description | | ------- | ----------- | ----------------------- | | `queue` | `"default"` | Which queue to process | | `limit` | `10` | Max jobs per invocation | In production, the endpoint requires `*:*` permission. Pass an API key via `x-api-key` header. ### 2. In-process polling (long-running servers) [Section titled “2. In-process polling (long-running servers)”](#2-in-process-polling-long-running-servers) commerce.config.ts ```ts export default defineConfig({ jobs: { autorun: { enabled: true, intervalMs: 10_000, }, tasks: [sendEmailTask, processOrderTask], }, }); ``` The runner uses `SELECT FOR UPDATE SKIP LOCKED`, so multiple instances can poll the same queue safely without double-processing. For tasks with a concurrency key, the claim transaction excludes keys already in `processing`. If a claimed batch contains multiple exclusive jobs with the same key, only the oldest runs; the rest are released to `pending` without consuming an attempt. Jobs with different keys execute in parallel. ### Processing order [Section titled “Processing order”](#processing-order) The default claim order is oldest `createdAt` first. Configure a sortable field or a comparator when a queue needs a different fairness policy: commerce.config.ts ```ts export default defineConfig({ jobs: { processingOrder: { field: "createdAt", direction: "asc" }, // Or: processingOrder: (left, right) => // Number(right.input.priority) - Number(left.input.priority), }, }); ``` Sort specifications support `createdAt`, `updatedAt`, `attempts`, and `taskSlug`. A comparator receives the stored job records and must be deterministic. ### 3. Custom worker process [Section titled “3. Custom worker process”](#3-custom-worker-process) worker.ts ```ts import { runPendingJobs } from "@porulle/core"; async function loop() { while (true) { const { processed } = await runPendingJobs({ db: kernel.database.db, tasks: taskMap, queue: "default", limit: 20, logger: kernel.logger, services: kernel.services, }); if (processed === 0) { await new Promise((r) => setTimeout(r, 5000)); } } } loop(); ``` `runPendingJobs` drives only the built-in Drizzle engine. A custom pull engine is polled through `server.runJobs()`. Push engines such as Inngest, Trigger.dev, and Cloudflare Workflows are invoked by their native runtime; do not enable `jobs.autorun` for them. *** ## Retry and backoff [Section titled “Retry and backoff”](#retry-and-backoff) When a handler throws, the runner checks `attempts < maxAttempts`: * **Under limit** — Job returns to `pending` with a `waitUntil` computed from the backoff policy * **At limit** — Job marked `failed` with the error message stored ```plaintext Attempt 1: immediate Attempt 2: wait 5s (exponential: 5000 × 2^0) Attempt 3: wait 10s (exponential: 5000 × 2^1) Attempt 4: wait 20s (exponential: 5000 × 2^2) Attempt 5: failed ``` ## Database schema [Section titled “Database schema”](#database-schema) ```sql CREATE TABLE commerce_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id TEXT NOT NULL, queue TEXT NOT NULL DEFAULT 'default', task_slug TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', input JSONB DEFAULT '{}', output JSONB, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1, wait_until TIMESTAMPTZ, concurrency_key TEXT, processing_started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ### Monitoring queries [Section titled “Monitoring queries”](#monitoring-queries) ```sql -- Pending jobs older than 1 hour (stuck) SELECT id, task_slug, created_at, wait_until FROM commerce_jobs WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 hour'; -- Queue depth per queue SELECT queue, COUNT(*) AS pending FROM commerce_jobs WHERE status = 'pending' GROUP BY queue; -- Recent failures SELECT id, task_slug, error, attempts, completed_at FROM commerce_jobs WHERE status = 'failed' ORDER BY completed_at DESC LIMIT 20; -- Processing time per task SELECT task_slug, COUNT(*) AS total, AVG(EXTRACT(EPOCH FROM (completed_at - processing_started_at))) AS avg_seconds FROM commerce_jobs WHERE status = 'succeeded' GROUP BY task_slug; ``` *** ## Register tasks [Section titled “Register tasks”](#register-tasks) All tasks must be registered before jobs referencing them are processed. If a job’s `task_slug` has no matching task definition, the runner marks it as `failed` with “Unknown task slug”. commerce.config.ts ```ts import { APPOINTMENT_EMAIL_TASKS } from "@porulle/plugin-appointments"; export default defineConfig({ jobs: { tasks: [ ...APPOINTMENT_EMAIL_TASKS, { slug: "analytics:daily-aggregate", handler: async ({ ctx }) => { // aggregate daily analytics return { output: { date: new Date().toISOString() } }; }, }, ], }, }); ``` # Packages > Every Porulle package on npm, grouped by role — core, adapters, plugins, importers. Every package below ships from this monorepo, lives under the `@porulle/*` scope on npm, and is versioned in lockstep through Changesets. ```bash bun add @porulle/core @porulle/adapter-postgres @porulle/adapter-stripe ``` 34 packages   v0.8.0 alpha *** ## Core [Section titled “Core”](#core) The four packages every Porulle install touches — kernel, CLI, database, SDK. [@porulle/core ](https://www.npmjs.com/package/@porulle/core)The kernel — services, adapters, hooks, state machines, auth, runtime. Everything else composes on top of it. [@porulle/cli ](https://www.npmjs.com/package/@porulle/cli)Command-line tool. Scaffolds new stores, runs migrations, mints API keys, doctors broken setups. [@porulle/db ](https://www.npmjs.com/package/@porulle/db)The database surface plugins use instead of importing Drizzle directly. [@porulle/sdk ](https://www.npmjs.com/package/@porulle/sdk)Typed TypeScript client for any Porulle server. Generated from the OpenAPI spec — every endpoint type-checked at compile time. *** ## Adapters [Section titled “Adapters”](#adapters) Pluggable infrastructure — pick a database, payment processor, storage backend, search engine, mailer, or tax calculator. Vendor SDKs never leak into core. ### Database [Section titled “Database”](#database) [@porulle/adapter-postgres ](https://www.npmjs.com/package/@porulle/adapter-postgres)The PostgreSQL DatabaseAdapter for @porulle/core. Drizzle on postgres-js. The default backend for long-lived Node servers. [@porulle/adapter-neon ](https://www.npmjs.com/package/@porulle/adapter-neon)Serverless/Workers-grade DatabaseAdapter for Neon. HTTP driver for queries, a fresh WebSocket pool per transaction, Hyperdrive-aware. ### Payment [Section titled “Payment”](#payment) [@porulle/adapter-stripe ](https://www.npmjs.com/package/@porulle/adapter-stripe)PaymentAdapter for Stripe. The reference implementation of the payment-adapter contract. ### Storage [Section titled “Storage”](#storage) [@porulle/adapter-s3 ](https://www.npmjs.com/package/@porulle/adapter-s3)StorageAdapter for AWS S3 (or any S3-compatible bucket — DigitalOcean Spaces, MinIO, Backblaze B2). [@porulle/adapter-r2 ](https://www.npmjs.com/package/@porulle/adapter-r2)StorageAdapter for Cloudflare R2 on Cloudflare Workers. Receives the R2 binding directly — no AWS SDK, no credentials in env. [@porulle/adapter-local-storage ](https://www.npmjs.com/package/@porulle/adapter-local-storage)Filesystem StorageAdapter for development and single-node deployments. Not for production. ### Search [Section titled “Search”](#search) [@porulle/adapter-pg-search ](https://www.npmjs.com/package/@porulle/adapter-pg-search)SearchAdapter powered by PostgreSQL full-text search (tsvector + tsquery). Zero extra infrastructure. [@porulle/adapter-meilisearch ](https://www.npmjs.com/package/@porulle/adapter-meilisearch)SearchAdapter for Meilisearch. Indexes catalog entities and answers /api/search and /api/search/suggest queries. ### Email [Section titled “Email”](#email) [@porulle/adapter-resend ](https://www.npmjs.com/package/@porulle/adapter-resend)Transactional email via Resend. Implements the email.send callback in CommerceConfig. [@porulle/adapter-ses ](https://www.npmjs.com/package/@porulle/adapter-ses)Transactional email via AWS SES v2. ### Tax [Section titled “Tax”](#tax) [@porulle/adapter-tax-manual ](https://www.npmjs.com/package/@porulle/adapter-tax-manual)TaxAdapter that applies a single flat rate. For stores that need basic tax math without a third-party service. [@porulle/adapter-taxjar ](https://www.npmjs.com/package/@porulle/adapter-taxjar)TaxAdapter for TaxJar. Calculates sales tax, files transactions, and voids them on refund. *** ## Plugins [Section titled “Plugins”](#plugins) Optional commerce capabilities — each one extends the kernel through hooks, routes, schema, and permission scopes. Install only what you need. ### Retail and POS [Section titled “Retail and POS”](#retail-and-pos) [@porulle/plugin-pos ](https://www.npmjs.com/package/@porulle/plugin-pos)In-store POS: terminals, shifts with cash events, transactions (sale/return/exchange), payments, barcode lookup, returns, receipts. [@porulle/plugin-pos-restaurant ](https://www.npmjs.com/package/@porulle/plugin-pos-restaurant)Restaurant layer on plugin-pos: tables, floor zones, modifiers, KDS, checklists, alerts, recipes (food BOM), analytics. [@porulle/plugin-layaway ](https://www.npmjs.com/package/@porulle/plugin-layaway)Partial-payment plans: reserve stock with a deposit, pay in installments, auto-complete to a cross-linked order at full payment, forfeit policy hook. ### Customer engagement [Section titled “Customer engagement”](#customer-engagement) [@porulle/plugin-loyalty ](https://www.npmjs.com/package/@porulle/plugin-loyalty)Points, tiers, leaderboard, and redemption offers tied to customers per organization. [@porulle/plugin-giftcards ](https://www.npmjs.com/package/@porulle/plugin-giftcards)Stored-value gift cards with balance checks, checkout redemption, issuance from qualifying purchases, admin lifecycle. [@porulle/plugin-reviews ](https://www.npmjs.com/package/@porulle/plugin-reviews)Customer reviews on catalog entities with moderation, replies, and aggregate summaries. [@porulle/plugin-wishlist ](https://www.npmjs.com/package/@porulle/plugin-wishlist)Per-customer saved items for later purchase, with admin listing across users. [@porulle/plugin-notifications ](https://www.npmjs.com/package/@porulle/plugin-notifications)Templates, multi-channel send, customer preferences, notification log, print jobs. Pluggable SMS / push / print adapters. [@porulle/plugin-appointments ](https://www.npmjs.com/package/@porulle/plugin-appointments)Headless booking — service types, providers, availability, customer appointments with slot-based scheduling. [@porulle/plugin-scheduled-orders ](https://www.npmjs.com/package/@porulle/plugin-scheduled-orders)Defer order execution: schedule future placement and process due schedules in batch. ### Multi-vendor and marketplace [Section titled “Multi-vendor and marketplace”](#multi-vendor-and-marketplace) [@porulle/plugin-marketplace ](https://www.npmjs.com/package/@porulle/plugin-marketplace)Production-grade headless multi-vendor marketplace. Vendors, vendor catalogs, payouts, commission rules, vendor portals. ### Supply chain and operations [Section titled “Supply chain and operations”](#supply-chain-and-operations) [@porulle/plugin-procurement ](https://www.npmjs.com/package/@porulle/plugin-procurement)Suppliers with catalog links, purchase orders through approval, goods-received notes for inbound receiving. [@porulle/plugin-warehouse ](https://www.npmjs.com/package/@porulle/plugin-warehouse)Inter-warehouse stock transfers with approval and dispatch/receive, wastage notes, stock reconciliations. [@porulle/plugin-production ](https://www.npmjs.com/package/@porulle/plugin-production)Bill of materials (BOM), cost rollup, explosion, production orders with material consumption tracking. [@porulle/plugin-uom ](https://www.npmjs.com/package/@porulle/plugin-uom)Units of measure, conversions between units, per-entity UOM assignments for catalog and operations. *** ## Importers [Section titled “Importers”](#importers) One-shot tools for pulling existing catalogs into Porulle. [@porulle/import-shopify ](https://www.npmjs.com/package/@porulle/import-shopify)Import a Shopify store's catalog. Maps product / variant / option / image onto sellable\_entities, variants, option\_types, option\_values, entity\_media. [@porulle/import-woocommerce ](https://www.npmjs.com/package/@porulle/import-woocommerce)Import a WooCommerce store's catalog. Maps product / variation / attribute / image onto Porulle's catalog model. [@porulle/import-flat ](https://www.npmjs.com/package/@porulle/import-flat)Import catalog entities from a flat JSON file. The lowest-friction format — define an array of products, run porulle import, done. *** ## Versioning [Section titled “Versioning”](#versioning) Every package above is published in lockstep — a Patch / Minor / Major bump on one bumps all of them. This is enforced by the [Changesets](https://github.com/changesets/changesets) `fixed` group in `.changeset/config.json`. To propose a version bump from a contributor PR: ```bash bun run changeset ``` Pick the affected packages, the bump kind, and write a one-line summary. The `release` script handles the rest at merge time: ```bash bun run release ``` Source is in [`packages/`](https://github.com/asyncdotengineering/porulle/tree/main/packages) on GitHub. # Plugin API > defineCommercePlugin, manifest, PluginContext, router() builder, and plugin testing helpers. For a step-by-step example, see the [Build a Loyalty Plugin tutorial](/tutorials/build-a-plugin/). For the conceptual model, see [Plugin Architecture](/concepts/plugin-architecture/). ## CommercePlugin [Section titled “CommercePlugin”](#commerceplugin) A plugin is a config transform function: ```ts type CommercePlugin = (config: CommerceConfig) => CommerceConfig | Promise; ``` Plugins are listed in `config.plugins[]` and applied sequentially during `defineConfig()`. *** ## defineCommercePlugin [Section titled “defineCommercePlugin”](#definecommerceplugin) ```ts function defineCommercePlugin(manifest: CommercePluginManifest): CommercePlugin; ``` ### CommercePluginManifest [Section titled “CommercePluginManifest”](#commercepluginmanifest) \| Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | `string` | Yes | Unique plugin identifier | | `version` | `string` | Yes | Semantic version | | `permissions` | `PluginPermission[]` | No | Permission scopes declared by this plugin | | `schema` | `() => Record` | No | Returns Drizzle `pgTable` objects | | `hooks` | `() => PluginHookRegistration[]` | No | Returns hook registrations | | `routes` | `(ctx: PluginContext) => PluginRouteRegistration[]` | No | Returns HTTP route registrations | | `analyticsModels` | `() => AnalyticsModel[]` | No | Returns analytics model definitions | | `apiKeyScopes` | `() => Record` | No | Registers named API-key scopes merged into `config.auth.apiKeyScopes` | ### PluginPermission [Section titled “PluginPermission”](#pluginpermission) ```ts interface PluginPermission { scope: string; description: string; } ``` Permissions declared here are registered with the authorization system at boot. Routes can require them via `.permission("scope")`. ```ts permissions: [ { scope: "wishlist:read", description: "View wishlists" }, { scope: "wishlist:write", description: "Create and modify wishlists" }, ], ``` *** ## PluginContext [Section titled “PluginContext”](#plugincontext) Provided to the `routes` factory function at kernel boot time: ```ts interface PluginContext { config: CommerceConfig; services: Record; database: { db: PluginDb; transaction(fn: (tx: PluginDb) => Promise): Promise; }; logger: PluginLogger; auth?: unknown; } ``` `database.db` is typed as `PluginDb`. Use it directly for Drizzle queries without casting. `auth` is the Better Auth instance. It is present when routes are mounted by `createServer` and absent in bare-kernel setups, so plugins must handle it being `undefined`. Plugins use it to mint or verify credentials at runtime — for example, plugin-pos PIN login. *** ## apiKeyScopes [Section titled “apiKeyScopes”](#apikeyscopes) Registers named API-key scopes that get merged into `config.auth.apiKeyScopes`. A user config entry of the same name wins over the plugin’s. ```ts apiKeyScopes?: () => Record; keyExpiration?: { minExpiresIn?: number; maxExpiresIn?: number }; }>; ``` `keyExpiration` bounds are in **days** and accept fractions (`1/24` = one hour, `1/96` = 15 minutes). This is how plugin-pos mints short-lived per-shift keys: ```ts apiKeyScopes: () => ({ pos: { prefix: "pos_shift_", description: "Per-shift POS key", permissions: { pos: ["operate"] }, keyExpiration: { minExpiresIn: 1 / 96 }, }, }), ``` *** ## PluginHookRegistration [Section titled “PluginHookRegistration”](#pluginhookregistration) ```ts interface PluginHookRegistration { key: string; handler: (...args: unknown[]) => unknown; } ``` `key` corresponds to any hook key (e.g., `"checkout.beforeCreate"`, `"orders.afterCreate"`). The handler is appended to the array for that key in `config.hooks`. *** ## router() builder [Section titled “router() builder”](#router-builder) The `router()` builder provides a fluent API for defining plugin routes with built-in authentication, permissions, input validation, and OpenAPI documentation. ```ts import { router } from "@porulle/core"; const routes = router("wishlist", "/api/wishlist") .get("/", { summary: "List wishlist items" }) .auth() .handler(async ({ actor, db }) => { const items = await db.select().from(wishlistItems) .where(eq(wishlistItems.customerId, actor.userId)); return { data: items }; }) .post("/", { summary: "Add item to wishlist" }) .auth() .permission("wishlist:write") .input(addItemSchema) .handler(async ({ input, actor, db }) => { const [item] = await db.insert(wishlistItems) .values({ customerId: actor.userId, ...input }) .returning(); return { data: item }; }) .delete("/:id", { summary: "Remove item from wishlist" }) .auth() .permission("wishlist:write") .handler(async ({ actor, db, params }) => { await db.delete(wishlistItems) .where(eq(wishlistItems.id, params.id)); return { data: { deleted: true } }; }) .build(); ``` **`router(tag, prefix)`** — creates a route group. `tag` is used for OpenAPI grouping; `prefix` is prepended to all route paths. **Chain methods:** \| Method | Description | |--------|-------------| | `.get(path, opts?)` / `.post()` / `.put()` / `.patch()` / `.delete()` | Start a new route definition. `opts` accepts `{ summary }` for OpenAPI. | | `.auth()` | Require authentication. `actor` is guaranteed in the handler context. | | `.permission(scope)` | Require a permission scope. Implicitly calls `.auth()`. | | `.input(zodSchema)` | Validate the request body with a Zod schema. Parsed value is available as `input`. | | `.handler(fn)` | The route handler. Receives `{ input, actor, services, db, params }`. | | `.build()` | Returns the array of `PluginRouteRegistration` objects. | All routes defined via `router()` appear automatically in the OpenAPI spec at `GET /api/doc` and the Swagger UI at `GET /api/reference`. *** ## Plugin lifecycle [Section titled “Plugin lifecycle”](#plugin-lifecycle) 1. **Collection** — Plugins are applied in array order during `defineConfig()`. Order matters: if Plugin B queries tables from Plugin A, Plugin A must come first. 2. **Permissions** — Permission scopes are registered globally at boot. 3. **Schema** — Table definitions are pushed into `config.customSchemas[]` and merged via `buildSchema(config)`. 4. **Hooks** — Hook registrations are merged into `config.hooks`. 5. **Routes** — Route factories are chained and executed at kernel boot, receiving `PluginContext`. 6. **Analytics models** — Models are pushed into `config.analytics.models`. *** ## Official plugins [Section titled “Official plugins”](#official-plugins) \| Package | Description | |---------|-------------| | `@porulle/plugin-marketplace` | Multi-vendor marketplace, commissions, sub-orders, financial ledger, disputes | | `@porulle/plugin-pos` | POS terminals, shifts, split payment, returns, Z-reports | | `@porulle/plugin-pos-restaurant` | Restaurant extensions: modifiers, tables, KDS, combos | | `@porulle/plugin-uom` | Units of measure, category-based conversions, AP/EP yield | | `@porulle/plugin-procurement` | Suppliers, purchase orders, goods received notes | | `@porulle/plugin-warehouse` | Stock transfers, wastage, stock reconciliation | | `@porulle/plugin-production` | Multi-level BOM, cost rollup, production orders | | `@porulle/plugin-notifications` | Unified dispatcher: SMS, push, print, templates, audit | | `@porulle/plugin-scheduled-orders` | Future/scheduled orders with auto-processing | | `@porulle/plugin-reviews` | Customer reviews, ratings, moderation, owner reply | | `@porulle/plugin-appointments` | Appointment scheduling, slot generation, double-booking prevention | | `@porulle/plugin-gift-cards` | Stored-value cards, balance tracking, checkout integration | *** ## Plugin testing helpers [Section titled “Plugin testing helpers”](#plugin-testing-helpers) ### `createPluginTestApp(plugin, overrides?)` [Section titled “createPluginTestApp(plugin, overrides?)”](#createplugintestappplugin-overrides) Boots an in-memory PGlite database, pushes the merged schema (core + plugin tables), mounts the `x-test-actor` middleware, and registers all plugin routes. ```ts import { createPluginTestApp } from "@porulle/core"; const { app, kernel, db } = await createPluginTestApp(myPlugin()); ``` **Returns:** \| Field | Type | Description | |-------|------|-------------| | `app` | `OpenAPIHono` | Hono app with routes registered. Call `app.request(url, init)` to test. | | `kernel` | `Kernel` | Booted kernel. Use for direct service-layer assertions. | | `db` | `PluginDb` | Drizzle database instance. Use for direct query assertions. | Pass `databaseAdapter` in overrides to use a real PostgreSQL instance instead of PGlite. ### `jsonHeaders(actor?)` [Section titled “jsonHeaders(actor?)”](#jsonheadersactor) Builds request headers with `x-test-actor` injection: ```ts import { jsonHeaders, testAdminActor } from "@porulle/core"; const res = await app.request("http://localhost/api/my-route", { method: "POST", headers: jsonHeaders(testAdminActor), body: JSON.stringify({ ... }), }); ``` ### Test actors [Section titled “Test actors”](#test-actors) \| Export | Role | Permissions | |--------|------|-------------| | `testAdminActor` | admin | `*:*` | | `testStaffActor` | staff | catalog, inventory, orders | | `testCustomerActor` | customer | catalog:read, cart, orders:read:own | | `testNoPermActor` | customer | (none) | ### Typed hook helpers [Section titled “Typed hook helpers”](#typed-hook-helpers) ```ts import { beforeHook, afterHook } from "@porulle/core"; hooks: () => [ afterHook<{ id: string; grandTotal: number }>("orders.afterCreate", async ({ result, context }) => { await context.jobs.enqueue("loyalty:award-points", { orderId: result.id }); }), ], ``` *** ## @porulle/db package [Section titled “@porulle/db package”](#porulledb-package) Plugin developers import from `@porulle/db` instead of `drizzle-orm` directly. ```ts import { defineTable, column, eq, and, desc, sql } from "@porulle/db"; ``` ### defineTable(name, columns) [Section titled “defineTable(name, columns)”](#definetablename-columns) Wraps Drizzle’s `pgTable` with auto-injected fields for org-scoped tables: \| Auto-injected field | Description | |--------------------|-------------| | `id` | `uuid`, primary key, `defaultRandom()` | | `organizationId` | `text`, NOT NULL, FK to `organization.id` | | `createdAt` | `timestamptz`, `defaultNow()`, NOT NULL | | `updatedAt` | `timestamptz`, `defaultNow()`, NOT NULL | Child tables (detected by FK to a table that has `organizationId`) get only `id` and `createdAt` — no duplicate `organizationId`. ### column namespace [Section titled “column namespace”](#column-namespace) \| Builder | Options | |---------|---------| | `column.text(opts?)` | `unique`, `optional`, `enum`, `default` | | `column.integer(opts?)` | `unique`, `optional`, `default` | | `column.boolean(opts?)` | `optional`, `default` | | `column.uuid(opts?)` | `optional`, `references` (PgTable for FK) | | `column.timestamp(opts?)` | `optional`, `default` (`"now"`) | | `column.json(opts?)` | `optional`, `default` | # REST API > All endpoints, methods, request bodies, query parameters, and response shapes. All routes are mounted under the `/api` prefix. The full interactive spec is available at `GET /api/reference` (Swagger UI) and `GET /api/doc` (OpenAPI 3.0 JSON). **Authentication:** API key via `x-api-key` header, or a Better Auth session cookie for browser clients. **Successful responses:** `{ data: ... }`. List endpoints include `meta.pagination`. Errors return `{ error: { code, message } }`. **`X-Request-Id`:** Every response includes this header with a unique request identifier. *** ## OpenAPI and Swagger [Section titled “OpenAPI and Swagger”](#openapi-and-swagger) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/api/doc` | OpenAPI 3.0 JSON spec | | `GET` | `/api/reference` | Swagger UI (interactive explorer) | All core routes and plugin routes registered via `router()` appear in the spec automatically. *** ## Health [Section titled “Health”](#health) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/api/health` | Server status, version, database connectivity | Probes the database connection. Returns `503` if the database is unreachable. ```json // healthy { "status": "ok", "version": "0.1.0" } // unhealthy { "status": "error", "version": "0.1.0", "database": "unreachable" } ``` *** ## Request limits [Section titled “Request limits”](#request-limits) **Body size:** 1 MB maximum. Requests exceeding this receive `413 Payload Too Large`. **Rate limiting:** Returns `429 Too Many Requests` when exceeded. Rate limit headers on every response: \| Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Max requests in the current window | | `X-RateLimit-Remaining` | Remaining requests in this window | | `X-RateLimit-Reset` | Unix timestamp when the window resets | | `Retry-After` | Seconds until retry (only on `429`) | **CSRF:** Mutation methods (`POST`, `PUT`, `PATCH`, `DELETE`) validate the `Origin` header for browser requests. API-key requests are exempt. *** ## Catalog [Section titled “Catalog”](#catalog) Base path: `/api/catalog` ### Entities [Section titled “Entities”](#entities) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/catalog/entities` | List catalog entities | | `GET` | `/catalog/entities/:idOrSlug` | Get entity by UUID or slug | | `POST` | `/catalog/entities` | Create a catalog entity | | `PATCH` | `/catalog/entities/:id` | Update an entity | | `DELETE` | `/catalog/entities/:id` | Delete an entity | | `POST` | `/catalog/entities/:id/publish` | Transition to `active` status | | `POST` | `/catalog/entities/:id/archive` | Transition to `archived` status | | `POST` | `/catalog/entities/:id/discontinue` | Transition to `discontinued` status | **`GET /catalog/entities` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `type` | `string` | Filter by entity type (`product`, `digitalDownload`, etc.) | | `status` | `string` | Filter by status (`draft`, `active`, `archived`, `discontinued`) | | `category` | `string` | Filter by category ID | | `brand` | `string` | Filter by brand ID | | `page` | `number` | Page number (default `1`) | | `limit` | `number` | Items per page (default `20`, max `100`) | | `sort` | `string` | Sort field and direction (e.g. `createdAt:desc`) | | `include` | `string` | Comma-separated: `attributes`, `variants`, `categories`, `brands`, `media`, `inventory`, `pricing` | **`POST /catalog/entities` body:** ```json { "type": "product", "slug": "blue-widget", "status": "draft", "metadata": {} } ``` ### Localized attributes [Section titled “Localized attributes”](#localized-attributes) \| Method | Path | Description | |--------|------|-------------| | `PUT` | `/catalog/entities/:id/attributes/:locale` | Set localized attributes | | `GET` | `/catalog/entities/:id/attributes/:locale` | Get localized attributes | **`PUT` body:** ```json { "title": "Blue Widget", "description": "A fine blue widget.", "seoTitle": "Buy Blue Widget", "seoDescription": "Best blue widget online." } ``` ### Categories [Section titled “Categories”](#categories) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/catalog/categories` | List all categories | | `POST` | `/catalog/categories` | Create a category | | `PATCH` | `/catalog/categories/:categoryId` | Update a category | | `DELETE` | `/catalog/categories/:categoryId` | Delete a category | | `POST` | `/catalog/entities/:id/categories/:categoryId` | Add entity to a category | | `DELETE` | `/catalog/entities/:id/categories/:categoryId` | Remove entity from a category | ### Brands [Section titled “Brands”](#brands) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/catalog/brands` | List all brands | | `POST` | `/catalog/brands` | Create a brand | | `PATCH` | `/catalog/brands/:brandId` | Update a brand | | `DELETE` | `/catalog/brands/:brandId` | Delete a brand | | `POST` | `/catalog/entities/:id/brands/:brandId` | Add entity to a brand | | `DELETE` | `/catalog/entities/:id/brands/:brandId` | Remove entity from a brand | ### Options and variants [Section titled “Options and variants”](#options-and-variants) \| Method | Path | Description | |--------|------|-------------| | `POST` | `/catalog/entities/:id/options` | Create an option type | | `POST` | `/catalog/options/:optionTypeId/values` | Create an option value | | `POST` | `/catalog/entities/:id/variants` | Create a single variant | | `POST` | `/catalog/entities/:id/variants/generate` | Generate variants from an option matrix | | `POST` | `/catalog/entities/:id/variants/quick` | One-call variant: upsert option axes inline + seed zero-stock (`201` created / `200` existing) | | `POST` | `/catalog/entities/:id/variants/bulk` | Create the full cartesian matrix from `axes`, skipping existing combos | *** ## Cart [Section titled “Cart”](#cart) Base path: `/api/carts` \| Method | Path | Description | |--------|------|-------------| | `POST` | `/carts` | Create a new cart | | `GET` | `/carts/:id` | Get cart with line items | | `POST` | `/carts/:id/items` | Add an item to the cart | | `PATCH` | `/carts/:id/items/:itemId` | Update item quantity | | `DELETE` | `/carts/:id/items/:itemId` | Remove an item from the cart | **`POST /carts` body:** ```json { "currency": "USD", "customerId": "optional-uuid" } ``` **`POST /carts/:id/items` body:** ```json { "entityId": "uuid", "variantId": "uuid-or-omit", "quantity": 2, "unitPriceSnapshot": 1999 } ``` **`PATCH /carts/:id/items/:itemId` body:** ```json { "quantity": 3 } ``` *** ## Checkout [Section titled “Checkout”](#checkout) \| Method | Path | Description | |--------|------|-------------| | `POST` | `/api/checkout` | Create an order from a cart | **Body:** ```json { "cartId": "uuid", "paymentMethodId": "pm_...", "currency": "USD", "customerId": "optional", "customerGroupIds": ["optional"], "promotionCodes": ["SAVE10"], "shippingAddress": { "line1": "123 Main St", "city": "Springfield", "state": "IL", "postalCode": "62701", "country": "US" } } ``` Returns the created order. If after-hooks produce non-fatal errors, the response includes `meta.hookErrors`. Failure: `422` with `{ error: { code: "CHECKOUT_FAILED", message: "..." } }`. *** ## Orders [Section titled “Orders”](#orders) Base path: `/api/orders` \| Method | Path | Description | |--------|------|-------------| | `GET` | `/orders` | List orders | | `GET` | `/orders/:idOrNumber` | Get order by UUID or order number (`ORD-YYYY-NNNNNN`) | | `PATCH` | `/orders/:id/status` | Change order status | | `GET` | `/orders/:id/fulfillments` | List fulfillments for an order | | `POST` | `/orders/:id/refunds` | Refund specific line quantities (`orders:update`) | | `POST` | `/orders/:id/refunds/:refundId/undo` | Undo a refund within the window (`orders:update`) | | `GET` | `/orders/:id/refunds` | List an order’s refunds (`orders:read`) | | `GET` | `/orders/refunds/cap` | Acting operator’s daily refund-cap status (`orders:read`) | | `POST` | `/orders/:id/notes` | Add a note (`orders:update`) | | `GET` | `/orders/:id/notes` | List notes, pinned-first (`orders:read`) | | `DELETE` | `/orders/:id/notes/:noteId` | Delete a note (`orders:update`) | | `GET` | `/orders/:id/timeline` | Merged activity timeline (`orders:read`) | | `GET` | `/orders/:id/invoice.pdf` | Fiscal PDF invoice (`orders:read`) | | `GET` | `/orders/:id/invoice.html` | HTML invoice (`orders:read`) | | `GET` | `/orders/:id/receipt.html` | HTML receipt (`orders:read`) | | `POST` | `/orders/:id/invoice/email` | Email the invoice (`orders:read`) | **`GET /orders` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `status` | `string` | Filter by status | | `page` | `number` | Page number | | `limit` | `number` | Items per page | **`PATCH /orders/:id/status` body:** ```json { "status": "confirmed", "reason": "optional reason string" } ``` Valid transitions: `pending → confirmed → processing → partially_fulfilled | fulfilled → refunded`. Cancel is allowed from `pending`, `confirmed`, `processing`, `partially_fulfilled`. **`POST /orders/:id/refunds` body:** ```json { "lines": [{ "lineItemId": "uuid", "quantity": 1 }], "reason": "optional, max 500 chars" } ``` Rejects refunding more than `quantity - refundedQuantity` on a line (`422`). A per-operator daily cap (`policies.refundDailyCap`, minor units) returns `403` when exceeded. Undo is blocked past `policies.refundUndoWindowMinutes` (default 15). See [Refunds & Exchanges](/building/refunds-and-exchanges/). **`POST /orders/:id/notes` body:** ```json { "body": "note text (1–2000 chars)", "pinned": false } ``` **`GET /orders/:id/timeline`** returns newest-first entries merging status changes, notes, and refunds (with undo events): ```json { "data": [{ "type": "status | note | refund", "at": "ISO", "actor": "...", "summary": "...", "data": {} }] } ``` *** ## Inventory [Section titled “Inventory”](#inventory) Base path: `/api/inventory` \| Method | Path | Description | |--------|------|-------------| | `GET` | `/inventory/check` | Check stock for one or more entities | | `POST` | `/inventory/adjust` | Adjust an inventory level | | `POST` | `/inventory/reserve` | Reserve inventory | | `POST` | `/inventory/release` | Release a reservation | | `POST` | `/inventory/warehouses` | Create a warehouse | | `GET` | `/inventory/warehouses` | List warehouses | **`GET /inventory/check` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `entityIds` | `string` | Comma-separated entity UUIDs | **`POST /inventory/adjust` body:** ```json { "entityId": "uuid", "variantId": "optional-uuid", "warehouseId": "uuid", "type": "restock", "quantity": 50 } ``` *** ## Pricing [Section titled “Pricing”](#pricing) Base path: `/api/pricing` \| Method | Path | Description | |--------|------|-------------| | `POST` | `/pricing/prices` | Create a price record | | `GET` | `/pricing/prices` | List prices | | `POST` | `/pricing/modifiers` | Create a price modifier | **`GET /pricing/prices` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `entityId` | `string` | Filter by entity ID | | `variantId` | `string` | Filter by variant ID | | `currency` | `string` | Filter by currency code | | `customerGroupId` | `string` | Filter by customer group | **`POST /pricing/prices` body:** ```json { "entityId": "uuid", "variantId": "optional-uuid", "currency": "USD", "amount": 2999 } ``` *** ## Promotions [Section titled “Promotions”](#promotions) Base path: `/api/promotions` \| Method | Path | Description | |--------|------|-------------| | `POST` | `/promotions` | Create a promotion | | `GET` | `/promotions` | List active promotions | | `POST` | `/promotions/validate` | Validate a promotion code against a cart | | `POST` | `/promotions/:id/deactivate` | Deactivate a promotion | **`POST /promotions` body:** ```json { "code": "SAVE10", "name": "Save 10%", "type": "percentage_off_order", "value": 10, "validFrom": "2025-01-01T00:00:00Z", "validUntil": "2025-12-31T23:59:59Z" } ``` `validFrom` and `validUntil` are ISO 8601 strings coerced to `Date` objects on the server. **`POST /promotions/validate` body:** ```json { "code": "SAVE10", "currency": "USD", "subtotal": 5000, "lineItems": [ { "entityId": "uuid", "entityType": "product", "quantity": 1, "unitPrice": 5000, "totalPrice": 5000 } ], "customerId": "optional", "customerGroupIds": ["optional"] } ``` *** ## Search [Section titled “Search”](#search) Base path: `/api/search` \| Method | Path | Description | |--------|------|-------------| | `GET` | `/search` | Full-text search across catalog entities | | `GET` | `/search/suggest` | Autocomplete prefix suggestions | **`GET /search` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `q` | `string` | Search query | | `type` | `string` | Filter by entity type | | `category` | `string` | Filter by category | | `brand` | `string` | Filter by brand | | `status` | `string` | Filter by status | | `page` | `number` | Page number (default `1`) | | `limit` | `number` | Results per page (default `20`, max `100`) | | `facets` | `string` | Comma-separated facet fields to return | Response includes `data` (hits array) and `meta` with `total`, `page`, `limit`, `facets`. **`GET /search/suggest` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `prefix` | `string` | Prefix to match | | `type` | `string` | Filter by entity type | | `limit` | `number` | Max suggestions (default `10`) | *** ## Media [Section titled “Media”](#media) Base path: `/api/media` \| Method | Path | Description | |--------|------|-------------| | `POST` | `/media/upload` | Upload a file (`multipart/form-data`) | | `GET` | `/media/:id` | Get media URL (redirects `302`) | | `DELETE` | `/media/:id` | Delete a media asset | | `POST` | `/media/attach` | Attach a media asset to an entity | **`POST /media/upload`** fields (`multipart/form-data`): \| Field | Type | Required | |-------|------|----------| | `file` | `File` | Yes | | `alt` | `string` | No | **`GET /media/:id` query parameters:** \| Parameter | Value | Description | |-----------|-------|-------------| | `signed` | `"true"` | Return a signed URL with expiry instead of a public URL | **`POST /media/attach` body:** ```json { "entityId": "uuid", "mediaAssetId": "uuid", "role": "thumbnail" } ``` *** ## Payments [Section titled “Payments”](#payments) \| Method | Path | Description | |--------|------|-------------| | `POST` | `/api/payments/webhook` | Payment provider webhook receiver | Accepts raw webhook payloads from the configured payment adapter. On `payment_intent.succeeded` with `metadata.orderId`, transitions the order to `confirmed` automatically. *** ## Webhooks [Section titled “Webhooks”](#webhooks) Base path: `/api/webhooks` Requires `webhooks:manage` permission. \| Method | Path | Description | |--------|------|-------------| | `POST` | `/webhooks` | Register a webhook endpoint | | `GET` | `/webhooks` | List registered endpoints | | `DELETE` | `/webhooks/:id` | Delete a webhook endpoint | *** ## Customer portal [Section titled “Customer portal”](#customer-portal) Base path: `/api/me` Requires an authenticated Better Auth session cookie. All routes return `401` without a session. \| Method | Path | Description | |--------|------|-------------| | `GET` | `/me/profile` | Get current customer profile | | `PATCH` | `/me/profile` | Update current customer profile | | `GET` | `/me/addresses` | List customer addresses | | `POST` | `/me/addresses` | Add a new address | | `DELETE` | `/me/addresses/:id` | Delete an address | | `GET` | `/me/orders` | List orders for the current customer | | `GET` | `/me/orders/:idOrNumber` | Get a specific order by UUID or order number | | `GET` | `/me/orders/:idOrNumber/tracking` | Get fulfillment tracking for an order | | `GET` | `/me/orders/:orderId/downloads` | Get download links for digital items | | `POST` | `/me/orders/:orderId/reorder` | Create a new cart pre-filled from a previous order | **`GET /me/orders` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `status` | `string` | Filter by order status | | `page` | `number` | Page number | | `limit` | `number` | Items per page | *** ## Analytics [Section titled “Analytics”](#analytics) Base path: `/api/analytics` \| Method | Path | Description | |--------|------|-------------| | `GET` | `/analytics/meta` | List available models, measures, and dimensions | | `POST` | `/analytics/query` | Execute an analytics query | | `GET` | `/analytics/reports` | List canned retail reports (`analytics:read`) | | `GET` | `/analytics/reports/:name` | Run a retail report (`analytics:read`) | **`POST /analytics/query` body:** See [Analytics reference](/reference/analytics/) for the full query format. **Retail reports** (`:name`): `daily-journal`, `tax-summary`, `inventory-aging`, `sell-through`, `reorder-needed`, `staff-sales`. Query params (all `YYYY-MM-DD`, optional): `date` for single-day reports; `from` + `to` for ranges. Calendar math uses the store’s `settings.general.timezone`. See [Retail reports](/building/analytics/#retail-reports). *** ## Settings [Section titled “Settings”](#settings) Base path: `/api/settings`. All routes require `settings:manage`. See [Store Settings](/building/settings/). \| Method | Path | Description | |--------|------|-------------| | `GET` | `/settings` | All groups | | `GET` | `/settings/:group` | One group’s value (`{}` when unset) | | `PATCH` | `/settings/:group` | Shallow-merge a group; a `null` value deletes that key | Known groups are validated (`422` on bad input): `general` (`currency`, `timezone`, `locale`), `branding` (store identity for receipts), `policies` (e.g. `refundDailyCap`, `refundUndoWindowMinutes`). Unknown groups accept any object. *** ## Tax [Section titled “Tax”](#tax) Base path: `/api/tax`. Class routes require `tax:manage`. See [Tax Classes](/building/tax/). \| Method | Path | Description | |--------|------|-------------| | `GET` | `/tax/classes` | List tax classes | | `POST` | `/tax/classes` | Create a class (`name` slug, `rateBps`, `isDefault?`, `isActive?`) | | `PATCH` | `/tax/classes/:id` | Update a class | | `DELETE` | `/tax/classes/:id` | Delete a class | Per-line tax resolves variant `taxClass` → entity `taxClass` → the org’s default class → `0`. When an org defines active classes they take precedence over runtime region rates. *** ## Audit [Section titled “Audit”](#audit) Base path: `/api/audit` Requires `audit:read` permission. \| Method | Path | Description | |--------|------|-------------| | `GET` | `/audit` | List audit entries | | `GET` | `/audit/:entityType/:entityId` | Audit history for a specific entity | **`GET /audit` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `entityType` | `string` | Filter by entity type (e.g. `order`, `catalog_entity`, `inventory`) | | `entityId` | `string` | Filter by entity UUID | | `event` | `string` | Filter by event name (e.g. `orders.statusChange`) | | `actorId` | `string` | Filter by actor | | `from` | `string` | ISO 8601 start date | | `to` | `string` | ISO 8601 end date | | `limit` | `number` | Max entries (default `50`, max `100`) | *** ## Auth [Section titled “Auth”](#auth) Base path: `/api/auth` Authentication is handled by [Better Auth](https://www.better-auth.com/). These are the core sign-up/sign-in endpoints. Better Auth exposes additional endpoints for password reset, email verification, sessions, and OAuth. \| Method | Path | Description | |--------|------|-------------| | `POST` | `/auth/sign-up/email` | Register a new user | | `POST` | `/auth/sign-in/email` | Sign in with email and password | | `GET` | `/auth/get-session` | Get the current session | **`POST /auth/sign-up/email` body:** ```json { "email": "user@example.com", "password": "secret", "name": "Jane Doe" } ``` **`POST /auth/sign-in/email` body:** ```json { "email": "user@example.com", "password": "secret" } ``` *** ## Job management [Section titled “Job management”](#job-management) Base path: `/api/admin/jobs` Requires `jobs:admin` permission. \| Method | Path | Description | |--------|------|-------------| | `GET` | `/admin/jobs/failed` | List failed background jobs | | `POST` | `/admin/jobs/:id/retry` | Re-enqueue a failed job | **`GET /admin/jobs/failed` query parameters:** \| Parameter | Type | Description | |-----------|------|-------------| | `page` | `number` | Page number | | `limit` | `number` | Items per page | **`POST /admin/jobs/:id/retry`** returns the updated job record. The cron job runner endpoint: \| Method | Path | Description | |--------|------|-------------| | `GET` | `/api/jobs/run` | Process pending jobs (cron target) | Query parameters: `queue` (default `"default"`), `limit` (default `10`). Requires `*:*` permission via `x-api-key`. *** ## Marketplace plugin [Section titled “Marketplace plugin”](#marketplace-plugin) Added by `@porulle/plugin-marketplace`. All paths prefixed with `/api/marketplace/`. ### Vendor management (admin) [Section titled “Vendor management (admin)”](#vendor-management-admin) \| Method | Path | Description | |--------|------|-------------| | `GET` | `/vendors` | List vendors | | `POST` | `/vendors` | Register a vendor | | `GET` | `/vendors/:id` | Vendor detail | | `PATCH` | `/vendors/:id` | Update vendor | | `POST` | `/vendors/:id/approve` | Approve vendor | | `POST` | `/vendors/:id/reject` | Reject with reason | | `POST` | `/vendors/:id/suspend` | Suspend vendor | | `POST` | `/vendors/:id/reinstate` | Reinstate vendor | | `GET` | `/vendors/:id/documents` | List documents | | `POST` | `/vendors/:id/documents` | Upload document | | `POST` | `/vendors/:id/documents/:docId/approve` | Approve document | | `POST` | `/vendors/:id/documents/:docId/reject` | Reject document | | `GET` | `/vendors/:id/balance` | Balance ledger | | `GET` | `/vendors/:id/performance` | Performance metrics and rating | ### Vendor portal (self-service) [Section titled “Vendor portal (self-service)”](#vendor-portal-self-service) Scoped automatically to `actor.vendorId`. \| Method | Path | Description | |--------|------|-------------| | `GET` | `/vendor/me` | Own vendor profile | | `PATCH` | `/vendor/me` | Update own profile | | `GET` | `/vendor/me/orders` | Own sub-orders | | `POST` | `/vendor/me/orders/:id/confirm` | Confirm sub-order | | `POST` | `/vendor/me/orders/:id/ship` | Ship with tracking number | | `POST` | `/vendor/me/orders/:id/deliver` | Mark delivered | | `POST` | `/vendor/me/orders/:id/cancel` | Cancel sub-order | | `GET` | `/vendor/me/payouts` | Payout history | | `GET` | `/vendor/me/balance` | Balance ledger | | `GET` | `/vendor/me/reviews` | Reviews received | ### Other marketplace endpoints [Section titled “Other marketplace endpoints”](#other-marketplace-endpoints) \| Group | Paths | |-------|-------| | Commission rules | `GET /commission-rules`, `POST /commission-rules`, `PATCH /commission-rules/:id`, `DELETE /commission-rules/:id` | | Payouts | `GET /payouts`, `POST /payouts/run`, `POST /payouts/:id/retry` | | Sub-orders (admin) | `GET /sub-orders`, `GET /sub-orders/:id`, `PATCH /sub-orders/:id/status` | | Disputes | `POST /disputes`, `GET /disputes`, `POST /disputes/:id/respond`, `POST /disputes/:id/escalate`, `POST /disputes/:id/resolve` | | Returns | `POST /returns`, `POST /returns/:id/ship-back`, `POST /returns/:id/receive` | | Reviews | `POST /vendors/:id/reviews`, `GET /vendors/:id/reviews`, `PATCH /vendors/:id/reviews/:reviewId` | *** ## POS plugin (auth & exchanges) [Section titled “POS plugin (auth & exchanges)”](#pos-plugin-auth--exchanges) Added by `@porulle/plugin-pos`. See [Point of Sale](/building/pos/) and [Refunds & Exchanges](/building/refunds-and-exchanges/). Service-level failures on these routes surface as `500` with the message. \| Method | Path | Description | |--------|------|-------------| | `PUT` | `/api/pos/auth/pin` | Set/rotate an operator PIN (`pos:admin`) | | `POST` | `/api/pos/auth/pin-login` | PIN login → mints a short-lived per-shift API key (`pos:operate`) | | `POST` | `/api/pos/auth/override` | Manager override by PIN (`pos:operate`) | | `POST` | `/api/pos/exchanges` | Return + replacement order in one transaction (`pos:manage` + `orders:update` + `orders:create`) | *** ## Layaway plugin [Section titled “Layaway plugin”](#layaway-plugin) Added by `@porulle/plugin-layaway`. All paths under `/api/layaways`. See [Layaway](/building/layaway/). \| Method | Path | Description | |--------|------|-------------| | `POST` | `/layaways` | Create a plan; reserves stock (`layaway:operate`) | | `GET` | `/layaways` | List plans, optional `?status` (`layaway:operate`) | | `GET` | `/layaways/:id` | Plan with its payment ledger (`layaway:operate`) | | `POST` | `/layaways/:id/payments` | Record an installment; auto-completes at full payment (`layaway:operate` + `orders:create`) | | `POST` | `/layaways/:id/forfeit` | Forfeit a plan; releases stock (`layaway:manage`) | *** ## Permission requirements [Section titled “Permission requirements”](#permission-requirements) \| Endpoint group | Required permission | |----------------|---------------------| | `POST /webhooks`, `GET /webhooks`, `DELETE /webhooks/:id` | `webhooks:manage` | | `GET /audit`, `GET /audit/:entityType/:entityId` | `audit:read` | | `POST /pricing/prices`, `POST /pricing/modifiers` | `pricing:create` | | `GET /pricing/prices` | `pricing:update` | | `POST /promotions` | `promotions:create` | | `PATCH /promotions/:id`, `POST /promotions/:id/deactivate` | `promotions:update` | | `POST /marketplace/vendors/:id/approve`, `.../suspend` | `marketplace:admin` | | `GET /admin/jobs/failed`, `POST /admin/jobs/:id/retry` | `jobs:admin` | | `GET /settings`, `PATCH /settings/:group` | `settings:manage` | | `GET /analytics/reports`, `GET /analytics/reports/:name` | `analytics:read` | | `GET/POST/PATCH/DELETE /tax/classes` | `tax:manage` | | `POST /orders/:id/refunds`, `.../undo`, notes write | `orders:update` | | `POST /pos/auth/pin` | `pos:admin` | | `POST /pos/auth/pin-login`, `.../override` | `pos:operate` | | `POST /pos/exchanges` | `pos:manage` (+ `orders:update`, `orders:create`) | | `POST /layaways`, `.../payments` | `layaway:operate` | | `POST /layaways/:id/forfeit` | `layaway:manage` | *** ## Webhook event types [Section titled “Webhook event types”](#webhook-event-types) Events that trigger delivery when a matching endpoint is registered: \| Event | Trigger | |-------|---------| | `orders.create` | New order created via checkout | | `orders.statusChange` | Order transitions to a new status | | `catalog.create` | New catalog entity created | | `catalog.update` | Catalog entity updated | | `catalog.delete` | Catalog entity deleted | | `inventory.afterAdjust` | Inventory level adjusted | | `customers.create` | New customer profile created | | `customers.update` | Customer profile updated | | `pricing.create` | New price record created | | `pricing.update` | Price record updated | | `promotions.create` | New promotion created | | `promotions.update` | Promotion updated or deactivated | | `fulfillment.create` | New fulfillment record created | | `cart.afterAddItem` | Item added to a cart | *** ## Error codes [Section titled “Error codes”](#error-codes) ```json { "error": { "code": "NOT_FOUND", "message": "Entity not found." } } ``` \| Code | HTTP status | Description | |------|-------------|-------------| | `NOT_FOUND` | `404` | Resource does not exist | | `VALIDATION_FAILED` | `422` | Input validation failed | | `CHECKOUT_FAILED` | `422` | Checkout pipeline error | | `FORBIDDEN` | `403` | Insufficient permissions | | `CONFLICT` | `409` | Conflicting state (e.g., duplicate slug) | | `TOO_MANY_REQUESTS` | `429` | Rate limit exceeded | | `PAYLOAD_TOO_LARGE` | `413` | Request body exceeds 1 MB | *** ## Pagination [Section titled “Pagination”](#pagination) All list endpoints accept `page` and `limit` query parameters. `limit` is capped at `100`. ```json { "data": [], "meta": { "pagination": { "page": 1, "limit": 20, "total": 142, "totalPages": 8 } } } ``` # Tutorials > Learn Porulle by building real things from scratch. Tutorials are learning-oriented. Each one walks you through building something real, step by step, so you develop practical skills with the engine. \| Tutorial | What you will learn | Time | |----------|---------------------|------| | [Your First Store](/tutorials/first-store/) | Config, products, inventory, cart-to-checkout flow | \~15 min | | [Build a Loyalty Plugin](/tutorials/build-a-plugin/) | Plugin system, hooks, custom routes, database-backed schema | \~25 min | | [Tea Shop POS](/tutorials/tea-shop-pos/) | POS plugin, terminals, shifts, split payment, Z-reports | \~20 min | Complete [Your First Store](/tutorials/first-store/) before the others — the later tutorials build on the running store it creates. # Build a Loyalty Plugin > Learn the plugin system by building a points-and-tiers loyalty program from scratch. In this tutorial you will build a loyalty plugin that awards points when customers place orders, tracks tier progression (bronze → silver → gold → platinum), and exposes REST endpoints for checking balances and a leaderboard. By the end you will understand how plugins define schema, register hooks, and add routes using the `router()` builder. ## What you will build [Section titled “What you will build”](#what-you-will-build) * A `loyalty_points` table tracking each customer’s balance and tier * A `loyalty_transactions` table logging every earn and redeem event * An `orders.afterCreate` hook that awards 1 point per dollar spent * REST routes: `GET /api/loyalty/points/:customerId` and `GET /api/loyalty/leaderboard` * Plugin permission scopes for read and write access * Routes that auto-appear in the OpenAPI spec at `/api/doc` ## Prerequisites [Section titled “Prerequisites”](#prerequisites) Complete [Your First Store](/tutorials/first-store/) or have a running Porulle instance with PostgreSQL. ## Step 1: Define the schema [Section titled “Step 1: Define the schema”](#step-1-define-the-schema) Plugins own their own database tables. Create a schema file with Drizzle `pgTable` definitions. src/plugins/loyalty-schema.ts ```ts import { pgTable, uuid, integer, text, timestamp } from "drizzle-orm/pg-core"; import { customers } from "@porulle/core/schema"; export const loyaltyPoints = pgTable("loyalty_points", { id: uuid("id").defaultRandom().primaryKey(), customerId: uuid("customer_id") .notNull() .unique() .references(() => customers.id, { onDelete: "cascade" }), points: integer("points").notNull().default(0), lifetimeSpend: integer("lifetime_spend").notNull().default(0), tier: text("tier", { enum: ["bronze", "silver", "gold", "platinum"], }).notNull().default("bronze"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); export const loyaltyTransactions = pgTable("loyalty_transactions", { id: uuid("id").defaultRandom().primaryKey(), customerId: uuid("customer_id") .notNull() .references(() => customers.id, { onDelete: "cascade" }), orderId: uuid("order_id"), type: text("type", { enum: ["earn", "redeem"] }).notNull(), amount: integer("amount").notNull(), description: text("description"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }); ``` The FK to `customers` comes from `@porulle/core/schema`. This sub-path export gives you access to all core Drizzle tables without importing through the ESM barrel. ## Step 2: Create the plugin [Section titled “Step 2: Create the plugin”](#step-2-create-the-plugin) The `router()` builder from `@porulle/core` provides a fluent API for defining routes with built-in authentication, permissions, input validation, and automatic OpenAPI documentation. src/plugins/loyalty-plugin.ts ```ts import { defineCommercePlugin, router } from "@porulle/core"; import { eq, desc, sql } from "drizzle-orm"; import { z } from "zod"; import { loyaltyPoints, loyaltyTransactions } from "./loyalty-schema.js"; interface LoyaltyOptions { pointsPerDollar: number; tierThresholds: { silver: number; gold: number; platinum: number; }; } function determineTier( lifetimeSpendCents: number, thresholds: LoyaltyOptions["tierThresholds"] ): "bronze" | "silver" | "gold" | "platinum" { const dollars = lifetimeSpendCents / 100; if (dollars >= thresholds.platinum) return "platinum"; if (dollars >= thresholds.gold) return "gold"; if (dollars >= thresholds.silver) return "silver"; return "bronze"; } const redeemSchema = z.object({ customerId: z.string().uuid(), amount: z.number().int().positive(), description: z.string().optional(), }); export function loyaltyPlugin(options: LoyaltyOptions) { return defineCommercePlugin({ id: "loyalty", version: "1.0.0", permissions: [ { scope: "loyalty:read", description: "View loyalty points and leaderboard" }, { scope: "loyalty:write", description: "Redeem loyalty points" }, ], schema: () => ({ loyaltyPoints, loyaltyTransactions }), hooks: () => [ { key: "orders.afterCreate", handler: async ({ result, context }) => { const order = result as { customerId?: string; grandTotal: number }; if (!order.customerId) return; const db = context.services.database.db; const pointsEarned = Math.floor( (order.grandTotal / 100) * options.pointsPerDollar ); await db.insert(loyaltyTransactions).values({ customerId: order.customerId, type: "earn", amount: pointsEarned, description: `Order placed — $${(order.grandTotal / 100).toFixed(2)}`, }); await db .insert(loyaltyPoints) .values({ customerId: order.customerId, points: pointsEarned, lifetimeSpend: order.grandTotal, tier: determineTier(order.grandTotal, options.tierThresholds), }) .onConflictDoUpdate({ target: loyaltyPoints.customerId, set: { points: sql`${loyaltyPoints.points} + ${pointsEarned}`, lifetimeSpend: sql`${loyaltyPoints.lifetimeSpend} + ${order.grandTotal}`, updatedAt: new Date(), }, }); const [current] = await db .select() .from(loyaltyPoints) .where(eq(loyaltyPoints.customerId, order.customerId)); if (current) { const newTier = determineTier(current.lifetimeSpend, options.tierThresholds); if (newTier !== current.tier) { await db .update(loyaltyPoints) .set({ tier: newTier, updatedAt: new Date() }) .where(eq(loyaltyPoints.customerId, order.customerId)); } } }, }, ], routes: (ctx) => router("loyalty", "/api/loyalty") .get("/points/:customerId", { summary: "Get loyalty points for a customer" }) .permission("loyalty:read") .handler(async ({ db, params }) => { const [row] = await db .select() .from(loyaltyPoints) .where(eq(loyaltyPoints.customerId, params.customerId)); if (!row) { return { data: { customerId: params.customerId, points: 0, tier: "bronze", lifetimeSpend: 0 }, }; } return { data: row }; }) .get("/leaderboard", { summary: "Get top 20 by points" }) .permission("loyalty:read") .handler(async ({ db }) => { const rows = await db .select({ customerId: loyaltyPoints.customerId, points: loyaltyPoints.points, tier: loyaltyPoints.tier, }) .from(loyaltyPoints) .orderBy(desc(loyaltyPoints.points)) .limit(20); return { data: rows.map((r, i) => ({ rank: i + 1, ...r })) }; }) .post("/redeem", { summary: "Redeem loyalty points" }) .auth() .permission("loyalty:write") .input(redeemSchema) .handler(async ({ input, db }) => { const [current] = await db .select() .from(loyaltyPoints) .where(eq(loyaltyPoints.customerId, input.customerId)); if (!current || current.points < input.amount) { throw new Error("Insufficient points"); } await db.insert(loyaltyTransactions).values({ customerId: input.customerId, type: "redeem", amount: -input.amount, description: input.description ?? "Points redeemed", }); const [updated] = await db .update(loyaltyPoints) .set({ points: sql`${loyaltyPoints.points} - ${input.amount}`, updatedAt: new Date() }) .where(eq(loyaltyPoints.customerId, input.customerId)) .returning(); return { data: updated }; }) .build(), }); } ``` The `router()` builder’s `.auth()`, `.permission()`, `.input()`, and `.handler()` chain methods wire up authentication, permission checks, Zod validation, and OpenAPI documentation automatically. ## Step 3: Register the plugin [Section titled “Step 3: Register the plugin”](#step-3-register-the-plugin) commerce.config.ts ```ts import { loyaltyPlugin } from "./src/plugins/loyalty-plugin.js"; export default defineConfig({ // ... existing config ... plugins: [ loyaltyPlugin({ pointsPerDollar: 1, tierThresholds: { silver: 500, // $500 lifetime spend gold: 1500, // $1,500 platinum: 3000, // $3,000 }, }), ], }); ``` ## Step 4: Add the schema to Drizzle [Section titled “Step 4: Add the schema to Drizzle”](#step-4-add-the-schema-to-drizzle) Update `drizzle.config.ts` to include the plugin schema: drizzle.config.ts ```ts export default defineConfig({ dialect: "postgresql", schema: [ "./node_modules/@porulle/core/src/kernel/database/schema.ts", "./node_modules/@porulle/core/src/auth/auth-schema.ts", "./src/plugins/loyalty-schema.ts", ], }); ``` Push the new tables: ```bash bunx drizzle-kit push --config drizzle.config.ts ``` ## Step 5: Write tests [Section titled “Step 5: Write tests”](#step-5-write-tests) `createPluginTestApp` boots an in-memory PGlite database, pushes the full schema, and wires routes. One call replaces all test boilerplate. test/loyalty.test.ts ```ts import { describe, expect, it, beforeAll } from "vitest"; import { createPluginTestApp, jsonHeaders, testAdminActor } from "@porulle/core"; import { loyaltyPlugin } from "../src/plugins/loyalty-plugin"; describe("loyalty plugin", () => { let app: Awaited>["app"]; beforeAll(async () => { const result = await createPluginTestApp( loyaltyPlugin({ pointsPerDollar: 1, tierThresholds: { silver: 500, gold: 1500, platinum: 3000 }, }), ); app = result.app; }); it("returns zero points for unknown customer", async () => { const res = await app.request( "http://localhost/api/loyalty/points/00000000-0000-0000-0000-000000000001", { headers: jsonHeaders(testAdminActor) }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.data.points).toBe(0); }); it("returns 403 without loyalty:read permission", async () => { const res = await app.request( "http://localhost/api/loyalty/points/00000000-0000-0000-0000-000000000001", ); expect([401, 403]).toContain(res.status); }); }); ``` ## Step 6: Try it manually [Section titled “Step 6: Try it manually”](#step-6-try-it-manually) Restart the server, place an order from the first tutorial, then check the loyalty balance: ```bash curl -s "http://localhost:4000/api/loyalty/points/$CUSTOMER_ID" \ -H "x-api-key: dev-staff-key" ``` ```json { "data": { "customerId": "...", "points": 65, "tier": "bronze", "lifetimeSpend": 6497 } } ``` Your routes also appear in the OpenAPI spec automatically: ```bash curl -s http://localhost:4000/api/doc | python3 -c \ "import sys,json; paths = json.load(sys.stdin)['paths']; print([p for p in paths if 'loyalty' in p])" ``` ## What you learned [Section titled “What you learned”](#what-you-learned) 1. **`defineCommercePlugin`** wraps a manifest with `schema`, `hooks`, `routes`, and `permissions` into a config transform function. 2. **`router()` builder** defines routes with `.auth()`, `.permission()`, `.input()`, `.handler()` — all auto-documented in the OpenAPI spec. 3. **Plugin schema** tables are pushed via `drizzle-kit` alongside core tables. 4. **`createPluginTestApp`** boots PGlite, pushes the merged schema, and wires routes — one call, full integration test. ## Next steps [Section titled “Next steps”](#next-steps) * [Plugin Architecture explanation](/concepts/plugin-architecture/) — understand why plugins are config transforms * [Plugin API Reference](/reference/plugins/) — full manifest spec including the `router()` API * [Testing guide](/extending/testing/) — actors, permissions, and live-server patterns * [Tea Shop POS tutorial](/tutorials/tea-shop-pos/) — a different kind of plugin: POS terminals and shifts # Your First Store > Build a working streetwear store with products, inventory, cart, and checkout. In this tutorial you will set up a complete commerce store called “Acme Streetwear” with real products, inventory, and a working checkout flow. By the end you will have placed an order through the REST API and understand how the pieces fit together. ## What you will build [Section titled “What you will build”](#what-you-will-build) * A PostgreSQL-backed store with two entity types (product and gift card) * Inventory tracking across a warehouse * A full cart-to-checkout-to-order flow * A seed script that populates demo data ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * Bun 1.3+ or Node.js 20+ * PostgreSQL running locally * A terminal and a text editor ## Step 1: Create the project [Section titled “Step 1: Create the project”](#step-1-create-the-project) ```bash mkdir acme-store && cd acme-store bun init -y bun add @porulle/core @porulle/adapter-postgres @hono/node-server bun add -d drizzle-kit typescript ``` ## Step 2: Write the config [Section titled “Step 2: Write the config”](#step-2-write-the-config) Create `commerce.config.ts` at the project root. This file declares everything about your store — entity types, adapters, auth, and shipping. 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/acme_store"; 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: "Acme Streetwear", databaseAdapter: postgresAdapter({ connectionString: DATABASE_URL }), auth: { requireEmailVerification: false, apiKeys: { enabled: true }, trustedOrigins: ["http://localhost:4000"], roles: { admin: { permissions: ["*:*"] }, staff: { permissions: [ "catalog:create", "catalog:update", "catalog:read", "inventory:adjust", "inventory:read", "orders:create", "orders:read", "orders:update", "cart:create", "cart:update", "customers:read", ], }, customer: { permissions: [ "catalog:read", "cart:create", "cart:read", "cart:update", "orders:create", "orders:read:own", ], }, }, }, entities: { product: { fields: [ { name: "weight", type: "number", unit: "grams" }, { name: "material", type: "text" }, ], variants: { enabled: true, optionTypes: ["size", "color"] }, fulfillment: "physical", }, gift_card: { fields: [{ name: "denomination", type: "number" }], variants: { enabled: false }, fulfillment: "digital", }, }, shipping: { type: "weight_based", flatRate: 500, freeShippingThreshold: 10000, brackets: [ { upToGrams: 500, cost: 499 }, { upToGrams: 1000, cost: 799 }, { upToGrams: 2000, cost: 1199 }, ], fallbackCost: 1599, }, payments: [mockPayments], }); ``` Two entity types are declared: `product` (physical, weight-based shipping) and `gift_card` (digital, no shipping). The engine treats them uniformly through the `sellable_entities` table. See [The Entity Model](/concepts/entity-model/) for why. ## Step 3: Create the server [Section titled “Step 3: Create the server”](#step-3-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", store: "Acme Streetwear" })); serve({ fetch: app.fetch, port: PORT }, (info) => { console.log(`Acme Streetwear running at http://localhost:${info.port}`); }); ``` ## Step 4: Set up the database [Section titled “Step 4: Set up the database”](#step-4-set-up-the-database) drizzle.config.ts ```ts import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://localhost:5432/acme_store", }, schema: [ "./node_modules/@porulle/core/src/kernel/database/schema.ts", "./node_modules/@porulle/core/src/auth/auth-schema.ts", ], }); ``` ```bash createdb acme_store bunx drizzle-kit push --config drizzle.config.ts bun run src/server.ts ``` You should see `Acme Streetwear running at http://localhost:4000`. Open a new terminal for the API calls. ## Step 5: Create products [Section titled “Step 5: Create products”](#step-5-create-products) ```bash # Create a Classic Tee entity 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": { "weight": 200, "material": "cotton" } }') ENTITY_ID=$(echo $ENTITY | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Entity: $ENTITY_ID" # Set the English title and description curl -s -X PUT "http://localhost:4000/api/catalog/entities/$ENTITY_ID/attributes/en" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{ "title": "Classic Logo Tee", "description": "Premium cotton tee with screen-printed logo." }' ``` ## Step 6: Add inventory [Section titled “Step 6: Add inventory”](#step-6-add-inventory) Before checkout can succeed, the engine needs to know how much stock exists. Create a warehouse and stock the tee. ```bash # Create a warehouse WAREHOUSE=$(curl -s -X POST http://localhost:4000/api/inventory/warehouses \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"code": "MAIN", "name": "Main Warehouse", "isActive": true}') WAREHOUSE_ID=$(echo $WAREHOUSE | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Warehouse: $WAREHOUSE_ID" # Stock 100 units curl -s -X POST http://localhost:4000/api/inventory/adjust \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d "{ \"entityId\": \"$ENTITY_ID\", \"warehouseId\": \"$WAREHOUSE_ID\", \"type\": \"receipt\", \"quantity\": 100 }" ``` ## Step 7: Cart and checkout [Section titled “Step 7: Cart and checkout”](#step-7-cart-and-checkout) ```bash # 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 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Cart: $CART_ID" # Add 2 tees (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\": 2, \"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\", \"state\": \"CA\", \"city\": \"Beverly Hills\", \"line1\": \"123 Commerce St\" } }" ``` You will see an order with an `orderNumber` like `ORD-2026-000001`, a calculated `grandTotal` (two tees at $29.99 + weight-based shipping), and a `status` of `pending`. ## What just happened [Section titled “What just happened”](#what-just-happened) The checkout pipeline ran eight steps: 1. Validated the cart is not empty 2. Resolved current prices for each line item 3. Checked inventory availability (100 units available, 2 requested — passes) 4. Applied any promotion codes (none) 5. Calculated tax (none configured, defaults to 0) 6. Calculated shipping (two 200g tees = 400g, falls into the 0–500g bracket = $4.99) 7. Authorized payment via the mock adapter 8. Created the order in a database transaction Steps 1–8 are before hooks. After the transaction commits, after hooks ran: reserved inventory (decrement by 2), captured payment, and recorded an analytics event. All of this is configurable through hooks. In the next tutorial you will build a loyalty plugin that hooks into this pipeline. ## Next steps [Section titled “Next steps”](#next-steps) * [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — extend the checkout pipeline with custom logic * [Tea Shop POS tutorial](/tutorials/tea-shop-pos/) — a different workflow: terminals, shifts, split payment * [Add custom tables](/extending/custom-tables/) — create your own database tables alongside the core schema # Tea Shop POS > Register a terminal, open a shift, ring up a sale with split payment, close the shift, and read the Z-report. In this tutorial you will build a point-of-sale system for a tea shop called “Ceylon Brew.” You will register a terminal, open a cashier shift, process a sale with split payment (cash + card), void a transaction, close the shift, and read the Z-report. Every step uses the REST API. ## What you will build [Section titled “What you will build”](#what-you-will-build) * A POS terminal registered at your store * A cashier shift with an opening float * Two completed transactions, one with split payment * One voided transaction * A closed shift with cash variance calculation * A Z-report showing sales totals and payment method breakdown ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * The store from [Your First Store tutorial](/tutorials/first-store/) running at `http://localhost:4000` * `curl` installed All commands use the dev API key `dev-staff-key`. ## Step 1: Install and enable the POS plugin [Section titled “Step 1: Install and enable the POS plugin”](#step-1-install-and-enable-the-pos-plugin) ```bash bun add @porulle/plugin-pos ``` Update `commerce.config.ts`: commerce.config.ts ```ts import { posPlugin } from "@porulle/plugin-pos"; export default defineConfig({ // ... existing config ... plugins: [posPlugin()], }); ``` Update `drizzle.config.ts` to pick up the POS 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-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`. ## Step 2: Register a terminal [Section titled “Step 2: Register a terminal”](#step-2-register-a-terminal) A terminal represents a physical register or device. ```bash TERMINAL=$(curl -s -X POST http://localhost:4000/api/pos/terminals \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"name": "Main Counter", "code": "MC1"}') TERMINAL_ID=$(echo $TERMINAL | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Terminal: $TERMINAL_ID" ``` Response: ```json { "data": { "id": "a1b2c3d4-...", "name": "Main Counter", "code": "MC1", "isActive": true } } ``` ## Step 3: Open a shift [Section titled “Step 3: Open a shift”](#step-3-open-a-shift) A shift tracks who is operating the register and how much cash is in the drawer. All monetary values are integers in the smallest currency unit (cents for USD). ```bash SHIFT=$(curl -s -X POST http://localhost:4000/api/pos/shifts/open \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d "{ \"terminalId\": \"$TERMINAL_ID\", \"operatorId\": \"cashier-1\", \"openingFloat\": 500000 }") SHIFT_ID=$(echo $SHIFT | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Shift: $SHIFT_ID" ``` `openingFloat: 500000` = $5,000.00 starting cash. ## Step 4: Create a cart and start a transaction [Section titled “Step 4: Create a cart and start a transaction”](#step-4-create-a-cart-and-start-a-transaction) Every POS transaction starts with a cart. ```bash 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 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") TXN=$(curl -s -X POST http://localhost:4000/api/pos/transactions \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d "{ \"shiftId\": \"$SHIFT_ID\", \"terminalId\": \"$TERMINAL_ID\", \"operatorId\": \"cashier-1\", \"cartId\": \"$CART_ID\" }") TXN_ID=$(echo $TXN | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") echo "Transaction: $TXN_ID ($(echo $TXN | python3 -c \"import sys,json; print(json.load(sys.stdin)['data']['receiptNumber'])\"))" ``` ## Step 5: Add a split payment [Section titled “Step 5: Add a split payment”](#step-5-add-a-split-payment) A split payment uses multiple payment methods on one transaction. $35 cash + $15 card: ```bash # Cash: $35.00 = 3500 cents curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN_ID/payments" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"method": "cash", "amount": 3500}' # Card: $15.00 = 1500 cents curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN_ID/payments" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"method": "card", "amount": 1500, "reference": "****4321"}' ``` ## Step 6: Complete the transaction [Section titled “Step 6: Complete the transaction”](#step-6-complete-the-transaction) ```bash curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN_ID/complete" \ -H "x-api-key: dev-staff-key" ``` ```json { "data": { "status": "completed", "total": 5000, "receiptNumber": "MC1-0001" } } ``` Total is 5000 (3500 + 1500). The shift’s `salesCount` increments to 1 and `salesTotal` to 5000. ## Step 7: Process a second transaction (cash only) [Section titled “Step 7: Process a second transaction (cash only)”](#step-7-process-a-second-transaction-cash-only) ```bash CART2=$(curl -s -X POST http://localhost:4000/api/carts \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"currency": "USD"}') CART2_ID=$(echo $CART2 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") TXN2=$(curl -s -X POST http://localhost:4000/api/pos/transactions \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d "{\"shiftId\": \"$SHIFT_ID\", \"terminalId\": \"$TERMINAL_ID\", \"operatorId\": \"cashier-1\", \"cartId\": \"$CART2_ID\"}") TXN2_ID=$(echo $TXN2 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN2_ID/payments" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"method": "cash", "amount": 2200}' curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN2_ID/complete" \ -H "x-api-key: dev-staff-key" ``` The shift now has 2 completed transactions totaling $72.00. ## Step 8: Void a transaction [Section titled “Step 8: Void a transaction”](#step-8-void-a-transaction) Voided transactions do not count toward shift sales. ```bash CART3=$(curl -s -X POST http://localhost:4000/api/carts \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"currency": "USD"}') CART3_ID=$(echo $CART3 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") TXN3=$(curl -s -X POST http://localhost:4000/api/pos/transactions \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d "{\"shiftId\": \"$SHIFT_ID\", \"terminalId\": \"$TERMINAL_ID\", \"operatorId\": \"cashier-1\", \"cartId\": \"$CART3_ID\"}") TXN3_ID=$(echo $TXN3 | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])") curl -s -X POST "http://localhost:4000/api/pos/transactions/$TXN3_ID/void" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"reason": "Customer changed mind"}' ``` ## Step 9: Close the shift [Section titled “Step 9: Close the shift”](#step-9-close-the-shift) The expected cash = opening float + cash sales = $5,000 + ($35 + $22) = $5,057. Say the cashier counts $5,056.50: ```bash curl -s -X POST "http://localhost:4000/api/pos/shifts/$SHIFT_ID/close" \ -H "content-type: application/json" \ -H "x-api-key: dev-staff-key" \ -d '{"closingCount": 505650}' ``` ```json { "data": { "status": "closed", "openingFloat": 500000, "closingCount": 505650, "expectedCash": 505700, "cashVariance": -50, "salesCount": 2, "salesTotal": 7200 } } ``` `cashVariance: -50` means 50 cents short. This could indicate a counting error or cash loss. ## Step 10: Read the Z-report [Section titled “Step 10: Read the Z-report”](#step-10-read-the-z-report) ```bash curl -s "http://localhost:4000/api/pos/shifts/$SHIFT_ID/report" \ -H "x-api-key: dev-staff-key" ``` ```json { "data": { "shift": { "status": "closed", "salesCount": 2, "salesTotal": 7200, "cashVariance": -50 }, "cashEvents": [ { "type": "float", "amount": 500000 } ], "paymentMethodTotals": [ { "method": "cash", "total": 5700, "count": 2 }, { "method": "card", "total": 1500, "count": 1 } ], "transactionCount": 2 } } ``` ## What you learned [Section titled “What you learned”](#what-you-learned) * **Terminals** represent physical registers. Each has a unique code. * **Shifts** track cash in/out for a cashier session. Opening float is the starting cash. * **Transactions** link to shifts. Completed transactions accumulate into shift totals. Voided transactions do not. * **Split payment** supports multiple methods (cash + card) on one transaction. * **Cash variance** = closing count − expected cash. Negative means cash is short. * **Z-report** summarizes the entire shift for end-of-day reconciliation. ## Next steps [Section titled “Next steps”](#next-steps) * [POS Plugin Reference](/reference/plugins/) — all POS endpoints and data types * [Build a Loyalty Plugin](/tutorials/build-a-plugin/) — hooks, schema, custom routes