Cartograph is a commerce platform monorepo: domain modules (Medusa-style boundaries), a pluggable host (Vendure-style plugins/), a runnable core-api, SQLite + Drizzle, background worker (outbox, capture, search, logistics hooks), optional BFFs, and contract + Playwright tests.
It is a structured kernel for carts, catalog, orders, payments, tax, inventory, and integrations—not a turnkey storefront UI.
- Goals and non-goals
- Architecture
- Repository layout
- Runtimes
- Request and event flows
- Authentication, tenancy, and RBAC
- Database and migrations
- Run locally
- Smoke tests
- Testing and CI
- Environment variables
- HTTP surfaces (overview)
- How to extend
- Common pitfalls
- Further documentation
- Deep-dive questions
| Goals | Non-goals |
|---|---|
Clear domain vs infrastructure split (packages/modules → ports → Drizzle) |
Full admin UI or storefront (BFFs are thin shells you can grow) |
| Admin vs store HTTP surfaces, versioning, problem+json errors | Production-complete tax/shipping for every country out of the box |
| Outbox, idempotency on mutating shop routes, worker processors | Replacing your PSP policy—Stripe/Meili/HTTP carriers are adapters |
| OIDC/JWT, API keys, tenant header, RBAC hooks | A single “batteries included” SaaS product |
| Layer | Location | Responsibility |
|---|---|---|
| HTTP / composition | apps/core-api, apps/*-bff |
Routing, validation at the edge, auth middleware, wiring services to repositories, JSON / RFC 7807 errors. |
| Domain | packages/modules/* |
Business language: cart, catalog, order, payment, tax, inventory, fulfillment, … Ports (interfaces) only—no SQL. |
| Orchestration | packages/workflows/* |
Checkout, returns, post–order-placed coordination (saga-friendly). |
| Events | packages/events/* |
Outbox enqueue/relay, optional BullMQ (REDIS_URL). |
| Infrastructure | packages/persistence-drizzle, plugins/* |
SQLite schema, repository implementations, Stripe, Meilisearch, flat-rate shipping, etc. |
Invariant: domain services depend on repository ports, not on Drizzle or Express (see packages/modules/*/.*.repository.port.ts).
flowchart TB
subgraph clients [Clients]
Browser[Browser / POS]
Admin[Admin tools]
end
subgraph apps [Apps]
CoreAPI[core-api]
Worker[worker]
BFF[admin-bff / storefront-bff]
end
subgraph domain [Domain packages]
Mod[packages/modules]
WF[packages/workflows]
end
subgraph data [Data and integrations]
DB[(SQLite + Drizzle)]
Redis[(Redis / BullMQ)]
Stripe[Stripe]
Meili[Meilisearch]
end
Browser --> BFF
Admin --> BFF
BFF --> CoreAPI
Browser --> CoreAPI
CoreAPI --> Mod
CoreAPI --> WF
Mod --> DB
WF --> Mod
CoreAPI --> DB
Worker --> DB
Worker --> Redis
Worker --> Stripe
Worker --> Meili
Paths are from the repo root (the long-form spec in docs/SERIES-B-PLATFORM.md still uses a historical platform/ prefix; this tree is what the code uses today).
| Path | Purpose |
|---|---|
apps/core-api/ |
Express app: env, /admin/v1, /store/v1, plugins, webhooks, metrics/tracing/audit hooks. |
apps/worker/ |
Periodic ticks + optional BullMQ consumer: outbox dispatch, reservation TTL, payment capture, search index, logistics sync. |
apps/admin-bff/ · apps/storefront-bff/ |
Optional BFFs (proxy patterns; extend as needed). |
packages/domain-contracts/ |
Branded IDs, Money, DomainError, pagination—no I/O. |
packages/modules/ |
Domain modules + *.repository.port.ts + services. |
packages/persistence-drizzle/ |
Drizzle schema, migrations, create*Repository factories. |
packages/kernel/ |
Plugin types, bootstrap/DI helpers for CommercePlugin. |
packages/api-rest/ |
asyncHandler, problem JSON, route manifests. |
packages/authz/ |
Policies, authorize, OIDC/JWT verification (jose). |
packages/events/ |
Outbox publisher/relay, queue helpers. |
packages/workflows/ |
Checkout, return eligibility, order-placed hooks. |
packages/observability/ |
Metrics, tracing, audit log abstractions. |
plugins/ |
payment-stripe, shipping-flat-rate, search-meilisearch, core-defaults, … |
tests/contract/ |
Fast node:test contract tests. |
tests/e2e/ |
Playwright against scripts/e2e-server.mjs. |
scripts/ |
seed-mvp.ts, smoke-mvp.ts, smoke-with-server.mjs, prod-migrate.mjs, e2e-server.mjs. |
docs/ |
Platform spec, ADRs, runbooks. |
infra/k8s/ |
Placeholder for future K8s/Helm notes. |
core-api (apps/core-api/src/main.ts)
parseEnv—apps/core-api/src/config/env.schema.ts(Zod).- Logger, metrics, tracing, audit log — wired into
AppContext. - SQLite —
openDrizzleSqlite; optional migrations on start (MIGRATIONS_ON_START). - OIDC verifier — if
OIDC_ISSUER,OIDC_AUDIENCE,OIDC_JWKS_URLare all set. createApp—apps/core-api/src/app.ts: rate limit, JSON + BigInt-safejson replacer, request context, shop/admin routers, idempotency middleware on selectedPOSTs.- Plugins —
apps/core-api/src/plugins.manifest.ts. - HTTP server — listen + graceful shutdown.
worker (apps/worker/src/main.ts)
- Interval tick (default
WORKER_TICK_MS): outbox batch relay, inventory reservation TTL, optional async Stripe capture, search indexing tick. REDIS_URLset: BullMQ outbox worker runs handlers that fan out to logistics sync + Meilisearch indexing for each message.- Shared
DATABASE_PATHwithcore-apiin local dev so outbox rows are visible to both processes.
sequenceDiagram
participant C as Client
participant API as core-api
participant S as Domain service
participant R as Drizzle repository
participant DB as SQLite
C->>API: POST /store/v1/checkout
API->>API: Idempotency, shop key, OIDC, tenant
API->>S: runCheckoutWorkflow / order service
S->>R: ports
R->>DB: read/write
API->>C: 200 JSON or problem+json
- Domain path enqueues rows into the outbox (same DB transaction as business writes when implemented that way).
relayOutboxBatch(packages/events/src/outbox.relay.ts): either marks published locally or pushes jobs to Redis whenREDIS_URLis configured.- Worker consumes jobs and runs logistics / search processors; periodic tick still drains relay for environments without Redis.
Versioned paths use apps/core-api/src/http/versioning.ts (/admin/v1, /store/v1).
| Mechanism | Usage |
|---|---|
X-Admin-Key / Authorization: Bearer |
Matches ADMIN_API_KEY; sets actor to admin for protected admin routes. |
X-Shop-Key / Bearer |
When SHOP_API_KEY is set, shop mutations require the shared secret. |
| OIDC JWT | Optional: if verifier is configured, shop writes require a valid token and tenant; integrates with packages/authz. |
X-Tenant-Id |
Resolved per request (DEFAULT_TENANT_ID fallback); required for many admin flows and OIDC shop writes. |
authorize(actorKind, action) |
packages/authz/src/authorize.ts + policies.ts. |
| Command | Purpose |
|---|---|
npm run db:push |
Push schema to local SQLite (dev). |
npm run db:generate |
Generate SQL migrations from schema drift. |
npm run db:migrate |
Production-oriented migrate script — scripts/prod-migrate.mjs. |
npm run db:studio |
Drizzle Studio. |
npm run db:seed |
Idempotent MVP catalog/stock/tax — scripts/seed-mvp.ts. |
Default DB file: packages/persistence-drizzle/data.sqlite (see root .gitignore for local DB patterns).
Drizzle + better-sqlite3: mutations inside db.transaction use synchronous callbacks; avoid async transaction bodies (see repositories).
Prerequisites: Node 18+ (global fetch for smoke), npm ci.
npm ci
npm run db:push
npm run db:seed
npm run dev:api- Readiness:
GET http://127.0.0.1:3000/ready→200, body{ "ok": true, ... }. - Optional second terminal:
npm run dev:worker(sameDATABASE_PATH; setREDIS_URLfor queue-backed outbox).
Typecheck (whole monorepo):
npm run typecheck| Script | When to use |
|---|---|
npm run smoke |
API already running; hits /ready, /store/v1/health, catalog. |
npm run smoke:deep |
Same, plus strict seed check and checkout flow (needs seeded DB; align SMOKE_SHOP_KEY / tenant with server). |
npm run smoke:local |
One shot: isolated DB under tests/smoke/, push + seed + temporary API + smoke (see scripts/smoke-mvp.ts header for env vars). |
| Command | What it runs |
|---|---|
npm run test:contract |
node:test contracts (authz, storefront, orders, queue integration when REDIS_URL set, …). |
npm run e2e |
Playwright; playwright.config.ts starts scripts/e2e-server.mjs. |
.github/workflows/ci.yml: Redis service, npm ci, typecheck, test:contract, Playwright install + e2e.
core-api (apps/core-api/src/config/env.schema.ts)
| Variable | Role |
|---|---|
NODE_ENV |
development | staging | production — migrations default, DATABASE_PATH required in prod. |
PORT |
HTTP port (default 3000). |
DATABASE_PATH |
SQLite path. |
MIGRATIONS_ON_START |
Run SQL migrations during startup (1/true or 0/false; default on in development only). |
ADMIN_API_KEY |
Protects admin routes such as GET /admin/v1/status. |
SHOP_API_KEY |
When set, shop mutations require matching header/Bearer. |
DEFAULT_TENANT_ID |
Fallback when X-Tenant-Id is absent. |
OIDC_ISSUER / OIDC_AUDIENCE / OIDC_JWKS_URL |
Optional JWT verification; all three required to enable verifier. |
REDIS_URL |
BullMQ outbox relay from API. |
MEILI_URL / MEILI_KEY |
Meilisearch (plugins + worker indexing). |
SHIPPING_API_KEY |
HTTP carrier / logistics integration. |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET |
Payments + webhook (raw body for signature). |
CORS_ORIGIN |
Single allowed origin for CORS middleware. |
PROMOTION_DISCOUNT_BPS |
Shop-wide discount in basis points. |
RATE_LIMIT_PER_MINUTE |
Per-IP limit (0 disables). |
PLUGIN_CORE_DEFAULTS_DISABLED |
Skip demo plugin. |
FEATURE_CHECKOUT_V2 / FEATURE_ASYNC_CAPTURE |
Feature flags consumed via app context. |
worker (apps/worker/src/env.ts)
| Variable | Role |
|---|---|
DATABASE_PATH |
Same SQLite as API in dev. |
WORKER_TICK_MS |
Poll interval (default 2000). |
WORKER_OUTBOX_BATCH |
Batch size for outbox relay tick. |
FEATURE_ASYNC_CAPTURE |
When truthy, payment capture tick may call Stripe. |
REDIS_URL |
BullMQ consumer for outbox jobs. |
STRIPE_SECRET_KEY |
Capture tick. |
SHIPPING_API_KEY |
Logistics processor input. |
MEILI_URL / MEILI_KEY |
Search indexing. |
SHIPPING_WEBHOOK_URL |
Optional HTTP target for logistics sync (apps/worker/src/processors/logistics-sync.ts). |
Global
GET /ready— DB ping (used by probes).
Store (/store/v1/...)
GET /health,GET /ready- Catalog, carts, lines, checkout (
POST /checkoutwith reservation payload), orders, payments, tax, inventory, … - Selected
POSTs useIdempotency-Keyand persisted replay (apps/core-api/src/http/idempotency-post.ts).
Admin (/admin/v1/...)
GET /health,GET /readyGET /statuswhenADMIN_API_KEYis configured- User management, cost intake, return eligibility, … (many routes require tenant + RBAC)
Webhooks
POST /webhooks/stripewhen webhook secret is set (must receive raw body for signature verification).
Exact route list: apps/core-api/src/app.ts and plugin registerRoutes.
- Domain — types +
*Service+*.repository.port.tsinpackages/modules/<module>/. - Persistence — schema + repository in
packages/persistence-drizzle/. - HTTP — thin handlers in
app.tsor a newCommercePlugin. - Tests — contract under
tests/contract/, HTTP flows undertests/e2e/.
Source file headers often cite requirements (e.g. R-NF-*)—treat them as local contracts.
better-sqlite3is native — ensure install scripts run in CI/containers.- Express async — use
asyncHandlerso rejections reach the error middleware. - BigInt in JSON — Express
json replacercoercesbigintto string; custom stores (idempotency) must do the same. - Spec path
platform/—docs/SERIES-B-PLATFORM.mdmay sayplatform/; this repo uses rootapps/andpackages/. - OIDC enabled — shop writes need a real JWT and tenant; local smoke without IdP should leave OIDC env unset or use
smoke:localwith default API env.
| Document | Contents |
|---|---|
docs/SERIES-B-PLATFORM.md |
Long-form platform spec, NFRs, module checklist (historical platform/ tree names—map mentally to this repo). |
docs/adr/ |
Architecture Decision Records (plugins vs modules, outbox/idempotency, …). |
docs/runbooks/ |
Operational notes (payments, inventory). |
Private monorepo ("private": true in package.json). Add a LICENSE if you open-source.