From 0ad3f0a0eb4119fd82d73fc7faa6c408ef283d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 20:18:39 +0000 Subject: [PATCH 1/2] feat(citrineos-railway): production-grade Railway template for CitrineOS EV charging stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys 9 services simultaneously with private networking and pre-wired environment variables: Infrastructure (private): - TimescaleDB (PostgreSQL 15 + TimescaleDB) for meter time-series data - RabbitMQ 3.12 for OCPP event streaming - Redis 7 for caching Application tier (private): - CitrineOS Core — OCPP 2.0.1 CPMS (HTTP :8080, WebSocket :8081) - Hasura GraphQL Engine v2.40 connected to TimescaleDB - Directus 11 CMS for CitrineOS data management - EVerest simulator for integration testing Custom services (private): - Extensions Service — TypeScript/Node.js 20 app that consumes OCPP events from RabbitMQ and exposes a REST API for custom business logic (billing, loyalty, alerts). Handlers registered in src/handlers/index.ts. Gateway (public — sole entry point): - Nginx 1.25 reverse proxy with envsubst-based runtime config - Routes: /api/graphql, /cms, /ocpp, /ocpp-ws, /api/extensions Includes docker-compose.yml for local dev, .env.example, railway.json template descriptor, railway.toml per service, and EXTENSIONS.md guide. https://claude.ai/code/session_01FqEkDaad3jTipkfUGBCjpE --- citrineos-railway/.env.example | 32 +++ citrineos-railway/EXTENSIONS.md | 272 ++++++++++++++++++ citrineos-railway/README.md | 216 ++++++++++++++ citrineos-railway/docker-compose.yml | 243 ++++++++++++++++ .../extensions-service/.eslintrc.json | 17 ++ .../extensions-service/Dockerfile | 32 +++ .../extensions-service/package.json | 31 ++ .../extensions-service/railway.toml | 9 + .../extensions-service/src/api/health.ts | 17 ++ .../extensions-service/src/api/router.ts | 24 ++ .../extensions-service/src/config.ts | 29 ++ .../src/handlers/authHandler.ts | 34 +++ .../extensions-service/src/handlers/index.ts | 45 +++ .../src/handlers/statusHandler.ts | 36 +++ .../src/handlers/transactionHandler.ts | 65 +++++ .../extensions-service/src/index.ts | 33 +++ .../extensions-service/src/logger.ts | 12 + .../src/rabbitmq/connection.ts | 67 +++++ .../src/rabbitmq/consumer.ts | 76 +++++ .../extensions-service/src/server.ts | 13 + .../extensions-service/tsconfig.json | 25 ++ citrineos-railway/gateway/Dockerfile | 21 ++ citrineos-railway/gateway/nginx.conf | 124 ++++++++ citrineos-railway/gateway/railway.toml | 9 + citrineos-railway/railway.json | 203 +++++++++++++ 25 files changed, 1685 insertions(+) create mode 100644 citrineos-railway/.env.example create mode 100644 citrineos-railway/EXTENSIONS.md create mode 100644 citrineos-railway/README.md create mode 100644 citrineos-railway/docker-compose.yml create mode 100644 citrineos-railway/extensions-service/.eslintrc.json create mode 100644 citrineos-railway/extensions-service/Dockerfile create mode 100644 citrineos-railway/extensions-service/package.json create mode 100644 citrineos-railway/extensions-service/railway.toml create mode 100644 citrineos-railway/extensions-service/src/api/health.ts create mode 100644 citrineos-railway/extensions-service/src/api/router.ts create mode 100644 citrineos-railway/extensions-service/src/config.ts create mode 100644 citrineos-railway/extensions-service/src/handlers/authHandler.ts create mode 100644 citrineos-railway/extensions-service/src/handlers/index.ts create mode 100644 citrineos-railway/extensions-service/src/handlers/statusHandler.ts create mode 100644 citrineos-railway/extensions-service/src/handlers/transactionHandler.ts create mode 100644 citrineos-railway/extensions-service/src/index.ts create mode 100644 citrineos-railway/extensions-service/src/logger.ts create mode 100644 citrineos-railway/extensions-service/src/rabbitmq/connection.ts create mode 100644 citrineos-railway/extensions-service/src/rabbitmq/consumer.ts create mode 100644 citrineos-railway/extensions-service/src/server.ts create mode 100644 citrineos-railway/extensions-service/tsconfig.json create mode 100644 citrineos-railway/gateway/Dockerfile create mode 100644 citrineos-railway/gateway/nginx.conf create mode 100644 citrineos-railway/gateway/railway.toml create mode 100644 citrineos-railway/railway.json diff --git a/citrineos-railway/.env.example b/citrineos-railway/.env.example new file mode 100644 index 0000000..c4f1053 --- /dev/null +++ b/citrineos-railway/.env.example @@ -0,0 +1,32 @@ +# CitrineOS Railway Template — Environment Variables +# Copy this file to .env and fill in all values marked with +# Generate secrets: openssl rand -hex 32 + +# ── PostgreSQL / TimescaleDB ──────────────────────────────────────────────── +POSTGRES_DB=citrineos +POSTGRES_USER=citrineos +POSTGRES_PASSWORD= + +# ── RabbitMQ ──────────────────────────────────────────────────────────────── +RABBITMQ_USER=citrineos +RABBITMQ_PASS= +RABBITMQ_VHOST=citrineos + +# ── Redis ─────────────────────────────────────────────────────────────────── +REDIS_PASSWORD= + +# ── CitrineOS Core ────────────────────────────────────────────────────────── +# BOTH = accept Basic auth + certificate auth from charge points +CITRINEOS_AUTH_SECURITY_MODE=BOTH + +# ── Hasura GraphQL Engine ─────────────────────────────────────────────────── +HASURA_ADMIN_SECRET= +# JSON string: {"type":"HS256","key":"<32-char-secret>"} +HASURA_JWT_SECRET= + +# ── Directus CMS ──────────────────────────────────────────────────────────── +DIRECTUS_SECRET= +DIRECTUS_ADMIN_EMAIL=admin@citrineos.local +DIRECTUS_ADMIN_PASSWORD= +# Long-lived API token used by CitrineOS Core to bootstrap schema +DIRECTUS_ADMIN_TOKEN= diff --git a/citrineos-railway/EXTENSIONS.md b/citrineos-railway/EXTENSIONS.md new file mode 100644 index 0000000..3d52ae6 --- /dev/null +++ b/citrineos-railway/EXTENSIONS.md @@ -0,0 +1,272 @@ +# Adding New Logic to the Extensions Service + +The extensions service lets you add custom business logic to CitrineOS without touching the core source code. It works in two ways: + +1. **Event-driven** — subscribe to OCPP events published by CitrineOS Core on RabbitMQ. +2. **API-driven** — call CitrineOS Core's REST API directly from your handler. + +--- + +## How It Works + +``` +CitrineOS Core + │ + │ publishes OCPP events to RabbitMQ + │ exchange: citrineos (topic) + │ routing key: ocpp. + ▼ + RabbitMQ + │ + │ queue: extensions.main + │ routing key: ocpp.# (all actions) + ▼ +Extensions Service + │ + ├── dispatch() looks up registered handler + │ + └── handler(event) ← your business logic runs here +``` + +The `event` object passed to every handler: + +```typescript +interface OcppEvent { + action: string; // e.g. "TransactionEvent", "StatusNotification" + stationId: string; // charge point identifier + tenantId: string; // multi-tenant identifier + payload: unknown; // the raw OCPP action payload (cast to your type) + timestamp: string; // ISO 8601 +} +``` + +--- + +## Step-by-Step: Adding a New Handler + +### 1. Create the handler file + +Create `extensions-service/src/handlers/myHandler.ts`: + +```typescript +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; + +// Define the shape of the OCPP payload you expect +interface MeterValuesPayload { + evseId?: number; + meterValue?: Array<{ + sampledValue?: Array<{ value?: number; measurand?: string }>; + }>; +} + +export async function handleMeterValues(event: OcppEvent): Promise { + const payload = event.payload as Partial; + + // Your logic here — call external APIs, write to a database, send webhooks + logger.info('MeterValues received', { + stationId: event.stationId, + evseId: payload.evseId, + }); +} +``` + +Rules: +- The function **must** be `async` and return `Promise`. +- Throw an error if you want the message to be nacked (it will be discarded — dead-letter queue handles it). +- Do not throw for recoverable errors — log them and return normally. + +### 2. Register the handler + +Open `extensions-service/src/handlers/index.ts` and add your handler: + +```typescript +import { handleMeterValues } from './myHandler.js'; // add this line + +const HANDLERS: HandlerEntry[] = [ + { action: 'TransactionEvent', handler: handleTransactionEvent }, + { action: 'StatusNotification', handler: handleStatusNotification }, + { action: 'Authorize', handler: handleAuthorize }, + { action: 'MeterValues', handler: handleMeterValues }, // add this line +]; +``` + +Action names are case-sensitive and match the OCPP 2.0.1 specification exactly. + +### 3. (Optional) Add an HTTP endpoint + +If your handler needs to expose data (e.g., a webhook receiver or a query endpoint): + +Create `extensions-service/src/api/myRoutes.ts`: + +```typescript +import { Router, type Request, type Response } from 'express'; + +export function buildMyRouter(): Router { + const router = Router(); + + router.get('/status', (_req: Request, res: Response) => { + res.json({ status: 'ok' }); + }); + + return router; +} +``` + +Then register it in `extensions-service/src/api/router.ts`: + +```typescript +import { buildMyRouter } from './myRoutes.js'; + +export function buildApiRouter(): Router { + const router = Router(); + router.use('/health', buildHealthRouter()); + router.use('/my-feature', buildMyRouter()); // available at /api/extensions/my-feature + return router; +} +``` + +### 4. Deploy + +```bash +# Local: rebuild the extensions service container +docker compose up -d --build extensions + +# Railway: push your changes — Railway auto-deploys on push to the configured branch +git commit -am "feat(extensions): add MeterValues handler" +git push +``` + +--- + +## Calling CitrineOS Core's REST API + +Your handler can call CitrineOS Core's HTTP API directly. The base URL is injected via `CITRINEOS_CORE_URL`: + +```typescript +import { config } from '../config.js'; + +async function sendRemoteStart(stationId: string, evseId: number): Promise { + const res = await fetch(`${config.citrineosCore.baseUrl}/ocpp/RequestStartTransaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stationId, evseId, idToken: { idToken: 'AUTO', type: 'Central' } }), + }); + if (!res.ok) throw new Error(`CitrineOS Core returned ${res.status}`); +} +``` + +CitrineOS Core API reference: https://github.com/citrineos/citrineos-core/tree/main/00_Base/src/interfaces + +--- + +## Querying Data via Hasura GraphQL + +To read charging data from TimescaleDB via Hasura from inside the extensions service: + +```typescript +const HASURA_URL = process.env.HASURA_INTERNAL_URL ?? 'http://hasura:8080/v1/graphql'; +const HASURA_ADMIN_SECRET = process.env.HASURA_GRAPHQL_ADMIN_SECRET ?? ''; + +async function getRecentTransactions(stationId: string): Promise { + const res = await fetch(HASURA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-hasura-admin-secret': HASURA_ADMIN_SECRET, + }, + body: JSON.stringify({ + query: ` + query RecentTransactions($stationId: String!) { + transactions(where: { station_id: { _eq: $stationId } }, limit: 10, order_by: { started_at: desc }) { + id + station_id + started_at + energy_kwh + } + } + `, + variables: { stationId }, + }), + }); + const data = (await res.json()) as { data?: { transactions: unknown[] } }; + return data.data?.transactions ?? []; +} +``` + +Add `HASURA_INTERNAL_URL` and `HASURA_GRAPHQL_ADMIN_SECRET` to the extensions service environment variables in Railway (reference `${{hasura.HASURA_GRAPHQL_ADMIN_SECRET}}`). + +--- + +## OCPP 2.0.1 Action Reference + +Common actions you can handle: + +| Action | When it fires | +|---|---| +| `BootNotification` | Charge point connects / reboots | +| `Authorize` | Driver presents RFID/app token | +| `TransactionEvent` | Session started, meter updated, session ended | +| `StatusNotification` | Connector status change (Available, Charging, Faulted…) | +| `MeterValues` | Periodic meter readings | +| `Heartbeat` | Charge point keepalive | +| `NotifyReport` | Charge point configuration report | +| `LogStatusNotification` | Upload log status | +| `FirmwareStatusNotification` | Firmware update progress | + +Full spec: https://www.openchargealliance.org/protocols/ocpp-201/ + +--- + +## Example: Billing Integration + +```typescript +// src/handlers/billingHandler.ts +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; + +interface TransactionEventPayload { + eventType: 'Started' | 'Updated' | 'Ended'; + transactionInfo?: { transactionId?: string }; + meterValue?: Array<{ sampledValue?: Array<{ value?: number; measurand?: string }> }>; +} + +export async function handleBillingTransactionEvent(event: OcppEvent): Promise { + const payload = event.payload as Partial; + const transactionId = payload.transactionInfo?.transactionId; + + switch (payload.eventType) { + case 'Started': + await fetch('https://your-billing-api.example.com/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.BILLING_API_KEY ?? ''}` }, + body: JSON.stringify({ transactionId, stationId: event.stationId, startedAt: event.timestamp }), + }); + break; + + case 'Ended': + await fetch(`https://your-billing-api.example.com/sessions/${transactionId ?? ''}/finalize`, { + method: 'POST', + headers: { Authorization: `Bearer ${process.env.BILLING_API_KEY ?? ''}` }, + }); + break; + } + + logger.info('Billing event processed', { eventType: payload.eventType, transactionId }); +} +``` + +Register it in `handlers/index.ts` with action `'TransactionEvent'` — or add it alongside the existing `handleTransactionEvent` by calling both from a combined handler. + +--- + +## Environment Variables for Extensions + +Add custom variables to the extensions service in Railway and they'll be available as `process.env.MY_VAR`. Reference other service secrets using `${{service.VARIABLE}}` syntax in Railway's variable editor. + +| Variable | Purpose | +|---|---| +| `BILLING_API_KEY` | Your billing provider API key | +| `WEBHOOK_URL` | Outbound webhook endpoint | +| `HASURA_INTERNAL_URL` | `http://${{hasura.RAILWAY_PRIVATE_DOMAIN}}:8080/v1/graphql` | +| `HASURA_GRAPHQL_ADMIN_SECRET` | `${{hasura.HASURA_GRAPHQL_ADMIN_SECRET}}` | diff --git a/citrineos-railway/README.md b/citrineos-railway/README.md new file mode 100644 index 0000000..0c53ec1 --- /dev/null +++ b/citrineos-railway/README.md @@ -0,0 +1,216 @@ +# CitrineOS Railway Template + +Production-grade OCPP 2.0.1 charge point management system on Railway. + +**One-click deploy** → everything boots, wires together, and is ready to accept charging station connections. + +--- + +## Architecture + +``` +Internet + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ gateway (PUBLIC — only service with a Railway domain) │ +│ Nginx 1.25 │ +│ │ +│ /api/graphql /cms /ocpp /ocpp-ws /api/extensions │ +└───┬─────────┬──────┬─────────┬──────────────┬──────────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + hasura directus citrineos-core extensions + :8080 :8055 :8080 :8081 :3001 + │ │ │ │ │ + └────┬────┘ │ └── rabbitmq ───┘ + │ │ :5672 + timescaledb redis + :5432 :6379 + +All internal services communicate via Railway private networking. +No internal service is reachable from the public internet. +``` + +### Services + +| Service | Image | Role | Public? | +|---|---|---|---| +| `timescaledb` | `timescale/timescaledb:latest-pg15` | Primary database + time-series meter data | No | +| `rabbitmq` | `rabbitmq:3.12-management-alpine` | OCPP event message bus | No | +| `redis` | `redis:7-alpine` | Cache (CitrineOS Core + Directus) | No | +| `citrineos-core` | `ghcr.io/citrineos/citrineos-core:latest` | OCPP 2.0.1 charge point management server | No | +| `hasura` | `hasura/graphql-engine:v2.40.0` | GraphQL API over the database | No | +| `directus` | `directus/directus:11` | Headless CMS / admin UI for CitrineOS data | No | +| `everest` | `ghcr.io/everest/everest-demo:latest` | EVerest charging station simulator | No | +| `extensions` | Custom Dockerfile | Custom business logic (billing, loyalty, alerts) | No | +| `gateway` | Custom Nginx Dockerfile | Reverse proxy — single public entry point | **Yes** | + +--- + +## Deploy to Railway + +### Prerequisites + +- A Railway account (railway.app) +- Railway CLI: `npm install -g @railway/cli && railway login` + +### One-click deploy + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template) + +Or via CLI: + +```bash +git clone https://github.com/cloudygetty-ai/run-down.git +cd run-down/citrineos-railway +railway init +railway up +``` + +### Manual service setup (Railway dashboard) + +1. Create a new Railway project. +2. Add each service listed in `railway.json`: + - For image-based services (timescaledb, rabbitmq, redis, hasura, directus, everest): **Add Service → Docker Image**. + - For custom services (extensions, gateway): **Add Service → GitHub Repo**, set root directory to `citrineos-railway/extensions-service` or `citrineos-railway/gateway`. +3. Configure environment variables from the table below — Railway provides `${{service.VARIABLE}}` cross-service references automatically. +4. Deploy all services. + +--- + +## Environment Variables + +All secrets are auto-generated by Railway when using `railway.json`. For manual setup, generate them with `openssl rand -hex 32`. + +### TimescaleDB + +| Variable | Value | +|---|---| +| `POSTGRES_DB` | `citrineos` | +| `POSTGRES_USER` | `citrineos` | +| `POSTGRES_PASSWORD` | *(generate)* | + +### RabbitMQ + +| Variable | Value | +|---|---| +| `RABBITMQ_DEFAULT_USER` | `citrineos` | +| `RABBITMQ_DEFAULT_PASS` | *(generate)* | +| `RABBITMQ_DEFAULT_VHOST` | `citrineos` | + +### Redis + +| Variable | Value | +|---|---| +| `REDIS_PASSWORD` | *(generate)* | + +### CitrineOS Core + +| Variable | Source | +|---|---| +| `DATABASE_HOST` | `${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}` | +| `DATABASE_PASSWORD` | `${{timescaledb.POSTGRES_PASSWORD}}` | +| `AMQP_HOSTNAME` | `${{rabbitmq.RAILWAY_PRIVATE_DOMAIN}}` | +| `AMQP_PASSWORD` | `${{rabbitmq.RABBITMQ_DEFAULT_PASS}}` | +| `REDIS_HOST` | `${{redis.RAILWAY_PRIVATE_DOMAIN}}` | +| `REDIS_PASSWORD` | `${{redis.REDIS_PASSWORD}}` | +| `CITRINEOS_DIRECTUS_TOKEN` | `${{directus.ADMIN_TOKEN}}` | + +### Hasura + +| Variable | Value | +|---|---| +| `HASURA_GRAPHQL_DATABASE_URL` | `postgres://citrineos:${{timescaledb.POSTGRES_PASSWORD}}@${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}:5432/citrineos` | +| `HASURA_GRAPHQL_ADMIN_SECRET` | *(generate)* | +| `HASURA_GRAPHQL_JWT_SECRET` | *(generate)* | + +### Directus + +| Variable | Value | +|---|---| +| `SECRET` | *(generate)* | +| `ADMIN_TOKEN` | *(generate — used by CitrineOS Core)* | +| `ADMIN_PASSWORD` | *(generate)* | +| `DB_HOST` | `${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}` | +| `REDIS` | `redis://:${{redis.REDIS_PASSWORD}}@${{redis.RAILWAY_PRIVATE_DOMAIN}}:6379` | + +### Extensions Service + +| Variable | Value | +|---|---| +| `RABBITMQ_URL` | `amqp://citrineos:${{rabbitmq.RABBITMQ_DEFAULT_PASS}}@${{rabbitmq.RAILWAY_PRIVATE_DOMAIN}}:5672/citrineos` | +| `CITRINEOS_CORE_URL` | `http://${{citrineos-core.RAILWAY_PRIVATE_DOMAIN}}:8080` | + +### Gateway + +| Variable | Value | +|---|---| +| `CITRINEOS_CORE_HOST` | `${{citrineos-core.RAILWAY_PRIVATE_DOMAIN}}` | +| `HASURA_HOST` | `${{hasura.RAILWAY_PRIVATE_DOMAIN}}` | +| `DIRECTUS_HOST` | `${{directus.RAILWAY_PRIVATE_DOMAIN}}` | +| `EXTENSIONS_HOST` | `${{extensions.RAILWAY_PRIVATE_DOMAIN}}` | + +--- + +## Local Development + +```bash +cd citrineos-railway +cp .env.example .env +# Fill in secrets in .env +docker compose up -d + +# Verify all services are healthy +docker compose ps +``` + +### Local endpoints + +| Path | Service | +|---|---| +| `http://localhost/health` | Gateway health | +| `http://localhost/api/graphql` | Hasura GraphQL | +| `http://localhost/cms` | Directus CMS admin | +| `http://localhost/ocpp/` | CitrineOS Core HTTP API | +| `ws://localhost/ocpp-ws` | CitrineOS Core WebSocket (connect charge points here) | +| `http://localhost/api/extensions/health` | Extensions service health | + +--- + +## Connecting a Charge Point + +Point your OCPP 2.0.1 charge point's WebSocket URL to: + +``` +# Production (Railway) +wss://YOUR-GATEWAY-DOMAIN.railway.app/ocpp-ws/YOUR_STATION_ID + +# Local +ws://localhost/ocpp-ws/YOUR_STATION_ID +``` + +The Everest simulator (included) connects automatically on startup using `STATION_ID=EVB-P17390866`. + +--- + +## Adding Custom Extensions + +See [EXTENSIONS.md](./EXTENSIONS.md) for the full guide on adding new business logic without modifying CitrineOS Core. + +--- + +## Private Networking + +Railway's private networking ensures that `timescaledb`, `rabbitmq`, `redis`, `citrineos-core`, `hasura`, `directus`, and `extensions` are **unreachable from the internet**. Only the `gateway` service has a public domain. + +Internal services communicate using `${{service.RAILWAY_PRIVATE_DOMAIN}}` — Railway resolves these to private IP addresses within the project's network. + +--- + +## Updating CitrineOS Core + +CitrineOS Core uses `ghcr.io/citrineos/citrineos-core:latest`. To pin a specific version: + +1. In Railway dashboard → citrineos-core service → Settings → Source → change the image tag. +2. CitrineOS releases: https://github.com/citrineos/citrineos-core/releases diff --git a/citrineos-railway/docker-compose.yml b/citrineos-railway/docker-compose.yml new file mode 100644 index 0000000..53297fd --- /dev/null +++ b/citrineos-railway/docker-compose.yml @@ -0,0 +1,243 @@ +version: "3.9" + +# CitrineOS EV Charging Stack — Local Development +# +# Mirrors the Railway production topology exactly. +# Private networking = the `citrineos-net` bridge network below. +# Only `gateway` publishes ports to the host machine. +# +# Usage: +# cp .env.example .env # fill in secrets +# docker compose up -d + +networks: + citrineos-net: + driver: bridge + +volumes: + timescaledb-data: + rabbitmq-data: + redis-data: + directus-uploads: + +# --------------------------------------------------------------------------- +# INFRASTRUCTURE TIER (no public ports) +# --------------------------------------------------------------------------- + +services: + timescaledb: + image: timescale/timescaledb:latest-pg15 + restart: unless-stopped + networks: + - citrineos-net + environment: + POSTGRES_DB: ${POSTGRES_DB:-citrineos} + POSTGRES_USER: ${POSTGRES_USER:-citrineos} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - timescaledb-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + + rabbitmq: + image: rabbitmq:3.12-management-alpine + restart: unless-stopped + networks: + - citrineos-net + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-citrineos} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:?required} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-citrineos} + volumes: + - rabbitmq-data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 15s + timeout: 10s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - citrineos-net + command: redis-server --requirepass ${REDIS_PASSWORD:?required} + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +# --------------------------------------------------------------------------- +# APPLICATION TIER (no public ports) +# --------------------------------------------------------------------------- + + citrineos-core: + image: ghcr.io/citrineos/citrineos-core:latest + restart: unless-stopped + networks: + - citrineos-net + depends_on: + timescaledb: + condition: service_healthy + rabbitmq: + condition: service_healthy + redis: + condition: service_healthy + environment: + APP_NAME: citrineos + CITRINEOS_AUTH_SECURITY_MODE: ${CITRINEOS_AUTH_SECURITY_MODE:-BOTH} + DATABASE_HOST: timescaledb + DATABASE_PORT: 5432 + DATABASE_NAME: ${POSTGRES_DB:-citrineos} + DATABASE_USER: ${POSTGRES_USER:-citrineos} + DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?required} + AMQP_HOSTNAME: rabbitmq + AMQP_PORT: 5672 + AMQP_USER: ${RABBITMQ_USER:-citrineos} + AMQP_PASSWORD: ${RABBITMQ_PASS:?required} + AMQP_VHOST: ${RABBITMQ_VHOST:-citrineos} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:?required} + CITRINEOS_DIRECTUS_URL: http://directus:8055 + CITRINEOS_DIRECTUS_TOKEN: ${DIRECTUS_ADMIN_TOKEN:?required} + PORT: 8080 + WEBSOCKET_PORT: 8081 + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + + hasura: + image: hasura/graphql-engine:v2.40.0 + restart: unless-stopped + networks: + - citrineos-net + depends_on: + timescaledb: + condition: service_healthy + environment: + HASURA_GRAPHQL_DATABASE_URL: postgres://${POSTGRES_USER:-citrineos}:${POSTGRES_PASSWORD:?required}@timescaledb:5432/${POSTGRES_DB:-citrineos} + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:?required} + HASURA_GRAPHQL_ENABLE_CONSOLE: "false" + HASURA_GRAPHQL_DEV_MODE: "false" + HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup,http-log,webhook-log,websocket-log" + HASURA_GRAPHQL_JWT_SECRET: ${HASURA_JWT_SECRET:?required} + PORT: 8080 + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + + directus: + image: directus/directus:11 + restart: unless-stopped + networks: + - citrineos-net + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + environment: + SECRET: ${DIRECTUS_SECRET:?required} + ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@citrineos.local} + ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:?required} + ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN:?required} + DB_CLIENT: pg + DB_HOST: timescaledb + DB_PORT: 5432 + DB_DATABASE: ${POSTGRES_DB:-citrineos} + DB_USER: ${POSTGRES_USER:-citrineos} + DB_PASSWORD: ${POSTGRES_PASSWORD:?required} + CACHE_ENABLED: "true" + CACHE_STORE: redis + REDIS: redis://:${REDIS_PASSWORD:?required}@redis:6379 + PUBLIC_URL: http://localhost/cms + PORT: 8055 + volumes: + - directus-uploads:/directus/uploads + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8055/server/health || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + + everest: + image: ghcr.io/everest/everest-demo:latest + restart: unless-stopped + networks: + - citrineos-net + depends_on: + citrineos-core: + condition: service_healthy + environment: + CITRINEOS_CORE_WS_URL: ws://citrineos-core:8081 + STATION_ID: EVB-P17390866 + OCPP_VERSION: OCPP201 + + extensions: + build: + context: ./extensions-service + dockerfile: Dockerfile + restart: unless-stopped + networks: + - citrineos-net + depends_on: + rabbitmq: + condition: service_healthy + citrineos-core: + condition: service_healthy + environment: + PORT: 3001 + NODE_ENV: production + RABBITMQ_URL: amqp://${RABBITMQ_USER:-citrineos}:${RABBITMQ_PASS:?required}@rabbitmq:5672/${RABBITMQ_VHOST:-citrineos} + RABBITMQ_EXCHANGE: citrineos + RABBITMQ_QUEUE: extensions.main + RABBITMQ_ROUTING_KEYS: "ocpp.#" + RABBITMQ_PREFETCH: "10" + CITRINEOS_CORE_URL: http://citrineos-core:8080 + LOG_LEVEL: info + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + +# --------------------------------------------------------------------------- +# GATEWAY TIER (single public entry point — host port 80) +# --------------------------------------------------------------------------- + + gateway: + build: + context: ./gateway + dockerfile: Dockerfile + restart: unless-stopped + networks: + - citrineos-net + depends_on: + - citrineos-core + - hasura + - directus + - extensions + ports: + - "80:80" + environment: + CITRINEOS_CORE_HOST: citrineos-core + HASURA_HOST: hasura + DIRECTUS_HOST: directus + EXTENSIONS_HOST: extensions + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 diff --git a/citrineos-railway/extensions-service/.eslintrc.json b/citrineos-railway/extensions-service/.eslintrc.json new file mode 100644 index 0000000..2b02202 --- /dev/null +++ b/citrineos-railway/extensions-service/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "parserOptions": { + "project": "./tsconfig.json" + }, + "rules": { + "no-console": "error", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "warn" + } +} diff --git a/citrineos-railway/extensions-service/Dockerfile b/citrineos-railway/extensions-service/Dockerfile new file mode 100644 index 0000000..ad3f44b --- /dev/null +++ b/citrineos-railway/extensions-service/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# ── Production image ──────────────────────────────────────────────────────── +FROM node:20-alpine + +ENV NODE_ENV=production + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +COPY --from=builder /app/dist ./dist + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +EXPOSE 3001 + +HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:3001/health || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/citrineos-railway/extensions-service/package.json b/citrineos-railway/extensions-service/package.json new file mode 100644 index 0000000..4d00e77 --- /dev/null +++ b/citrineos-railway/extensions-service/package.json @@ -0,0 +1,31 @@ +{ + "name": "citrineos-extensions", + "version": "1.0.0", + "description": "CitrineOS Extensions Service — custom business logic for EV charging events", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "type-check": "tsc --noEmit", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "amqplib": "^0.10.4", + "express": "^4.18.2", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/amqplib": "^0.10.4", + "@types/express": "^4.17.21", + "@types/node": "^20.11.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.57.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/citrineos-railway/extensions-service/railway.toml b/citrineos-railway/extensions-service/railway.toml new file mode 100644 index 0000000..37cb4fa --- /dev/null +++ b/citrineos-railway/extensions-service/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 diff --git a/citrineos-railway/extensions-service/src/api/health.ts b/citrineos-railway/extensions-service/src/api/health.ts new file mode 100644 index 0000000..3bbbd5c --- /dev/null +++ b/citrineos-railway/extensions-service/src/api/health.ts @@ -0,0 +1,17 @@ +import { type Request, type Response, type Router, Router as ExpressRouter } from 'express'; + +const startTime = Date.now(); + +export function buildHealthRouter(): Router { + const router = ExpressRouter(); + + router.get('/', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + service: 'citrineos-extensions', + uptimeSeconds: Math.floor((Date.now() - startTime) / 1000), + }); + }); + + return router; +} diff --git a/citrineos-railway/extensions-service/src/api/router.ts b/citrineos-railway/extensions-service/src/api/router.ts new file mode 100644 index 0000000..4d37c47 --- /dev/null +++ b/citrineos-railway/extensions-service/src/api/router.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { buildHealthRouter } from './health.js'; + +// ── API surface exposed by the extensions service ────────────────────────── +// +// Add new route groups here as your extensions grow. +// Each route group should live in its own file under src/api/. +// +// Example structure: +// src/api/billing.ts → /api/billing/* +// src/api/loyalty.ts → /api/loyalty/* +// src/api/alerts.ts → /api/alerts/* +// +// ───────────────────────────────────────────────────────────────────────── + +export function buildApiRouter(): Router { + const router = Router(); + + router.use('/health', buildHealthRouter()); + + // TODO[NORMAL]: add /api/billing, /api/loyalty, /api/alerts routes here + + return router; +} diff --git a/citrineos-railway/extensions-service/src/config.ts b/citrineos-railway/extensions-service/src/config.ts new file mode 100644 index 0000000..61a22a7 --- /dev/null +++ b/citrineos-railway/extensions-service/src/config.ts @@ -0,0 +1,29 @@ +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) throw new Error(`Missing required environment variable: ${key}`); + return value; +} + +function optionalEnv(key: string, fallback: string): string { + return process.env[key] ?? fallback; +} + +export const config = { + port: parseInt(optionalEnv('PORT', '3001'), 10), + + rabbitmq: { + url: requireEnv('RABBITMQ_URL'), + exchange: optionalEnv('RABBITMQ_EXCHANGE', 'citrineos'), + exchangeType: 'topic' as const, + queue: optionalEnv('RABBITMQ_QUEUE', 'extensions.main'), + routingKeys: optionalEnv('RABBITMQ_ROUTING_KEYS', 'ocpp.#').split(','), + prefetch: parseInt(optionalEnv('RABBITMQ_PREFETCH', '10'), 10), + reconnectDelayMs: 5_000, + }, + + citrineosCore: { + baseUrl: optionalEnv('CITRINEOS_CORE_URL', 'http://citrineos-core:8080'), + }, + + logLevel: optionalEnv('LOG_LEVEL', 'info'), +} as const; diff --git a/citrineos-railway/extensions-service/src/handlers/authHandler.ts b/citrineos-railway/extensions-service/src/handlers/authHandler.ts new file mode 100644 index 0000000..d8f8ee7 --- /dev/null +++ b/citrineos-railway/extensions-service/src/handlers/authHandler.ts @@ -0,0 +1,34 @@ +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; + +interface AuthorizePayload { + idToken?: { + idToken?: string; + type?: string; + }; +} + +export async function handleAuthorize(event: OcppEvent): Promise { + const payload = event.payload as Partial; + const tokenId = payload.idToken?.idToken ?? 'unknown'; + const tokenType = payload.idToken?.type ?? 'unknown'; + + logger.info('Authorize request received', { + stationId: event.stationId, + tokenId, + tokenType, + }); + + // ── Extend this section with your business logic ───────────────────────── + // + // Examples: + // • Look up loyalty points balance for this token + // • Apply a discount rate for fleet accounts + // • Log access for audit trail / compliance + // + // Note: CitrineOS Core handles the actual OCPP Authorize response. + // This handler runs *after* the event is published — it is a side-effect + // hook, not an authorisation gate. + // + // ───────────────────────────────────────────────────────────────────────── +} diff --git a/citrineos-railway/extensions-service/src/handlers/index.ts b/citrineos-railway/extensions-service/src/handlers/index.ts new file mode 100644 index 0000000..f76e92c --- /dev/null +++ b/citrineos-railway/extensions-service/src/handlers/index.ts @@ -0,0 +1,45 @@ +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; +import { handleTransactionEvent } from './transactionHandler.js'; +import { handleStatusNotification } from './statusHandler.js'; +import { handleAuthorize } from './authHandler.js'; + +// Handler registry — maps OCPP action names to handler functions. +// +// To add a new handler: +// 1. Create src/handlers/myHandler.ts exporting an async function +// 2. Import it here +// 3. Add an entry: { action: 'OcppActionName', handler: handleMyAction } +// +// Action names match the OCPP 2.0.1 spec (PascalCase). + +type Handler = (event: OcppEvent) => Promise; + +interface HandlerEntry { + action: string; + handler: Handler; +} + +const HANDLERS: HandlerEntry[] = [ + { action: 'TransactionEvent', handler: handleTransactionEvent }, + { action: 'StatusNotification', handler: handleStatusNotification }, + { action: 'Authorize', handler: handleAuthorize }, +]; + +const handlerMap = new Map( + HANDLERS.map(({ action, handler }) => [action, handler]), +); + +export async function dispatch(event: OcppEvent): Promise { + const handler = handlerMap.get(event.action); + + if (!handler) { + logger.debug('No handler registered for action', { + action: event.action, + stationId: event.stationId, + }); + return; + } + + await handler(event); +} diff --git a/citrineos-railway/extensions-service/src/handlers/statusHandler.ts b/citrineos-railway/extensions-service/src/handlers/statusHandler.ts new file mode 100644 index 0000000..c8d708c --- /dev/null +++ b/citrineos-railway/extensions-service/src/handlers/statusHandler.ts @@ -0,0 +1,36 @@ +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; + +interface StatusNotificationPayload { + connectorId?: number; + connectorStatus?: string; + errorCode?: string; +} + +export async function handleStatusNotification(event: OcppEvent): Promise { + const payload = event.payload as Partial; + + logger.info('StatusNotification received', { + stationId: event.stationId, + connectorId: payload.connectorId, + status: payload.connectorStatus, + errorCode: payload.errorCode, + }); + + // ── Extend this section with your business logic ───────────────────────── + // + // Examples: + // • 'Faulted' → page on-call engineer, open a support ticket + // • 'Available' → update network availability map + // • 'Occupied' → push real-time occupancy to your fleet dashboard + // + // ───────────────────────────────────────────────────────────────────────── + + if (payload.connectorStatus === 'Faulted') { + logger.warn('Charger fault detected', { + stationId: event.stationId, + connectorId: payload.connectorId, + errorCode: payload.errorCode, + }); + } +} diff --git a/citrineos-railway/extensions-service/src/handlers/transactionHandler.ts b/citrineos-railway/extensions-service/src/handlers/transactionHandler.ts new file mode 100644 index 0000000..b66ac50 --- /dev/null +++ b/citrineos-railway/extensions-service/src/handlers/transactionHandler.ts @@ -0,0 +1,65 @@ +import { type OcppEvent } from '../rabbitmq/consumer.js'; +import { logger } from '../logger.js'; + +interface TransactionEventPayload { + eventType: 'Started' | 'Updated' | 'Ended'; + transactionInfo?: { + transactionId?: string; + chargingState?: string; + }; + meterValue?: Array<{ + sampledValue?: Array<{ value?: number; measurand?: string; unit?: string }>; + }>; +} + +export async function handleTransactionEvent(event: OcppEvent): Promise { + const payload = event.payload as Partial; + const eventType = payload.eventType ?? 'Unknown'; + const transactionId = payload.transactionInfo?.transactionId ?? 'unknown'; + + logger.info('TransactionEvent received', { + stationId: event.stationId, + transactionId, + eventType, + chargingState: payload.transactionInfo?.chargingState, + }); + + // ── Extend this section with your business logic ───────────────────────── + // + // Examples: + // • Started → create billing session in your system + // • Updated → record meter values to your time-series store + // • Ended → finalize invoice, send receipt email + // + // The event object contains stationId, tenantId, timestamp, and full payload. + // + // ───────────────────────────────────────────────────────────────────────── + + switch (eventType) { + case 'Started': + logger.info('Charging session started', { stationId: event.stationId, transactionId }); + break; + + case 'Updated': { + const kwh = extractKwh(payload); + if (kwh !== null) { + logger.info('Meter update', { stationId: event.stationId, transactionId, kWh: kwh }); + } + break; + } + + case 'Ended': + logger.info('Charging session ended', { stationId: event.stationId, transactionId }); + break; + + default: + logger.warn('Unknown TransactionEvent type', { eventType }); + } +} + +function extractKwh(payload: Partial): number | null { + const sampledValues = payload.meterValue?.[0]?.sampledValue ?? []; + const energyReading = sampledValues.find((sv) => sv.measurand === 'Energy.Active.Import.Register'); + if (energyReading?.value !== undefined) return energyReading.value; + return null; +} diff --git a/citrineos-railway/extensions-service/src/index.ts b/citrineos-railway/extensions-service/src/index.ts new file mode 100644 index 0000000..3064613 --- /dev/null +++ b/citrineos-railway/extensions-service/src/index.ts @@ -0,0 +1,33 @@ +import { config } from './config.js'; +import { logger } from './logger.js'; +import { buildApp } from './server.js'; +import { startConsumer } from './rabbitmq/consumer.js'; +import { closeConnection } from './rabbitmq/connection.js'; + +async function main(): Promise { + logger.info('Starting CitrineOS Extensions Service', { port: config.port }); + + const app = buildApp(); + + const server = app.listen(config.port, () => { + logger.info('HTTP server listening', { port: config.port }); + }); + + await startConsumer(); + + // Graceful shutdown on SIGTERM (Railway sends this before stopping the container) + const shutdown = async (signal: string): Promise => { + logger.info('Shutdown signal received', { signal }); + server.close(() => logger.info('HTTP server closed')); + await closeConnection(); + process.exit(0); + }; + + process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); + process.on('SIGINT', () => { void shutdown('SIGINT'); }); +} + +main().catch((err: unknown) => { + logger.error('Fatal startup error', { error: String(err) }); + process.exit(1); +}); diff --git a/citrineos-railway/extensions-service/src/logger.ts b/citrineos-railway/extensions-service/src/logger.ts new file mode 100644 index 0000000..dcc9040 --- /dev/null +++ b/citrineos-railway/extensions-service/src/logger.ts @@ -0,0 +1,12 @@ +import winston from 'winston'; +import { config } from './config.js'; + +export const logger = winston.createLogger({ + level: config.logLevel, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json(), + ), + transports: [new winston.transports.Console()], +}); diff --git a/citrineos-railway/extensions-service/src/rabbitmq/connection.ts b/citrineos-railway/extensions-service/src/rabbitmq/connection.ts new file mode 100644 index 0000000..dee0c5f --- /dev/null +++ b/citrineos-railway/extensions-service/src/rabbitmq/connection.ts @@ -0,0 +1,67 @@ +import amqplib, { type Channel, type Connection } from 'amqplib'; +import { config } from '../config.js'; +import { logger } from '../logger.js'; + +type ConnectionState = { + connection: Connection | null; + channel: Channel | null; +}; + +const state: ConnectionState = { connection: null, channel: null }; + +async function connect(): Promise { + logger.info('Connecting to RabbitMQ', { url: config.rabbitmq.url.replace(/:\/\/.*@/, '://***@') }); + + const connection = await amqplib.connect(config.rabbitmq.url); + const channel = await connection.createChannel(); + + await channel.assertExchange( + config.rabbitmq.exchange, + config.rabbitmq.exchangeType, + { durable: true }, + ); + + channel.prefetch(config.rabbitmq.prefetch); + + connection.on('error', (err: Error) => { + logger.error('RabbitMQ connection error — reconnecting', { error: err.message }); + scheduleReconnect(); + }); + + connection.on('close', () => { + logger.warn('RabbitMQ connection closed — reconnecting'); + scheduleReconnect(); + }); + + state.connection = connection; + state.channel = channel; + + logger.info('RabbitMQ connected'); + return channel; +} + +function scheduleReconnect(): void { + state.connection = null; + state.channel = null; + setTimeout(() => { + connect().catch((err: unknown) => { + logger.error('RabbitMQ reconnect failed', { error: String(err) }); + }); + }, config.rabbitmq.reconnectDelayMs); +} + +export async function getChannel(): Promise { + if (state.channel) return state.channel; + return connect(); +} + +export async function closeConnection(): Promise { + try { + await state.channel?.close(); + await state.connection?.close(); + } catch { + // Already closed — no action needed + } + state.channel = null; + state.connection = null; +} diff --git a/citrineos-railway/extensions-service/src/rabbitmq/consumer.ts b/citrineos-railway/extensions-service/src/rabbitmq/consumer.ts new file mode 100644 index 0000000..439cc38 --- /dev/null +++ b/citrineos-railway/extensions-service/src/rabbitmq/consumer.ts @@ -0,0 +1,76 @@ +import { type ConsumeMessage } from 'amqplib'; +import { config } from '../config.js'; +import { logger } from '../logger.js'; +import { getChannel } from './connection.js'; +import { dispatch } from '../handlers/index.js'; + +export interface OcppEvent { + action: string; + stationId: string; + tenantId: string; + payload: unknown; + timestamp: string; +} + +function parseMessage(msg: ConsumeMessage): OcppEvent | null { + try { + const raw = msg.content.toString('utf8'); + const parsed = JSON.parse(raw) as Partial; + + if (!parsed.action || !parsed.stationId) { + logger.warn('Received malformed OCPP event — missing action or stationId', { + routingKey: msg.fields.routingKey, + }); + return null; + } + + return { + action: parsed.action, + stationId: parsed.stationId, + tenantId: parsed.tenantId ?? 'default', + payload: parsed.payload ?? {}, + timestamp: parsed.timestamp ?? new Date().toISOString(), + }; + } catch (err) { + logger.error('Failed to parse OCPP event message', { error: String(err) }); + return null; + } +} + +export async function startConsumer(): Promise { + const channel = await getChannel(); + const { queue, exchange, routingKeys } = config.rabbitmq; + + await channel.assertQueue(queue, { durable: true }); + + for (const key of routingKeys) { + await channel.bindQueue(queue, exchange, key); + logger.info('Bound queue to exchange', { queue, exchange, routingKey: key }); + } + + await channel.consume(queue, async (msg) => { + if (!msg) return; + + const event = parseMessage(msg); + + if (!event) { + channel.nack(msg, false, false); + return; + } + + try { + await dispatch(event); + channel.ack(msg); + } catch (err) { + logger.error('Handler threw — message will be requeued once', { + action: event.action, + stationId: event.stationId, + error: String(err), + }); + // Requeue=false to avoid poison-message loops; dead-letter queue handles it + channel.nack(msg, false, false); + } + }); + + logger.info('OCPP event consumer started', { queue }); +} diff --git a/citrineos-railway/extensions-service/src/server.ts b/citrineos-railway/extensions-service/src/server.ts new file mode 100644 index 0000000..9eb3d49 --- /dev/null +++ b/citrineos-railway/extensions-service/src/server.ts @@ -0,0 +1,13 @@ +import express, { type Application } from 'express'; +import { buildApiRouter } from './api/router.js'; + +export function buildApp(): Application { + const app = express(); + + app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + + app.use('/', buildApiRouter()); + + return app; +} diff --git a/citrineos-railway/extensions-service/tsconfig.json b/citrineos-railway/extensions-service/tsconfig.json new file mode 100644 index 0000000..d2121fe --- /dev/null +++ b/citrineos-railway/extensions-service/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/citrineos-railway/gateway/Dockerfile b/citrineos-railway/gateway/Dockerfile new file mode 100644 index 0000000..fb033ef --- /dev/null +++ b/citrineos-railway/gateway/Dockerfile @@ -0,0 +1,21 @@ +FROM nginx:1.25-alpine + +# envsubst (part of gettext) substitutes env vars into nginx.conf at startup +RUN apk add --no-cache gettext + +COPY nginx.conf /etc/nginx/nginx.conf.template + +# Start script: substitute env vars into the config, then start Nginx +RUN printf '#!/bin/sh\n\ +envsubst "$$CITRINEOS_CORE_HOST $$HASURA_HOST $$DIRECTUS_HOST $$EXTENSIONS_HOST" \\\n\ + < /etc/nginx/nginx.conf.template \\\n\ + > /etc/nginx/nginx.conf\n\ +exec nginx -g "daemon off;"\n' > /docker-entrypoint-init.sh && \ +chmod +x /docker-entrypoint-init.sh + +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost/health || exit 1 + +CMD ["/docker-entrypoint-init.sh"] diff --git a/citrineos-railway/gateway/nginx.conf b/citrineos-railway/gateway/nginx.conf new file mode 100644 index 0000000..c99f5a2 --- /dev/null +++ b/citrineos-railway/gateway/nginx.conf @@ -0,0 +1,124 @@ +# CitrineOS Gateway — Nginx Reverse Proxy +# +# This file is a template. ${VAR} placeholders are replaced by envsubst +# at container start (see Dockerfile CMD). Nginx variables ($uri, $host, etc.) +# are NOT touched because Dockerfile only substitutes the four named vars. +# +# Route map: +# GET /health → gateway health (local, no upstream) +# /api/graphql → Hasura GraphQL HTTP (port 8080) +# /api/graphql/ws → Hasura GraphQL WebSocket +# /cms/ → Directus CMS (port 8055) +# /ocpp/ → CitrineOS Core HTTP (port 8080) +# /ocpp-ws → CitrineOS Core WebSocket (port 8081) +# /api/extensions/ → Extensions Service (port 3001) + +worker_processes auto; +error_log stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - "$request" $status $body_bytes_sent "$http_user_agent"'; + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 16m; + + # Railway / Docker internal DNS resolver + resolver 127.0.0.11 valid=30s ipv6=off; + + server { + listen 80; + server_name _; + + # ── Gateway health check ───────────────────────────────────────────── + location = /health { + access_log off; + add_header Content-Type application/json; + return 200 '{"status":"ok","service":"citrineos-gateway"}'; + } + + # ── Hasura GraphQL HTTP ────────────────────────────────────────────── + location = /api/graphql { + set $hasura_upstream http://${HASURA_HOST}:8080; + proxy_pass $hasura_upstream/v1/graphql; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Hasura GraphQL WebSocket (subscriptions) ───────────────────────── + location = /api/graphql/ws { + set $hasura_upstream http://${HASURA_HOST}:8080; + proxy_pass $hasura_upstream/v1/graphql; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + # ── Directus CMS ───────────────────────────────────────────────────── + location /cms/ { + set $directus_upstream http://${DIRECTUS_HOST}:8055; + rewrite ^/cms(/.*)$ $1 break; + proxy_pass $directus_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── CitrineOS Core HTTP API ─────────────────────────────────────────── + location /ocpp/ { + set $citrineos_upstream http://${CITRINEOS_CORE_HOST}:8080; + proxy_pass $citrineos_upstream/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── CitrineOS Core WebSocket (OCPP 2.0.1) ──────────────────────────── + # Charge point URL: wss://YOUR_GATEWAY/ocpp-ws/STATION_ID + location /ocpp-ws { + set $citrineos_ws_upstream http://${CITRINEOS_CORE_HOST}:8081; + proxy_pass $citrineos_ws_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400s; + } + + # ── Extensions Service ──────────────────────────────────────────────── + location /api/extensions/ { + set $extensions_upstream http://${EXTENSIONS_HOST}:3001; + proxy_pass $extensions_upstream/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── 404 fallthrough ─────────────────────────────────────────────────── + location / { + add_header Content-Type application/json; + return 404 '{"error":"not_found"}'; + } + } +} diff --git a/citrineos-railway/gateway/railway.toml b/citrineos-railway/gateway/railway.toml new file mode 100644 index 0000000..37cb4fa --- /dev/null +++ b/citrineos-railway/gateway/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 diff --git a/citrineos-railway/railway.json b/citrineos-railway/railway.json new file mode 100644 index 0000000..681c023 --- /dev/null +++ b/citrineos-railway/railway.json @@ -0,0 +1,203 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "name": "CitrineOS EV Charging Stack", + "description": "Production-grade OCPP 2.0.1 charge point management system. Includes CitrineOS Core, Everest simulator, TimescaleDB, RabbitMQ, Redis, Hasura GraphQL, Directus CMS, an extensible custom-logic service, and an Nginx gateway. Only the gateway is publicly reachable — all internal services communicate over Railway private networking.", + "tags": ["ev-charging", "ocpp", "citrineos", "timescaledb", "rabbitmq", "hasura", "directus"], + "services": [ + { + "name": "timescaledb", + "description": "PostgreSQL 15 + TimescaleDB for time-series meter data. Private only.", + "source": { + "image": "timescale/timescaledb:latest-pg15" + }, + "variables": { + "POSTGRES_DB": "citrineos", + "POSTGRES_USER": "citrineos", + "POSTGRES_PASSWORD": { "generator": "secret", "length": 32 }, + "PGDATA": "/var/lib/postgresql/data/pgdata" + }, + "volumes": [ + { "mountPath": "/var/lib/postgresql/data", "name": "timescaledb-data" } + ], + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "rabbitmq", + "description": "RabbitMQ 3 message broker for OCPP event streaming. Private only.", + "source": { + "image": "rabbitmq:3.12-management-alpine" + }, + "variables": { + "RABBITMQ_DEFAULT_USER": "citrineos", + "RABBITMQ_DEFAULT_PASS": { "generator": "secret", "length": 24 }, + "RABBITMQ_DEFAULT_VHOST": "citrineos" + }, + "volumes": [ + { "mountPath": "/var/lib/rabbitmq", "name": "rabbitmq-data" } + ], + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "redis", + "description": "Redis 7 cache used by CitrineOS Core and Directus. Private only.", + "source": { + "image": "redis:7-alpine" + }, + "variables": { + "REDIS_PASSWORD": { "generator": "secret", "length": 24 } + }, + "startCommand": "redis-server --requirepass ${{redis.REDIS_PASSWORD}}", + "volumes": [ + { "mountPath": "/data", "name": "redis-data" } + ], + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "citrineos-core", + "description": "CitrineOS OCPP 2.0.1 charge point management server. Private HTTP (8080) and WebSocket (8081).", + "source": { + "image": "ghcr.io/citrineos/citrineos-core:latest" + }, + "variables": { + "APP_NAME": "citrineos", + "CITRINEOS_AUTH_SECURITY_MODE": "BOTH", + "DATABASE_HOST": "${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}", + "DATABASE_PORT": "5432", + "DATABASE_NAME": "${{timescaledb.POSTGRES_DB}}", + "DATABASE_USER": "${{timescaledb.POSTGRES_USER}}", + "DATABASE_PASSWORD": "${{timescaledb.POSTGRES_PASSWORD}}", + "AMQP_HOSTNAME": "${{rabbitmq.RAILWAY_PRIVATE_DOMAIN}}", + "AMQP_PORT": "5672", + "AMQP_USER": "${{rabbitmq.RABBITMQ_DEFAULT_USER}}", + "AMQP_PASSWORD": "${{rabbitmq.RABBITMQ_DEFAULT_PASS}}", + "AMQP_VHOST": "${{rabbitmq.RABBITMQ_DEFAULT_VHOST}}", + "REDIS_HOST": "${{redis.RAILWAY_PRIVATE_DOMAIN}}", + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "${{redis.REDIS_PASSWORD}}", + "CITRINEOS_DIRECTUS_URL": "http://${{directus.RAILWAY_PRIVATE_DOMAIN}}:8055", + "CITRINEOS_DIRECTUS_TOKEN": "${{directus.ADMIN_TOKEN}}", + "PORT": "8080", + "WEBSOCKET_PORT": "8081" + }, + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "hasura", + "description": "Hasura GraphQL Engine connected to TimescaleDB. Private only — accessed through gateway.", + "source": { + "image": "hasura/graphql-engine:v2.40.0" + }, + "variables": { + "HASURA_GRAPHQL_DATABASE_URL": "postgres://${{timescaledb.POSTGRES_USER}}:${{timescaledb.POSTGRES_PASSWORD}}@${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}:5432/${{timescaledb.POSTGRES_DB}}", + "HASURA_GRAPHQL_ADMIN_SECRET": { "generator": "secret", "length": 32 }, + "HASURA_GRAPHQL_ENABLE_CONSOLE": "false", + "HASURA_GRAPHQL_DEV_MODE": "false", + "HASURA_GRAPHQL_ENABLED_LOG_TYPES": "startup, http-log, webhook-log, websocket-log", + "HASURA_GRAPHQL_JWT_SECRET": { "generator": "secret", "length": 32 }, + "PORT": "8080" + }, + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "directus", + "description": "Directus headless CMS for CitrineOS data management. Private only.", + "source": { + "image": "directus/directus:11" + }, + "variables": { + "SECRET": { "generator": "secret", "length": 32 }, + "ADMIN_EMAIL": "admin@citrineos.local", + "ADMIN_PASSWORD": { "generator": "secret", "length": 20 }, + "ADMIN_TOKEN": { "generator": "secret", "length": 32 }, + "DB_CLIENT": "pg", + "DB_HOST": "${{timescaledb.RAILWAY_PRIVATE_DOMAIN}}", + "DB_PORT": "5432", + "DB_DATABASE": "${{timescaledb.POSTGRES_DB}}", + "DB_USER": "${{timescaledb.POSTGRES_USER}}", + "DB_PASSWORD": "${{timescaledb.POSTGRES_PASSWORD}}", + "CACHE_ENABLED": "true", + "CACHE_STORE": "redis", + "REDIS": "redis://:${{redis.REDIS_PASSWORD}}@${{redis.RAILWAY_PRIVATE_DOMAIN}}:6379", + "PUBLIC_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/cms", + "PORT": "8055" + }, + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "everest", + "description": "EVerest charging station simulator for integration testing. Private only.", + "source": { + "image": "ghcr.io/everest/everest-demo:latest" + }, + "variables": { + "CITRINEOS_CORE_WS_URL": "ws://${{citrineos-core.RAILWAY_PRIVATE_DOMAIN}}:8081", + "STATION_ID": "EVB-P17390866", + "OCPP_VERSION": "OCPP201" + }, + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "extensions", + "description": "Custom extensions service. Listens to RabbitMQ OCPP events and exposes a REST API for custom business logic (billing, loyalty, alerts). Add new handlers in src/handlers/.", + "source": { + "repo": "cloudygetty-ai/run-down", + "branch": "claude/citrineos-railway-template-PRfh5", + "rootDirectory": "citrineos-railway/extensions-service" + }, + "variables": { + "PORT": "3001", + "NODE_ENV": "production", + "RABBITMQ_URL": "amqp://${{rabbitmq.RABBITMQ_DEFAULT_USER}}:${{rabbitmq.RABBITMQ_DEFAULT_PASS}}@${{rabbitmq.RAILWAY_PRIVATE_DOMAIN}}:5672/${{rabbitmq.RABBITMQ_DEFAULT_VHOST}}", + "RABBITMQ_EXCHANGE": "citrineos", + "RABBITMQ_QUEUE": "extensions.main", + "RABBITMQ_ROUTING_KEYS": "ocpp.#", + "RABBITMQ_PREFETCH": "10", + "CITRINEOS_CORE_URL": "http://${{citrineos-core.RAILWAY_PRIVATE_DOMAIN}}:8080", + "LOG_LEVEL": "info" + }, + "networking": { + "tcpProxies": [] + } + }, + + { + "name": "gateway", + "description": "Nginx reverse proxy. The ONLY service with a public domain. Routes /api/graphql → Hasura, /cms → Directus, /ocpp → CitrineOS Core, /extensions → Extensions.", + "source": { + "repo": "cloudygetty-ai/run-down", + "branch": "claude/citrineos-railway-template-PRfh5", + "rootDirectory": "citrineos-railway/gateway" + }, + "variables": { + "CITRINEOS_CORE_HOST": "${{citrineos-core.RAILWAY_PRIVATE_DOMAIN}}", + "HASURA_HOST": "${{hasura.RAILWAY_PRIVATE_DOMAIN}}", + "DIRECTUS_HOST": "${{directus.RAILWAY_PRIVATE_DOMAIN}}", + "EXTENSIONS_HOST": "${{extensions.RAILWAY_PRIVATE_DOMAIN}}" + }, + "networking": { + "serviceDomains": [{}] + } + } + ] +} From 75bc603f12df796ab4531aac4531f53bba106b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:10:35 +0000 Subject: [PATCH 2/2] chore: update railway.json branch reference to citrineos-railway-template https://claude.ai/code/session_01FqEkDaad3jTipkfUGBCjpE --- citrineos-railway/railway.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/citrineos-railway/railway.json b/citrineos-railway/railway.json index 681c023..9a20c7b 100644 --- a/citrineos-railway/railway.json +++ b/citrineos-railway/railway.json @@ -162,7 +162,7 @@ "description": "Custom extensions service. Listens to RabbitMQ OCPP events and exposes a REST API for custom business logic (billing, loyalty, alerts). Add new handlers in src/handlers/.", "source": { "repo": "cloudygetty-ai/run-down", - "branch": "claude/citrineos-railway-template-PRfh5", + "branch": "citrineos-railway-template", "rootDirectory": "citrineos-railway/extensions-service" }, "variables": { @@ -186,7 +186,7 @@ "description": "Nginx reverse proxy. The ONLY service with a public domain. Routes /api/graphql → Hasura, /cms → Directus, /ocpp → CitrineOS Core, /extensions → Extensions.", "source": { "repo": "cloudygetty-ai/run-down", - "branch": "claude/citrineos-railway-template-PRfh5", + "branch": "citrineos-railway-template", "rootDirectory": "citrineos-railway/gateway" }, "variables": {