Skip to main content

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/trpc on the site, which the site passes to the API.
  • Type only. The web app imports the API’s AppRouter type and nothing else. No API runtime code, and no database client, can reach the web bundle.
The routers are in 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 is apps/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.ts offers 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.
To add an endpoint, give a public procedure a .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, in apps/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/admin address, with the staff scope.
  • Discovery is at /.well-known/oauth-protected-resource/mcp/admin and /.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/consent refuses anyone else.
  • Sign-in resumes the request. After any sign-in, /oauth/resume carries 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.ts runs migrations before it loads the server.
To add a tool, add a row to 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.ts reads 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 tag catalog.
  • Change notices. After each change to a report, the API posts to /api/revalidate on the site (apps/api/src/site-revalidate.ts). The API also posts when it starts. The request carries SITE_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.
To add a public page that shows catalog data, read it through 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 in apps/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-owner script 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.
The owner account is made at boot from 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.