Last updated:
Lunora's own clients speak typed RPC over WebSocket. When something else has to
call your backend (a partner integration, a mobile app you don't control, a
curl in a runbook), you can publish an existing procedure as a plain REST
endpoint without rewriting it:
export const list = query
.args({ limit: v.optional(v.number()) })
.expose({ rest: true }) // ← publishes GET /_lunora/rest/messages/list
.handler(async (ctx, { limit }) => ctx.db.query("messages").take(limit ?? 20));The call is routed through the procedure, so everything that guards it over
RPC still guards it over REST: ctx.auth, RLS, and the
v.* validators. It is the same code path, not a
parallel one.
:::note[This or httpRoute?]
Both give you HTTP. They answer different questions.
.expose({ rest: true }): "this procedure I already have should also be callable over HTTP." The URL, method, and OpenAPI entry are derived for you; you write no routing code.httpRoute/httpAction: "I need a specific URL, shape, or protocol." A Stripe webhook, an OAuth callback, a file download, a REST shape that has to match someone else's spec.
Reach for .expose when the endpoint exists to serve your procedure, and for
httpRoute when the endpoint's shape is dictated by something outside your app.
:::
Default-closed
A procedure is unreachable over REST unless it says otherwise. Without
.expose({ rest: true }) there is no route at all, and the URL 404s, the same
answer an unknown path gets, so probing cannot enumerate your functions.
.expose is only available on the public builders. internalQuery,
internalMutation, and internalAction don't have the modifier: an internal
function is server-to-server (crons, ctx.runMutation) and never client-callable,
so there is nothing to publish.
stream procedures are also never exposed. They are a WebSocket surface, and a
request for one 404s like any unexposed procedure.
URLs and methods
The route is derived from the procedure's path: <namespace>:<function> becomes
/_lunora/rest/<namespace>/<function>:
| Procedure | Method | URL |
|---|---|---|
query (messages:list) | GET (or POST) | /_lunora/rest/messages/list |
mutation (messages:send) | POST | /_lunora/rest/messages/send |
action (billing:sync) | POST | /_lunora/rest/billing/sync |
A query is a safe read, so it is a GET; a mutation or action changes state, so
it is a POST. Queries also accept POST for the case where the arguments
are too large or too structured to sit comfortably in a query string.
The wrong method gets a 405 with an Allow header listing what the endpoint
does accept. The /_lunora/* prefix is reserved, so these routes can never
collide with your own httpRouter paths.
Arguments
On GET, arguments come from the query string. Each value is parsed as JSON
when it looks like JSON, and kept as a string otherwise, so a typed procedure
receives real numbers, booleans, and arrays rather than strings of them:
curl 'https://app.example/_lunora/rest/messages/list?limit=5&tags=["a","b"]&q=hello'
# → { limit: 5, tags: ["a", "b"], q: "hello" }limit=5 arrives as the number 5, tags=[…] as an array, and q=hello stays
a string because it isn't valid JSON. A string that is valid JSON is parsed
(?q=null is null, not "null"), so send those in a POST body if you need
them kept verbatim.
On POST, arguments are the JSON body. The body is optional; a no-arg
procedure can be called with none at all:
curl -X POST https://app.example/_lunora/rest/messages/send \
-H 'content-type: application/json' \
-d '{"text":"hi"}'Either way the arguments then go through the procedure's v.* validators, so a
malformed call is rejected exactly as it would be over RPC.
Choosing a shard
For a sharded app, pick the shard with ?shardKey= or
the x-lunora-shard-key header; the query parameter wins if both are present.
Omit it and the call routes to the default shard.
curl 'https://app.example/_lunora/rest/messages/list?shardKey=tenant-42&limit=5'shardKey is reserved for routing and is never passed to your handler as an
argument, so a procedure may safely declare an argument of any other name.
Authentication
Credentials are read from the request exactly as on the RPC path (an
Authorization header or cookies) and resolved through your
resolveIdentity. The resulting identity reaches
the procedure as ctx.auth, and RLS policies apply unchanged.
There is no REST-specific auth path, and no way to reach a procedure over REST that you could not reach over RPC with the same credentials. Exposing a procedure publishes its URL, not its data.
Rate limiting
The public surface is the one an anonymous internet client can reach, so it takes
its own gate. Build one over @lunora/ratelimit and
pass it as restRateLimit:
import { createRestRateLimit } from "@lunora/runtime";
export default createWorker({
// …schema, functions…
restRateLimit: createRestRateLimit(limiter, { name: "rest" }),
});It runs before dispatch, so a limited request costs you nothing downstream. By
default it keys on the caller's IP from CF-Connecting-IP, but only while running
on Cloudflare, where the edge stamps that header over anything the client sent.
On any other target nothing overwrites it, so it is a header the caller typed and
trusting it would hand an attacker a fresh bucket per request — those deployments
resolve no IP at all, and the gate answers 500 rather than pooling every caller
behind one bucket a single client could drain for everybody. An origin
fronted by a proxy that does stamp a client address declares it with
trustedClientIpHeader to get per-IP buckets back; see
@lunora/runtime for what that assertion commits you
to. Otherwise pass key to key on something you control, such as an API key
header, or the functionPath to limit per endpoint:
createRestRateLimit(limiter, {
name: "rest",
key: (request, functionPath) => `${request.headers.get("x-api-key") ?? "anon"}:${functionPath}`,
});A rejected call gets a 429 with Retry-After. The gate applies only to REST;
typed RPC is unaffected.
Caching
A GET-able exposed query can declare an HTTP cache policy. Unlike RPC — which is
a POST and therefore not edge-cacheable at all — a REST GET has a URL the
colo cache can key on:
export const list = query
.input({ limit: v.optional(v.number()) })
.expose({ rest: true, cache: { scope: "public", maxAge: 60, staleWhileRevalidate: 300 } })
.query(async ({ ctx, args }) => ctx.db.query("posts").take(args.limit ?? 20));This does two things. It emits the Cache-Control / Vary headers the response
advertises — which browsers and any CDN in front of you honour — and, on a host
with a cache the Worker can write to (caches.default on Cloudflare), it stores
the response there. A subsequent identical request is answered from the colo with
no shard dispatch, and comes back with X-Lunora-Edge-Cache: hit. The
rate-limit gate still runs first — a cache hit is still a request this caller
made — so caching changes what a request costs you, not what it costs them.
scope is a request, not a promise
The hazard of caching a procedure-backed endpoint is that the procedure runs under
ctx.auth and RLS, so its body is frequently caller-specific. Lunora therefore
re-derives the effective scope from the live request and downgrades public →
private whenever the caller presented a credential — an Authorization header,
a cookie, a Cloudflare Access identity, or any header you named in
credentialHeaders. A downgraded exchange is never written to the shared cache
and never served from it.
So the cost of a wrong scope is a missed cache hit, never a cross-user leak. A
policy declared scope: "private" is never stored at the edge at all — it is
caller-specific by definition.
If your app authenticates on something outside the built-in list, you must say so, or those callers read as anonymous:
.expose({ rest: true, cache: { scope: "public", maxAge: 60, credentialHeaders: ["x-api-key"] } })The same applies to a body that varies on something the request does not carry as
a credential. A scope: "public" query whose result depends on ctx.ip — geo
content, a per-IP quota display — has one caller's answer served to the whole
colo. Either declare vary: "cf-connecting-ip" (which keys per IP, and so caches
almost nothing) or leave the endpoint private.
Vary is enforced in the key
Cloudflare's cache honours Vary for Accept-Encoding only, so Lunora does not
delegate it: every header the policy varies on — including x-lunora-shard-key
and x-d1-bookmark, which select which data a request sees — is folded into the
cache key itself. Two requests that differ on any of them can never share an entry.
Only a 200 is stored, and a response carrying Set-Cookie is skipped. So is a
response advertising a Vary header the key does not fence on — a procedure that
negotiates on Accept-Language of its own accord costs a cache miss rather than
risking one variant being served in place of another.
The stored copy also drops the headers that describe the exchange rather than the
resource (X-D1-Bookmark, X-Lunora-Shard-Key): the first caller still receives
them, but replaying them would hand a later caller someone else's read cursor.
Opting out
Pass restEdgeCache: null to createWorker to keep the surface headers-only —
the declared Cache-Control still goes out and browsers and any CDN in front
still honour it, but nothing is written to the colo:
export default createWorker({
// …schema, functions…
restEdgeCache: null,
});A host with no cache of its own needs no opt-out: there is nothing to find, and the surface degrades to the same headers-only behaviour.
OpenAPI
The exposed surface is described by the generated OpenAPI 3.1 document, so
consumers can generate their own clients. Codegen emits it as both
_generated/openapi.json and _generated/openapi.ts (a Worker can't read a JSON
file at runtime, so the inlined export const openApiSpec is what you serve):
import { openApiSpec } from "@/lunora/_generated/openapi";
export default createWorker({
// …schema, functions…
openApiSpec,
});That exposes the admin-gated GET /_lunora/admin/openapi.
The spec and the live router derive the path/method mapping from the same helper, so "the published OpenAPI matches what is actually routable" is a structural property rather than something kept in sync by hand.
Observability
REST calls emit the same telemetry as any dispatch; see
Observability. One thing specific to this surface:
span attributes include url.path, which is the raw inbound path. On the RPC
transport that is a constant, but a REST path can carry ids, so prefer http.route
(always the templated function path) when grouping or alerting, and check what
leaves the deployment before pointing a sink at a third party.