Litestore · Developer guide

Overview

Learn which of Litestore's three transports to use — zsa server actions for admin and storefront writes, oRPC at /api/rpc for service-key consumers, and the REST endpoints described by /api/openapi.json.

Litestore ships three server transports and gives each consumer exactly one.

Admin screens and the storefront call zsa server actions. External service-key consumers call oRPC. Machine and agent clients call REST route handlers.

The split is deliberate rather than historical. Nothing in this repository calls oRPC to write admin data, and the oRPC admin write surface that once existed was deleted rather than left in place for a caller that might arrive later.

Where each transport lives

Server actions live beside the domain they write:

server/admin/<domain>/actions.ts
server/web/<domain>/actions.ts

Their callers are the admin screens and storefront components in this repository.

oRPC is one router, mounted by one route handler:

server/router.ts  →  app/api/rpc/[[...rest]]/route.ts

Its callers are external integrations and AI agents holding a ServiceApiKey.

REST is the widest surface:

app/api/**/route.ts
app/ai/v1/**/route.ts

Its callers are AI agents, feed consumers, webhook senders, and the browser's own fetch calls.

Server actions

Every admin and storefront write is a zsa action. Admin actions build on the permission procedures in lib/permissions/procedures.ts.

permissionProcedure does four things before a handler body runs:

  1. Loads the session
  2. Resolves the permission context
  3. Rejects a request with no admin role
  4. Re-checks the ban state

The fourth step looks redundant, because the admin layout already blocks navigation for a banned account. It is not. A server action is a direct POST that never renders the layout, so the ban has to be enforced again at the procedure.

modulePermission(module)(action) then narrows the gate to a single <module>:<action> permission string:

server/admin/tags/actions.ts
const tagPermission = modulePermission(AdminModule.tags)

export const bulkImportTags = tagPermission("create")
  .createServerAction()
  .input(z.object({ slugs: z.array(z.string().min(1)) }))
  .handler(async ({ input }) => { /* … */ })

Storefront actions use the mirror procedure, customerProcedure in lib/permissions/customer.ts. It resolves the signed-in customer — creating the Customer row when a logged-in user has none — and exposes ctx.customerId and ctx.userId.

Because both sides are zsa procedures, admin and storefront actions return the same [data, error] tuple. A caller does not have to know which half of the application it is talking to in order to handle a failure.

Modules that extend BaseCRUD get six actions generated from their declaration:

createAction
updateAction
duplicateAction
deleteAction
restoreAction
permanentDeleteAction

A module re-exports the ones it uses. Anything beyond plain persistence is written by hand in the same file.

oRPC at /api/rpc

The router's own header states the scope, and the reasoning behind it:

server/router.ts
/**
 * Scope is deliberately narrow: oRPC is the EXTERNAL service-key API surface
 * (AI agents / integrations managing webhooks), plus a health ping. Admin and
 * storefront writes are zsa server actions in `server/{admin,web}/<domain>/actions.ts`
 * — the one transport story per consumer. A parallel oRPC admin write surface
 * was deleted: it had no client in this repo and is not part of the published
 * OpenAPI contract (app/api/openapi.json describes the REST endpoints instead).
 */

export const appRouter = {
  ping,
  web: webRouter,
}

webRouter in server/web/router.ts contains one entry, webhooks, with six procedures:

list
create
get
update
delete
rotateSecret

Each is built with serviceProcedureWithScope from lib/orpc.ts. That procedure authenticates a ServiceApiKey through requireServiceKey() and then checks the scope — read for the two reads, write:webhooks for the four writes. A key without the scope gets an ORPCError with FORBIDDEN.

The valid scopes are listed in lib/api-keys/constants.ts:

read
read:cart
write:cart
write:orders
write:webhooks
admin:mcp

That file's comment records the rule that produced the list. A scope no request path enforces was removed, because advertising a scope lets an operator grant power that is never checked — the grant looks like a boundary and is not one.

lib/orpc.ts also defines baseProcedure, which injects db and a revalidate helper into context. The deleted admin surface is named in that file too: the session-auth and permission procedures that once mirrored the server actions went with it.

The route handler wraps the router in an oRPC RPCHandler with the prefix /api/rpc, logs failures through the api-rpc-route logger, and answers GET, POST, PUT, PATCH and DELETE. It returns a 404 when no procedure matches.

REST endpoints

app/api/openapi.json/route.ts returns a hand-written OpenAPI 3.0.3 document, titled from config.site.name and served with Cache-Control: public, max-age=3600.

It documents eight paths:

GET  /.well-known/ai-commerce
GET  /api/capabilities
GET  /api/products
GET  /api/products/feed
GET  /api/products/{id}
GET  /api/recommendations
POST /api/track
POST /ai/v1/suggest-categories

Those eight sit under five tags: Discovery for the first two, Products for the next three, then Recommendations, Tracking and AI.

Six of the eight are unauthenticated. /api/capabilities and /ai/v1/suggest-categories require an admin session, and sessionAuth is the only security scheme the document declares — an apiKey carried in the session cookie.

/api/products takes the query parameters:

page
limit
category
collection
q
format

format is json or jsonld. /api/products/feed returns the whole catalog as a Schema.org ProductCollection for Google Merchant Center and agents.

What the spec leaves out

The spec is a curated agent contract, not an index of the app's routes.

Litestore ships more than forty route handlers under app/api and seven under app/ai/v1, of which the document names one.

The service-key REST endpoints are among the omissions. app/api/webhooks/route.ts authenticates through authenticateWebhookRequest(request, scope) and appears in neither the spec nor the oRPC router's scope description.

The MCP surfaces are also outside it:

  • app/api/mcp/route.ts for shoppers
  • app/api/mcp/admin/route.ts for the operator tool kit behind the admin:mcp scope

The spec is maintained by hand

Nothing generates app/api/openapi.json from the route handlers or their zod schemas. The TrackEventRequest schema carries the comment "Mirrors trackEventSchema in app/api/track/route.ts — keep in sync", which is the whole enforcement mechanism. A route that changes shape will not fail a check.

Which transport a new endpoint belongs to

Start from the caller, not from the shape of the data.

If the caller is an admin screen or a storefront component in this repository, write a zsa server action next to its domain. It is the only transport that gets the permission procedures and the [data, error] tuple for free.

If the caller is an outside integration holding a ServiceApiKey and it is managing webhooks, it belongs on the oRPC router, behind the scope that already exists for it.

If the caller is an agent, a feed consumer, a provider posting a webhook, or the browser fetching from a page, write a REST route handler. If that route is meant to be discoverable by agents, remember that the OpenAPI document is a literal someone has to edit — the route will work without being listed, and no check will tell you it is missing.

The rule the codebase is enforcing throughout is the one written in the router header:

One transport story per consumer.

On this page