Skip to content

Architecture

A deeper tour of how A-Market fits together. For onboarding see Getting started; for the capability map see Features → code.

                          stdio (local-trust, no auth)
  ┌────────────────┐  ────────────────────────────────►  ┌──────────────────────┐
  │ ChatGPT /      │                                      │  @redline/mcp-server  │
  │ Claude / MCP   │     Streamable HTTP + OAuth 2.1      │  Express + MCP SDK     │ ──► PostgreSQL
  │ Inspector / AI │  ◄────────────────────────────────► │  • MCP tools  (/mcp)   │     (Kysely + pg)
  └────────────────┘            validates JWT (JWKS)      │  • REST API   (/v1)    │
                                       ▲                  │  • SEO pages  (/, …)   │
  ┌────────────────┐    REST /v1       │                  │  • media      (/media) │
  │ React webapp   │ ──────────────────┘                  └───────────┬────────────┘
  │ (apps/webapp)  │    bearer token from Keycloak                    │ JWKS / issuer
  └────────────────┘                                                  ▼
  Browsers + AI crawlers ──► edge Caddy (TLS) ──► webapp + proxies   ┌──────────────────────┐
  (schema.org, sitemap, llms.txt)                /v1 + /media + SEO  │ Keycloak (OAuth 2.1   │
                                                                     │ Authorization Server) │
                                                                     └──────────────────────┘

Workspaces

npm-workspaces monorepo (Node ≥24.16, ESM throughout, NodeNext resolution — relative imports use explicit .js extensions).

WorkspacePackageWhat it is
packages/shared@redline/sharedZod domain schemas + types + OAuth scope constants — the single source of truth (tools use Schema.shape, REST uses .parse()).
apps/mcp-server@redline/mcp-serverThe deployable service: MCP tools, REST /v1, server-rendered SEO pages, media.
apps/webapp@redline/webappThe React SPA (Vite). Talks only to the REST API.
apps/docs@redline/docsThis documentation site (VitePress).

The mcp-server

One Express app exposes four surfaces (apps/mcp-server/src/transport/http.ts):

  • /mcp — the MCP endpoint (Streamable HTTP). On stdio there's no URL; the client spawns the process.
  • /v1 — the REST API, consumed by the webapp and any HTTP client.
  • /, /listings/:id, /dealers/:slug, /sitemap.xml, /robots.txt, /llms.txt — server-rendered, crawler-facing HTML with schema.org JSON-LD (seo/).
  • /media — stored images/videos + a browser upload page at /u/:ticket/media.
  • /healthz (liveness) and /readyz (DB-ping readiness).

Transports (src/transport/, chosen by MCP_TRANSPORT)

  • stdio.ts — a local-trust channel with no OAuth and no database (in-memory store). The client launches the server as a subprocess.
  • http.ts — Express + StreamableHTTPServerTransport with an in-memory session map; a fresh McpServer is built per session via the createServer factory. DNS-rebinding protection allowlists MCP_PUBLIC_URL.

Auth

On HTTP the server is an OAuth 2.1 Resource Server (src/auth/):

  • bearer.ts validates Keycloak JWTs with jose (JWKS + issuer + aud per RFC 8707). The audience is the stable OAUTH_AUDIENCE (default https://api.redline.app), identical in every environment and in the realm's audience mapper.
  • The SDK's requireBearerAuth + mcpAuthMetadataRouter provide the bearer middleware and /.well-known/oauth-protected-resource.
  • A PrincipalResolver (principal.ts) maps the token (sub → user/dealer id, scopes) — or, on stdio, a fixed dev principal (DEV_DEALER_ID).
  • Scopes (@redline/shared): listings:read, listings:write, listings:moderate. Capability is purely scope-based (no realm roles read in code); ownership is enforced per row.

Fail closed on a missing subject

A token without a sub claim is rejected (createOAuthPrincipalResolver) — falling back to anything shared (e.g. the client id) would collapse every user of a client into one principal and leak data across accounts.

Persistence (src/db/)

  • A VehicleRepository interface with two implementations: PostgresVehicleRepository (Kysely + pg) and InMemoryVehicleRepository (used when DATABASE_URL is unset and in tests). The same pattern backs sellers, accounts, reviews, audit, and organizations.
  • Migrations are an explicit ordered list in db/migrate.ts (no directory scan, so dev .ts and built .js behave identically) and run automatically on startup.
  • snake_case columns map to camelCase domain types in the repository layer.

Ownership & organizations

Listings carry dealer_id (the creator, for audit) and an optional organization_id. A write is authorized if the caller is the creator or a member of the listing's organization — a ListingWriteAuth = { userId, orgIds } resolved per request. Org membership is app-level (Postgres organization_members, not Keycloak), with owner/member roles.

Geocoding

On write, a listing's free-text location is forward-geocoded to lat/lng (GEOCODER_PROVIDER=geoadmin uses Swisstopo's free SearchServer; none disables it). This powers radius search and the map/distance UI. /v1/geo/suggest backs the address autocomplete.

The webapp

A static React SPA built with Vite. It calls only the REST /v1 API and, when VITE_KEYCLOAK_URL is set, authenticates against Keycloak (OIDC, listings:read + listings:write). With no Keycloak URL baked in it runs browse-only. The SPA is baked into a container image; the edge Caddy serves it and proxies /v1 + /media to the mcp-server.

Deployment

Staging and production run side-by-side on one VPS behind a single auto-TLS Caddy, deployed from GitHub Actions (build image → GHCR → SSH compose pull && up). See Deploy and Operations.

A-Market — AI-first marketplace for cars, motorcycles and scooters.