Public API
apps/public-api is the externally-facing HTTP API CardNexus customers (TCG stores, third-party tool builders) integrate against. It runs as its own process, separate from apps/backend, behind public-api.cardnexus.com (and public-api.dev.cardnexus.com for staging).
This page covers how it’s wired internally — auth, scopes, rate limiting, idempotency — and where to look when you need to extend it. To actually add an endpoint, use the extend-public-api Claude skill — it codifies the rules below into a step-by-step procedure.
Overview
| Concept | Where |
|---|---|
| Process | apps/public-api (Express + oRPC, runs on its own port + container) |
| Contracts | @repo/public-api-dtos (separate from @repo/api-dtos — strict separation) |
| Domain logic | The same @repo/<domain> services apps/backend already uses |
| Auth | API keys issued from cardnexus.com (@repo/api-key) |
| Customer-facing docs | docs.cardnexus.com — Scalar Docs (hosted), source in apps/public-api-docs |
| OpenAPI spec endpoint | /v1/openapi.json (always public) |
| Local dev /docs | Scalar OSS embed at localhost:3002/docs, gated to ENVIRONMENT=local only — staging/prod 301 to docs.cardnexus.com |
The defining decision: the public API contract is a long-term external commitment. We chose to mirror domain services rather than recreate them, but the wire format is independent — a refactor of the internal API never forces a public-API breaking change.
The public API and the internal API are intentionally separate processes with separate Express apps. They share domain services through the workspace but never share an oRPC router or contract package. This is what lets us iterate on internal contracts without coordinating with external consumers.
Request lifecycle
Every request hitting the oRPC surface flows through the same middleware stack:
The request-ID middleware runs at the Express layer (so it sets X-Request-Id even on pre-auth 401s and on /docs requests). Everything else is an oRPC middleware bound to the procedure builder, so its order and typing are guaranteed by the contract.
Authentication
API keys live in their own collection, separate from the user document. Every authenticated request:
Extract the bearer token
The auth middleware reads Authorization: Bearer cnk_live_<random>. Anything else (missing header, wrong scheme, empty token) → 401.
Verify via the domain service
ApiKeyService.verifyToken(plaintext) SHA-256-hashes the token and looks it up by hashedSecret (uniquely indexed). If revoked → 401 API_KEY_REVOKED. If unknown → 401 API_KEY_INVALID_TOKEN.
Update lastUsedAt (fire-and-forget)
The service fires an updateOne({ $set: { lastUsedAt: now } }) without awaiting it. Auth must never block on or fail because of this write — it’s an audit-flavoured field, not a correctness one.
Build the unified actor
context.actor = {
userId: apiKey.userId.toString(),
clientId: null, // OAuth slot — null for API keys today
scopes: apiKey.scopes,
authMethod: "api_key",
}The actor shape is deliberately abstract: when OAuth lands post-GA, a sibling resolver builds the same shape from a Clerk JWT, and handlers don’t change. The clientId field is reserved for the third-party OAuth application id — null for first-party API keys today, but the Redis bucket key already partitions on (userId, clientId) so there’s no migration when OAuth introduces real client ids.
The auth middleware runs on every request — we want one indexed Mongo lookup, not a $elemMatch array scan. Power users with many integrations would also bloat their User document unboundedly. Keeping keys in their own collection means cleaner audit, revocation, analytics, and a clean place to add OAuth tokens later.
Scopes
API keys carry a list of scopes (account:read, inventory:write, …). The catalogue lives in @repo/shared-primitives (PUBLIC_API_SCOPES), so it’s a single source of truth across @repo/api-dtos (key-creation contract), @repo/public-api-dtos (every public contract), and the public-API middleware.
Every public contract declares the minimum scopes it needs:
export const getInventoryLineContract = publicOc
.meta({ scopes: ["inventory:read"] })
// ...The scopes middleware checks requiredScopes ⊆ actor.scopes ∪ {"*"}. The "*" wildcard is a deliberate full-access option for API keys — OAuth tokens (post-GA) will never carry it.
| Scope | What it grants |
|---|---|
* | Full access (API keys only) |
account:read / account:write | Account profile read/write |
inventory:read / inventory:write | Stock state |
listings:read / listings:write | Public marketplace listings |
sales:read / sales:write | Outgoing orders |
purchases:read / purchases:write | Incoming orders |
disputes:read / disputes:write | Dispute lifecycle |
messaging:read / messaging:write | Conversations |
pricing:read / products:read | Catalogue / pricing |
financial:read | Stripe, payouts |
marketplace:read | Cross-cutting marketplace metadata |
When adding a new scope: add it to PUBLIC_API_SCOPES first, then use it in the relevant contract’s .meta({ scopes }). The full list is enforced by Zod, so nothing else compiles until the catalogue agrees.
Rate limiting
The rate limiter is the most architecturally interesting piece, because the day-one default is the simplest case of a much richer model we already have to support. Two upcoming requirements drove the design:
- Large-account overrides — some launch customers will need higher limits than the default.
- Per-endpoint named buckets — heavy endpoints like inventory export/import need their own slower bucket independent of the global one (e.g. 5 exports/hour even with a 60/min global limit).
The policy resolver seam
type RateLimitBucket = {
name: string // e.g. "global", "inventory-export"
limit: number
windowSec: number
}
type RateLimitPolicyResolver = (
actor: Actor,
routeMeta: RateLimitMeta | undefined,
) => Promise<RateLimitBucket[]>The middleware doesn’t know what buckets exist — it just calls the resolver and applies whatever it gets back. The default resolver returns:
- A
globalbucket atPUBLIC_API_RATE_LIMIT_PER_MINUTE / 60s(60/min by default). - Whatever named buckets the contract declared in
meta.rateLimit.buckets. - Plus any per-actor overrides from
getActorOverrides(actor)(today returns[]— when the override storage lands, this is the only function that changes).
If an override returns a bucket named global, it replaces the default global — that’s how a launch customer gets a higher per-actor limit without inheriting the default’s stricter ceiling.
Algorithm
Fixed-window via Redis pipeline:
INCR rl:{userId}:{clientId ?? "self"}:{bucket}:{floor(now/windowSec)}
EXPIRE … windowSecOne round-trip per request, no sorted sets, easy to reason about. We intentionally accept that bursts at window boundaries are possible — sliding-window can come later if it ever shows up as a real customer complaint in beta.
The strictest bucket (lowest remaining = limit - count) drives the response headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. On 429, Retry-After is keyed off whichever bucket overflowed, so the developer sees exactly which constraint they hit.
Fail-open
If Redis is unreachable or any pipeline reply errors, the middleware logs a warning and lets the request through. Better to over-serve briefly than to take production down on an infra blip. Same for idempotency. The trade-off is real — log loud, alert on warning rate, and don’t paper over a longer Redis outage.
Bucket key partitioning
rl:{userId}:{clientId ?? "self"}:{bucket}:{window}userIdpartitions per CardNexus account.clientIdis"self"for first-party API keys today, and the third-party OAuthclient_idonce OAuth lands. “DeckBuilder.io made 10k requests on behalf of 500 users” is an answerable question from day one of OAuth, with no migration.bucketis the named bucket from the policy resolver.windowisfloor(now / windowSec)— rolls over on the boundary.
Idempotency
Opt-in per route via .meta({ idempotency: true }). Phase 0 ships the middleware fully tested with mock routes; the first real consumer is Phase 1 mutation contracts. /v1/account/me is a GET and doesn’t opt in.
Race-safety: two concurrent requests with the same key; the second one sees the lock, then short-polls (≤ 200ms, every 50ms) for the first writer to commit. If the first writer hasn’t committed by the timeout (slow handler) or has errored (lock got deleted), the second runs the handler itself rather than block.
We do not cache failures. If the handler throws, the lock is DEL’d so a retry runs fresh. Caching errors permanently would defeat the point — retries should heal transient problems.
Body cap: 8KB. Larger responses succeed but aren’t cached; subsequent same-key requests run fresh. We log a warning so we notice if a write endpoint is consistently overflowing the cap.
Key shape:
idem:{userId}:{clientId ?? "self"}:{method}:{path}:{idempotencyKey}Per-actor isolation: two CardNexus users sending the same Idempotency-Key value never collide.
Optimistic concurrency
There is no ETag/If-Match middleware. Two earlier prototypes both broke:
- A post-handler check (validate
If-Matchafter the handler runs) doesn’t actually prevent stale-data writes — the mutation already happened by the time the 412 fires. - A pre-handler check (a
getCurrentcallback that fetches the resource before the handler) has a TOCTOU race: another concurrent client can write between the middleware’s fetch and the handler’s own write. It also forces every service to expose agetCurrentmethod just so the middleware can call it.
The right place for the precondition is the database write itself — Mongo’s findOneAndUpdate({ _id, writtenAt: clientEtag }, ...) is naturally atomic and is what real-world services use. Handlers that need optimistic concurrency read If-Match from the request directly and pass it to their domain service, which embeds the value in the conditional update. The middleware would just be in the way.
ETag headers on responses are likewise the handler’s job (set the response header from a write timestamp the service returns) — generic enough that a middleware buys nothing.
Request ID + Datadog correlation
The Express-layer middleware reads X-Request-Id (if a valid UUIDv4) or mints one, sets it on the response, attaches it to req.requestId, and tags the active dd-trace span with request_id. This lets a customer’s failure correlate to a Datadog trace by request id — and the same id appears on INTERNAL_SERVER_ERROR responses so support can resolve them quickly.
Note: dd-trace is imported as the very first thing in apps/public-api/src/main.ts. It patches Node internals on load and misses spans on anything imported before it. This is the same constraint as apps/backend.
Customer-facing docs site (Scalar Docs + Registry)
The customer-facing developer hub at docs.cardnexus.com is Scalar Docs — the hosted, paid product (not the OSS @scalar/api-reference package we embed locally). Scalar Cloud renders the site; we own the source content + config in the monorepo.
Repo layout
| Lives in | Owns |
|---|---|
apps/public-api-docs/scalar.config.json | Navigation tree, branding, OpenAPI source |
apps/public-api-docs/docs/content/ | Markdown / MDX guides + recipes |
apps/public-api-docs/docs/assets/ | Logos, favicon, the brand stylesheet (scalar-theme.css) |
packages/core/public-api-dtos/ | The Zod contracts that drive the OpenAPI spec |
Scalar Registry (@cardnexus/public-api) | The published OpenAPI artifact the docs site references |
apps/public-api-docs is content-only — no Next.js, no build step, no static export. Scalar’s GitHub App pulls content directly from the repo on every push and renders it on Scalar’s CDN. Don’t mirror the apps/internal-doc Nextra pattern; that would be wrong here.
Where the OpenAPI spec lives
The spec is generated from the oRPC contracts by apps/public-api’s generateOpenApiSpec(...). One function, two callers:
- Runtime — the on-API
/v1/openapi.jsonendpoint serves it lazily-cached. - Build-time —
pnpm --filter @repo/public-api openapi:generate <path>writes the spec to a file. This is what CI pipes intoscalar registry publish.
The build-time call passes includeLocalServer: false so the published artifact never advertises a developer’s machine in its servers: block.
scalar.config.json references the spec by Registry slug, not by file path or live URL:
{
"type": "openapi",
"title": "API Reference",
"url": "https://registry.scalar.com/@cardnexus/public-api/latest?format=json"
}The spec is never committed to the repo — apps/public-api/.gitignore excludes openapi.json so a local generation never sneaks into a PR. The Registry is the canonical artifact store.
CI flow
| Workflow | Trigger | What it does |
|---|---|---|
openapi-validate.yml | PR touching apps/public-api/**, packages/core/public-api-dtos/**, or packages/shared/primitives/** | Generate spec → validate (schema) → lint (Spectral). Fails the PR check if either step fails. |
openapi-publish.yml | Push to develop with same path filter | Generate spec → validate → push to Registry as @cardnexus/public-api/latest with --force. Scalar Docs auto-redeploys against the new spec. |
Required secrets
SCALAR_API_KEY— Scalar dashboard → User → API keys, scoped to thecardnexusnamespace. Repo secret. Without it, the publish workflow can’t auth and the docs site stays pinned to whatever spec it last successfully fetched.
Brand theme — single source
The brand palette lives at apps/public-api/src/lib/scalar-theme.ts (a TS template literal). Two consumers:
- The local
/docsroute (Scalar OSS) imports it at runtime. - Scalar Docs needs it as a static
.cssfile referenced fromsiteConfig.head.styles.
pnpm --filter @repo/public-api theme:export writes the template literal to apps/public-api-docs/docs/assets/scalar-theme.css with an “auto-generated” banner. The docs project’s predev script runs the export so local previews always pick up the latest theme. The CSS file is committed (Scalar’s GitHub App reads from the repo).
When you edit the palette, edit the .ts file — never the .css file directly. Re-run the export (or just pnpm dev --filter @repo/public-api-docs and the predev hook handles it).
Local preview
# The on-API /docs (Scalar OSS embed, ENVIRONMENT=local only)
pnpm dev --filter @repo/public-api # http://localhost:3002/docs
# The customer-facing docs site (Scalar Cloud, local preview)
pnpm dev --filter @repo/public-api-docs # http://localhost:7970The Scalar Docs preview pulls content from local markdown but the API reference page hits the published Registry spec — i.e. you see what’s currently live on docs.cardnexus.com, not your in-flight contract changes. To preview unpublished spec changes, point the OpenAPI route in scalar.config.json at a local file path produced by openapi:generate, then revert before committing.
Gotchas
- First spec publish. The Registry slug
@cardnexus/public-api/latestdoesn’t exist until the publish workflow runs once. Until then, the API reference page in Scalar Docs 404s. Fix: merge any PR that touches the API surface todevelop(workflow fires automatically) or do a one-off manualscalar registry publishfrom your machine. - Scalar Docs doesn’t render in-flight spec changes. PR previews only show docs content changes (markdown). The reference page always pulls
@latestfrom the Registry. The PR validate workflow is the gate that catches spec breakage; the visual diff in Scalar Docs lags behind the merge. - Custom domain isn’t wired yet. We’re on
cardnexus.scalar.com(thesubdomainfield inscalar.config.json). When DNS lands fordocs.cardnexus.com, also update the redirect target inapps/public-api/src/routes/docs.ts.
How to extend
For the full procedure, use the extend-public-api skill. The headlines:
- Handlers never touch models or the database directly. Always go through an
@injectable()domain service — same as the internal API. - Reuse the services the internal API uses. Don’t reimplement domain logic, don’t add a “public-API-shaped” method to a service. If the existing method’s return shape doesn’t match what the contract should expose, that’s a mapping concern in the handler.
Every contract must declare:
.route({ method, path, summary, description, tags })— without this, oRPC defaults to POST with an RPC-style path..meta({ scopes: [...] })— minimum scopes the handler logic actually needs..errors({ ... })for endpoint-specific errors. Common errors (UNAUTHORIZED,FORBIDDEN,BAD_REQUEST,TOO_MANY_REQUESTS,INTERNAL_SERVER_ERROR) are already inherited frompublicOc— don’t redeclare them..describe(...)on every input and output field — these become the OpenAPI field docs..meta({ examples: [...] })on the response schema and on every endpoint-specific error’sdata— Scalar’s default fixture ("id": "string","createdAt": "2025-01-01T00:00:00.000Z") is worse than nothing and must always be replaced with realistic CardNexus-flavoured data.
The description in .route(...) is user-facing documentation. Write it like a Stripe API reference page: factual, no invented “Common uses” sections, no roadmap leaks, no internal architecture notes.
Error envelope
Every error response — typed contract error or uncaught exception — comes back in the same wire shape:
{
"code": "FORBIDDEN",
"status": 403,
"message": "Insufficient scope",
"data": { "required": ["inventory:write"], "granted": ["inventory:read"] }
}oRPC’s default envelope includes a "defined": true | false discriminator so SDKs can distinguish typed errors from uncaught exceptions. We strip it on the way out via customErrorResponseBodyEncoder in routes/orpc.ts and customErrorResponseBodySchema in routes/docs.ts so the developer-facing surface is one shape, no protocol-leak.
If a handler throws an uncaught Error (not an ORPCError), oRPC auto-converts it to INTERNAL_SERVER_ERROR before it hits our encoder — so the unified shape always holds. INTERNAL_SERVER_ERROR is in the common error map baked into publicOc, so it’s documented as a possible response on every endpoint.
Versioning
The public API is v1. Breaking changes are not permitted. Additive is fine: new fields on responses (clients ignore unknowns), new optional inputs, new endpoints, new optional scopes. Removing a field, renaming, tightening validation, or making an existing optional input required is breaking and requires a v2 conversation.
Why a dedicated apps/public-api (not a route prefix on apps/backend)
| Concern | Outcome |
|---|---|
| Independent autoscaling | Public-API traffic is bursty and low-baseline; internal-API traffic is steady and high-baseline. Scaling them together is wasteful in both directions. |
| Separate deploy cadence | Public API is a long-lived contract; internal API ships daily. Coupling their deploys creates drag on both. |
| Strict type isolation | A tsconfig reference graph that includes both @repo/api-dtos and @repo/public-api-dtos would make accidental cross-imports too easy. Two apps means two compile graphs. |
| Auth simplicity | The public API only knows API keys (and OAuth later). The internal API only knows Clerk session tokens. Separate apps means separate oc builders, separate context types, no if (authMethod === ...) branching anywhere. |