Litestore · Developer guide

oRPC

Learn what the narrow oRPC surface at /api/rpc actually exposes, why it is limited to webhook management and a health ping, and how service-key scopes gate it.

oRPC is mounted at /api/rpc and is the external service-key API.

Its scope is deliberately small: a webhook management router, and a health ping. Admin and storefront writes are not here and are not meant to be.

The reason is one transport story per consumer. An integration holding a service key uses oRPC; the admin and the storefront use zsa server actions; the published contract in app/api/openapi.json describes the REST endpoints. A surface that exists in two transports is a surface where the two can disagree.

The router file states the boundary in its own header:

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,
}

What is mounted

webRouter contains exactly one router today.

ProcedureScope requiredWhat it does
web.webhooks.listreadid, url, events, isActive, name, createdAt
web.webhooks.getreadOne webhook by id, or null
web.webhooks.createwrite:webhooksCreates one and returns the secret once
web.webhooks.updatewrite:webhooksUpdates url, events, name
web.webhooks.deletewrite:webhooksRemoves it
web.webhooks.rotateSecretwrite:webhooksIssues a new secret and returns it once
pingnone{ status: "ok", timestamp }

Create and rotate return the signing secret in the response body, and say so plainly, because it cannot be read back afterwards.

That is worth designing a client around. A caller that discards the create response has no second chance at the secret and has to rotate to get another one.

How a procedure is built

lib/orpc.ts layers three procedures, each adding to the one before it:

  1. baseProcedure injects db and a revalidate helper into context.
  2. serviceProcedure resolves a service key and converts an AppError into an ORPCError.
  3. serviceProcedureWithScope adds the scope check.

The scope check is the whole of the third layer:

lib/orpc.ts
export function serviceProcedureWithScope(scope: ApiScope) {
  return serviceProcedure.use(async ({ next, context }) => {
    if (!hasScope(context.apiKey.scopes, scope)) {
      throw new ORPCError("FORBIDDEN", {
        message: `Requires ${scope} scope`,
      })
    }
    return next()
  })
}

Because the layers stack, a procedure cannot get a database handle without having gone through key resolution first. Authentication is not something a procedure opts into.

There is no admin path through this transport

The session-auth and permission procedures that once mirrored the admin actions were deleted, along with the unused admin router.

So there is no way to reach an admin write through oRPC. Not by convention or by review — the procedures that would carry a session simply do not exist here.

The Webhook entity is admin-owned

The oRPC router imports the webhook lifecycle from server/admin/webhooks/lifecycle and carries a discipline-ok(no-admin-in-storefront) annotation explaining why: it is the API-key service surface over the same admin-owned entity, not storefront code. The discipline check would otherwise reject the import.

Which transport to use

Use oRPC when the caller is an external integration holding a service key and the operation is webhook management or a health check. That is the surface it covers.

Use a zsa server action for an admin or storefront write. That is where those live, and adding an oRPC mirror recreates the parallel surface that was deleted.

Use the REST endpoints when the caller needs the published contract, which is what app/api/openapi.json describes.

Before adding a procedure here, the question is not "would this be convenient over RPC?" It is:

Does this operation belong to a service key, and is it something the published contract does not already cover?

On this page