Cloudflare-native headless commerce — Workers · Durable Objects · React · HeroUI
Fufuni is a production-ready e-commerce engine built entirely on Cloudflare primitives. A single Durable Object backed by SQLite holds your entire store state — no external database, no cold starts, globally consistent.
| Layer | Service | Price |
|---|---|---|
| Backend API | Cloudflare Workers + Durable Objects | Free tier (100k req/day) |
| Database | Durable Object SQLite | Included in Workers free tier |
| Image storage | Cloudflare R2 | Free tier (10 GB / month) |
| KV caching | Cloudflare KV | Free tier (100k reads / day) |
| Identity & auth | Auth0 | Free tier (7500 MAU) |
| Frontend | GitHub Pages (or Cloudflare Pages) | Free |
| Transactional email | Mailgun | Free tier (3 000 emails / month) |
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.
- Features
- Architecture
- KV Cache & CDN
- Quick Start
- Configuration Reference
- API Reference
- Database Schema
- Shipping System
- Multi-Region & Multi-Currency
- Authentication & Security
- AI Features
- Internationalisation
- Admin Panel
- Deployment
- GitHub Actions Workflows
- Contributing
- License
- Product catalogue with variants, SKUs and per-variant multi-currency pricing
- Multilingual product titles — plain text or JSON per locale, with AI translation
- Multilingual product descriptions — rich HTML (Tiptap editor) or JSON per locale, with AI translation
- RTL language support (Arabic, Hebrew)
- Product image management via Cloudflare R2
- Inventory management across multiple warehouses
- Product shipping class assignment (per product or per variant override)
- Per-variant weight (
weightg) used for automatic cart weight calculation
- Hierarchical product categories with optional parent-child relationships
- Multilingual category names — JSON per locale with fallback resolution
- Multilingual descriptions — rich HTML or JSON per locale
- One-click AI translation for names and descriptions across all locales
- Category image URLs for storefront display
- Position-based sorting for custom category ordering
- Public read-only catalog endpoints, admin CRUD operations
- Real-time permission checking for AI translation features
- Stripe Checkout integration with full webhook reconciliation
- Multi-currency, multi-region pricing — explicit per-variant prices in
variantprices - Dynamic shipping options in Stripe — rates come from your DB, not hardcoded values
- Order lifecycle:
pending → paid → processing → shipped → delivered → refunded → canceled - Tracking number and URL per order
- Discount codes — fixed amount or percentage, with Stripe coupon sync
- Order confirmation emails via Mailgun with signed 30-day view-token links
- Secure order status page accessible via JWT token (no login required)
- Public order lookup by Stripe session ID
- Shipping rates with weight limits and delivery day estimates
- Shipping classes for product-specific transport constraints (
exclusiveoradditive) - Cart weight calculation from variant weights — automatic, computed server-side
- Multi-class cart filtering — exclusive classes hide incompatible rates; additive classes add theirs
- Region-bound rates — rates are linked to regions, not shown globally
- Address collection before checkout with automatic revalidation of the selected rate
- Per-rate multi-currency pricing via
shippingrateprices
- Auth0-based authentication for admin (JWT, RBAC, configurable permissions)
- Customer accounts with address book
- OAuth 2.0 / UCP (Universal Commerce Protocol) for customer-facing flows
- Wishlist (favorites) — products stored in Auth0 user_metadata
- Saved carts — full cart snapshots stored in Auth0 user_metadata for quick retrieval
- Magic-link checkout
- Multi-theme architecture — built-in
classic(light/dark) andluxurythemes - Themes stored in
store_themestable and served viaGET /v1/theme/active StoreThemeProviderappliesdata-themeon<html>at boot, preventing FOUC- Persistent user preference via
localStorage(ui-themekey) - Floating
ThemeSwitcherdropdown for instant preview/switch - Theme config overrides CSS variables (
--accent,--radius, font stacks) without a redeploy
- CartDrawer — slide-in right panel for cart items with quantity controls, no separate cart page needed
- WishlistButton — heart toggle on product cards; LoginModal shown to unauthenticated users with pending action preserved across Auth0 redirect via
sessionStorage - HeroBanner — full-width hero section with configurable image, overlay, title and CTA
- ProductCarousel — horizontal snap-scroll carousel of product cards
- CategoryBentoGrid — asymmetric editorial grid (up to 5 categories, first spanning 2 columns)
- 6 built-in locales: English (US), French, Spanish, Chinese (Simplified), Arabic, Hebrew
availableLanguagesregistry withnativeName,isRTL,isDefaultflags- Locale-aware price display (
Intl.NumberFormat, ISO 4217)
- One-click translation for product titles and descriptions directly in the admin panel
- AI-assisted review moderation — admins can analyze pending reviews individually or in batch; the AI returns an
approve/rejectrecommendation with a one-sentence reason displayed inline - Batch workflow: select reviews → Analyze with AI → one-click "Approve all AI-recommended" or "Reject all AI-flagged"
- Provider auto-detection: Groq, OpenAI, Anthropic
- Permission-gated — only admins holding the
AI_PERMISSIONclaim can trigger AI features - The backend exposes
GET /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 proxy - HTML-aware translation preserves Tiptap markup; plain-text mode for titles
- Admin-only analytics endpoint — all metrics computed server-side from existing tables, no external service needed
- Period selector: last 7 days, 30 days, 90 days, or all time
- KPIs: total revenue, order count, average order value, customer count (new vs returning), low-stock SKU count
- Top 10 products by revenue with unit sales and revenue per product
- Orders by status breakdown (paid, processing, shipped, delivered, …)
- Multilingual product title decoding via
resolveTitle()— JSON-localized titles render in the admin's active locale
- Per-event transactional emails — independent template for each lifecycle event:
pending,paid,payment_failed,processing,shipped,delivered,refunded,canceled, plus aglobalfallback - TipTap WYSIWYG editor for the HTML body with full rich-text controls
- Plain-text body — editable textarea; one-click Generate from HTML button strips tags from the current HTML body (does not translate)
- Multilingual templates — locale selector (6 built-in locales); each field (subject, HTML body, plain-text body) stores a JSON locale map (
{"en-US":"...", "fr-FR":"..."}) - One-click AI translation — Sparkles button on both the subject and HTML body fields; tokenises
{{variables}}before sending to the LLM so placeholders survive translation; uses formal register andvous/usted/Sie/Leiaddress forms - Default templates — per-event polished HTML with status badge, heading, dynamic order details and CTA button; inserted with a single click
- Default subjects — per-event pre-written English subject line (e.g. Your order has shipped! — {{storeName}}), insertable with one click
- Test send — send a live email to any address from within the admin UI
- Enable/disable per event — each event-specific template can be toggled on/off independently; the
globalrow acts as the catch-all fallback - Template variables available in all fields:
{{storeName}},{{orderNumber}},{{orderUrl}},{{status}},{{total}},{{customerName}},{{trackingNumber}},{{trackingUrl}}
Full-featured back-office covering:
- Products, Variants, Inventory, Orders, Customers
- Regions, Currencies, Countries, Warehouses
- Shipping Rates + Shipping Classes
- Webhooks, Discounts, Users & Permissions
- Analytics Dashboard — store KPIs and top products
- Order Email Templates — per-event configurable HTML/plain-text emails with AI translation
- OpenAPI / Swagger UI integrated
- Cloudflare Workers — zero-cold-start edge API (Hono + Zod-OpenAPI)
- Durable Objects (SQLite) — 100 % of store state, no D1 required
- Cloudflare R2 — product image storage
- Cloudflare KV — caching layer (
KV_CACHE) with variable TTL, bypass rules for admin tokens, and prefix-based invalidation on mutations - Rate limiting middleware (per endpoint, per role)
- Outbound webhooks with HMAC signing and delivery log
- Scheduled cron every hour at :15 for cart expiry cleanup
flowchart 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):
- On
GET /v1/images/:key, the Worker checkscaches.default.match(request)first. - On a miss, the image is pulled from R2 and stored via
cache.put()(insidewaitUntil). - On
DELETE /v1/images/:key, the CDN entry is purged alongside the R2 object. - Admins can force-purge all or individual CDN entries at any time via dedicated endpoints.
⚠️ 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.
- Create Auth0 tenant (e.g.
mystore.us.auth0.com). - In Auth0 dashboard:
- Go to APIs > Auth0 Management API > Test.
- Click Create and Authorize Test Application.
- In Application Access, choose the test app and select All permissions.
- Copy values into your env file:
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:5173
- Run the script:
npx tsx scripts/auth0/deploy-tenant-resources.ts -- --env-file=.env
cp 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 automaticallyRun 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.
mcp/*.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 mcp/*.md file becomes:
- a dedicated tool named after the slug (e.g.
cloudflare_worker_patterns) - accessible via the generic
get_topic(slug)tool - listed by
list_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 browserTo 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 --forceWhen to regenerate:
- After adding a new route, migration, or feature — regenerate the relevant topic
- After adding a new topic definition to
scripts/generate-static-mcp-response.ts - Before deploying the MCP Worker (
deploy-mcp.yamlrunsgen-knowledgeautomatically)
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/mcpThe 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 --remoteAll 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) |
⚠️ No D1 binding — all store data lives in the Durable Object's built-in SQLite. Migrations are applied automatically viaensureInitialized()indo.ts.
| 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:
-
Plain text (for single-locale categories):
{ "name": "T-Shirts" } -
JSON per-locale (for multilingual catalogs):
{ "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:
- Requires the
AI_PERMISSIONclaim (ai:apiby default) - Uses the same AI provider configured for product translations
- Automatically detects the source language from fallback locales
- Preserves HTML markup in descriptions via
isHtmlmode - Updates form state atomically (both display value and JSON structure)
Admin panel:
Access category management at /admin/categories (requires admin:store permission).
Features include:
- Full CRUD with parent category support for hierarchies
- Multilingual name and description editor
- Locale switcher with auto-migration
- One-click AI translation (when user has
ai:apipermission) - Image URL assignment
- Position-based ordering
- Status (active/inactive) toggle
- Delete confirmation dialog
| 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:
- Region (only rates linked to the cart's region via
regionshippingrates) - Weight (
maxweightg >= carttotalweightg, or null = no limit) - Shipping class (see Shipping System)
| 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.
- Customers can submit star ratings (1–5) and optional text reviews from the product page.
- Reviews require moderation before appearing publicly (status:
pending → approved). - Verified purchase badge appears when the reviewer has a delivered order containing the product.
- Product rating cache (
review_count,average_ratingonproductstable) is updated on every moderation action. - Reviews can be submitted from the order history page (authenticated customers) or from the order tracking page (magic-link recipients) for delivered orders.
- Guest reviews — customers who checked out without creating an account can leave a review directly from the signed order-view link; identity is verified via the order's JWT token (
ORDER_TOKEN_SECRET) and the hashedviewtoken, no Auth0 required. - Admin moderation panel at
/admin/reviewswith 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:
- Checkbox selection — select individual reviews or all pending reviews at once.
- Analyze with AI — sends selected reviews to the configured LLM (via
GET /v1/ai/parameters); each review receives aapprove/rejectrecommendation with a one-sentence reason shown inline. Up to 5 reviews are processed concurrently; a livedone/totalcounter is displayed during analysis. - Batch approve / reject — act on all selected reviews in a single click.
- AI-guided batch — dedicated buttons to approve all AI-recommended or reject all AI-flagged reviews from the current selection.
- All AI calls are made client-side — the LLM API key is fetched from the backend but never proxied through it.
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 URLStorage 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: {{storeName}}, {{orderNumber}}, {{orderUrl}}, {{status}}, {{total}}, {{customerName}}, {{trackingNumber}}, {{trackingUrl}}.
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_URLis available in backend asenv.STORE_URLand in the frontend asimport.meta.env.STORE_URL.- The value is normalized by removing a trailing slash.
- Data is stored in
auth.user_metadata[STORE_URL].wishlistwhenSTORE_URLis defined. - For backward compatibility, the endpoints still read from and write to legacy
auth.user_metadata.wishlistwhen no store namespace is set.
Behaviour of endpoints:
GET /v1/me/wishlist: reads first fromauth0.user_metadata[STORE_URL]?.wishlist, then falls back toauth.user_metadata.wishlist.POST /v1/me/wishlist: updatesauth0.user_metadata[STORE_URL]with the new array.DELETE /v1/me/wishlist/:productId: updatesauth0.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.
- Saves into
auth.user_metadata[STORE_URL].saved_cartswhenSTORE_URLis available. - Falls back to legacy
auth.user_metadata.saved_cartsfor backwards compatibility. - Cart metadata is still mirrored in the Durable Object
saved_cartstable 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:
- Frontend → User clicks "Save Cart" button with active cart ID (UUID)
- POST /v1/me/saved-carts with
{cartId: "uuid-string"} - Backend:
- Inserts row into
saved_cartstable (for audit) - Calls
updateUserMetadata(userId, {saved_carts: [..., cartId]})to persist in Auth0
- Inserts row into
- Frontend →
useTokenRefresh()refreshes JWT to pull updateduser_metadata.saved_carts - Cross-component sync → CustomEvent
fufuni:saved-carts-updatednotifies alluseSavedCartshooks - UI Update → UserListsMenu dropdown immediately shows the saved cart
Database 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 mutations
Frontend 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 {apiKey, model, url} for the AI provider |
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.
| 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_ratestable (seeapps/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}: delete
In apps/merchant/src/routes/catalog.ts:
- Active tax rates loaded:
SELECT * FROM tax_rates WHERE status = 'active' - Per-variant lookup:
variant.tax_codematchestax_rates.tax_code - Fallback tax rate: first active row with
tax_code IS NULL
Variant mapping (mapVariant):
tax_rate_percentageset from selected or fallback ratetax_display_nameset from selected or fallback ratetax_inclusiveset from varianttax_inclusiveflag, otherwise default region value fromregions.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 {{name}}whentax_inclusive=true{{name}}whentax_inclusive=false
This reflects the expected user-visible behaviour:
- Included tax variant: price is TTC, label
Dont TVA, tax computed from list price - Excluded tax variant: price is HT, label
TVA, tax computed from list price
- Regions group a currency, countries, warehouses and shipping rates
- When a cart is created with a
regionid, all prices (variants and shipping) are resolved in that region's currency - Prices are explicit — no automatic conversion. Admin must configure
variantpricesper currency. GET /v1/products/pricing-audit?currencyId=<uuid>lists variants missing a price for a given currency.- If no
regionidis provided at cart creation, the default region is used automatically.
- Generated at init time via
scripts/init.ts - Stored as SHA-256 hashes — plaintext never persisted after creation
sk...prefix = admin,pk...prefix = public
- RS256 tokens verified against JWKS endpoint
- Required JWT permissions are fully configurable via env vars (see table above)
- Auto-provisioning:
POST /v1/__auth0/autopermissionsassigns the permissions listed inAUTH0_AUTOMATIC_PERMISSIONSto the current user
- HMAC-HS256 JWT signed with
ORDER_TOKEN_SECRET - 30-day expiry, stored as SHA-256 hash (plaintext never in DB)
- Allow customers to view their order status without an account via
GET /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>
Response: { "apiKey": "...", "model": "...", "url": "..." }
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.
Configured via wrangler.jsonc:
AI_API_URL: provider base URL (e.g.https://api.groq.com/openai/v1)AI_MODEL: model name (e.g.openai/gpt-oss-20b)AI_API_KEY: API key (set as a Wrangler secret in production)
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:
- Enter email address on the login page
- Auth0 sends a magic link to their inbox
- Click the link → automatic JWT token acquisition
- Redirected to account dashboard
Configuration required in Auth0 Dashboard:
- Go to Authentication → Passwordless → Email, enable it
- Customize the email template with your store branding
- Ensure your app's Callback URLs include your storefront domain (e.g.
https://store.example.com/) - Add
openid profile emailto the OAuth scopes
All 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:
- No server call required
- No Worker request consumption
- All styling and formatting done client-side
- Download or print directly from the order detail page
The downloadInvoicePdf() function in apps/client/src/utils/invoice-pdf.ts handles:
- Order header (store name, invoice number, date)
- Itemized list with quantities and pricing
- Totals (subtotal, shipping, tax, discount, final total)
- Tracking information (if available)
- Format currency symbols based on order currency
Lightweight preferences (locale, theme, marketing opt-in) are stored in Auth0 user_metadata, not in the Durable Object database:
- Avoids extra Worker request consumption
- Stays within Auth0's free tier limits
- Synced automatically across all devices
- Can be updated via the
/v1/me/preferencesendpoint
When a customer logs in for the first time:
- UUID is generated
- Customer record created in the database with:
- Auth0 sub claim as
auth_provider_id - Email from JWT
- Default locale (
en-US)
- Auth0 sub claim as
- Indexed for fast lookup by
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):
- Email claim missing from JWT - Auth0 isn't including the email in the access token
- Solution: Configure Auth0 to include email in the access token claims, OR the token will generate a placeholder email from the user's sub claim automatically
- Callback URL not configured - Auth0 throws your redirects away
- Solution: Add your site URL (e.g.,
http://localhost:5173/) to Auth0 Application Settings → Allowed Callback URLs
- Solution: Add your site URL (e.g.,
- Audience/Scopes mismatch - The JWT audience doesn't match what the backend expects
- Solution: Verify
AUTH0_AUDIENCEmatches your Auth0 API identifier, and OAuth scopes includeopenid profile email
- Solution: Verify
- Token expired or invalid - The JWT is corrupted or no longer valid
- Solution: Clear browser local storage, log out, and try logging in again
Quick Debug:
- Open browser DevTools (F12) → Network tab
- Try accessing
/account/orders - Click the failed request → Response tab
- Check the error message from the backend
Translations live in apps/client/src/locales/base/.
To add a language:
- Add an entry to
availableLanguagesinapps/client/src/i18n.ts(code,nativeName,isRTL,isDefault) - Create
apps/client/src/locales/base/<code>.json - Add a
casein theloadPathswitch ini18n.ts - Set
isRTL: truefor right-to-left locales — the layout addsdir="rtl"automatically
Supported 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:
- Plain string — legacy single-language value
- JSON locale map —
{"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.
Triggered on every push to main that touches apps/merchant/**.
What it does:
- Calls
create-env-artifact.yamlto build the encrypted env artifact. - Decrypts
.env+wrangler.jsoncinto the workspace. - Pushes all secrets from
apps/merchant/secrets.jsonto Cloudflare viawrangler secret bulk. - Deploys the Worker with
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.
Triggered on every push to main that touches apps/client/**.
What it does:
- Calls
create-env-artifact.yamlfor the env artifact. - Decrypts
.envand builds the React SPA withnpm run build:env. - Also builds the LLM export (
llm*.txt) and copies it intodist/. - Deploys the
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.
Resets 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.
- Scheduled — cron disabled by default; uncomment and configure in the workflow file
- Manual — via Actions → Reset and Reinitialize Demo → Run workflow
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:
- On each run,
scripts/jwt-exp.pydecodes theexpclaim of the stored token. - If the token is still valid for more than 7 days, it is reused directly.
- If it is absent or about to expire, a new 30-day token is obtained via
client_credentialsand saved back viagh 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:
- Add it to
.github/workflows/create-env-artifact.yaml(the encrypted artifact builder) so CI can pass it to the Worker build. - If it must be exposed to the frontend, also add it to the
defineblock inapps/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 |
- Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Click Generate new token
- Fill in:
- Token name —
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RW(or any descriptive name) - Expiration — set to 1 year (maximum) and calendar-remind yourself to rotate it
- Resource owner — your user or organisation that owns the
fufunirepository - Repository access — select Only select repositories → choose
fufuni
- Token name —
- Under Permissions → Repository permissions, set Secrets to Read and write
- All other permissions can remain No access
- Click Generate token, copy it
- In the
fufunirepository go to Settings → Secrets and variables → Actions → New repository secret:- Name:
GH_PAT_WITH_FUFUNI_SECRETS_ACCESS_RW - Value: the token you just copied
- Name:
⚠️ Fine-grained PATs have a maximum lifetime of 1 year on GitHub.com. Set a calendar reminder to rotateGH_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).
- Fork the repository
- Create a feature branch (
git checkout -b feat/my-feature) - Migrations must be additive — no
DROP TABLE, no breaking column changes - New API routes must have Zod-OpenAPI schemas
- Submit a PR against
main
This project is dual-licensed:
© 2024-2026 Ronan LE MEILLAT — SCTG Development


{ "durable_objects": { "bindings": [{ "name": "MERCHANT", "class_name": "MerchantDO" }] }, "r2_buckets": [{ "binding": "IMAGES" }], "kv_namespaces": [{ "binding": "KV_CACHE", "id": "<your-kv-id>" }], "triggers": { "crons": ["15 * * * *"] } }