The two services
The web app (apps/web) renders pages and runs server actions. The API (apps/api) owns the database, sign-in, files, email and payments.
- Server to server. Server components and server actions call the API through the client in
apps/web/lib/api.ts. It forwards the visitor’s cookies and address, so the API can identify the caller. - Browser to API. A few admin screens call tRPC from the browser. They go through
/api/trpcon the site, which the site passes to the API. - Type only. The web app imports the API’s
AppRoutertype and nothing else. No API runtime code, and no database client, can reach the web bundle.
apps/api/src/routers, one file per area: requests, quotes, reports, deliveries, invoices, organizations, users and the rest.
Rewrites on the site
apps/web/next.config.ts passes three paths to the API. The browser sees only the site’s own address, so the session cookie is first-party.
The API’s routes
In production the API answers on its CloudFront address. It has no custom domain yet.
The public API and MCP server
A read-only API over the report catalog, for other people’s code and AI agents. No key is needed. The public page is oncoeverything.com/developers.- REST. A tRPC procedure joins the REST API when it sets
meta.openapi, through trpc-to-openapi. Today that is five catalog reads: search, one report, a report’s sample, the topics, and one topic. The handler isapps/api/src/public-api.ts. - Output schemas are the contract. Each public procedure declares
.output(). The OpenAPI document is generated from them, and the site’s own calls go through the same schemas. A field removed from a schema is removed from the API, so treat a change there as a change to a published API. - MCP.
apps/api/src/mcp.tsoffers the same reads as five tools over Streamable HTTP. It is stateless: each request builds its own server and answers plain JSON. - Never signed in. Both run with no session, whatever cookie a browser sends.
- Rate limit. The Vercel firewall allows 120 requests a minute per IP on these paths (
infra/vercel.tf). Past that it answers 429.
.meta({ openapi: { method: "GET", path: "/…" } }) and an .output() schema, and add a test to apps/api/src/public-api.test.ts. The directory data gets its endpoints this way when it moves into the database.
The staff MCP server
/mcp/admin gives Claude the admin’s reads and two safe writes, for staff: a note on a request, and a blog draft from Markdown (posts.createDraft, apps/api/src/blog/markdown.ts). A person publishes the draft in the admin. Setup for staff is in Use Claude with the admin. The code is apps/api/src/staff-mcp.ts.
- OAuth 2.1. Better Auth’s MCP plugin (
@better-auth/mcp, inapps/api/src/auth.ts) makes the platform an OAuth server. Claude registers itself by dynamic client registration, signs the person in on/sign-in, and asks them to approve it on/connect. Tokens are signed JWTs bound to the/mcp/adminaddress, with thestaffscope. - Discovery is at
/.well-known/oauth-protected-resource/mcp/adminand/.well-known/oauth-authorization-server/api/auth. The site passes both to the API. - Three checks on every call. The token (signature, issuer, audience, expiry, scope). The person (an active owner, read from the database). The connection (their consent for that app still exists). A disconnect on Connect Claude therefore stops the app at once.
- Tools reuse the admin’s procedures. Each tool calls an owner-only tRPC procedure as the signed-in person and takes that procedure’s input schema. Nothing sends email.
- Only owners can approve. A hook on
/oauth2/consentrefuses anyone else. - Sign-in resumes the request. After any sign-in,
/oauth/resumecarries the person back to the authorization request (apps/web/lib/oauth-resume.ts). - Migrations first. The OAuth provider writes to its tables as Better Auth starts, so
apps/api/src/index.tsruns migrations before it loads the server.
STAFF_TOOLS naming an owner procedure, and a case to apps/api/src/staff-mcp.test.ts if it writes.
Cached pages
The public pages that show reports are static. The Vercel CDN serves them, and the site does not render them again for each visitor.- Cached reads.
apps/web/lib/catalog.tsreads the catalog through its own client. The client sends no cookies, because the catalog is the same for everyone. Next.js caches each answer under the tagcatalog. - Change notices. After each change to a report, the API posts to
/api/revalidateon the site (apps/api/src/site-revalidate.ts). The API also posts when it starts. The request carriesSITE_REVALIDATE_KEY, which Terraform generates. - Stale, then new. The notice marks the cached pages stale. The first visit after a change gets the old page and starts a render. The next visit gets the new page.
- Backstop. Each page also refreshes every five minutes. This covers a change that the API did not report, such as a direct database edit.
- API down. A page keeps its last good copy. A build with no API makes pages that say the catalog is unavailable. They refresh within five minutes after the API is back.
- Per request. The report search page (
/segments/market/reports) and checkout render for each visit, because they depend on the query string or the buyer. Their catalog reads still come from the cache.
lib/catalog.ts and export revalidate = 300 from the page.
Markdown for agents
Every public page also answers in Markdown: at its.md address, when a request sends Accept: text/markdown, or when a known AI agent asks without naming a format. apps/web/proxy.ts decides, and app/markdown converts the page’s own HTML. /llms.txt indexes the site for agents.
Sign-in
Better Auth runs inside the API. Its settings are inapps/api/src/auth.ts.
- Emailed link. This is the main sign-in method. A link works once and expires in 10 minutes.
- Google. Google sign-in shows only when the Google client ID and secret are in the app secret.
- Password. Password sign-in is on, with a 12-character minimum. Only the
create-ownerscript sets one, for the owner. There is no reset page yet, so everyone else signs in with a link or Google. - Sessions last seven days and renew once a day with use. Sessions are rows in Postgres.
- Sign-up through Better Auth is closed. Accounts come from the site’s own sign-up form, an invitation, an accepted quote or the admin.
OWNER_EMAIL. Changing it later adds another owner. It never changes an existing account.
Roles
Each tRPC procedure checks the role. Buyer procedures filter by the caller’s organization. A CI test against a real Postgres database checks that one organization cannot read the requests of another.

