The 100% free-tier, AI-native e-commerce framework.
Built on Cloudflare Workers Β· Auth0 Β· Stripe Β· Mailgun Β· GitHub Pages
| Service | Free Tier | Usage in Fufuni |
|---|---|---|
| Cloudflare Workers | 100k req/day | Backend API + Durable Objects SQLite |
| Cloudflare R2 | 10 GB storage | Product images & assets |
| Auth0 | 7,500 active users | Auth passwordless + social |
| Mailgun | 1,000 emails/month | Order notifications |
| Stripe | Pay-as-you-go | Payments (no monthly fee) |
| GitHub Pages | Unlimited | SPA frontend hosting |
The entire stack can be self-hosted for β¬0 / month within these generous free tiers. GitHub Actions automates deployment β push to main and everything deploys automatically.
If you appreciate my work, please consider giving it a star! π€©
Click the screenshot below to try the public deployment. You can checkout with any Stripe test card for example 4242 4242 4242 4242 (any future expiry, CVC, and ZIP) β no real charges will be made.
Theme switcher in the top-right corner lets you toggle between classic and luxury themes, showcasing the dynamic theming capabilities of the platform.
Visitors see an attractive landing page with a Log in button and direct links to a sample API and autogenerated OpenAPI/Swagger docs β no auth required to view the interface.
Fufuni is one of the first open-source frameworks to implement UCP, allowing AI shopping agents (Claude, Gemini, ChatGPT) to interact with your store natively.
# Discover capabilities
curl https://your-worker.workers.dev/.well-known/ucp
# Browse catalog as an AI agent
curl "https://your-worker.workers.dev/ucp/v1/products?limit=5&q=golf"
# Create a checkout session (no UI needed)
curl -X POST https://your-worker.workers.dev/ucp/v1/checkout-sessions \
-H "Content-Type: application/json" \
-d '{"currency":"EUR","line_items":[{"item":{"id":"SKU-001"},"quantity":1}]}'
weightg) used for automatic cart weight calculationvariantpricespending β paid β processing β shipped β delivered β refunded β canceledexclusive or additive)shippingratepricesclassic (light/dark) and luxury themesstore_themes table and served via GET /v1/theme/activeStoreThemeProvider applies data-theme on <html> at boot, preventing FOUClocalStorage (ui-theme key)ThemeSwitcher dropdown for instant preview/switch--accent, --radius, font stacks) without a redeploysessionStorageavailableLanguages registry with nativeName, isRTL, isDefault flagsIntl.NumberFormat, ISO 4217)approve/reject recommendation with a one-sentence reason displayed inlineAI_PERMISSION claim can trigger AI featuresGET /v1/ai/parameters (key, model, URL) β all AI calls are made client-side so the LLM API key never leaves the browser via a server-side proxyresolveTitle() β JSON-localized titles render in the adminβs active localepending, paid, payment_failed, processing, shipped, delivered, refunded, canceled, plus a global fallback{"en-US":"...", "fr-FR":"..."})vous/usted/Sie/Lei address formsglobal row acts as the catch-all fallback,, ,, ,, ,Full-featured back-office covering:
KV_CACHE) with variable TTL, bypass rules for admin tokens, and prefix-based invalidation on mutationsflowchart LR
subgraph Cloudflare Edge
React["React SPA<br/>(Vite 8)<br/>HeroUI v3<br/>Auth0"]
Merchant["Merchant Worker<br/>Hono + Zod-OpenAPI<br/>Durable Object (SQLite)"]
Stripe["Stripe Checkout"]
Mailgun["Mailgun (email)"]
R2["R2 (images)"]
KV["KV Cache (KV_CACHE)"]
end
React -->|API calls| Merchant
React -->|Checkout redirect| Stripe
Merchant -->|Stripe webhook| Stripe
Merchant -->|Send email| Mailgun
Merchant -->|Store images| R2
Merchant -->|Cache| KV
.
βββ apps/
β βββ client/ # React frontend (storefront + admin)
β β βββ src/
β β βββ pages/admin/
β β β βββ products.tsx
β β β βββ shipping-classes.tsx
β β β βββ shipping-rates.tsx
β β β βββ ...
β β βββ lib/store-api.ts # Typed API client
β β βββ utils/ai-client.ts # AI translation (runs entirely client-side)
β βββ merchant/ # Cloudflare Worker
β βββ migrations/ # 001 β 018 SQL files
β βββ src/
β βββ do.ts # Durable Object (SCHEMA + ensureInitialized)
β βββ lib/
β β βββ shipping.ts # getCompatibleShippingRates, computeCartWeightG
β β βββ pricing.ts
β β βββ order-email.ts
β β βββ order-token.ts
β βββ routes/
β βββ checkout.ts
β βββ regions.ts # Regions + Shipping Classes CRUD
β βββ catalog.ts
β βββ ...
Fufuni caches public read endpoints in Cloudflare KV to slash Durable Object invocations, and uses the Cloudflare Cache API to serve product images from edge PoPs.
The kvCacheMiddleware in apps/merchant/src/middleware/kv-cache.ts intercepts GET requests on /v1/products/* and /v1/categories/* before they reach the Durable Object.
Cache key: cache:<full-url> β query params are included, so paginated and filtered responses are cached independently.
TTL policy:
| Endpoint pattern | TTL |
|---|---|
/v1/products/search |
5 minutes |
/v1/products/:id/reviews |
10 minutes |
| All other catalog & category routes | 1 hour |
Bypass rules:
| Request type | Cached? | Reason |
|---|---|---|
| Non-GET methods | β | Mutations must always reach the DO |
Authorization: Bearer sk_... |
β | Admin key β may return unpublished data |
| Auth0 JWT | β | Permission-scoped response |
Authorization: Bearer pk_... |
β | Public store key β identical response for every visitor |
| No Authorization header | β | Truly public endpoints |
Invalidation: on any successful POST, PATCH, or DELETE to products or categories, kvInvalidateMiddleware purges all cache:*/v1/products and cache:*/v1/categories entries by iterating kv.list() pages β covers paginated/filtered keys automatically.
Response header: X-KV-Cache: HIT | MISS set on every cached route.
KV_CACHEmust be declared inwrangler.jsonc(see Cloudflare Bindings).
Environment variables (and GitHub Secrets) KV_CACHE_SEARCH_TTL_SECONDS, KV_CACHE_REVIEWS_TTL_SECONDS, and KV_CACHE_DEFAULT_TTL_SECONDS allow TTL customization without code changes (defaults: 300, 600, and 3600 seconds respectively).
Product images served from R2 are additionally cached at the Cloudflare edge via the Cache API (caches.default):
GET /v1/images/:key, the Worker checks caches.default.match(request) first.cache.put() (inside waitUntil).DELETE /v1/images/:key, the CDN entry is purged alongside the R2 object.β οΈ wrangler dev note:
caches.defaultis not available inwrangler dev. CDN hit/miss counters will stay at 0 during local development β deploy to Cloudflare to see live CDN metrics.
The GET /v1/analytics/cache-stats endpoint (admin-only) returns cumulative counters stored in KV:
{
"kv": { "hits": 1234, "misses": 89, "hit_rate": 0.93, "entries": 47 },
"cdn": { "hits": 5012, "misses": 203, "hit_rate": 0.96 }
}
These are surfaced as progress bars on the /admin/analytics dashboard. Counters are cumulative and never reset; accuracy is approximate under concurrent load (non-atomic KV read-modify-write).
# 1. Install from monorepo root
npm install
# 2. Create initial API keys + schema
npx tsx apps/merchant/scripts/init.ts
# 3. Run locally
cd apps/merchant && npm run dev
# 4. Seed demo data (optional)
npx tsx apps/merchant/scripts/seed.ts
# 5. Connect Stripe
curl -X POST http://localhost:8787/v1/setup/stripe \
-H "Authorization: Bearer sk-your-admin-key" \
-H "Content-Type: application/json" \
-d '{"stripesecretkey":"sk_test_..."}'
The project includes scripts that automate Auth0 tenant setup and post-login action configuration.
mystore.us.auth0.com).AUTH0_DOMAIN=<tenant>.us.auth0.comAUTH0_TENANT=<tenant>AUTH0_MANAGEMENT_API_CLIENT_ID=<client_id>AUTH0_MANAGEMENT_API_CLIENT_SECRET=<client_secret>AUTH0_AUDIENCE=https://api.<your-domain>/STORE_URL=http://localhost:5173npx tsx scripts/auth0/deploy-tenant-resources.ts -- --env-file=.envcp apps/client/.env.example apps/client/.env
# β Fill in AUTH0_*, API_BASE_URL, MERCHANT_PK, STRIPE_PUBLISHABLE_KEY
# (donβt forget AUTH0_SCOPE and AUTH0_AUTOMATIC_PERMISSIONS for Auth0 auth)
npm run dev:env # from monorepo root
# Starts both workspaces in parallel with .env variables loaded:
# β Merchant Worker : http://localhost:8787 (.env is copied to apps/merchant/.dev.vars)
# β Client Vite SPA : http://localhost:5173 (.env sourced for vite.config.ts define block)
# Optional: Stripe webhook forwarder (requires Stripe CLI installed)
npm run stripe:listen
# Reads STRIPE_SECRET_KEY from .env and forwards Stripe events to localhost:8787
# Optional: mount the store at a subpath (e.g. for testing CORS_ORIGIN adjustments)
npm run dev:env -- --base=/fufuni
# β client Vite: http://localhost:5173/fufuni
# β merchant .dev.vars: STORE_URL/CORS_ORIGIN adjusted automatically
Run all scripts from the monorepo root unless noted otherwise.
| Command | Description |
|---|---|
npm run dev:env |
Start full dev environment (Worker + Vite) with .env variables loaded. Worker: localhost:8787. Frontend: localhost:5173. |
npm run dev:env -- --base=/fufuni |
Same, but mounts the SPA at /fufuni and adjusts STORE_URL/CORS_ORIGIN in the Worker .dev.vars. |
npm run stripe:listen |
Forward Stripe webhook events to localhost:8787 (requires Stripe CLI; reads STRIPE_SECRET_KEY from .env). |
npm run build:env |
Build all workspaces (Worker + client SPA) sourcing .env. Used by CI. |
npm run build:client:env |
Build only the client SPA sourcing .env. |
npm run preview:env |
Preview the built SPA (and merchant) with .env loaded. |
npm run dev |
Start both workspaces without loading .env (no Auth0/API keys β limited functionality). |
npm run clean:seed:start |
Reset local DB, seed demo data, and start dev environment. |
npm run mcp:generate |
Generate MCP knowledge-base files in mcp/ (AI-assisted). |
npm run mcp:generate:force |
Regenerate all MCP files (overwrites existing). |
dev:envvsdev: Always usedev:envfor real development β it loads.envso Auth0, Stripe, Mailgun, and API keys are available.devis only useful for quick smoke-tests with no external services.
apps/mcp is a Cloudflare Worker that exposes the Fufuni codebase knowledge as a remote Model Context Protocol (MCP) server. AI assistants (Claude, VS Code Copilot, Cursorβ¦) can connect to it and query structured documentation about the project without needing access to the source files.
apps/mcp/src/knowledge/generated/*.md β static knowledge files (one per topic, generated by a script)
β
apps/mcp/scripts/gen-knowledge.ts β bundles all .md into src/knowledge.ts
β
apps/mcp/src/index.ts β MCP Worker (FufuniMCP extends McpAgent)
β
/mcp (Streamable HTTP) β endpoint for MCP clients
/sse (SSE, legacy) β fallback for older clients
Each apps/mcp/src/knowledge/generated/<topic>.md file becomes:
cloudflare_worker_patterns)get_topic(slug) toollist_topics# From apps/mcp/ β generates knowledge bundle, starts wrangler dev + MCP inspector
npm run dev
# β MCP Worker: http://localhost:8788/mcp
# β MCP Inspector opens automatically in your browser
To connect VS Code Copilot or Claude Desktop to the local server:
{
"mcpServers": {
"fufuni": {
"command": "npx",
"args": ["mcp-remote", "http://localhost:8788/mcp"]
}
}
}
The knowledge files (mcp/*.md) are generated by an AI-assisted script that reads the source code and writes structured Markdown.
# Generate only missing topics (safe, won't overwrite)
npm run mcp:generate
# Force-regenerate all 17 topics (overwrites existing files)
npm run mcp:generate:force
# Regenerate a single topic
npx tsx scripts/generate-static-mcp-response.ts --topic=cloudflare-worker-patterns --force
When to regenerate:
scripts/generate-static-mcp-response.tsdeploy-mcp.yaml runs gen-knowledge automatically)Adding a new topic: edit scripts/generate-static-mcp-response.ts, add a new entry to the TOPICS array with name, description, sources[], and buildPrompt. Then run npm run mcp:generate.
CI deploys the MCP Worker automatically on every push to main that changes apps/mcp/** or mcp/** (via .github/workflows/deploy-mcp.yaml).
# Manual deploy from apps/mcp/
npm run deploy
# Worker live at: https://fufuni-mcp.<your-account>.workers.dev/mcp
The MCP Worker is publicly deployed at https://fufuni-mcp.fufuni.workers.dev/mcp.
To connect a remote MCP client (Claude Desktop, Cursor, generic mcpServers config):
{
"mcpServers": {
"fufuni": {
"command": "npx",
"args": ["mcp-remote", "https://fufuni-mcp.fufuni.workers.dev/mcp"]
}
}
}
VS Code Copilot β add to .vscode/mcp.json (already included in this repository):
{
"servers": {
"fufuni": {
"type": "http",
"url": "https://fufuni-mcp.fufuni.workers.dev/mcp"
}
}
}
Once configured, VS Code Copilot Agent will automatically discover and use the Fufuni knowledge tools (list_topics, get_topic, per-topic tools) when working in this repository.
cd apps/merchant
wrangler deploy # DO, R2 and KV auto-provisioned
# Set secrets
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put ORDER_TOKEN_SECRET
wrangler secret put MAILGUN_API_KEY
wrangler secret put AUTH0_CLIENT_SECRET
wrangler secret put AUTH0_MANAGEMENT_API_CLIENT_SECRET
# Initialise remote store
npx tsx scripts/init.ts --remote
All variables are set under vars in wrangler.jsonc.
Sensitive values (secrets) should be set with wrangler secret put.
| Variable | Required | Description |
|---|---|---|
MERCHANT_SK |
β secret | Admin API key (sk...) |
MERCHANT_PK |
β | Public API key (pk...) |
STRIPE_SECRET_KEY |
β secret | Stripe secret key |
STRIPE_WEBHOOK_SECRET |
β secret | Stripe webhook signing secret |
ORDER_TOKEN_SECRET |
β secret | HMAC secret for order view tokens |
AUTH0_DOMAIN |
β | Auth0 tenant domain (e.g. fufuni.eu.auth0.com) |
AUTH0_AUDIENCE |
β | Auth0 API audience (e.g. https://api.fufuni.pp.ua) |
AUTH0_CLIENT_ID |
β | Auth0 M2M or SPA client ID |
AUTH0_CLIENT_SECRET |
β secret | Auth0 client secret |
AUTH0_MANAGEMENT_API_CLIENT_ID |
β | Auth0 Management API client ID |
AUTH0_MANAGEMENT_API_CLIENT_SECRET |
β secret | Auth0 Management API client secret |
AUTH0_SCOPE |
βοΈ | OAuth scopes (default: openid profile email) |
AUTH0_AUTOMATIC_PERMISSIONS |
βοΈ | Comma-separated permissions to auto-provision |
ADMIN_STORE_PERMISSION |
βοΈ | JWT permission for store admin routes (default: admin:store) |
ADMIN_AUTH0_PERMISSION |
βοΈ | JWT permission for Auth0 management routes (default: auth0:admin:api) |
DATABASE_PERMISSION |
βοΈ | JWT permission for database admin routes (default: admin:database) |
AI_PERMISSION |
βοΈ | JWT permission for AI parameter endpoint (default: ai:api) |
MAIL_PERMISSION |
βοΈ | JWT permission for test mail endpoint (default: mail:api) |
WANT_PERMISSION |
βοΈ | JWT permission for UCP/want routes (default: wantapi) |
AI_API_KEY |
βοΈ | AI provider API key (returned to authorised clients) |
AI_MODEL |
βοΈ | AI model name (e.g. openai/gpt-oss-20b) |
AI_API_URL |
βοΈ | AI provider base URL (e.g. https://api.groq.com/openai/v1) |
MAILGUN_API_KEY |
βοΈ secret | Mailgun API key |
MAILGUN_DOMAIN |
βοΈ | Mailgun sending domain |
MAILGUN_BASE_URL |
βοΈ | Mailgun API base URL (default: https://api.mailgun.net) |
MAILGUN_USER |
βοΈ | Mailgun sender address |
STORE_URL |
βοΈ | Public storefront URL (used in email links) |
STORE_NAME |
βοΈ | Store display name (used in emails) |
IMAGES_URL |
βοΈ | Public URL of the R2 bucket |
CORS_ORIGIN |
βοΈ | Allowed CORS origin (e.g. http://localhost:5173) |
API_BASE_URL |
βοΈ | Worker URL (used internally) |
CRYPTOKEN |
βοΈ | Hex token for crypto operations |
KV_CACHE_ID |
βοΈ | KV namespace ID (mirrors the KV_CACHE binding) |
KV_CACHE_SEARCH_TTL_SECONDS |
βοΈ | Custom TTL for product search cache (default: 300) |
KV_CACHE_REVIEWS_TTL_SECONDS |
βοΈ | Custom TTL for product reviews cache (default: 600) |
KV_CACHE_DEFAULT_TTL_SECONDS |
βοΈ | Custom TTL for all other cache entries (default: 3600) |
{
"durable_objects": {
"bindings": [{ "name": "MERCHANT", "class_name": "MerchantDO" }]
},
"r2_buckets": [{ "binding": "IMAGES" }],
"kv_namespaces": [{ "binding": "KV_CACHE", "id": "<your-kv-id>" }],
"triggers": { "crons": ["15 * * * *"] }
}
β οΈ No D1 binding β all store data lives in the Durable Objectβs built-in SQLite. Migrations are applied automatically via
ensureInitialized()indo.ts.
.env| Variable | Required | Description |
|---|---|---|
AUTHENTICATION_PROVIDER_TYPE |
β | auth0 or dex |
AUTH0_CLIENT_ID |
β | Auth0 SPA Client ID |
AUTH0_DOMAIN |
β | Auth0 tenant domain |
AUTH0_AUDIENCE |
β | Auth0 API identifier |
AUTH0_SCOPE |
β | Auth0 OAuth scopes (e.g. openid profile email) |
AUTH0_CACHE_DURATION_S |
βοΈ | Cache duration (seconds) for Auth0 Management API tokens (default: 300) |
AUTH0_AUTOMATIC_PERMISSIONS |
βοΈ | Comma-separated permissions to auto-assign to the signed-in user |
API_BASE_URL |
β | Worker base URL |
MERCHANT_PK |
β | Public API key for storefront calls |
ADMIN_AUTH0_PERMISSION |
β | Auth0 permission granting admin panel access |
ADMIN_STORE_PERMISSION |
β | Auth0 permission granting store management |
DATABASE_PERMISSION |
βοΈ | Auth0 permission for database admin routes (used by backend) |
AI_PERMISSION |
βοΈ | Auth0 permission for AI credentials endpoint |
MAIL_PERMISSION |
βοΈ | Auth0 permission for test mail endpoint |
STORE_URL |
βοΈ | Public storefront URL (used in emails) |
STORE_NAME |
βοΈ | Store name (used in emails) |
STRIPE_PUBLISHABLE_KEY |
βοΈ | Stripe publishable key |
π‘ Vite access pattern: in the frontend source code, all variables above are accessed as
import.meta.env.VARIABLE_NAME(defined byvite.config.tsβsdefineblock β not the nativeVITE_prefix mechanism).import.meta.env.PERMISSIONSis a string array derived from all*_PERMISSIONvalues in.env. To expose a new variable to the frontend, add it to thedefineblock inapps/client/vite.config.tsAND tocreate-env-artifact.yaml(GitHub CI encrypted artifact).
All routes (unless marked public) require:
Authorization: Bearer <key_or_jwt>
Accepted credentials:
| Credential | Format | Access level |
|---|---|---|
| Admin API key | sk... (64+ chars) |
Full admin access to legacy endpoints |
| Public API key | pk... |
Cart, checkout, read-only catalog |
| Auth0 JWT | RS256 signed by your tenant | Role determined by JWT permissions |
| 64-char hex token | customer OAuth token | Customer-scoped routes |
JWT permission claims (values configurable via env vars):
| Permission | Routes | Env var |
|---|---|---|
admin:store |
All store management routes | ADMIN_STORE_PERMISSION |
auth0:admin:api |
Auth0 Management API proxy | ADMIN_AUTH0_PERMISSION |
admin:database |
Database reset, config read | DATABASE_PERMISSION |
ai:api |
GET /v1/ai/parameters |
AI_PERMISSION |
mail:api |
POST /v1/mails/send |
MAIL_PERMISSION |
want:sapi |
UCP / want routes | WANT_PERMISSION |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/products |
bearer | List products (?limit, ?cursor, ?status) |
POST |
/v1/products |
admin:store |
Create product |
GET |
/v1/products/search?q= |
bearer | Full-text search |
GET |
/v1/products/pricing-audit?currencyId= |
admin:store |
List active variants missing a price for a currency |
GET |
/v1/products/:id |
bearer | Get product |
PATCH |
/v1/products/:id |
admin:store |
Update product (title, description, shippingclassid, status) |
DELETE |
/v1/products/:id |
admin:store |
Delete product |
POST |
/v1/products/:id/variants |
admin:store |
Add variant (sku, title, pricecents, currency, weightg, shippingclassid) |
PATCH |
/v1/products/:id/variants/:variantId |
admin:store |
Update variant |
DELETE |
/v1/products/:id/variants/:variantId |
admin:store |
Delete variant |
GET |
/v1/products/:id/variants/:variantId/prices |
admin:store |
List per-currency prices |
POST |
/v1/products/:id/variants/:variantId/prices |
admin:store |
Set price for a currency (currencyid, pricecents) |
DELETE |
/v1/products/:id/variants/:variantId/prices/:currencyId |
admin:store |
Remove a currency price |
shippingclassidcan be set on both the product (default for all variants) and per-variant (overrides product).
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/categories |
public | List all active categories (flat list ready for tree building) |
POST |
/v1/categories |
admin:store |
Create category |
GET |
/v1/categories/:handle |
public | Get a category by handle with full details |
GET |
/v1/categories/:handle/products |
public | List products in a category (?limit, ?cursor) |
PATCH |
/v1/categories/:id |
admin:store |
Update category (handle, name, description, parent_id, image_url, position, status) |
DELETE |
/v1/categories/:id |
admin:store |
Delete category |
POST |
/v1/categories/:id/products |
admin:store |
Assign products to category (product_ids: [...]) |
DELETE |
/v1/categories/:id/products/:productId |
admin:store |
Remove product from category |
Category data structure:
{
"id": "uuid",
"handle": "t-shirts",
"name": "{\"en-US\":\"T-Shirts\",\"fr-FR\":\"T-Shirts\"}",
"description": "{\"en-US\":\"Collection of classic tees\"}",
"parent_id": null,
"position": 0,
"image_url": "https://r2.example.com/category-tees.jpg",
"status": "active",
"created_at": "2026-03-28T08:01:31.772Z",
"updated_at": "2026-03-28T08:01:31.778Z"
}
Multilingual storage:
Category name and description fields support two formats:
{ "name": "T-Shirts" }
{
"name": "{\"en-US\":\"T-Shirts\",\"fr-FR\":\"T-Shirts\",\"es-ES\":\"Camisetas\"}",
"description": "{\"en-US\":\"Classic tee collection by season\",\"fr-FR\":\"...\"}"
}
The admin UI automatically migrates from plain text to JSON when switching locales, preserving all existing translations and allowing per-locale AI translation.
AI Translation:
The CategoryAdmin component includes one-click AI translation buttons for category names and descriptions. This feature:
AI_PERMISSION claim (ai:api by default)isHtml modeAdmin panel:
Access category management at /admin/categories (requires admin:store permission).
Features include:
ai:api permission)| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/inventory |
admin:store |
List inventory (?lowstock=true, ?limit, ?cursor) |
POST |
/v1/inventory/:sku/adjust |
admin:store |
Adjust stock (delta, reason) |
GET |
/v1/inventory/warehouse |
admin:store |
Per-warehouse inventory |
POST |
/v1/inventory/{sku}/warehouse-adjust |
admin:store |
Adjust warehouse stock (warehouse_id, delta, reason) |
POST |
/v1/inventory/{sku}/warehouse-delete |
admin:store |
Remove warehouse inventory entry |
reason values: restock, correction, damaged, return, sale, release
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/carts |
public | Create cart (customeremail, optional regionid) |
GET |
/v1/carts/:cartId |
public | Get cart with items, shipping and totals |
POST |
/v1/carts/:cartId/items |
public | Replace cart items ([{sku, qty}]) |
PUT |
/v1/carts/:cartId/shipping-address |
public | Set shipping address (required before rate selection) |
GET |
/v1/carts/:cartId/available-shipping-rates |
public | List compatible rates for this cart |
PUT |
/v1/carts/:cartId/shipping-rate |
public | Select a shipping rate (shippingrateid) |
POST |
/v1/carts/:cartId/apply-discount |
public | Apply a discount code (code) |
DELETE |
/v1/carts/:cartId/discount |
public | Remove discount |
POST |
/v1/carts/:cartId/checkout |
public | Create Stripe Checkout session β {checkouturl, stripecheckoutsessionid} |
Standard checkout flow:
POST /v1/carts β create cart
POST /v1/carts/:id/items β add items
PUT /v1/carts/:id/shipping-address β set delivery address
GET /v1/carts/:id/available-shipping-rates β list rates (weight + class aware)
PUT /v1/carts/:id/shipping-rate β select rate
POST /v1/carts/:id/checkout β redirect to Stripe
β customer pays
Stripe webhook β order created, stock committed, confirmation email sent
The POST /v1/carts/:id/checkout body:
| Field | Type | Description |
|---|---|---|
successurl |
string (uri) | Redirect after successful payment |
cancelurl |
string (uri) | Redirect on cancel |
collectshipping |
boolean | Whether Stripe collects address (default false) |
shippingcountries |
string[] | Restrict allowed countries in Stripe |
shippingoptions |
array|null | Override shipping options (leave null to use DB rates) |
GET /v1/carts/:cartId/available-shipping-rates
Response AvailableShippingRates:
{
"items": [
{
"id": "uuid",
"displayname": "Standard Shipping",
"description": "5-7 business days",
"amountcents": 499,
"currency": "EUR",
"mindeliverydays": 5,
"maxdeliverydays": 7,
"maxweightg": 5000
}
],
"carttotalweightg": 1200
}
Rates are filtered by:
regionshippingrates)maxweightg >= carttotalweightg, or null = no limit)| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/orders |
admin:store |
List orders (?status, ?email, ?limit, ?cursor) |
POST |
/v1/orders/test |
admin:store |
Create test order (bypasses Stripe) |
GET |
/v1/orders/lookup?session_id= |
public | Look up order by Stripe checkout session ID |
GET |
/v1/orders/:orderId |
admin:store |
Get order by ID |
PATCH |
/v1/orders/:orderId |
admin:store |
Update status / tracking |
POST |
/v1/orders/:orderId/refund |
admin:store |
Full or partial Stripe refund |
POST |
/v1/orders/:orderId/resend-confirmation |
admin:store |
Resend confirmation email (regenerates token if missing) |
POST |
/v1/orders/:orderId/regenerate-tracking-link |
admin:store |
Regenerate view token + resend email |
GET |
/v1/orders/:orderId/status?token= |
public | View order status via signed JWT token |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/customers |
admin:store |
List customers |
GET |
/v1/customers/:id |
admin:store |
Get customer with addresses |
PATCH |
/v1/customers/:id |
admin:store |
Update customer (name, phone, acceptsmarketing, metadata) |
DELETE |
/v1/customers/:id |
admin:store |
Delete customer |
GET |
/v1/customers/:id/addresses |
admin:store |
List addresses |
POST |
/v1/customers/:id/addresses |
admin:store |
Add address |
DELETE |
/v1/customers/:id/addresses/:addressId |
admin:store |
Delete address |
GET |
/v1/customers/:id/orders |
admin:store |
List customer orders |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/discounts |
admin:store |
List discounts |
POST |
/v1/discounts |
admin:store |
Create discount (auto-synced to Stripe coupon) |
PATCH |
/v1/discounts/:id |
admin:store |
Update discount |
DELETE |
/v1/discounts/:id |
admin:store |
Delete discount |
| Method | Path | Auth | Description |
|---|---|---|---|
GET/POST |
/v1/regions |
admin:store |
List / create regions |
GET/PATCH/DELETE |
/v1/regions/:id |
admin:store |
Get / update / delete region |
POST |
/v1/regions/:id/countries |
admin:store |
Associate country with region |
POST |
/v1/regions/:id/warehouses |
admin:store |
Associate warehouse with region |
POST |
/v1/regions/:id/shipping-rates |
admin:store |
Associate shipping rate with region |
GET/POST |
/v1/regions/currencies |
admin:store |
List / create currencies |
GET/PATCH/DELETE |
/v1/regions/currencies/:id |
admin:store |
Manage a currency |
GET/POST |
/v1/regions/countries |
admin:store |
List / create countries |
GET/POST |
/v1/regions/countries/batch |
admin:store |
Get all / batch-create countries |
GET/PATCH/DELETE |
/v1/regions/countries/:id |
admin:store |
Manage a country |
| Method | Path | Auth | Description |
|---|---|---|---|
GET/POST |
/v1/regions/warehouses |
admin:store |
List / create warehouses |
GET/PATCH/DELETE |
/v1/regions/warehouses/:id |
admin:store |
Manage a warehouse |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/regions/shipping-classes |
admin:store |
List all shipping classes |
POST |
/v1/regions/shipping-classes |
admin:store |
Create a shipping class |
GET |
/v1/regions/shipping-classes/:id |
admin:store |
Get a shipping class |
PATCH |
/v1/regions/shipping-classes/:id |
admin:store |
Update a shipping class |
DELETE |
/v1/regions/shipping-classes/:id |
admin:store |
Delete a shipping class |
CreateShippingClass body:
| Field | Type | Required | Description |
|---|---|---|---|
code |
string ([a-z0-9-], max 50) |
β | Unique machine identifier |
displayname |
string | β | Human-readable label |
description |
string | β | Optional description |
resolution |
exclusive | additive |
β | Default: exclusive |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/regions/shipping-rates |
admin:store |
List all rates |
POST |
/v1/regions/shipping-rates |
admin:store |
Create a rate |
GET |
/v1/regions/shipping-rates/:id |
admin:store |
Get a rate |
PATCH |
/v1/regions/shipping-rates/:id |
admin:store |
Update a rate |
DELETE |
/v1/regions/shipping-rates/:id |
admin:store |
Delete a rate |
POST |
/v1/regions/shipping-rates/:id/prices |
admin:store |
Add/update price in a currency |
GET |
/v1/regions/shipping-rates/:id/prices |
admin:store |
List prices for a rate |
CreateShippingRate body:
| Field | Type | Description |
|---|---|---|
displayname |
string | Rate label |
description |
string | Optional description |
maxweightg |
integer | null | Max cart weight in grams (null = unlimited) |
mindeliverydays |
integer | null | Minimum delivery days |
maxdeliverydays |
integer | null | Maximum delivery days |
shippingclassid |
uuid | null | null = universal rate; a UUID = class-specific |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/analytics/dashboard |
admin:store |
Store dashboard stats (?period=7d\|30d\|90d\|all) |
GET |
/v1/analytics/cache-stats |
admin:store |
KV and CDN cache performance counters (hits, misses, hit rate, active entries) |
Response shape:
{
"revenue": {
"total_cents": 125000,
"order_count": 42,
"avg_order_cents": 2976
},
"customers": {
"total": 18,
"new": 5,
"returning": 9
},
"top_products": [
{ "product_id": "uuid", "product_title": "{\"en-US\":\"Cap\"}", "units_sold": 12, "revenue_cents": 35880 }
],
"orders_by_status": [
{ "status": "delivered", "count": 30 },
{ "status": "processing", "count": 5 }
],
"low_stock_count": 3
}
Implementation notes:
- No DB migration required β all metrics are aggregated from
orders,order_items,customers, andinventory.- The
periodparameter is validated via a Zod enum before being interpolated into the SQL date expression β no user input reaches the query directly.product_titlemay be a plain string or a JSON locale map; the frontend decodes it withresolveTitle(raw, i18n.language).- Revenue counts only paid/processing/shipped/delivered orders (
status IN ('paid','processing','shipped','delivered')).- Low-stock threshold:
(on_hand - reserved) <= 5.
pending β approved).review_count, average_rating on products table) is updated on every moderation action.ORDER_TOKEN_SECRET) and the hashed viewtoken, no Auth0 required./admin/reviews with AI-assisted analysis.| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/products/:productId/reviews |
public | List approved reviews (cursor-paginated) |
POST |
/v1/products/:productId/reviews |
customer JWT | Submit a review (authenticated customer) |
POST |
/v1/products/:productId/reviews/guest |
order JWT | Submit a review as a guest (magic-link verified) |
GET |
/v1/reviews/admin |
admin:store |
List reviews by status (?status=pending\|approved\|rejected\|all) |
PATCH |
/v1/reviews/:reviewId/status |
admin:store |
Approve or reject a review |
Admins holding the AI_PERMISSION claim (ai:api by default) see additional controls on the /admin/reviews page:
GET /v1/ai/parameters); each review receives a approve/reject recommendation with a one-sentence reason shown inline. Up to 5 reviews are processed concurrently; a live done/total counter is displayed during analysis.Implementation notes:
- Duplicate reviews are blocked: one review per
(customer_id, product_id)pair (or(NULL, product_id, order_id)for guests).- Guest review auth: backend verifies HMAC-HS256 JWT + compares
SHA-256(token)againstorders.viewtoken; checks order status isdeliveredand product is in the order.- Verified purchase is detected by joining
orders β order_items β variantsfor delivered orders.- Cursor pagination uses
created_at < ?(notid > ?) β UUIDs are not chronologically ordered.- SQL status filter uses parameterized queries β no user input is interpolated directly.
product_idis resolved server-side viaLEFT JOIN variants ON variants.sku = order_items.skuso clients never need to look it up separately.
| Method | Path | Auth | Description |
|---|---|---|---|
GET/POST |
/v1/webhooks |
admin:store |
List / create webhooks |
GET/PATCH/DELETE |
/v1/webhooks/:id |
admin:store |
Manage a webhook |
POST |
/v1/webhooks/:id/rotate-secret |
admin:store |
Rotate HMAC signing secret |
GET |
/v1/webhooks/:id/deliveries/:deliveryId |
admin:store |
Get delivery detail |
POST |
/v1/webhooks/:id/deliveries/:deliveryId/retry |
admin:store |
Retry failed delivery |
POST |
/v1/webhooks/stripe |
Stripe sig | Inbound Stripe webhook (not included in OpenAPI spec) |
Outbound event types: order.created, order.updated, order.shipped, order.refunded, inventory.low, order.* (wildcard)
All outbound webhooks are signed with X-Fufuni-Signature (HMAC-SHA256).
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/images |
admin:store |
Upload image to R2 β {url, key} |
DELETE |
/v1/images/:key |
admin:store |
Delete image from R2 and purge its CDN cache entry |
DELETE |
/v1/images/cache/all |
admin:store |
Purge all image CDN cache entries (iterates R2 bucket) |
DELETE |
/v1/images/:key/cache |
admin:store |
Purge a single image CDN cache entry without deleting the R2 object |
Use the ImageUploadInput component (apps/client/src/components/image-upload-input.tsx) for any upload UI β it handles file picking, conversion to WebP, storage method selection, and preview.
Or call uploadImageFile() from apps/client/src/utils/image-upload.ts directly:
import { uploadImageFile } from '@/utils/image-upload';
import { useSecuredApi } from '@/features/auth/components/auth-components';
const { postForm } = useSecuredApi();
const result = await uploadImageFile(file, apiBaseUrl, postForm);
// result.url is either a data: URI (base64) or an R2 URL
Storage selection (automatic):
| Method | Condition | Stored as |
|---|---|---|
base64 |
File < 1 MB after WebP conversion | data:image/webp;base64,... in the SQLite column |
r2 |
File β₯ 1 MB OR forceR2 = true |
R2 object URL via POST /v1/images |
url |
Manual external URL input | External URL as-is |
All files are converted to WebP (max 1200 px) before upload. Thumbnails are generated separately at 300 px.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/mails/send |
mail:api |
Send a test email via Mailgun |
Per-event transactional email templates stored in the order_email_settings table.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/admin/order-email-settings |
admin:store |
List all event settings |
GET |
/v1/admin/order-email-settings/:event |
admin:store |
Get setting for one event |
PUT |
/v1/admin/order-email-settings/:event |
admin:store |
Upsert (create or update) a setting |
DELETE |
/v1/admin/order-email-settings/:event |
admin:store |
Delete a setting (reverts to global fallback) |
Valid :event values: global Β· pending Β· paid Β· payment_failed Β· processing Β· shipped Β· delivered Β· refunded Β· canceled
PUT body (all fields optional):
| Field | Type | Description |
|---|---|---|
enabled |
boolean | Enable this event template (overrides global fallback when true) |
subject |
string | Subject line β plain text or a JSON locale map {"en-US":"...","fr-FR":"..."} |
html_body |
string | HTML email body β plain HTML or a JSON locale map |
text_body |
string | Plain-text fallback body β plain text or a JSON locale map |
Fallback logic: when an order event fires, sendOrderStatusEmail() in lib/order-email.ts first looks for an enabled event-specific row; if none is found it falls back to the global row; if that is also absent or disabled it generates a default email via buildDefaultStatusEmail().
Template variables resolved at send time: ,, ,, ,, ,.
Multilingual delivery: sendOrderStatusEmail() reads the customerβs locale preference (stored on the order) and extracts the matching locale from the JSON map via getEditorContent(raw, locale).
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/__auth0/token |
auth0:admin:api |
Get Auth0 Management API access token (cached) |
POST |
/v1/__auth0/autopermissions |
auth0:admin:api |
Auto-assign configured permissions to current user |
/v1/me/wishlist operations persist the wishlist as JSON in Auth0 user_metadata under the storefront namespace key.
STORE_URL is available in backend as env.STORE_URL and in the frontend as import.meta.env.STORE_URL.auth.user_metadata[STORE_URL].wishlist when STORE_URL is defined.auth.user_metadata.wishlist when no store namespace is set.Behaviour of endpoints:
GET /v1/me/wishlist: reads first from auth0.user_metadata[STORE_URL]?.wishlist, then falls back to auth.user_metadata.wishlist.POST /v1/me/wishlist: updates auth0.user_metadata[STORE_URL] with the new array.DELETE /v1/me/wishlist/:productId: updates auth0.user_metadata[STORE_URL] without the deleted item.This avoids storing wishlist in the Durable Object / worker state, and reuses Auth0 profile data as a customer preference store while supporting multi-store isolation.
/v1/me/saved-carts operations persist saved cart IDs as JSON in Auth0 user_metadata under the storefront namespace key.
auth.user_metadata[STORE_URL].saved_carts when STORE_URL is available.auth.user_metadata.saved_carts for backwards compatibility.saved_carts table for audit/recovery.Additionally, cart snapshots are stored in a relational saved_carts table in the Durable Object for full audit and recovery purposes.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/me/saved-carts |
Auth0 JWT | List all saved cart IDs for the authenticated user |
POST |
/v1/me/saved-carts |
Auth0 JWT | Save a new cart ({cartId: string}) and add to user_metadata.saved_carts |
DELETE |
/v1/me/saved-carts/:cartId |
Auth0 JWT | Remove a saved cart and update user_metadata.saved_carts |
How it works:
{cartId: "uuid-string"}saved_carts table (for audit)updateUserMetadata(userId, {saved_carts: [..., cartId]}) to persist in Auth0useTokenRefresh() refreshes JWT to pull updated user_metadata.saved_cartsfufuni:saved-carts-updated notifies all useSavedCarts hooksDatabase table:
CREATE TABLE saved_carts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth0_user_id TEXT NOT NULL,
cart_id TEXT NOT NULL, -- UUID, foreign key to carts(id)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(auth0_user_id, cart_id),
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE
);
Frontend hooks:
useSavedCarts() β Returns {savedCarts: string[], toggleSavedCart, isSaved, isLoading, isError}useTokenRefresh() β Force JWT token refresh after mutationsFrontend components:
<SaveCartButton cartId={string} onBeforeSave={async () => string} /> β Button to toggle cart save state<UserListsMenu /> β Dropdown menu showing both wishlists and saved carts| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v1/ai/parameters |
ai:api |
Returns AI provider credentials, optionally including Cloudflare AI Gateway fields |
Response shape:
{
"providerName": "groq",
"apiKey": "gsk_...",
"model": "llama-3.3-70b-versatile",
"url": "https://api.groq.com/openai/v1",
"cloudflareAigToken": "cfut_...",
"cloudflareAigUrl": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat"
}
cloudflareAigToken and cloudflareAigUrl are only present when the Worker env var CLOUDFLARE_AIG_TOKEN is set. The frontend casts this response directly as AiParams β field names must match exactly.
The backend only transmits credentials β all AI calls (LLM requests) are executed client-side in
utils/ai-client.ts. This keeps the server stateless with respect to AI and avoids proxying large payloads.
AI_API_KEYmay contain one or more keys separated by commas (for key rotation/load balancing). The endpoint randomly selects one non-empty key per request.When using the encrypted
ai.json.encconfig (recommended), the backend selects the best available provider/model by priority and returns its credentials. See Cloudflare AI Gateway for the full multi-provider setup.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/setup/init |
public (once) | Create initial API keys (only works if no keys exist) |
POST |
/v1/setup/stripe |
admin:store |
Configure Stripe API keys |
GET |
/v1/setup/config |
admin:database |
Read all config key-value pairs |
POST |
/v1/setup/reset |
admin:database |
Wipe and reset the database |
GET |
/v1/setup/migrations/list |
admin:database |
List applied migrations |
POST |
/v1/setup/migrations/clean |
admin:database |
Delete all migration records |
POST |
/v1/setup/migrations/run |
admin:database |
Re-run pending migrations |
All data lives in the Durable Objectβs built-in SQLite. The full schema is defined in do.ts (SCHEMA constant) and migrations are applied automatically by ensureInitialized() on startup. No D1 is required.
| Table | Description |
|---|---|
products |
Catalogue (shippingclassid for default class) |
variants |
SKUs (weightg for cart weight, shippingclassid overrides product) |
inventory |
Aggregated per-SKU stock |
warehouseinventory |
Per-warehouse stock with reservations |
carts |
Shopping sessions with full shipping address fields |
cartitems |
Line items (SKU, qty, price snapshot, currency) |
orders |
Completed orders with view token and email delivery status |
orderitems |
Order line items |
customers |
Customer accounts with address book |
discounts |
Discount codes with Stripe coupon sync |
| Table | Description |
|---|---|
shippingclasses |
Transport constraint groups (exclusive / additive) |
shippingrates |
Options (weight limit, delivery days, optional class) |
shippingrateprices |
Per-currency amounts for each rate |
regionshippingrates |
Many-to-many: rates available in a region |
| Table | Description |
|---|---|
regions |
Geographic/commercial regions with default currency |
currencies |
ISO 4217 currencies |
countries |
ISO 3166 countries |
warehouses |
Physical locations with address and priority |
variantprices |
Per-variant, per-currency pricing (strict β no automatic conversion) |
Migrations are run both via ensureInitialized() (inline in do.ts) and as standalone .sql files in apps/merchant/migrations/.
| File | Description |
|---|---|
001 |
Expand order status CHECK constraint |
002 |
Outbound webhooks |
003 |
Customers table + link to orders |
004 |
updatedat on carts |
005 |
Performance indexes |
006 |
UCP checkout sessions |
007 |
Currencies |
008 |
Countries |
009 |
Warehouses |
010 |
Shipping rates + prices |
011 |
Regions + relationships |
012 |
Warehouse inventory + logs |
013 |
Variant prices (multi-currency) |
014 |
regionid / shippingrateid on carts + orders |
015 |
currency on cart items |
016 |
Order view token + confirmation email audit fields |
017 |
Shipping address fields on carts |
018 |
Shipping classes + shippingclassid on products, variants, rates |
019 |
Variant enrichment β weight, dimensions, requires_shipping, barcode, compare_at_price, tax_code; product enrichment β vendor, tags, handle |
020 |
Tax rates table β tax_rates with country_code, tax_code, rate_percentage, status |
021 |
tax_inclusive column on regions |
022 |
taxes_json on carts and orders (tax snapshot at checkout time) |
023 |
tax_code on shipping_rates |
024 |
tax_inclusive on shipping_rates |
025 |
Index on customers.auth_provider_id (Auth0 sub claim β O(log n) JWT lookup) |
026 |
Saved carts β saved_carts table linking Auth0 user IDs to cart UUIDs |
027 |
Product categories β categories + product_categories join table (hierarchical, multilingual) |
028 |
Product reviews & ratings β reviews table with moderation workflow and guest review support |
029 |
thumbnail_url column on variants (small WebP thumbnails, base64 or R2 URL) |
030 |
Store themes β store_themes table with config_json + seed rows for classic and luxury |
031 |
thumbnail_url column on categories |
032 |
Order email settings β order_email_settings table (per-event HTML/text templates, enabled flag); locale column on orders |
033 |
Expand order_email_settings CHECK constraint to include pending and paid events |
A shipping class groups products with identical transport constraints. Assign a class to a product (default for all its variants) or directly to a variant (overrides product).
product.shippingclassid β default for all variants
β overridden by
variant.shippingclassid β per-SKU override
Resolution modes:
| Mode | Behaviour | Use case |
|---|---|---|
exclusive |
When any cart item has this class, only rates tagged with this class are shown | Furniture, palletised freight |
additive |
Rates of this class are added to universal rates | Hazmat surcharge, insurance |
computeCartWeightG(db, cartId) in lib/shipping.ts sums variant.weightg Γ qty for all cart items.
It is called automatically inside getCompatibleShippingRates β the weight is never passed as a parameter by callers.
The GET /v1/carts/:cartId/available-shipping-rates response includes carttotalweightg so the storefront can display it.
getCompatibleShippingRates(db, regionid, cartid, currencyid) in lib/shipping.ts:
1. Compute total cart weight (computeCartWeightG)
2. Resolve effective shipping class per cart item (variant class > product class)
3. Apply class filter:
βββ No special class β only universal rates (shippingclassid IS NULL)
βββ Exclusive class β only rates matching those class IDs
βββ Additive class β universal rates + rates matching those class IDs
4. Filter by: active, linked to region, maxweightg >= cart weight (or null)
5. Return sorted by amountcents ASC
tax_rates table (see apps/merchant/src/do.ts):
id, display_namecountry_code (ISO-3166-1 alpha-2, nullable fallback)tax_code (optional, nullable fallback)rate_percentage (e.g., 20.0)status (active/inactive)Protected by admin:store (or legacy sk_) plus middleware adminOnly.
GET /v1/tax-rates : list (paginated, limit 500)POST /v1/tax-rates : createGET /v1/tax-rates/{id} : getPATCH /v1/tax-rates/{id} : updateDELETE /v1/tax-rates/{id} : deleteIn apps/merchant/src/routes/catalog.ts:
SELECT * FROM tax_rates WHERE status = 'active'variant.tax_code matches tax_rates.tax_codetax_code IS NULLVariant mapping (mapVariant):
tax_rate_percentage set from selected or fallback ratetax_display_name set from selected or fallback ratetax_inclusive set from variant tax_inclusive flag, otherwise default region value from regions.tax_inclusive (default region is_default=1)In apps/client/src/components/product-card-full.tsx:
taxAmount = price_cents Γ (tax_rate_percentage / 100)taxLabel:
Dont when tax_inclusive=truetax_inclusive=falseThis reflects the expected user-visible behaviour:
Dont TVA, tax computed from list priceTVA, tax computed from list priceregionid, all prices (variants and shipping) are resolved in that regionβs currencyvariantprices per currency.GET /v1/products/pricing-audit?currencyId=<uuid> lists variants missing a price for a given currency.regionid is provided at cart creation, the default region is used automatically.scripts/init.tssk... prefix = admin, pk... prefix = publicPOST /v1/__auth0/autopermissions assigns the permissions listed in AUTH0_AUTOMATIC_PERMISSIONS to the current userORDER_TOKEN_SECRETGET /v1/orders/:id/status?token=Configurable in src/config/rate-limits.ts:
| Scope | Default |
|---|---|
| Default | 100 req/min |
| Admin JWT | 2 000 req/min |
| Public key | 60 req/min |
/v1/carts/* |
30 req/min |
/v1/webhooks/stripe |
1 000 req/min |
/v1/images |
20 req/min |
The backend exposes a single endpoint that returns AI credentials to the client:
GET /v1/ai/parameters
Authorization: Bearer <token-with-ai:api-permission>
All actual LLM requests are made by the browser in apps/client/src/utils/ai-client.ts.
The server acts only as a credential dispenser β it never proxies AI payloads.
Simple configuration via wrangler.jsonc (single provider, no encryption):
AI_API_URL: provider base URL (e.g. https://api.groq.com/openai/v1)AI_MODEL: model name (e.g. llama-3.3-70b-versatile)AI_API_KEY: API key (Wrangler secret in production)For multi-provider, priority-based selection and gateway routing, use ai.json.enc β see Cloudflare AI Gateway.
Admins with the AI_PERMISSION permission (ai:api by default) get AI analysis controls on the /admin/reviews page.
analyzeReviewWithAi and analyzeReviewsBatchWithAi (in ai-client.ts) build a structured prompt asking the LLM to return a JSON object:
{ "recommendation": "approve" | "reject", "reason": "<one sentence>" }
The client strips any markdown code fences before JSON.parse, handles both Anthropic and OpenAI-compatible APIs, and processes up to 5 reviews concurrently.
Fufuni includes a full customer portal accessible at /account once the user is authenticated via Auth0 Passwordless (magic links).
Customers log in without a password using Auth0βs Passwordless Email flow:
Configuration required in Auth0 Dashboard:
https://store.example.com/)openid profile email to the OAuth scopesAll routes require a valid Auth0 JWT in the Authorization: Bearer header.
| Method | Path | Description |
|---|---|---|
GET |
/v1/me/profile |
Get my profile |
PATCH |
/v1/me/profile |
Update name, phone, locale, marketing preferences |
GET |
/v1/me/orders |
List my orders (paginated) |
GET |
/v1/me/addresses |
List saved delivery addresses |
POST |
/v1/me/addresses |
Add a new address |
DELETE |
/v1/me/addresses/:id |
Delete a saved address |
GET |
/v1/me/preferences |
Read preferences (Auth0 user_metadata) |
PATCH |
/v1/me/preferences |
Update preferences (Auth0 user_metadata) |
Located under /account/*:
| Route | Feature |
|---|---|
/account |
Dashboard (profile summary, quick stats) |
/account/orders |
Order history with pagination |
/account/orders/:number |
Order detail with items and tracking |
/account/addresses |
Saved delivery addresses (CRUD) |
/account/preferences |
Profile & communication preferences |
The following components in apps/client/src/components/ are designed to be reused across pages:
| Component | Import path | Description |
|---|---|---|
ProductCard |
@/components/product-card |
Compact product card with image, price, variant chips and add-to-cart button |
ProductCardFull |
@/components/product-card-full |
Full product detail view: variant selector, description, tax breakdown |
ProductImage |
@/components/product-image |
Square image with hover-zoom, WebP fallback and optional variant-count badge |
ProductReviews |
@/components/product-reviews |
Review list + write-review form (auth-gated by purchase eligibility) |
CategoryBentoGrid |
@/components/ui/bento-grid |
5-item bento grid layout for category landing sections |
ProductCarousel |
@/components/ui/product-carousel |
Horizontal snap-scroll product carousel for landing pages |
ImageUploadInput |
@/components/image-upload-input |
Full image upload UI: file picker, manual URL input, R2/base64 auto-selection, preview |
Invoice PDFs are generated entirely in the browser using jsPDF:
The downloadInvoicePdf() function in apps/client/src/utils/invoice-pdf.ts handles:
Lightweight preferences (locale, theme, marketing opt-in) are stored in Auth0 user_metadata, not in the Durable Object database:
/v1/me/preferences endpointWhen a customer logs in for the first time:
auth_provider_iden-US)auth_provider_id (Migration 025)On subsequent logins, database is queried by the Auth0 sub claim in O(log n) time.
If you see 401 errors when accessing authenticated routes like /account/orders, itβs likely an Auth0 JWT configuration issue:
Problem: GET http://localhost:8787/v1/me/orders 401 (Unauthorized)
Causes (in order of likelihood):
http://localhost:5173/) to Auth0 Application Settings β Allowed Callback URLsAUTH0_AUDIENCE matches your Auth0 API identifier, and OAuth scopes include openid profile emailQuick Debug:
/account/ordersTranslations live in apps/client/src/locales/base/.
To add a language:
availableLanguages in apps/client/src/i18n.ts (code, nativeName, isRTL, isDefault)apps/client/src/locales/base/<code>.jsoncase in the loadPath switch in i18n.tsisRTL: true for right-to-left locales β the layout adds dir="rtl" automaticallySupported locales: en-US Β· fr-FR Β· es-ES Β· zh-CN Β· ar-SA Β· he-IL
Product titles, descriptions, vendor names, tags, and URL handles are stored in the database as either:
{"en-US":"Wool Coat","fr-FR":"Manteau en laine","es-ES":"Abrigo de lana",...}All helpers live in apps/client/src/utils/description.ts:
| Helper | Purpose |
|---|---|
resolveTitle(raw, locale) |
Resolve product title for the current locale (fallback chain) |
resolveDescription(raw, locale) |
Resolve HTML description for the current locale |
resolveVendor(raw, locale) |
Resolve vendor name for the current locale |
resolveTags(raw, locale) |
Resolve comma-separated tag list for the current locale |
resolveHandle(raw, locale) |
Resolve URL handle for the current locale |
mergeLocale(raw, locale, html) |
Add / update one locale in an HTML description JSON map |
mergeTitleLocale(raw, locale, text) |
Add / update one locale in a title JSON map |
getTitleForLocale(raw, locale) |
Alias for resolveTitle |
isLocalized(raw) |
Returns true if the value is a JSON locale map |
Fallback chain when the requested locale is absent: en-US β fr-FR β es-ES β zh-CN β ar-SA β he-IL.
The admin editor stores multilingual content via mergeLocale() / mergeTitleLocale(), which migrate a plain-string value to the JSON format on first save.
Available at /admin/* β requires the permission configured in ADMIN_STORE_PERMISSION (default: admin:store).
| Route | Description |
|---|---|
/admin/products |
Products, variants, pricing, images, shipping class assignment |
/admin/inventory |
Stock levels and adjustments |
/admin/orders |
Order management and fulfilment |
/admin/customers |
Customer CRM |
/admin/webhooks |
Outbound webhook management |
/admin/regions |
Region setup |
/admin/currencies |
Currency management |
/admin/countries |
Country list |
/admin/warehouses |
Warehouse management |
/admin/shipping-rates |
Shipping rate management (with class assignment) |
/admin/shipping-classes |
Transport constraint groups |
/admin/discounts |
Discount codes |
/admin/analytics |
Store analytics dashboard (revenue, customers, top products, stock alerts) |
/admin/email-templates |
Configurable transactional email templates per order event β TipTap editor, multilingual, AI translate, test send |
/admin/users |
Auth0 user and permission management |
/openapi |
Swagger UI |
{
"name": "merchant",
"main": "src/index.ts",
"compatibility_date": "2025-12-01",
"durable_objects": {
"bindings": [{ "name": "MERCHANT", "class_name": "MerchantDO" }],
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MerchantDO"] }]
},
"r2_buckets": [{ "binding": "IMAGES" }],
"kv_namespaces": [{ "binding": "KV_CACHE", "id": "<your-kv-id>" }],
"triggers": { "crons": ["15 * * * *"] },
"vars": {
"ADMIN_STORE_PERMISSION": "admin:store",
"STORE_URL": "https://your-store.example.com"
// ... other vars
}
}
In the Stripe dashboard, point checkout.session.completed to:
https://<your-worker>.workers.dev/v1/webhooks/stripe
Three production workflows ship with the repository. All of them consume the shared encrypted .env artifact produced by create-env-artifact.yaml, which bundles .env + wrangler.jsonc and encrypts them with AES-256 using the CRYPTOKEN GitHub Secret.
deploy-cloudflare-worker.yaml β Worker auto-deployTriggered on every push to main that touches apps/merchant/**.
What it does:
create-env-artifact.yaml to build the encrypted env artifact..env + wrangler.jsonc into the workspace.apps/merchant/secrets.json to Cloudflare via wrangler secret bulk.wrangler deploy.Required GitHub Secrets for automatic Worker deployment:
| Secret | Description |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare API token with Worker deploy permission |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID |
CRYPTOKEN |
Symmetric encryption key for the env artifact |
All .env variables |
Same names as in the local .env file |
Once
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDare set as GitHub Secrets, every push tomainthat changes the Worker source deploys automatically β no manualwrangler deployneeded.
pages.yaml β Frontend auto-deployTriggered on every push to main that touches apps/client/**.
What it does:
create-env-artifact.yaml for the env artifact..env and builds the React SPA with npm run build:env.llm*.txt) and copies it into dist/.dist/ folder to GitHub Pages.The frontend is served as a static SPA on GitHub Pages (https://<user>.github.io/<repo>/). Moving to Cloudflare Pages instead requires only changing the deploy step β the build process is identical.
reset-demo.yaml β Demo reset & re-seedResets the hosted demo to a clean state and re-populates it with sample data.
What it does:
reset β init β seed
| Job | Description |
|---|---|
reset |
Obtains an Auth0 M2M token with admin:database permission and calls POST /v1/setup/reset |
init |
Re-initialises the Cloudflare Worker store (runs scripts/init.ts --remote) and re-configures Stripe |
seed |
Seeds the store with sample products, categories and orders (runs scripts/seed.ts + seed-data.ts) |
The seed data is defined in apps/merchant/scripts/seed-data.ts (products, categories, countries, regions) and orchestrated by apps/merchant/scripts/seed.ts which calls the live API.
Calling the Auth0 Management API token endpoint is rate-limited per tenant (free tier: 2 tokens / minute, low M2M monthly quota). To avoid exhausting the quota, the workflow caches the Management token in the GitHub secret AUTH0_MANAGEMENT_TOKEN:
scripts/jwt-exp.py decodes the exp claim of the stored token.client_credentials and saved back via gh secret set.The script scripts/jwt-exp.py reads the token from the JWT_TOKEN environment variable and prints the exp claim as a Unix timestamp (prints 0 on any error).
This same caching pattern is used by the backend GET /v1/__auth0/token endpoint (KV-cached with ~23h TTL) to serve the Management API token to the frontend without re-requesting from Auth0.
All variables from the local .env file have a direct equivalent as a GitHub secret (same name). When you add a new environment variable:
.github/workflows/create-env-artifact.yaml (the encrypted artifact builder) so CI can pass it to the Worker build.define block in apps/client/vite.config.ts.Two additional secrets are required specifically for this workflow:
| Secret | Purpose |
|---|---|
AUTH0_MANAGEMENT_TOKEN |
Cached Management API JWT β create it initially with any value (e.g. none); the workflow will replace it on first run |
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RW |
Fine-grained PAT allowing the workflow to update AUTH0_MANAGEMENT_TOKEN via the GitHub API |
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RWGH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RW (or any descriptive name)fufuni repositoryfufunifufuni repository go to Settings β Secrets and variables β Actions β New repository secret:
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RWβ οΈ Fine-grained PATs have a maximum lifetime of 1 year on GitHub.com. Set a calendar reminder to rotate
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RWbefore it expires, otherwise the workflow will still run but the Management token cache will no longer be updated (it will be re-fetched from Auth0 on every run until the PAT is replaced).
Fufuni supports routing all AI calls through the Cloudflare AI Gateway for unified observability, logging, caching, and rate limiting across providers.
Browser ββ(credentials only)βββΊ GET /v1/ai/parameters βββΊ Merchant Worker
β
Decrypts ai.json.enc
Selects best provider
Returns credentials
β
Browser βββββ { apiKey, model, url, cloudflareAigToken, cloudflareAigUrl } ββββββ
β
β POST chat/completions (with cf-aig-authorization header)
βΌ
Cloudflare AI Gateway (/compat endpoint)
β
βΌ
AI Provider (Groq, Anthropic, OpenAIβ¦)
Note: Direct browser-to-gateway calls currently fail on the CORS preflight when βAuthenticated Gatewayβ is enabled (the browser cannot send
cf-aig-authorizationin the OPTIONS request β a browser security constraint). TheDISABLE_CLOUDFLARE_AIGflag is provided as a workaround until Cloudflare addresses this. See Known limitation below.
ai.json / ai.json.encInstead of (or in addition to) the simple AI_API_KEY / AI_MODEL / AI_API_URL env vars, you can configure multiple providers with priorities, key rotation, and gateway routing by editing ai.json at the repo root and uploading its encrypted form to Cloudflare KV.
ai.json structure:
{
"version": 1,
"providers": {
"groq": {
"protocol": "openai",
"endpoint": "https://api.groq.com/openai/v1",
"gatewayEndpoint": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat",
"gatewayModelPrefix": "groq",
"keys": [
{ "key": "gsk_...", "owner": "main", "type": "paid" }
],
"models": [
{
"id": "llama-3.3-70b-versatile",
"usage": "chat",
"contextWindow": 128000,
"maxOutputTokens": 32768,
"tpmLimit": null,
"priority": 1
}
]
}
}
}
Key fields:
| Field | Description |
|β|β|
| protocol | Wire format: openai, anthropic, or gemini |
| endpoint | Direct provider base URL (fallback when gateway is not configured) |
| gatewayEndpoint | Cloudflare AI Gateway compat URL (used when CLOUDFLARE_AIG_TOKEN is set) |
| gatewayModelPrefix | Prefix added to model IDs for the gateway (e.g. "groq" β "groq/llama-3.3-70b-versatile") |
| model.usage | "chat" (default) or "embedding" β must be set to prevent embedding models from being selected as chat models |
| model.priority | 1 = most preferred, higher = fallback |
Encrypting and uploading:
# Encrypt (CRYPTOKEN must match the Wrangler secret)
openssl enc -aes-256-cbc -a -pbkdf2 -iter 100000 -salt \
-in ai.json -out ai.json.enc -pass pass:"${CRYPTOKEN}"
# Upload to Worker KV (once the Worker is deployed)
curl -X POST https://your-worker.workers.dev/v1/ai/config \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$(cat ai.json.enc)\"}"
The Worker decrypts the config in memory at runtime using the Web Crypto API (decryptAiConfig() in lib/ai-enc.ts). The plaintext never leaves the Workerβs memory.
The Cloudflare AI Gateway compat endpoint requires model IDs to be prefixed with the provider slug:
| Provider | Direct model ID | Gateway model ID |
|---|---|---|
| Groq | llama-3.3-70b-versatile |
groq/llama-3.3-70b-versatile |
| Anthropic | claude-haiku-4-5 |
anthropic/claude-haiku-4-5 |
| Google AI | gemini-3-flash-preview |
google-ai-studio/gemini-3-flash-preview |
The frontend (ai-client.ts) automatically prefixes the model when cloudflareAigUrl is set. The MCP server (generate.ts, tools.ts) uses resolveModelId() from lib/ai-enc.ts for the same purpose.
| Variable | Where | Description |
|---|---|---|
CLOUDFLARE_AIG_TOKEN |
Wrangler secret + GitHub Secret | Cloudflare AI Gateway bearer token (cfut_...). When set, the Worker includes cloudflareAigToken and cloudflareAigUrl in the /v1/ai/parameters response. |
CRYPTOKEN |
Wrangler secret + GitHub Secret | Decryption password for ai.json.enc. Must match the password used during openssl enc. |
DISABLE_CLOUDFLARE_AIG |
.env β vite.config.ts define block |
Build-time flag for the frontend SPA. Set to true to force direct provider calls, bypassing gateway routing entirely. See Known limitation. |
DISABLE_CLOUDFLARE_AIG in detail:
This flag is exposed to the frontend at build time via vite.config.ts as import.meta.env.DISABLE_CLOUDFLARE_AIG. When true, analyzeReviewWithAi() and translateWithAi() ignore cloudflareAigToken/cloudflareAigUrl and call the AI provider directly.
.env: DISABLE_CLOUDFLARE_AIG=trueDISABLE_CLOUDFLARE_AIG with value true so CI builds also disable the gateway path.Remove the variable (or set it to false) once Cloudflare resolves the CORS preflight issue.
When βAuthenticated Gatewayβ is enabled on your Cloudflare AI Gateway, the browser sends a CORS preflight (OPTIONS) before every cross-origin request. The browser cannot include custom headers (cf-aig-authorization) in this preflight β this is an inviolable browser security restriction that no JavaScript library can bypass.
Consequence: Cloudflare AI Gateway returns 401 on the preflight β the actual POST is never sent.
Current workaround: set DISABLE_CLOUDFLARE_AIG=true in .env (and as a GitHub Secret). The gateway is still used by the MCP server and the backend generation scripts (Node.js/Worker context, no CORS restrictions).
git checkout -b feat/my-feature)DROP TABLE, no breaking column changesmainThis project is dual-licensed:
Β© 2024-2026 Ronan LE MEILLAT β SCTG Development