Skip to content
DocsconceptsDocumentation

Public REST API

Publish a query, mutation, or action as a plain REST endpoint with .expose({ rest: true }) — default-closed, routed through the procedure so auth, RLS, and validators still apply, and described by the generated OpenAPI.

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>:

ProcedureMethodURL
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 publicprivate 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.