Architecture
Lucille Order Center is a three-tier internal web application with a fourth long-running deployable: a background worker that owns every outbound supplier side effect. Two short-lived workloads — the Sysco inbound-poll CronJob and the Prisma migration Job — run from the same image as the API.
Component diagram
┌──────────────────────────────┐
│ Browser (desktop, internal) │
└──────────────┬───────────────┘
│ HTTPS, cert-manager certs (withSSL: true)
┌─────────────────┴─────────────────┐
▼ ▼
┌───────────────────────┐ CORS REST │ ┌────────────────────────┐
│ lucille-frontend │ + cookie │ │ lucille-api │
│ React 18 + Vite 5 │─────────────┼─►│ Fastify v4 + TS │
│ nginx static, 2 pods │ orders.<d> │ │ 2 pods, port 3000 │
└───────────────────────┘ │ └───┬────────┬───────────┘
api.<d> │ │ BullMQ enqueue
Prisma │ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ PostgreSQL │ │ Redis (HA + │
│ schema app │ │ Sentinel 3+3) │
└──────┬───────┘ └─────── ─┬─────────┘
│ │ consume
│ ┌────────▼──────────────┐
└──────────┤ lucille-worker │
│ BullMQ, 2 pods │
│ same image as API │
└───┬───────┬───────┬───┘
│ │ │
Sysco SFTP ◄───X12 850─────┘ │ └───► SMTP relay
(+ inbound 855/997 poll) │ (Costco email)
│
DO Spaces ◄──PDF upload────┘──► SMTP relay
lucille-po-pdfs(-dev) (Central Kitchen PDF)
┌──────────────────────┐ ┌────────────────────────┐ ┌─────────────────────┐
│ Zitadel (OIDC) │ │ lucille-sysco-poll │ │ lucille-migrate │
│ roles: chef, admin │ │ CronJob */15 * * * * │ │ Job, migrate deploy│
└──────────────────────┘ └────────────────────────┘ └─────────────────────┘
Three Docker images cover six workloads. lucille/api (built from apps/api/Dockerfile) runs the API,
the worker, the CronJob and the migration Job; lucille/frontend and lucille/docs are nginx images
carrying the built SPA and this documentation site. The API and the SPA are on separate subdomains —
api.<domain>, orders.<domain> and docs.<domain> — because one hostname can only route to one
backend Service (decisions D5 and D10).
The tiers
Frontend SPA — apps/frontend
React 18 and TypeScript, built with Vite 5, served as static files by nginx. shadcn/ui-style components over Radix primitives and Tailwind CSS. TanStack Query owns server state (catalog, orders, audit events); Zustand owns the ephemeral cart. No Redux, no SSR — there is nothing to server-render for an authenticated internal tool, and Vite's dev loop is materially faster to work in.
The access token lives in memory only, never in localStorage: the API hands it over in the URL
fragment of its redirect to {frontendOrigin}/auth/callback, which is never sent to a server and
never appears in an access log (decision D12). The refresh token lives in an httpOnly, Secure,
SameSite=Strict cookie the API sets and reads.
In development the SPA calls /api/* and Vite proxies it to http://localhost:3000, so the browser
stays on one origin. In the cluster there is no proxy: VITE_API_BASE_URL is baked into the bundle as
https://api.<domain>, so calls are genuinely cross-origin and rely on the API's CORS configuration
(FRONTEND_ORIGIN, credentials: true).
API server — apps/api
Fastify v4 and TypeScript, one deployable container, two replicas, stateless. It owns all business logic: catalog reads and writes, order placement, supplier resolution, the audit trail, admin operations, and the OIDC code exchange.
Request bodies, query strings and route parameters are validated with the Zod schemas from
packages/types through apps/api/src/http/validate.ts — not with Fastify's AJV/JSON Schema
integration as the original plan sketched. Decision D9 records the reason: the SPA already codes against
those Zod schemas, so validating with them means one definition instead of a JSON Schema on the server
and a Zod schema on the client that can drift apart.
The API performs no outbound supplier I/O. It writes rows and enqueues jobs. That separation is what makes order placement fast and predictable regardless of how slow Sysco's SFTP server is on a given afternoon.
Background worker — apps/worker
BullMQ consumers in a separate Kubernetes Deployment, two replicas, built from the same image as the
API. It owns every side effect that leaves the cluster: generating and dropping X12 850 EDI files,
sending order emails, rendering PDFs with puppeteer-core against system Chromium, uploading to object
storage, and performing back-order reroutes.
The image's default command is not the API server but a mode dispatcher,
node apps/api/dist/entrypoint.js, which selects a process from the environment: WORKER_MODE=true
starts the worker, CRON_MODE=true runs one Sysco poll, MIGRATE_MODE=true runs
prisma migrate deploy, and the default is the Fastify server. This exists because the Chrono
regular-deployment and regular-job templates at v0.1.0 render no container command; without the
dispatcher the worker Deployment would start a second copy of the API. An explicit
node apps/worker/dist/worker.js still works wherever a template does render command — the CronJob
does.
The worker exposes port 3001 with three routes (/healthz, /readyz, /metrics) and no ingress. The
image runs as the non-root pptruser (UID 1001) because it launches Chromium; the matching pod-level
securityContext is declared in infra/*/lucille-worker.yaml but is not rendered by
regular-deployment v0.1.0, so today the image's USER is what actually applies.
Shared types — packages/types
Zod schemas plus the TypeScript types inferred from them, consumed by the API, the worker and the
frontend. Enumerations that exist as CHECK constraints in PostgreSQL — order status, line-item status,
supplier type, audit event type, X12 acknowledgement codes — are declared once here and re-used
everywhere, which is why a status string cannot be misspelled in one tier only. Queue names and the
deterministic deduplication ids (dispatch:<orderId>:<supplierType>,
reroute:<lineItemId>:<supplierId>) live here too, so producer and consumer cannot drift.