Appearance
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).
| Workspace | Package | What it is |
|---|---|---|
packages/shared | @redline/shared | Zod domain schemas + types + OAuth scope constants — the single source of truth (tools use Schema.shape, REST uses .parse()). |
apps/mcp-server | @redline/mcp-server | The deployable service: MCP tools, REST /v1, server-rendered SEO pages, media. |
apps/webapp | @redline/webapp | The React SPA (Vite). Talks only to the REST API. |
apps/docs | @redline/docs | This 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 +StreamableHTTPServerTransportwith an in-memory session map; a freshMcpServeris built per session via thecreateServerfactory. DNS-rebinding protection allowlistsMCP_PUBLIC_URL.
Auth
On HTTP the server is an OAuth 2.1 Resource Server (src/auth/):
bearer.tsvalidates Keycloak JWTs withjose(JWKS + issuer +audper RFC 8707). The audience is the stableOAUTH_AUDIENCE(defaulthttps://api.redline.app), identical in every environment and in the realm's audience mapper.- The SDK's
requireBearerAuth+mcpAuthMetadataRouterprovide 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
VehicleRepositoryinterface with two implementations:PostgresVehicleRepository(Kysely +pg) andInMemoryVehicleRepository(used whenDATABASE_URLis 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.tsand built.jsbehave 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.