Commerce¶
Purpose¶
Production Commerce/marketplace site: public catalog, guest browsing, authenticated checkout, real order history, and Admin-managed categories/products/images.
Current functional status¶
No longer localStorage-backed. As of feature/commerce-production-backend, Commerce is a
D1-backed production application:
- Public catalog (categories, products, product images) is read from D1 via Commerce's own Worker.
- Checkout is real: the server reloads every product from D1 and computes price/quantity/total itself - the browser never supplies (or is trusted for) a price, name, or total.
- Orders are real rows in the shared
orders/order_items/order_addresstables, created through the same server-to-server-only central API surface introduced by PR #9 (POST /api/commerce/orders), now extended to accept real line items and a delivery address. - Customer identity comes from the existing Corporate customer session - see "Customer identity architecture" below. There is no second customer database and no separate Commerce login.
- The guest cart remains client-side/localStorage (
apps/commerce-web/src/cart.ts) - a deliberate, documented exception, not an oversight. It only ever stores{productId, quantity}; nothing from it is trusted at checkout (see "Cart & checkout" below). Products, categories, and orders are all D1-backed - the cart is the one and only browser-local piece of state remaining. - Category visibility is enforced, not just product status: a product is only ever publicly
visible when BOTH
commerce_products.status='Active'AND itscommerce_categories.status='Active' - an Active product filed under a deactivated category does not appear in any public listing, featured section, or product detail response. See "Category/product visibility" below.
Source directory¶
apps/commerce-web/ - src/ (SPA) and worker/ (the app's own Cloudflare Worker, new in this
PR).
Framework¶
React 18 + TypeScript + Vite (SPA), Cloudflare Worker (API) sharing the same D1 database as every other app in this repo.
Commands¶
Output¶
apps/commerce-web/dist/ (static assets) + apps/commerce-web/worker/index.ts (Worker script).
Deployment mechanism¶
Cloudflare Worker with Static Assets, name akshaya-commerce.
wrangler deploy --config apps/commerce-web/wrangler.jsonc. Domain:
commerce.akshayagroupglobal.com. run_worker_first: ["/api/*"] - only API routes run through the
Worker; everything else falls through to the static SPA build, same pattern already used by
Corporate (/customer-portal*, /api/customer/*) and Software (/freelancer-portal*,
/api/freelancer/*).
Customer identity architecture¶
Commerce does not duplicate customer authentication. The existing Corporate Worker
(apps/corporate-web/worker/) remains the single source of truth for users, customer_profiles,
auth_sessions, password hashing, lockout rules and session security - none of that changed.
Commerce needs a way to know which authenticated customer is checking out without trusting a browser-supplied id. The bridge:
Customer session on Corporate (host-only cookie, akshayagroupglobal.com)
-> POST https://akshayagroupglobal.com/api/customer/commerce-token (requireCustomer + CSRF)
-> short-lived (5 min) signed Commerce token (HS256 JWT, HMAC-SHA256 via Web Crypto)
-> browser holds the token in memory only (React state, never localStorage)
-> Commerce Worker verifies the token (apps/commerce-web/worker/commerce-token.ts)
-> Commerce Worker calls the central operations API
-> X-Akshaya-User-Id is attached server-side only, from the verified token's `sub` claim
- Token claims:
sub(user id),role: 'Customer',aud: 'commerce',iat,exp,jti. Never email/mobile/password. - Signing: hand-rolled HS256 (a real, standard JWT shape) with Web Crypto - no JWT library
dependency added, since the claim set is small and this repo has none today. Implemented in
apps/corporate-web/worker/commerce-token.ts(signs) and duplicated verbatim inapps/commerce-web/worker/commerce-token.ts(verifies) - see that file's header comment for why it's duplicated rather than shared, and keep both in sync if you ever change the claim shape. - Secret:
CUSTOMER_SSO_SECRET, a Worker secret bound to bothcorporate-webandcommerce-web. See CI/CD → Secrets. - CSRF/CORS:
POST /api/customer/commerce-tokengoes through the exact same cross-origin (same-site) CSRF pattern already used for the Freelancer Marketplace's calls into Corporate - a narrow origin allowlist (https://commerce.akshayagroupglobal.comadded toCORS_ALLOWED_ORIGINS) plus a required custom header (X-Akshaya-Commerce-Request: commerce) that a plain HTML form cannot set, forcing a CORS preflight. SeecheckCustomerMutationOrigininapps/corporate-web/worker/index.ts. - Sign-in redirect: reuses the existing, already-shared Customer Account redirect mechanism
(
customerPortalHref/isSafeReturnPathinpackages/ui/src/index.tsx, and the portal-sidereturn-context.mjshost allowlist) -'commerce'was already a recognizedsourcebefore this PR. A guest who clicks checkout is sent tohttps://akshayagroupglobal.com/customer-portal/?source=commerce&returnTo=<path>and lands back on the exact Commerce page they started from after signing in.
Cart & checkout¶
The cart (apps/commerce-web/src/cart.ts) is intentionally client-side/localStorage - guests must
be able to browse and add to cart without an account. It stores only productId + quantity.
Displayed prices while shopping are read from the last catalog response, purely for the shopper's
estimate.
At checkout:
- The SPA sends
{ items: [{productId, quantity}], address, paymentMethod }to this Worker's ownPOST /api/commerce/checkout- no price, name or total field exists in this request shape at all, so there is nothing for a compromised/buggy browser to lie about. - The Commerce Worker verifies the Commerce token, then forwards the request as-is to the central
operations API's
POST /api/commerce/orders, attachingAuthorization: Bearer <OPERATIONS_API_TOKEN>andX-Akshaya-User-Id: <verified sub>- both added server-side, never read from the incoming request. - The central API (
workers/api/src/commerce-orders.ts+entry.ts) is the sole authority on pricing: it re-fetches every product from D1 by id, checksstatus='Active'andstock_quantity >= quantity, and computesunitPrice/lineTotal/order total itself. Even if the Commerce Worker were compromised, it cannot set an arbitrary price - the field doesn't exist on the wire. - Order header, line items, and delivery address are written in one D1
batch(), alongside a conditional stock decrement (WHERE stock_quantity >= ?) and aninventory_movementsaudit row per line.batch()genuinely is a transaction - Cloudflare documents it as running in an implicit transaction, and a real SQL execution error in any statement rolls the whole batch back. The race described here isn't a transaction failure:UPDATE ... WHERE stock_quantity >= ?matching zero rows (stock sold out between the read and the batch running) is not a SQL error - it's a successful statement that happens to affect 0 rows, so it doesn't abort anything. A transaction can't roll itself back over a "successful" result it has no way to know was semantically wrong. So when that happens, the order/item/address rows are already committed alongside a stock update that silently did nothing - a compensating batch afterward deletes what was just created and reverts any stock that did decrement, returning409. Seecommerce-orders.ts's andentry.ts's comments for the full detail.
Payments¶
Cash on Delivery (COD) only, for now. workers/api/src/commerce-orders.ts's
COMMERCE_PAYMENT_METHODS accepts exactly one value, 'COD' - UPI and Card are rejected by the
central API regardless of what the Commerce Worker or the browser sends, and the Commerce checkout
UI does not offer them at all. This is deliberate, not an oversight: this repo has no payment
gateway integration (no capture, no webhook, no reconciliation), so accepting an "online" payment
method today would let an order be marked Confirmed with no actual payment having been verified.
Razorpay integration is a planned future enhancement, not implemented in this PR. When it
lands, the expected shape is: Commerce Worker creates a Razorpay order, the browser completes
payment via Razorpay Checkout, Razorpay's webhook (verified server-side, signature-checked) is the
only thing allowed to transition an online-payment order from a PendingPayment-style status to
Confirmed - never the client redirect alone, since that can be spoofed or simply never fire. Until
that lands, COMMERCE_PAYMENT_METHODS should not be widened to include UPI/Card.
Category/product visibility¶
A product is only ever publicly visible when both it and its category are Active -
commerce_products.status='Active' AND commerce_categories.status='Active'. Enforced in
apps/commerce-web/worker/catalog.ts with an INNER JOIN commerce_categories c ON c.id =
p.category_id AND c.status='Active' present, unconditionally, in every product list/count/detail
query - not a runtime check applied after the fact, so there is no parameter combination (search,
category filter, sort, featured, pagination) that can bypass it. Applies identically to the product
list, the homepage's featured-products section (same query, featured=1), and product detail -
there is only one query-building path for all three. Covered by catalog.test.ts, including a real
SQLite execution test (via node:sqlite) against every Active/Inactive × Active/Inactive
combination plus a dangling category_id.
The same list/detail queries also expose exactly one primary_image_key per product (the lowest
sort_order image, via a correlated subquery) - never the full image list, so a catalog page never
leaks every R2 key for every product on it. The full gallery is only returned from the single-
product detail response.
Order visibility (Customer Account integration)¶
Commerce orders use the same orders/order_items/order_address tables Corporate's existing
GET /api/customer/me already reads (WHERE user_id=?) - a Commerce order therefore appears there
automatically, with no separate ownership model. Commerce's own "My Orders" page
(GET /api/commerce/my-orders) reads the same tables directly from its own D1 binding, scoped
unconditionally to the verified token's sub - there is no code path that accepts a
caller-supplied customer id, so one customer can never retrieve another's orders through this
route.
Fulfilment¶
Placing an order does not automatically create a Logistics shipment. Unchanged from PR #9:
POST /api/commerce/orders/:id/fulfil remains the Commerce → Logistics handoff (server-to-server
only, still propagates user_id onto the generated shipment) - but nothing in this PR's
cart/checkout flow calls it. A confirmed order's status stays Confirmed - ready for fulfilment
until something (today: a manual/future Admin action) explicitly calls /fulfil for that order id.
The Commerce UI reflects this - it never claims an order "automatically hands off" anywhere. A full
Logistics UI/backend (to actually drive that handoff) is explicitly out of scope here.
Admin catalog management¶
Extends the existing Admin application - both the Worker API (apps/admin-web/worker/index.ts)
and the React UI (apps/admin-web/src/App.tsx, new Categories and Products sidebar
sections) - rather than introducing another admin login. Every route below sits behind the same
requireAdmin gate as the rest of Admin's API; the UI is reached through the same authenticated
Admin session as Customers/Freelancers/Orders/Shipments.
GET /api/commerce/categories
POST /api/commerce/categories
PATCH /api/commerce/categories/:id
GET /api/commerce/products
POST /api/commerce/products
PATCH /api/commerce/products/:id
GET /api/commerce/products/:id/images (list)
POST /api/commerce/products/:id/images (multipart upload)
DELETE /api/commerce/products/:id/images/:imageId
GET /api/orders/:id (order detail: header + items + address)
Categories UI: list, create, edit (name/slug/description/sort order), activate/deactivate - no
delete button anywhere. Products UI: list with status/category filters, create/edit covering
every field (category, SKU, name, slug, brand, short/long description, price, compare-at price,
stock quantity, status, featured), activate/deactivate, and a per-product image manager (upload,
thumbnail grid, delete) shown inline. No hard delete for categories or products (an order_items
row may reference a product) - status='Inactive' is the only supported removal path, same
discipline as freelancer/requirement moderation elsewhere in Admin.
Product images / R2¶
Product images reuse the existing R2 bucket (akshaya-group-global-files), the same
freelancer-avatar security pattern (magic-byte MIME sniffing, size cap, server-generated key, no
directory nesting), served through the same public, unauthenticated /api/files/:key route in the
central API:
- Upload:
apps/admin-web/worker/index.ts'sPOST /api/commerce/products/:id/images,requireAdmin-gated, 4 MB cap, JPEG/PNG/WebP only (sniffed, not just declaredContent-Type), key formatproduct-image-<productId>-<uuid>.<ext>with no directory nesting. - Public read:
workers/api/src/public-file-access.ts's allowlist was extended (not loosened) to recognize this exact new key shape alongside the pre-existingfreelancer-avatar-...shape - every other object, includingprivate/-prefixed embroidery references, is unaffected and stays rejected. Proven by tests inpublic-file-access.test.ts. - Upload consistency: if the R2
putsucceeds but the following D1 insert fails, the R2 object is deleted immediately (a manual compensating action) rather than left behind as an untracked but still-publicly-fetchable orphan - its key already matches the public allowlist regardless of whether any D1 row references it. - Delete consistency and ordering: verifies the image row belongs to the given product and
that the R2 key was actually generated for that product (
ownedProductImageKey) - never deletes an arbitrary caller-supplied key. The R2 object is deleted first; thecommerce_product_imagesrow is only removed after that succeeds. If the R2 delete fails, the route returns500and deliberately leaves the D1 row in place (state stays consistent and recoverable - retry later) rather than removing the D1 row first, which would risk leaving the R2 object as an untracked public orphan if the R2 delete then failed.
Environment variables / configuration¶
apps/commerce-web/wrangler.jsonc:
| Name | Kind | Purpose |
|---|---|---|
OPERATIONS_API_BASE |
vars (non-secret) |
Which host to call for privileged order creation - https://api.akshayagroupglobal.com |
OPERATIONS_API_TOKEN |
Worker secret | Server-to-server credential for the central operations API - see CI/CD → Secrets |
CUSTOMER_SSO_SECRET |
Worker secret | Verifies the Commerce handoff token minted by Corporate |
DB |
D1 binding | Same shared database (akshaya-group-global) - used for public catalog reads and the customer's own order history, never for privileged order writes |
apps/corporate-web/wrangler.jsonc needs the same CUSTOMER_SSO_SECRET bound (it signs the
token that commerce-web verifies).
Related domain¶
commerce.akshayagroupglobal.com
Dependencies on shared packages¶
@akshaya/ui (including customerPortalHref/isSafeReturnPath), @akshaya/shared.
Data model¶
See Architecture → Data Platform for the full migration
0010 schema (commerce_categories, commerce_products, commerce_product_images, order_items,
order_address, inventory_movements).
Known limitations / follow-ups (deliberately not solved in this PR)¶
- Inventory reservation: stock is decremented at order-creation time with a best-effort
conditional
UPDATE+ compensating rollback (see "Cart & checkout" above), not a true reserved hold while a customer is mid-checkout. Two customers racing for the last unit will not both succeed (the conditionalUPDATEprevents overselling), but there's no "hold for N minutes while in cart" mechanism. Documented as a follow-up, not implemented. - Order cancellation / refund flow: not implemented -
inventory_movementsgives an audit trail to build one on top of, but no cancellation endpoint exists yet. - Seller/marketplace multi-vendor model: the previous localStorage demo had a "Sellers" concept; the production schema does not (single-seller/Akshaya-operated catalog only, matching the in-scope requirements for this PR).
- Full Logistics UI/backend, driver management, route optimization, wallet/escrow, commissions, bidding, disputes, chat, recommendations, multi-warehouse inventory: explicitly out of scope, per the platform-hardening/production-backend PRs' own scope boundaries.
- Investors: remains excluded/inactive - unrelated to and unaffected by this PR.
Troubleshooting¶
- If the static site looks stale after deploy, see Cloudflare → Static Assets.
- If a direct browser call to
api.akshayagroupglobal.com/api/commerce/ordersreturns 401/403, that is expected; the operational endpoint is server-to-server only - checkout must go throughcommerce.akshayagroupglobal.com/api/commerce/checkoutinstead. POST /api/customer/commerce-tokenreturning 503 meansCUSTOMER_SSO_SECRETis not configured (or too short) oncorporate-web.- Checkout returning 401 from the Commerce Worker means the Commerce token is missing, expired,
malformed, or was verified with a
CUSTOMER_SSO_SECRETthat doesn't match the one Corporate signed with - confirm both Workers have the same secret value. - Checkout returning 409 with "went out of stock" means a concurrent order won the race for the same stock - this is the compensating-rollback path described above working as intended, not a bug.