8.2 KiB
Travel Planner
Self-hosted, AI-assisted travel planner. Angular PWA frontend, NestJS API + background worker, PostgreSQL, Redis/BullMQ, and a Mistral-backed travel agent behind a strict tool boundary.
.env.examplelists configuration names and non-secret defaults only. Real production secrets (database password, SMTP credentials, LLM API keys, etc.) are never committed and live only on the deployment host / secret store, supplied to containers as environment variables.
Repository layout
frontend/ Angular PWA (pnpm workspace member)
backend/ NestJS workspace: apps/api, apps/worker, libs/*
docker/ Production Dockerfiles and edge (Nginx) config
scripts/ Compose-invariant checks and TeamCity entry points
docs/ Specs, plans, and architecture documentation
Developer setup
pnpm install
pnpm dev:infra
pnpm dev:infra starts local PostgreSQL/Redis (see compose.dev.yml); pnpm dev:infra:down stops them.
Create a .env file at the repository root (already gitignored, never committed) with the required values. backend's start:api/start:worker/migrate scripts load it automatically via Node's --env-file-if-exists — no manual export needed. OIDC_CLIENT_SECRET is a real secret — put it only in this local, gitignored file, never in .env.example, never pasted into a shared/logged terminal. OIDC_AUDIENCE should be set to the same value as OIDC_CLIENT_ID unless your IdP issues a distinct API audience.
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
REDIS_URL=redis://localhost:6379
OIDC_ISSUER=https://auth.forgecore.work
OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_AUDIENCE=client_a297fd8d9c1f47a79d3600ea0c96984
OIDC_CLIENT_SECRET=<your-client-secret>
Run pending migrations against the dev database (first time only), then start the API and frontend:
pnpm --filter backend build:api
pnpm --filter backend migrate
pnpm --filter backend start:api
pnpm --filter frontend start
Open http://localhost:4200. The Angular dev server proxies /api/* and /health/* to the API on localhost:3000 (see frontend/proxy.conf.json), so no CORS configuration is needed locally. Make sure the IdP client client_a297fd8d9c1f47a79d3600ea0c96984 allows the redirect URI http://localhost:4200/auth/callback.
This IdP client is confidential (it has a client secret), so the Authorization Code + PKCE token exchange happens server-side via POST /api/v1/auth/session (see "OIDC client type" below) — the secret never reaches the browser.
Quality gates
pnpm lint
pnpm test
pnpm test:compose
pnpm build
TeamCity deployment scripts
TeamCity invokes repository-owned scripts rather than duplicating deployment logic in step configuration:
scripts/teamcity/validate.sh # install, lint, test, compose-invariant check, build
scripts/teamcity/build-images.sh # build + push immutable IMAGE_TAG images
scripts/teamcity/deploy.sh # pull, migrate, compose up -d, smoke test
scripts/teamcity/smoke.sh # health/root checks against APP_BASE_URL
scripts/teamcity/rollback.sh # redeploy the previous IMAGE_TAG
deploy.sh never runs docker compose down as part of a routine deployment; rollback.sh never attempts an automatic database downgrade.
Production topology
Production Docker Compose (compose.yml) publishes exactly one host port, on the edge (Nginx) service, which serves the built Angular app and reverse-proxies /api/* and /health/* to the internal api service. api, worker, postgres, and redis are reachable only over the internal Docker network. See docs/architecture/deployment.md for the full contract and scripts/teamcity/ for the TeamCity-invoked build/deploy/rollback scripts.
Phase 01 status: foundation complete
GET /health/live— process liveness only, no dependency checks.GET /health/ready— validates PostgreSQL and Redis connectivity.GET /api/v1/version— safe build metadata only (appVersion,teamCityBuildNumber,sourceRevision); never database/Redis URLs.- The compiled no-op migration entry point (
backend/dist/apps/api/src/migration.js) givesdeploy.sha stable container command contract; Phase 02 replaces its body with the real versioned migration runner. - Verified end-to-end:
docker compose -f compose.yml upwith a real TLS certificate serves/health/live,/health/ready,/, and/api/v1/versionthrough the single published edge port, withpostgres/redis/api/workerunreachable from the host.
Phase 02 status: OIDC auth, users, and trip core complete
-
Authentication: OIDC Authorization Code + PKCE against an external IdP. No local password storage; users are keyed by the OIDC
subclaim and just-in-time provisioned on first login. -
OIDC client type: this deployment's IdP client is confidential (has a client secret), not a plain public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the frontend performs only the browser-side Authorization Code + PKCE redirect (hand-rolled PKCE in
frontend/src/app/auth/pkce.ts, nooidc-client-tsdependency); the resultingcode+ PKCEcode_verifierare then POSTed to the backend'sPOST /api/v1/auth/session(unauthenticated by design — there is no token yet), which performs the actual code-for-tokens exchange usingOIDC_CLIENT_SECRETserver-side (TokenExchangeService) and returns only{ accessToken, expiresIn }to the frontend —refresh_token/id_tokenare never forwarded. If a future deployment instead uses a public PKCE-only client, this proxy step could be skipped in favor of a direct frontend-to-IdP exchange, but the current IdP requires it. -
New required backend env vars:
OIDC_ISSUER,OIDC_AUDIENCE,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET(validated fail-fast likeDATABASE_URL/REDIS_URL;OIDC_CLIENT_SECRETis a real secret, never committed). New frontend build-time values:OIDC_ISSUER,OIDC_CLIENT_ID(non-secret; baked into the production bundle bydocker/edge.Dockerfile, never read from the container at runtime). -
Real, versioned database migrations (
node-pg-migrate, files underbackend/migrations/) replace the Phase 01 no-opmigration.tsbody; the container command contract (node backend/dist/apps/api/src/migration.js) is unchanged. Database access goes throughkysely(a type-safe query builder, not an ORM) over the existingpg.Pool; there is no schema auto-sync anywhere. -
New routes:
GET/PUT /api/v1/users/me(/preferences),GET/POST /api/v1/trips,GET/PATCH/DELETE /api/v1/trips/:tripId,GET/PUT /api/v1/trips/:tripId/settings,GET/PATCH/DELETE /api/v1/trips/:tripId/members(/:memberId),POST/GET/DELETE /api/v1/trips/:tripId/invitations(/:invitationId),POST /api/v1/invitations/:token/accept,GET/POST/PATCH/DELETE /api/v1/trips/:tripId/travelers(/:travelerId),GET/PUT/DELETE /api/v1/trips/:tripId/preference-overrides(/:overrideId). -
Authorization is enforced backend-side by
OidcAuthGuard(who) andTripMembershipGuard(trip access +@TripRoles('OWNER')), never only in the frontend.TripMemberandTravelerare independent tables — aTravelernever implies or requires trip membership. -
Trip.versionoptimistic locking: a stalePATCH(mismatchedversion) returns HTTP 409 and never silently overwrites; proven by both a mocked unit test and a real-database integration test. -
Run backend integration tests (migration idempotency + optimistic-locking conflict) against
compose.dev.yml:pnpm dev:infra DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \ REDIS_URL=redis://localhost:6379 \ OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner \ OIDC_AUDIENCE=travel-planner-api \ pnpm --filter backend test:integration -
Verified end-to-end against the real running API and a mocked IdP (local JWKS + discovery document): missing token → 401, valid token →
/users/mesucceeds and JIT-provisions the user, trip create/read, firstPATCHwith the correct version succeeds, a secondPATCHreusing the stale version → 409, and a user with notrip_membersrow for the trip → 403.