NOVUS / RESTAURANT

Documentation

The public read API

What the version one API serves without a key, why the health route says nothing about the price data, and the limits that apply to every caller.

Last reviewed against the product on .

The public read API at /api/v1 answers ordinary HTTP GET requests with JSON, with no key, no account and no cookie. It exists because this site's other machine-readable surface is JSON-RPC over MCP, which suits an AI client and suits nothing else: a crawler, a spreadsheet or a shell script had no way in.

What each route answers

The routes served under /api/v1
RouteWhat the body carries
/api/v1The index: site name, description, a documentation link, an absolute URL for each of the other routes, and readOnly set to true
/api/v1/healthA status of ok, a version, and the moment the request was served
/api/v1/siteSite name, canonical origin, description, version, and the address of the MCP endpoint
/api/v1/capabilitiesA count, then each capability as a name and one sentence
/api/v1/siblingsA count, then each sibling Novus property as a name and a URL

Measured from createPublicApiHandler in src/lib/shared/public-api.ts and the route files under app/api/v1.

The shape is the estate's, not this site's. Every Novus property mounts the same contract from the same shared module, so a client that can read one site's API can walk them all without special casing any of them.

The field called version means two different things

On the index it is the version segment of the path itself, and it moves only when the shape of these responses breaks. On /health and /site it is the application's own version, read from the package manifest. Nothing in the response distinguishes them by name, so a client that reads one where it meant the other will be quietly wrong rather than noisily broken.

Health describes the process, not the prices

Capabilities are the MCP catalogue, projected

The capability list is not written out for this API. It is built from the same tool array the MCP handler dispatches on and the public MCP server page renders. A second hand-written list would drift the first time somebody renamed a tool, and it would drift silently, because nothing compares the two.

Each entry carries a name and a description and no link. The tools share one human page and it has no per-tool anchors, so minting anchor links here would publish deep links to ids that do not exist in the served markup.

The list is also not gated on the MCP feature flag. The flag decides whether the JSON-RPC transport answers, not what the site is for, so switching the transport off does not change what this API says the site can do. Whether the endpoint is reachable is /mcp's own answer to give, and the MCP server explains how it gives it.

Routes
5
the index, plus health, site, capabilities and siblings
Capabilities listed
3
projected from the MCP tool catalogue rather than listed again
Siblings listed
9
every other Novus property; this site is deliberately absent from its own list

Measured from PUBLIC_API_ROUTES in src/lib/shared/public-api.ts, mcpServerDefinition in src/lib/mcp/server.ts and NOVUS_APPS in src/lib/novus-apps.ts.

Methods, and what a browser gets

How each HTTP method is answered
MethodAnswerWhy
GET and HEADThe document described aboveThe only methods that read. Both take the same path through the handler.
OPTIONS204, no body, cross-origin headers onlyAnswered before the rate limiter is consulted, so a browser preflight never spends the caller's budget.
Anything else405, an Allow header, and a method_not_allowed bodySaid in the body rather than left to the status code, because the alternative is somebody writing a POST client and discovering the truth in production.

Measured from createPublicApiHandler and the CORS constant in src/lib/shared/public-api.ts.

Cross-origin access is open to any origin, and that is not a loosening. Every response is public and unauthenticated, and none of them carries a cookie. Restricting the origin would block exactly the browser-based clients the API exists for while protecting nothing, since anything server-side ignores cross-origin rules entirely. Credentials are not permitted on these responses, so a browser cannot be talked into attaching a session to one.

Caching, and why the routes are dynamic

Browser cache
300 seconds
max-age on the four cacheable routes
Shared cache
600 seconds
s-maxage, so the edge answers most repeat traffic
Stale while revalidate
86400 seconds
a burst after expiry is served from the edge while one request refreshes behind it
Health
no-store
a cached health check reports the past

Measured from the CACHE_CONTROL and NO_STORE constants in src/lib/shared/public-api.ts.

Each route is rendered dynamically, which looks like a contradiction next to shared caching and is not one. A statically rendered route is generated once at build time and served to everyone from the edge, which would mean the rate limiter ran exactly once, during the build, and never again. Dynamic rendering keeps the limiter on the requests that reach the origin; the cache headers keep most requests from reaching it.

Rate limiting

Budget
60 per minute
per caller address, counted separately for each route
Window
60 seconds
opened by the first request after the previous window expired
Tracked callers
5,000
a ceiling on the bucket map, so a flood of distinct addresses cannot exhaust memory

Measured from RATE_POLICIES.publicRead, checkRateLimit and guard in src/lib/shared/rate-limit.ts.

The budget is scoped per route rather than per site on purpose. A client hammering /capabilities cannot exhaust the allowance for /health and take the site's own monitoring down with it.

Every served response carries the rate-limit headers, not only the refusals, because a client can pace itself only if it learns its budget while it is still being served. A refusal answers 429 as a problem document with a Retry-After header, and is marked as never cacheable: a shared cache that stored one over-budget client's refusal would serve it to everyone behind the same edge node, turning a rate limit into an outage.

What this API will not do

It does not write. Every route is a read, and the index says so in its own body. There is no POST, no upload and no mutation, because an endpoint that changes state needs authentication, and most of the estate has no account system to authenticate against.

It has no keys, no dashboard and no usage console. Nothing here is issued, metered or revoked per caller. If you need a credential, that is the private MCP surface and its personal access tokens, which reach a different endpoint entirely.

It serves nothing private. Everything these routes return is already on this site's own public pages. No route reads a row belonging to a person or an organization, so there is nothing here that signing in would show you more of.

It takes no parameters. There is no filtering, no paging and no query string. Each route answers one fixed document, whole, which is what makes the responses identical for every caller and therefore safe for a shared cache to hold.

It is asked not to be crawled. robots.txt disallows the whole of /api/, which keeps a crawler from spending its budget on routes that answer with limits or redirects. That is a courtesy to crawlers and not a security control: the endpoints answer anyone who asks.