Skip to Content
BackendPublic API

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

ConceptWhere
Processapps/public-api (Express + oRPC, runs on its own port + container)
Contracts@repo/public-api-dtos (separate from @repo/api-dtos — strict separation)
Domain logicThe same @repo/<domain> services apps/backend already uses
AuthAPI keys issued from cardnexus.com (@repo/api-key)
Customer-facing docsdocs.cardnexus.com  — Scalar Docs (hosted), source in apps/public-api-docs
OpenAPI spec endpoint/v1/openapi.json (always public)
Local dev /docsScalar 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.

ScopeWhat it grants
*Full access (API keys only)
account:read / account:writeAccount profile read/write
inventory:read / inventory:writeStock state
listings:read / listings:writePublic marketplace listings
sales:read / sales:writeOutgoing orders
purchases:read / purchases:writeIncoming orders
disputes:read / disputes:writeDispute lifecycle
messaging:read / messaging:writeConversations
pricing:read / products:readCatalogue / pricing
financial:readStripe, payouts
marketplace:readCross-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:

  1. Large-account overrides — some launch customers will need higher limits than the default.
  2. 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:

  1. A global bucket at PUBLIC_API_RATE_LIMIT_PER_MINUTE / 60s (60/min by default).
  2. Whatever named buckets the contract declared in meta.rateLimit.buckets.
  3. 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 … windowSec

One 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}
  • userId partitions per CardNexus account.
  • clientId is "self" for first-party API keys today, and the third-party OAuth client_id once OAuth lands. “DeckBuilder.io made 10k requests on behalf of 500 users” is an answerable question from day one of OAuth, with no migration.
  • bucket is the named bucket from the policy resolver.
  • window is floor(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-Match after 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 getCurrent callback 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 a getCurrent method 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 inOwns
apps/public-api-docs/scalar.config.jsonNavigation 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:

  1. Runtime — the on-API /v1/openapi.json endpoint serves it lazily-cached.
  2. Build-timepnpm --filter @repo/public-api openapi:generate <path> writes the spec to a file. This is what CI pipes into scalar 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

WorkflowTriggerWhat it does
openapi-validate.ymlPR 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.ymlPush to develop with same path filterGenerate 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 the cardnexus namespace. 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:

  1. The local /docs route (Scalar OSS) imports it at runtime.
  2. Scalar Docs needs it as a static .css file referenced from siteConfig.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:7970

The 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/latest doesn’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 to develop (workflow fires automatically) or do a one-off manual scalar registry publish from your machine.
  • Scalar Docs doesn’t render in-flight spec changes. PR previews only show docs content changes (markdown). The reference page always pulls @latest from 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 (the subdomain field in scalar.config.json). When DNS lands for docs.cardnexus.com, also update the redirect target in apps/public-api/src/routes/docs.ts.

How to extend

For the full procedure, use the extend-public-api skill. The headlines:

  1. Handlers never touch models or the database directly. Always go through an @injectable() domain service — same as the internal API.
  2. 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 from publicOc — 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’s data — 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)

ConcernOutcome
Independent autoscalingPublic-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 cadencePublic API is a long-lived contract; internal API ships daily. Coupling their deploys creates drag on both.
Strict type isolationA 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 simplicityThe 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.

Code Locations

ComponentLocation
App entryapps/public-api/src/main.ts
Express server + middleware orderapps/public-api/src/server.ts
oRPC builder + contextapps/public-api/src/orpc/oc.ts, context.ts
Authenticated procedure stackapps/public-api/src/orpc/procedures.ts
Auth middlewareapps/public-api/src/orpc/middleware/auth.ts
Scopes middlewareapps/public-api/src/orpc/middleware/scopes.ts
Rate-limit middleware + policyapps/public-api/src/orpc/middleware/rate-limit.ts, rate-limit/policy.ts
Idempotency middlewareapps/public-api/src/orpc/middleware/idempotency.ts
Request ID + Datadogapps/public-api/src/middleware/request-id.ts
Redis client (fail-open)apps/public-api/src/lib/redis.ts
DI container bootstrapapps/public-api/src/lib/container.ts
OpenAPI generator (runtime + build-time)apps/public-api/src/routes/docs.ts (generateOpenApiSpec)
Build-time spec emitterapps/public-api/scripts/generate-openapi.ts
Brand theme (TS source)apps/public-api/src/lib/scalar-theme.ts
Brand theme exporterapps/public-api/scripts/export-theme.ts
Customer-facing docs siteapps/public-api-docs/
Scalar Docs configapps/public-api-docs/scalar.config.json
Validate workflow (PR).github/workflows/openapi-validate.yml
Publish workflow (merge → Registry).github/workflows/openapi-publish.yml
Contracts (DTOs)packages/core/public-api-dtos/src/
publicOc builder + common errorspackages/core/public-api-dtos/src/oc.ts, errors.ts
API key issuance + verifyTokenpackages/backend/domains/api-key/src/api-key.service.ts
Scope cataloguepackages/shared/primitives/src/api-key-scope.ts
Extend-public-api Claude skill.claude/skills/extend-public-api/SKILL.md
Last updated on