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 on Firebase, where the browser talks to Firestore directly. But the SQL backends — SQLite, Postgres/Prisma, and Supabase — hold their credentials server-side (a connection string or file path, 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 covers the architecture. For the exhaustive per-method reference — every service, method, access level, and payload — see the Commerce RPC API reference.
This layer carries every commerce call for the server-backed values of NEXT_PUBLIC_COMMERCE_BACKEND (sqlite / postgres / supabase). On the default firebase backend the browser reads and writes Firestore directly, and only the ten admin mutations come back through here — so they can be re-authorized and audited server-side. See audited admin writes.
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> (or an Auth.js cookie)
│ { "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 (identify the caller)
│ 5. dispatch → server adapter method
▼
getServerCommerce() → Prisma (Postgres/SQLite) 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:
export async function rpcCall(
service: string,
method: string,
args: unknown[]
): Promise<unknown> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(await authHeaders()),
};
const response = await fetch(`/api/commerce/${service}/${method}`, {
method: "POST",
headers,
body: JSON.stringify({ args }),
});
// ...unwraps { result } on success, throws body.error on failure
}authHeaders() (src/lib/auth/client.ts) is where the provider difference
lives: a Firebase ID token as Authorization: Bearer, or nothing at all on
Auth.js, whose httpOnly session cookie the browser attaches by itself. This
transport does not know which is configured. See
authentication.
Three more properties fall out of the same shape:
- 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 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.
The four methods with no HTTP surface
Two of them are CartService members 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.
Two more are absent for the opposite reason — they are server-only:
| Method | Why it never gets an HTTP surface |
|---|---|
orders.recordPaidSession | The Stripe webhook calls the server adapter directly after verifying the event signature. Exposing it here would let anyone mint a paid order. |
audit.record | The route writes audit entries itself, in-process, with the actor taken from the verified caller. An HTTP-callable write taking a caller-supplied actorUid would let an admin forge or flood the very trail that exists to hold them accountable. |
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";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 adapter COMMERCE_BACKEND names: Prisma (Postgres or SQLite), Supabase, or — on a default Firebase deployment — the admin-only firebaseAdminCommerce. If it throws (an unrecognized value), the route logs and returns 500 ("Commerce backend is not configured.").
Access control. For any non-public method the caller is resolved with getAuthUser(request) (from src/lib/auth) 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."). On success, a catalog mutation drops the cached catalog reads server components use, and an admin mutation is written to the audit trail before the response goes out — awaited, so the entry is durable before the admin sees success, but never able to fail the request.
Access levels
Every whitelisted method carries one of three access levels, enforced against whatever uid getAuthUser() returns. Which provider supplies that uid — a verified Firebase ID token, or an Auth.js session cookie — is a separate configuration the route deliberately cannot see.
| Level | Rule enforced by the route |
|---|---|
public | No auth. Catalog reads (products.list, listByCategory, countByCategory, getById, search, listFeatured) and discount lookup (discounts.findUsable, plus discounts.listAutomatic — automatic promotions need no secret code, since the cart applies them for everyone). |
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:
const caller = await getAuthUser(request);
if (service === "quotes" && method === "submit") {
// Guests (userId null) may submit; a non-null userId must be the caller.
if (uidArg != null) {
if (!caller) return error("Authentication required.", 401);
if (uidArg !== caller.uid) {
return error("You may only submit quotes as yourself.", 403);
}
}
} else if (access === "user") {
if (!caller) return error("Authentication required.", 401);
if (typeof uidArg !== "string" || uidArg !== caller.uid) {
return error("You may only act on your own account.", 403);
}
} else {
// admin
if (!caller) return error("Authentication required.", 401);
const isAdmin = await backend.admin.isAdmin(caller.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> # or a session cookie on Auth.js
{ "args": ["<caller-uid>"] }A mismatched uid is rejected before the database is touched:
{ "error": "You may only act on your own account." }