Commerce RPC Layer
How the browser reaches SQL backends through one authenticated RPC route.
The boilerplate ships several commerce backends behind one interface (CommerceServices). The default quote-based store runs entirely on Firebase, where the browser talks to Firestore directly. But the SQL backends — Postgres/Prisma and Supabase — hold their credentials server-side (a connection string, a Supabase service-role key), so the browser cannot reach the database. Instead, every commerce call is forwarded as a small RPC to a single Next.js route that runs the matching server adapter.
This page explains the architecture: how a client call becomes an HTTP request, how the route authenticates and authorizes it, and how data crosses the wire. For the exhaustive per-method reference (every service, method, access level, and payload), see the Commerce RPC API reference.
This layer only exists for the server-backed values of NEXT_PUBLIC_COMMERCE_BACKEND (supabase / postgres). With the default firebase backend the browser reads and writes Firestore directly and this route is never called.
The big picture
Component / hook
│ import { commerce } from "@/lib/commerce"
▼
httpCommerce (src/lib/commerce/http.ts) ← client adapter
│ POST /api/commerce/<service>/<method>
│ Authorization: Bearer <firebase-id-token>
│ { "args": [...] }
▼
route.ts (src/app/api/commerce/[service]/[method]/route.ts) ← runtime: nodejs
│ 1. whitelist check
│ 2. parse { args }
│ 3. resolve server backend
│ 4. access control (verify Firebase ID token)
│ 5. dispatch → server adapter method
▼
getServerCommerce() → Postgres/Prisma or Supabase adapter
│
▼
{ "result": <value> } or { "error": string }Call sites never import httpCommerce directly. They import the backend-agnostic commerce object from @/lib/commerce, and src/lib/commerce/index.ts picks the HTTP adapter when a server backend is selected. The same component code works against Firebase, Postgres, or Supabase.
The client adapter: httpCommerce
src/lib/commerce/http.ts is the client-side implementation of CommerceServices. Every method funnels through one rpc() helper:
async function rpc(
service: string,
method: string,
args: unknown[]
): Promise<unknown> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const user = auth.currentUser;
if (user) {
try {
headers.Authorization = `Bearer ${await user.getIdToken()}`;
} catch (error) {
console.error("[commerce.http] could not get ID token:", error);
}
}
const response = await fetch(`/api/commerce/${service}/${method}`, {
method: "POST",
headers,
body: JSON.stringify({ args }),
});
// ...unwraps { result } on success, throws body.error on failure
}A few architectural properties fall out of this:
- One method → one POST. The
serviceandmethodbecome the URL path; the method's positional arguments become{ args: [...] }.products.getById(id)posts to/api/commerce/products/getByIdwith{ "args": ["<id>"] }. - The token is attached only when a user is signed in. Public reads and guest quote submissions simply omit the
Authorizationheader. A token-refresh failure is logged, not thrown — the request is sent unauthenticated and the route decides the outcome (401/403). - Success unwraps
result; failure throws. A non-2xx response reads{ error }from the body and throws it as anError, so call sites get a normal rejected promise.
Two methods that never hit the network
CartService has two members with no HTTP surface, implemented locally in the adapter:
| Method | Why it stays client-side |
|---|---|
carts.mergeCarts | A pure, synchronous function (reuses the same cartLineKey merge logic as the Firebase adapter). No server state involved. |
carts.listen | RPC has no realtime push, so it polls: an immediate carts.get, then another every 5s (CART_POLL_INTERVAL_MS). This matches the server adapters' poll contract. |
Because these two are handled entirely in the browser, they are deliberately absent from the route's whitelist (see below) — there is nothing for the server to dispatch.
The route
src/app/api/commerce/[service]/[method]/route.ts exposes a single POST handler. It runs on the Node.js runtime, not Edge — firebase-admin, the pg pool, and the Supabase client all require it, and every request resolves live data:
export const runtime = "nodejs";
export const dynamic = "force-dynamic";The handler processes each request in five steps.
Whitelist check. The service.method pair is looked up in a static WHITELIST. Anything not listed returns 404 with { "error": "Unknown commerce method: <service>.<method>" }. An unknown method is indistinguishable from a non-existent route — the database is never touched for calls that aren't explicitly allowed.
Parse the body. The body must be exactly { args: unknown[] }. A non-array args yields 400 ("Request body must be { args: unknown[] }."); invalid JSON yields 400 ("Request body must be valid JSON.").
Resolve the backend. getServerCommerce() returns the configured Postgres or Supabase adapter. If it throws (misconfiguration), the route logs and returns 500 ("Commerce backend is not configured.").
Access control. For any non-public method the caller's Firebase ID token is verified with verifyIdToken (from src/lib/firebase/admin.ts) and checked against the method's access level. See Access levels.
Dispatch. Date arguments are revived (see Dates on the wire), then the adapter method is invoked with the spread args. The return value is wrapped as { result: result ?? null }. A thrown adapter error is logged and returned as 500 ("<service>.<method> failed.").
Access levels
Every whitelisted method carries one of three access levels. Firebase Auth is the identity provider for all backends — the SQL adapters still trust a verified Firebase ID token to identify the caller.
| Level | Rule enforced by the route |
|---|---|
public | No auth. Catalog reads (products.list, getById, search, listFeatured) and discount lookup (discounts.findUsable). |
user | A valid token is required, and the method's uid argument — always args[0] — must equal the token's uid. A signed-in user can only act on their own cart, quotes, orders, and profile. |
admin | A valid token is required and backend.admin.isAdmin(uid) must return true for the backend in use. |
The core of the check:
if (service === "quotes" && method === "submit") {
// Guests (userId null) may submit; a non-null userId must be the caller.
if (uidArg != null) {
if (!decoded) return error("Authentication required.", 401);
if (uidArg !== decoded.uid) {
return error("You may only submit quotes as yourself.", 403);
}
}
} else if (access === "user") {
if (!decoded) return error("Authentication required.", 401);
if (typeof uidArg !== "string" || uidArg !== decoded.uid) {
return error("You may only act on your own account.", 403);
}
} else {
// admin
if (!decoded) return error("Authentication required.", 401);
const isAdmin = await backend.admin.isAdmin(decoded.uid);
if (!isAdmin) return error("Admin privileges required.", 403);
}Two deliberate exceptions
quotes.submitallows anulluserId. A guest checkout submits a quote with no account, soargs[0]may benulland no token is needed. But a non-nulluserIdmust still match the token — you cannot submit a quote in someone else's name.admin.isAdminis ausermethod, notadmin. It is exposed at theuserlevel so the client can check its own admin status (itsuidargument must match the token). The route also callsbackend.admin.isAdmininternally to gateadmin-level methods.
The user check requires args[0] to be a non-empty string equal to the token uid. Every user-level client method therefore passes the caller's uid as its first argument — that ordering is not incidental, it is the security boundary.
Dates on the wire
JSON has no Date type. The route and client cooperate so that call sites see real Date objects regardless of backend:
- Outbound (client → route). Some methods take arguments containing dates (a discount's
created/expiresAt; a quote or ordercreated). These cross the wire as ISO strings, soreviveArgDatesin the route converts them back toDates before handing the args to the adapter. It is targeted per method — not a blind deep-walk — so schemaless string fields on products or customers are never mistaken for timestamps. - Inbound (route → client).
Dates in the result serialize to ISO strings automatically viaJSON.stringify/Date.prototype.toJSON. ThehttpCommerceadapter then revives the known timestamp fields —Quote.created,Order.created,Discount.created, andDiscount.expiresAt— so the shapes match exactly what the Firebase adapter returns.
This symmetry is what lets the same UI code consume any backend without special-casing date handling.
Request and response by example
Fetching a page of products (public, no token):
POST /api/commerce/products/list HTTP/1.1
Content-Type: application/json
{ "args": [24, null] }{ "result": { "products": [/* … */], "cursor": "…" } }A signed-in user reading their own cart (user level — args[0] is their uid):
POST /api/commerce/carts/get HTTP/1.1
Content-Type: application/json
Authorization: Bearer <firebase-id-token>
{ "args": ["<caller-uid>"] }A mismatched uid is rejected before the database is touched:
{ "error": "You may only act on your own account." }