Overview
The proxy addresses endpoints by name. A page's call carries ?_s=api&_g=users&_m=getAll, and the server resolves those names against the sources your application registered — all of them, including the ones only server code was ever meant to call.
So the proxy serves only what you mark. An endpoint, a group or a whole API says expose where it is defined, and a call to an endpoint no mark opens is refused as if the endpoint did not exist. A new endpoint is the server's until someone marks it, so one written for a cron job or an admin tool never becomes a page's to call by accident — and with it the server's credentials, which the proxy lends every call it serves.
The page learns the same answer from a module Courier writes from your marks: the routes. They name each endpoint a page may call and how the page reaches it, and nothing else — not the upstream host, not your middleware, not an endpoint you kept back.
Marking endpoints
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
const { endpoint, stream } = courier()
type User = { id: string; name: string }
const users = defineGroup({
prefix: "users",
expose: true,
endpoints: {
getAll: endpoint.get("").response<User[]>().cache({ revalidate: 60 }),
getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
create: endpoint.post("").variables<{ name: string }>().response<User>(),
purge: endpoint.del(":id").variables<{ id: string }>().expose(false),
},
})
const notifications = defineGroup({
prefix: "notifications",
endpoints: {
feed: stream.get("feed").response<{ text: string }>().expose(),
},
})
const admin = defineGroup({
prefix: "admin",
endpoints: {
exportPayouts: endpoint.post("payouts/export"),
},
})
export const api = defineApi({
baseURL: process.env.API_URL!,
groups: { users, notifications, admin },
})The nearest mark decides:
| mark | what it opens or shuts |
|---|---|
.expose() on an endpoint | the endpoint itself; .expose(false) shuts it |
expose on a group | each endpoint of the group, and of the groups inside it, that nothing nearer marks |
expose on the API | every endpoint that nothing nearer marks |
| none at all | nothing: the endpoint is the server's alone |
Above, every endpoint of users is open but purge, notifications.feed is open by its own mark, and nothing marks admin.exportPayouts, so no page reaches it. An API shut with expose: false may still open one group, and an open group may shut one endpoint. A mark set to anything but true shuts what it is set on, so expose: "false" in a JavaScript config fails closed rather than open.
Everything a mark opens is a public API. Any browser, script or server can call it through your proxy, and it runs with what your server has: the key in the source's headers, the network the server sits in. The origin check stops another site's page from driving it with your users' cookies; a script sends no Origin and is served. Expose a read as you would publish it, and a write as you would put a form on the open web: let it act for a user only with the user's session and its double-submit token, put a captcha before one anyone may make, and limit how often one caller calls — see Rate limits. An endpoint of a group inside another is named by the whole path, billing.invoices.list.
What the proxy answers
A call to an endpoint no mark opens is answered exactly as a call to a name the source does not define: 400, with [api][admin][exportPayouts]: method not found. Saying "forbidden" would confirm that the endpoint exists, which is the one thing a caller guessing names is trying to learn.
While you develop, the server's log says why, once for each endpoint:
[api][admin][exportPayouts]: a page called it, and nothing marks it .expose(), so the proxy answered as for an unknown name. Mark it where it is defined if a page may call it, and write the routes again.In production it says nothing. The marks gate every route of the proxy — HTTP calls, streams and sockets alike — since the proxy resolves each call against the source it was registered as.
Server-side callers are unaffected
The marks gate the calls that arrive over the proxy. Code running in your own process holds the API object and never addresses anything by name, so createCaller(api) reaches every endpoint, marked or not.
The routes module
A page needs to know, as it runs, which endpoints it may call and how to reach each — a read it may cache, a stream, a socket. Types are gone once the page is built, and importing the API would ship its definition with them, so the routes travel as a module of their own: data alone, written for you, and short enough to read in a review.
Courier writes them into courier.routes.ts, beside your courier.config.ts, from the endpoints marked in every source the config declares. For the API above:
import { defineConfig } from "@noverax/courier/config"
import { api } from "./api"
export default defineConfig({ sources: { api } })// Written by Courier from courier.config.ts. Do not edit it:
// `courier-cli routes` writes it again from the endpoints marked .expose().
import type { CourierRoutes, RouteMap } from "@noverax/courier/define";
import type config from "./courier.config";
type Sources = (typeof config)["sources"];
const apiEndpoints = {
"notifications.feed": { transport: "sse" },
"users.create": { type: "mutation" },
"users.getAll": { type: "query", get: true },
"users.getById": { type: "query" },
} as const satisfies RouteMap<Sources["api"]>;
/** What a page's proxy client may call of `api`, and how it reaches each endpoint. */
export const apiRoutes: CourierRoutes<Sources["api"], typeof apiEndpoints> = {
source: "api",
endpoints: apiEndpoints,
};Two tools write it, with the same code and to the same bytes, and each only when the module would change:
- Studio, each time it loads the config — when it starts, and when you save a file the config reads.
courier-cli routes, from@noverax/courier-cli, without Studio — in a project that does not run it, in a script, in CI.
The module holds data and types alone: it imports the config as a type, so a page that imports it bundles the endpoints' names and shapes and nothing of their definitions. It is formatted as the rest of your project is, by the formatter the project uses.
Review it as a permission
Commit it with the marks. It answers "what can a page call in this release" in one file that a review reads and a diff shows. A CI step keeps the two together:
npx @noverax/courier-cli routes --checkIt writes nothing, and fails when the module on disk is not what the marks give: a mark changed without the module written again fails the build rather than a page. Install the CLI in the project — npm install --save-dev @noverax/courier-cli — so the job runs the version your lockfile pins.
A line in CODEOWNERS gives the module to the people who own the API, so no endpoint becomes a page's to call without their review:
/courier.routes.ts @your-org/api-ownersstudio.routes in courier.config.ts puts the module somewhere else, by its path from the project root, or false keeps the project without one — see defineConfig. Neither tool writes over a file Courier did not write: the module's first line says it is Courier's. With no endpoint marked, the module exports nothing.
Wiring the client
Give the client the routes:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const client = createProxyCaller(apiRoutes)import { createProxyQueryCaller } from "@noverax/courier-react"
import { apiRoutes } from "@/courier.routes"
export const q = createProxyQueryCaller(apiRoutes)The client is typed from the API the routes were written from, with the endpoints they name alone: client.admin.exportPayouts() does not compile, and the hooks of q offer no endpoint the proxy would refuse. Called anyway — from JavaScript, or past a cast — the client throws a ConfigurationError with the code ENDPOINT_NOT_IN_ROUTES before anything is sent.
The routes do not say where the proxy is, since that depends on where the page is served. The client sends to /api/request and /api/sse on the page's own origin, and opens a socket only where its proxyPath names one — see createProxyCaller:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const client = createProxyCaller(apiRoutes, {
proxyPath: { ws: "wss://realtime.example.com" },
})The shapes
Each entry says how the client reaches its endpoint:
| shape | the endpoint it describes |
|---|---|
{ type: "query", get: true } | a read that may be cached: it declares .cache(), and its variables are not sensitive |
{ type: "query" } | any other read — declared with get, head, options or query, or marked .reads() |
{ type: "mutation" } | a write: every other verb |
{ transport: "sse" } | a server-sent event stream |
{ transport: "ws" } | a socket the server pushes on |
{ transport: "ws", bidirectional: true } | a socket the caller may also send on — a channel with .send() |
A read that may be cached goes to the proxy as GET, its variables in the address, so a browser and a CDN may keep its answer. Every other call goes as POST: GET gains a read nothing when nothing on the way may keep the answer, and POST keeps the variables of a read nobody marked out of every log between the page and your server. A read whose definition declares sensitiveVariables goes as POST even with .cache(), and the proxy refuses it over GET.
Routes without a config
A project with no courier.config.ts writes its routes by hand. Held to the API by RouteMap, they cannot drift from it:
import type { CourierRoutes, RouteMap } from "@noverax/courier/define"
import type { api } from "@/api"
const endpoints = {
"users.getAll": { type: "query", get: true },
"users.create": { type: "mutation" },
"notifications.feed": { transport: "sse" },
} as const satisfies RouteMap<typeof api>
export const apiRoutes: CourierRoutes<typeof api, typeof endpoints> = { source: "api", endpoints }A path the API does not define, or a shape it declares otherwise, does not compile:
import type { RouteMap } from "@noverax/courier/define"
import type { api } from "@/api"
// @ts-expect-error — no such endpoint
const misspelt = { "users.getAl": { type: "query" } } as const satisfies RouteMap<typeof api>
// @ts-expect-error — it is a stream
const notARead = { "notifications.feed": { type: "query" } } as const satisfies RouteMap<typeof api>
// @ts-expect-error — a POST writes
const aWrite = { "users.create": { type: "query" } } as const satisfies RouteMap<typeof api>The API is imported as a type. That is load-bearing rather than stylistic: a value import pulls your defineApi call into the module, and from there into the bundle — the upstream host, your middleware bodies and whatever literal sits inside them, and the path of every endpoint, the ones you held back included. The module Courier writes imports the config the same way.
The marks still decide what the proxy serves. Routes that name an endpoint no mark opens give the page a client that offers it and a proxy that refuses it. courier-cli routes --check keeps the module Courier writes in step with the marks; one written by hand is yours to keep in step.
What the browser learns
The routes are plain data, and they are all the client gets:
{
"source": "api",
"endpoints": {
"notifications.feed": { "transport": "sse" },
"users.create": { "type": "mutation" },
"users.getAll": { "type": "query", "get": true },
"users.getById": { "type": "query" }
}
}No upstream host. No middleware. No endpoint you did not mark. An API's cancelPrevious and coalesce come with them where the API sets either, since the client honours both.
Studio shows the marks where you look at your endpoints. The Explorer tags each endpoint a page may call exposed, and its definition names the mark that decided: expose: true on users, marked .expose(false), nothing marks it .expose(). In Proxy mode the Playground calls as a page would: it flags an endpoint no mark opens not exposed, and refuses to send it, saying where to open it — so what you try there is what a page may do.
To try an endpoint that stays the server's, run it as server code. Run as server code, under the not exposed chip, switches the Playground to Direct mode and sends the call there; Try as server code, on the Explorer's Pages row, opens the Playground in Direct mode on the endpoint and sends nothing until you do. Direct mode runs the call as your server code does, on the app's runtime, which reaches every endpoint, marked or not. Nothing changes at the proxy: a page's call is still refused, so no endpoint has to be opened to be tested.
Direct mode is a different trade
In direct mode the browser calls your backend itself, so it must hold the API definition — the URL and the endpoint paths are in the bundle by construction. The marks cannot change that, and are not meant to. Proxy mode is the one that keeps them on the server.