Skip to main content

Local development

Every backing service the project needs runs locally from a single docker-compose.yml at the repository root. Nothing in this stack requires cloud credentials.

Compose is the supported path. If the machine you are on has no Docker daemon — an unprivileged container, a locked-down CI runner — scripts/native-services.sh installs and runs PostgreSQL, Redis, MinIO and Mailpit as ordinary background processes on the same ports instead. See running without Docker below.

Prerequisites

ToolVersionNotes
Node.js>= 20Root package.json declares engines.node >= 20; Docker images use node:20-slim
pnpm10.33.4Pinned via packageManager; enable with corepack enable
Docker with Compose v2currentdocker compose, not docker-compose. Optional if you use the native fallback
sudo + Debian/UbuntuOnly for the native fallback: it runs apt-get and su postgres, and downloads linux-amd64 binaries

First run

git clone <repo> && cd <repo> # every path below is relative to this repository root
corepack enable
pnpm install

cp .env.example .env # then edit as needed — the defaults work as-is

docker compose up -d # start every backing service
pnpm db:migrate # apps/api: prisma migrate dev — creates the `app` schema
pnpm db:seed # 3 suppliers, 5 restaurants, 120 catalog items

pnpm db:migrate, db:deploy, db:seed and db:generate at the root are thin pass-throughs to pnpm --filter @lucille/api <same script>; there is no root db:reset (see migrations and seeds).

Then start the applications you are working on, either all at once through Turborepo or individually:

pnpm dev # turbo run dev — every app in parallel

pnpm --filter @lucille/api dev # tsx watch src/server.ts → :3000
pnpm --filter @lucille/worker dev # tsx watch src/worker.ts → :3001 health
pnpm --filter @lucille/frontend dev # vite → :5173
pnpm --filter @lucille/docs dev # docusaurus start → :3002

The compose file also contains api, worker, frontend and docs services built from the repository's Dockerfiles. Use those to verify a production-shaped build; use the dev scripts above for day-to-day work, because they have hot reload and the containers do not.

Nothing in this repository auto-loads .env

There is no dotenv dependency and no --env-file flag anywhere in the workspace, and no application module reads a .env file. The root .env is used by docker compose for variable interpolation (ZITADEL_CLIENT_ID, ZITADEL_PROJECT_ID) and by the Prisma CLI, which loads a .env relative to its own working directory. A pnpm dev process therefore sees only what your shell exports, and otherwise falls back to the defaults baked into apps/api/src/config/index.ts. Those defaults already describe the compose stack on localhost, which is why the API boots with no configuration at all — but a value you edited in .env will not take effect until you export it:

set -a && . ./.env && set +a && pnpm dev

Service and port map

ServiceImagePorts (host → container)CredentialsPurpose
postgrespostgres:165432:5432lucille / lucille, database lucille_order_centerPrimary datastore. Also hosts Zitadel's own database
redisredis:7.2-alpine6379:6379noneBullMQ backend. Started with --appendonly no --save '' — a local queue does not need persistence
miniominio/minio9000:9000, 9001:9001minioadmin / minioadminS3-compatible stand-in for DigitalOcean Spaces. Console on 9001
mailpitaxllent/mailpit1025:1025, 8025:8025any (auth accept-any)SMTP capture. Web UI on 8025
sftpatmoz/sftp2222:22lucille / lucilleSysco SFTP simulator with outbound, inbound and archive directories
zitadelghcr.io/zitadel/zitadel:latest8080:8080admin@lucille.localhost / Password1!OIDC provider, start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
apibuilt from apps/api/Dockerfile3000:3000Production-shaped API container
workerbuilt from apps/api/Dockerfile3001:3001Same image, node apps/worker/dist/worker.js
frontendbuilt from apps/frontend/Dockerfile5173:80nginx serving the built SPA
docsbuilt from apps/docs/Dockerfile3002:80nginx serving this site

Named volumes postgres-data, minio-data and sftp-data persist across restarts. To start completely clean:

docker compose down -v && docker compose up -d

Postgres, Redis and MinIO each define a healthcheck (pg_isready, redis-cli ping, mc ready local). The zitadel service waits on postgres: service_healthy; api and worker wait on postgres: service_healthy and redis: service_healthy, and on minio, mailpit and sftp with service_started only — so a cold docker compose up orders itself correctly for the two services where ordering matters. Note that neither api nor worker declares a dependency on zitadel: the API boots happily without a reachable identity provider and fails at the first token verification instead.

The containerised api and worker are configured with ZITADEL_ISSUER: http://zitadel:8080, while a browser reaches the same instance at http://localhost:8080. That difference is deliberate but it is also a trap — see troubleshooting.

Environment

Copy .env.example to .env. Every variable in it is optional as far as the schema in apps/api/src/config/index.ts is concerned — that schema has a default for everything and no required key, deliberately, so a partially configured environment can still boot, migrate and answer /healthz; each integration validates its own credentials at the point of use. The variables that matter most:

DATABASE_URL=postgresql://lucille:lucille@localhost:5432/lucille_order_center?schema=app
REDIS_URL=redis://localhost:6379

ZITADEL_ISSUER=http://localhost:8080
ZITADEL_CLIENT_ID= # filled in after seeding Zitadel
ZITADEL_CLIENT_SECRET= # ditto — the app uses client_secret_basic
ZITADEL_PROJECT_ID= # filled in after seeding Zitadel
ZITADEL_REDIRECT_URI=http://localhost:3000/api/auth/callback
ZITADEL_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/login

S3_ENDPOINT=http://localhost:9000
S3_BUCKET=lucille-po-pdfs
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true # required for MinIO; false against Spaces

SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM_ADDRESS=orders@lucille.localhost
ADMIN_ALERT_EMAIL=ops@lucille.localhost

SYSCO_SFTP_HOST=localhost
SYSCO_SFTP_PORT=2222
SYSCO_SFTP_USERNAME=lucille
SYSCO_SFTP_PASSWORD=lucille # password auth is for the simulator only

SENTRY_DSN= # empty disables Sentry entirely
OTEL_ENABLED=false

Note the ?schema=app on DATABASE_URL: the application owns the app schema, mirroring the Chrono postgres-db template's provisioning. Three variables deserve a comment:

  • SENTRY_DSN — leave it empty. initSentry() returns early when the DSN is falsy, so nothing is reported and no network calls are made. There is no local Sentry container; use a personal free-tier DSN if you need to exercise the integration.
  • SYSCO_SFTP_PASSWORD — password authentication exists only for the atmoz/sftp simulator. Production uses key authentication via SYSCO_SFTP_KEY_B64, a base64-encoded PEM private key.
  • AUTH_JWKS_URL — commented out in .env.example and meant to stay that way. It overrides the JWKS URL that is otherwise derived as ${ZITADEL_ISSUER}/oauth/v2/keys, and exists so the API test harness can serve its own key set and exercise the production createRemoteJWKSet path verbatim.

The config layer also aliases the names the cluster injects onto the names the code reads — DB_LUCILLE_API_URIDATABASE_URL, REDIS_URIREDIS_URL, SPACES_*S3_* — because the Chrono service templates cannot rename their output keys. An explicitly set canonical name always wins, so this never overrides a local .env.

Database: migrations and seeds

pnpm db:migrate # prisma migrate dev — create and apply a new migration
pnpm db:deploy # prisma migrate deploy — apply committed migrations (what CI and the cluster run)
pnpm db:generate # prisma generate — regenerate the client after a schema edit
pnpm db:seed # tsx prisma/seed.ts

apps/api additionally defines db:reset (prisma migrate reset --force), reachable as pnpm --filter @lucille/api db:reset. Be aware that Prisma 6.19 guards migrate reset against non-interactive/agent invocation (decision D13 in IMPLEMENTATION.md), and the project does not bypass that guard: if it refuses, drop and recreate the database yourself and run pnpm db:deploy instead. The test harnesses truncate rather than reset for the same reason.

The seed is upsert-based and derives every id from a UUIDv5 namespace, so it is safe to re-run. It produces three suppliers — one per dispatch channel (sysco, costco, centralKitchen) — five restaurant locations (Downtown, Riverside, Midtown, Harborview, Uptown), four application users (one admin, two single-location chefs, one chef assigned to two locations, which is what exercises multi-location scoping) and exactly 120 catalog items. Every catalog item gets a catalog_supplier_mappings row; 50 of the 120 also carry an alternate supplier, which is what makes the back-order reroute path exercisable. There is no deliberately unmapped item in the seed data — to exercise the "item has no mapping" rejection path, create an item through the admin API and leave it unmapped, or soft-delete its mapping.

Compose runs any .sql in scripts/postgres-init/ on first initialisation of the data volume. 01-create-databases.sql creates two extra databases alongside the application's own lucille_order_center (which the image creates from POSTGRES_DB): zitadel, for the identity provider, and lucille_test, which is the database the API and worker test suites point at. Because the script only runs on an empty volume, changing it — or picking up a change to it — requires docker compose down -v.

That also means the test suites need this stack running. Both apps/api/vitest.config.ts and apps/worker/vitest.config.ts describe integration suites against the real PostgreSQL and Redis; Prisma is never mocked. Both run pool: 'forks' with singleFork: true, and the API suite additionally sets fileParallelism: false and takes a PostgreSQL session-level advisory lock in apps/api/tests/global-setup.ts, so two concurrent runs queue instead of truncating the shared lucille_test database out from under each other. See testing.

Zitadel: seeding with scripts/seed-zitadel.ts

Zitadel starts with start-from-init and bootstraps the lucille-org organisation with the admin user from the compose environment (admin@lucille.localhost / Password1!). Everything the application itself needs — the project, its roles, the OIDC client and the test users — is created by scripts/seed-zitadel.ts.

Getting a token first

The script authenticates with a Personal Access Token for a machine user holding IAM_OWNER, read from ZITADEL_ADMIN_PAT. It is the only truly required variable: with it unset the script prints the five steps below and exits with status 64 before touching anything.

  1. Open http://localhost:8080 and log in as the first-instance admin.
  2. Users → Service Users → New, name seeder, Access Token Type Bearer.
  3. Grant it IAM_OWNER under Organisation → Managers.
  4. On that service user, Personal Access Tokens → New, and copy the token.
  5. export ZITADEL_ADMIN_PAT=<token> and run the script.
note

The comment block at the top of the script mentions ZITADEL_ADMIN_USERNAME/ZITADEL_ADMIN_PASSWORD as an alternative that mints a token interactively. That path is not implemented — the code reads only ZITADEL_ADMIN_PAT. Treat the PAT as mandatory.

Running it

# From the repository root. The `--filter` sets the cwd to apps/api, hence the ../../ prefix.
ZITADEL_ADMIN_PAT=<token> pnpm --filter @lucille/api exec tsx ../../scripts/seed-zitadel.ts

Everything else has a default, and every override is read straight from the environment:

VariableDefaultEffect
ZITADEL_ADMIN_PAT— (required)Bearer token for every Management API call; trailing-slash-safe
ZITADEL_ISSUERhttp://localhost:8080Base URL of the instance; trailing slashes are stripped
ZITADEL_ORG_NAMElucille-orgOrganisation to create or reuse
ZITADEL_PROJECT_NAMElucille-order-centerProject to create or reuse
ZITADEL_APP_NAMElucille-web-appOIDC application to create or reuse
API_ORIGINhttp://localhost:3000Redirect URI becomes ${API_ORIGIN}/api/auth/callback; also sets devMode
FRONTEND_ORIGINhttp://localhost:5173Post-logout redirect becomes ${FRONTEND_ORIGIN}/login
SEED_USER_PASSWORDPassword1!Password for the four seeded humans — local only

It first polls ${ZITADEL_ISSUER}/debug/healthz every two seconds for up to 120 seconds, so running it immediately after docker compose up -d is fine. Then, in order, it:

  • creates the organisation, and looks up its id;
  • creates the project with projectRoleAssertion: true, projectRoleCheck: false, hasProjectCheck: false, then PUTs the project again unconditionally so role assertion is on even if the project already existed with it off;
  • creates the chef ("Chef") and admin ("Administrator") project roles, both in group lucille;
  • creates the OIDC application: code flow plus refresh token, OIDC_APP_TYPE_WEB, OIDC_AUTH_METHOD_TYPE_BASIC, JWT access tokens, accessTokenRoleAssertion, idTokenRoleAssertion and idTokenUserinfoAssertion all true, and devMode on unless API_ORIGIN is https://;
  • imports four human users with verified emails and no forced password change — admin@lucille.example.com, chef.downtown@…, chef.riverside@…, chef.multi@…, which are exactly the addresses in apps/api/prisma/seed-constants.ts — and grants each its project role.

Finally it prints a paste-ready block of ZITADEL_ISSUER, ZITADEL_PROJECT_ID, ZITADEL_CLIENT_ID and ZITADEL_CLIENT_SECRET, followed by the Zitadel subject ids of the four users. Copy those into .env and restart the API. Note that client credentials are only printed the first time: Zitadel does not re-issue them, so on a re-run the script tells you to read them from ${ZITADEL_ISSUER}/ui/console/projects/{projectId}/apps/{appId} instead.

What it is idempotent about

Every create is wrapped so that a 409 or an AlreadyExists body logs exists <thing> and continues, which makes the whole script safe to re-run: org, project, both roles, the application, the four users and their role grants. The project's projectRoleAssertion is re-asserted on every run. Two things are not re-run: the client secret (never re-issued), and the application's own settings — because when the app already exists the create is skipped and no update call is made.

One manual step

Open the application in the console and enable Token Settings → "User Roles Inside Access Token":

http://localhost:8080/ui/console/projects/{projectId}/apps/{appId}

The script prints that exact URL at the end. This application-level toggle is not exposed on the OIDC application API surface the script uses, and is not settable through the Zitadel custom resource either. The script does send accessTokenRoleAssertion: true when it creates the application, and on a recent Zitadel that is accepted — but it is a create-only field here, so on any re-run against an existing application nothing sets it, and on an instance that ignores the field it was never set at all. Either way the console click is the reliable answer.

Without it — and without the project-level "Assert Roles on Authentication", which the script does set — Zitadel omits the urn:zitadel:iam:org:project:roles claim entirely, with no error. Verification then fails in apps/api/src/auth/jwt.ts with 401 Access token carries no Lucille Order Center role. See authentication and troubleshooting.

One last wrinkle the script itself flags: pnpm db:seed gives the application's users rows fixed placeholder UUIDs, while the API mirrors the real Zitadel subject on first login. To keep the seeded restaurant assignments, either re-point those rows at the subject ids the script printed, or assign chefs with POST /api/admin/restaurants/:id/chefs after each has logged in once.

Mailpit — outbound email

Web UI at http://localhost:8025. Every message the worker sends to Costco or Central Kitchen lands here, including the PDF attachment and the admin failure-alert emails. Mailpit accepts any credentials and any sender, so the local relay never rejects a send.

Useful while testing: place an order containing items mapped to both email suppliers and confirm two separate messages arrive, each listing only its own supplier's lines.

MinIO — object storage

Console at http://localhost:9001, credentials minioadmin / minioadmin. The API calls ensureBucketExists() during boot and creates lucille-po-pdfs if it is missing — but only when NODE_ENV !== 'production', so in the cluster the bucket is expected to be provisioned by infrastructure. Central Kitchen PDFs land at central-kitchen/{orderId}/{timestamp}.pdf.

Remember S3_FORCE_PATH_STYLE=true for MinIO and false for Spaces — MinIO does not do virtual-hosted addressing on localhost.

The SFTP simulator

atmoz/sftp is started with lucille:lucille:::outbound,inbound,archive, which creates the user and the three directories the Sysco integration expects.

# What has the worker dropped?
docker compose exec sftp ls -la /home/lucille/outbound

# Read a generated EDI file. The name is PO_{restaurantCode}_{orderNumber}_{yyyyMMddHHmmss}.edi
docker compose exec sftp cat /home/lucille/outbound/PO_DOWNTOWN_LOC-20260813-0007_20260813143200.edi

# Feed an acknowledgment back in to exercise the inbound path
docker compose cp apps/worker/tests/fixtures/855-backorder.edi sftp:/home/lucille/inbound/

# Confirm the poll archived what it processed
docker compose exec sftp ls -la /home/lucille/archive

Dropping a fixture into /inbound/ is how the whole inbound flow is tested end to end: the poll picks it up, the parser interprets BAK and the ACK loops, line statuses change, a reroute is enqueued, and the file moves to /archive/. 997 fixtures work the same way and are how the EDI_REJECTED path is exercised.

Running without Docker: scripts/native-services.sh

Some environments have no Docker daemon at all — an unprivileged container, a locked-down CI runner, the build pod this project was developed in (decision D1 in IMPLEMENTATION.md). For those, scripts/native-services.sh installs the same programs from Debian packages and vendor release binaries and runs them as ordinary background processes on the ports compose publishes. Everything then lives on localhost rather than on compose service names, which is what the config defaults already assume.

sudo ./scripts/native-services.sh # no argument == start
sudo ./scripts/native-services.sh install # install packages/binaries only, start nothing
sudo ./scripts/native-services.sh start # install (idempotently), then start, then bootstrap, then status
sudo ./scripts/native-services.sh stop # stop all four
./scripts/native-services.sh status # one line per service: up / down

Those four subcommands are all it accepts. Anything else prints usage: … [install|start|stop|status] to stderr and exits 64. There are no flags; the three knobs are environment variables:

VariableDefaultMeaning
PG_VERSION15Debian PostgreSQL major version — the package and the cluster
MINIO_DATA/var/lib/minio-dataMinIO's data directory, created if missing
LOG_DIR/var/logWhere minio.log and mailpit.log are written

What install does

apt-get update then apt-get install -y --no-install-recommends postgresql-${PG_VERSION} postgresql-common redis-server curl ca-certificates, with DEBIAN_FRONTEND=noninteractive. MinIO and Mailpit are not packaged, so if the binary is not already on PATH it is downloaded:

  • MinIO → https://dl.min.io/server/minio/release/linux-amd64/minio into /usr/local/bin/minio
  • Mailpit → the mailpit-linux-amd64.tar.gz of the latest GitHub release, extracted to /usr/local/bin/mailpit

Both URLs are hard-coded to linux-amd64. The script needs root (it runs apt-get, writes to /usr/local/bin and uses su postgres) and outbound network access. set -euo pipefail is in force, so a failed download or a failed apt-get aborts the whole run before anything starts.

What start does

There is no systemd in these environments, so each daemon is started with its own tooling rather than systemctl:

ServiceHow it is startedPort(s)
PostgreSQLpg_ctlcluster ${PG_VERSION} main start — the ordinary Debian cluster, data under /var/lib/postgresql/15/main5432
Redisredis-server --daemonize yes --save '' --appendonly no — no config file, so stock defaults otherwise6379
MinIOnohup minio server ${MINIO_DATA} --address :9000 --console-address :9001, root user/password minioadmin9000, 9001
Mailpitnohup mailpit --smtp 0.0.0.0:1025 --listen 0.0.0.0:8025 --smtp-auth-accept-any --smtp-auth-allow-insecure1025, 8025

MinIO and Mailpit are only launched if pgrep -x does not already find them, so start is safe to repeat. Their stdout and stderr go to ${LOG_DIR}/minio.log and ${LOG_DIR}/mailpit.log — those two files are the only place a startup failure is recorded. The script writes no PID files of its own: it tracks MinIO and Mailpit purely by process name with pgrep/pkill, and leaves Postgres and Redis to manage their own.

Rather than the sleep-free healthcheck graph compose has, start waits a flat 4 seconds and then bootstraps the databases directly — scripts/postgres-init/*.sql is a compose entrypoint mount and is not executed on this path. The equivalent work is done inline and conditionally:

  • a lucille role with password lucille and SUPERUSER, if pg_roles does not already have it;
  • the databases lucille_order_center, lucille_test and zitadel, each OWNER lucille, if pg_database does not already have them.

The app schema is not created here — prisma migrate deploy/dev creates it from the ?schema=app on DATABASE_URL. Finally start calls status, which probes pg_isready -h 127.0.0.1, redis-cli ping, http://127.0.0.1:9000/minio/health/live and http://127.0.0.1:8025/api/v1/messages.

stop is the mirror image: pg_ctlcluster … stop, redis-cli shutdown nosave, pkill -x minio, pkill -x mailpit. Every one of those is || true, so stop always exits 0 even if nothing was running.

How it differs from the compose path

Aspectdocker-compose.ymlnative-services.sh
PostgreSQLpostgres:16postgresql-15 — what Debian bookworm ships
Redisredis:7.2-alpineDebian redis-server 7.x, started without a config file
MinIO / Mailpitminio/minio, axllent/mailpit imagesLatest upstream release binaries in /usr/local/bin
ZitadelIncluded, on 8080Not covered — needs a full instance bootstrap
SFTP simulatoratmoz/sftp on 2222Not covered — worker tests use an in-process ssh2 server
Extra databasesscripts/postgres-init/01-create-databases.sql on an empty volumeCreated inline by bootstrap_databases, on every start
lucille roleCreated by the image from POSTGRES_USERCreated as a superuser
PersistenceNamed volumes; down -v wipesSystem paths; nothing is wiped unless you delete them
Startup orderingHealthchecks plus depends_onA flat sleep 4
Restart on failurerestart: unless-stoppedNone — a crashed process stays dead until you re-run start
HostnamesService names inside the compose networkEverything on localhost

Because Zitadel is absent, scripts/seed-zitadel.ts cannot be run on this path, and neither can a real browser login. Token verification is instead exercised by the API test harness, which serves a local JWKS via AUTH_JWKS_URL and signs RS256 tokens with genuine Zitadel claim shapes (decision D2) — the production createRemoteJWKSet code path runs unmodified.

Repository-wide scripts

pnpm build # turbo run build
pnpm lint # turbo run lint
pnpm typecheck # turbo run typecheck
pnpm test # turbo run test
pnpm test:e2e # pnpm --filter @lucille/frontend test:e2e → playwright test
pnpm format # prettier --write "**/*.{ts,tsx,js,jsx,json,md,yml,yaml}"
pnpm format:check # prettier --check, same glob
pnpm clean # turbo run clean && rm -rf node_modules

build, lint, typecheck and test all declare dependsOn: ["^build"] in turbo.json, so packages/types is always built before anything that consumes it; build caches dist/**, build/** and .docusaurus/**, test caches coverage/**, and dev is cache: false, persistent: true.

Unverified: the E2E suite

pnpm test:e2e delegates to the frontend's playwright test, but no playwright.config.* and no *.spec.ts files are committed anywhere in the repository, and IMPLEMENTATION.md still lists "P15 Playwright E2E at 3 viewports" as outstanding. Expect the command to fail until that lands. The three-viewport arrangement is the recorded intent (decision D4), not something the tree currently contains.

Documentation site specifically

pnpm --filter @lucille/docs dev # docusaurus start --port 3002
pnpm --filter @lucille/docs build # production build; fails on any broken internal link
pnpm --filter @lucille/docs typecheck # tsc --noEmit
pnpm --filter @lucille/docs lint # eslint src docusaurus.config.ts sidebars.ts
pnpm --filter @lucille/docs test # node ./scripts/check-docs.mjs
pnpm --filter @lucille/docs serve # docusaurus serve --port 3002

The build is the real link checker: onBrokenLinks and onBrokenMarkdownLinks are both set to throw, so a typo in a relative link fails the build rather than shipping a dead page. test runs the zero-dependency check-docs.mjs, which asserts that every page carries id, title, sidebar_label and description front-matter, that the front-matter id matches the filename, that every doc id referenced in sidebars.ts exists, that no page is an orphan, and that no page falls below 300 prose words (fenced code, inline code, HTML comments and table pipes are excluded from that count).

Known local differences from production

AspectLocalCluster
RedisSingle node, no persistenceHA with Sentinel, 3 + 3 replicas
Object storageMinIO, path-style addressingDO Spaces, virtual-hosted addressing, full-access key
SFTP authPasswordSSH key from Vault (SYSCO_SFTP_KEY_B64)
SMTPMailpit, accepts anythingAuthenticated relay over TLS
SentryDisabled (empty DSN)Enabled from a Vault-supplied DSN
TracingOTEL_ENABLED=falseExported to the cluster OTLP collector
TLSNoneTerminated at the Kong ingress with cert-manager

Verification

This page was verified against the source tree rather than against the plan. The commands, ports, credentials, environment-variable names and defaults above were read from docker-compose.yml, scripts/native-services.sh, scripts/postgres-init/01-create-databases.sql, scripts/seed-zitadel.ts, the root package.json, turbo.json, .env.example, apps/api/src/config/index.ts, apps/api/src/auth/jwt.ts, apps/api/prisma/seed.ts, apps/api/prisma/seed-constants.ts, apps/api/vitest.config.ts, apps/api/tests/global-setup.ts, apps/worker/vitest.config.ts, each app's package.json, and IMPLEMENTATION.md. Anything that could not be confirmed there is flagged inline as unverified, with the reason.

Where to go next

  • Testing — the suites and how to run them.
  • Troubleshooting — when something in this stack misbehaves.
  • Deployment — how the same images reach the cluster.