diff --git a/.env.example b/.env.example index 93403b8..433af92 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,24 @@ JWT_PREVIOUS_KEYS= # upgrades new hashes immediately; existing hashes upgrade lazily on next login. BCRYPT_SALT_ROUNDS=12 -# Database Configuration +# Database Configuration (local / non-Docker development) DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=public" +# Docker Compose development stack (docker-compose.yml) +# The API/worker containers build their DATABASE_URL from these values and +# reach PostgreSQL at the `db` service host — no host networking needed. +POSTGRES_USER=learnault +POSTGRES_PASSWORD=learnault +POSTGRES_DB=learnault_dev +# Host ports exposed by the stack (change if 5432/6379/5000 are taken) +POSTGRES_PORT=5432 +REDIS_PORT=6379 +API_PORT=5000 +# Redis is provisioned for upcoming queue-backed work; not yet consumed by the app +REDIS_URL=redis://localhost:6379 +# Wallet-provisioning worker poll interval (ms) +WORKER_POLL_INTERVAL_MS=5000 + # Logging Configuration # LOG_LEVEL=info (options: error, warn, info, http, verbose, debug, silly) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18f9861..43105a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Lint (ESLint) run: pnpm run lint + - name: Validate Docker Compose stack + run: docker compose config --quiet + - name: Run tests with coverage run: pnpm run test:coverage diff --git a/Dockerfile b/Dockerfile index d2ad7db..db4dc82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,6 +98,6 @@ USER appuser EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD node -e "fetch('http://localhost:5000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD node -e "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ENTRYPOINT ["./entrypoint-api.sh"] diff --git a/README.md b/README.md index 1e38e66..3f542e6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,19 @@ pnpm db:seed pnpm dev ``` +### Run the full stack with Docker Compose (recommended) + +The local stack — API, wallet worker, PostgreSQL, and Redis — starts with one command: + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Migrations and deterministic seed fixtures run automatically on boot. See +[Local Development Stack](./docs/DEVELOPMENT_STACK.md) for health checks, logs, +reset, and the smoke test (`pnpm stack:smoke`). + For detailed database setup instructions, see [Prisma Setup Guide](./prisma/SETUP.md) ### Development Workflow diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8921f69 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,122 @@ +# ============================================================================ +# Learnault API — Local Development Stack +# +# One command starts a healthy stack (API + wallet worker + PostgreSQL + Redis): +# docker compose up -d --build +# +# The API container applies migrations and seeds deterministic fixtures on +# boot (see docker/entrypoint-dev-api.sh). The worker drains the idempotent +# wallet-provisioning outbox (see src/workers/wallet-provisioning.worker.ts). +# +# Useful commands: +# docker compose ps → service status + health +# docker compose logs -f → follow logs (all services) +# docker compose down → stop the stack (keeps data volumes) +# docker compose down -v → stop and delete data volumes (project-scoped reset) +# pnpm stack:smoke → validate config + run the smoke test +# +# Docs: docs/DEVELOPMENT_STACK.md +# ============================================================================ + +name: learnault-dev + +services: + # -------------------------------------------------------------------------- + # PostgreSQL — primary datastore + # -------------------------------------------------------------------------- + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-learnault} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-learnault} + POSTGRES_DB: ${POSTGRES_DB:-learnault_dev} + ports: + - '${POSTGRES_PORT:-5432}:5432' + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_dev}'] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + # -------------------------------------------------------------------------- + # Redis — cache/queue (reserved for upcoming queue-backed work) + # -------------------------------------------------------------------------- + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - '${REDIS_PORT:-6379}:6379' + volumes: + - redisdata:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 10 + + # -------------------------------------------------------------------------- + # API — Express server (nodemon, hot reload) + # -------------------------------------------------------------------------- + api: + build: + context: . + dockerfile: docker/Dockerfile.dev + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: development + PORT: 5000 + DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET:-dev-only-secret-change-me} + JWT_ISSUER: ${JWT_ISSUER:-learnault-api} + JWT_AUDIENCE: ${JWT_AUDIENCE:-learnault-clients} + RUN_MIGRATIONS: 'true' + RUN_SEED: 'true' + ports: + - '${API_PORT:-5000}:5000' + volumes: + # Bind-mount source for hot reload; keep the image's node_modules + - .:/app + - /app/node_modules + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + stop_grace_period: 30s + + # -------------------------------------------------------------------------- + # Worker — drains the wallet-provisioning outbox + # -------------------------------------------------------------------------- + worker: + build: + context: . + dockerfile: docker/Dockerfile.dev + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: development + DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public + RUN_MIGRATIONS: 'true' + WORKER_POLL_INTERVAL_MS: ${WORKER_POLL_INTERVAL_MS:-5000} + volumes: + - .:/app + - /app/node_modules + command: ['./docker/entrypoint-dev-worker.sh'] + stop_grace_period: 30s + +volumes: + pgdata: + redisdata: diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..b13a45f --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,33 @@ +# ============================================================================ +# Learnault API — Development Docker Image +# +# Used by docker-compose.yml for local development. Installs all dependencies +# (including dev tooling) and generates the Prisma client. The source tree is +# bind-mounted from the host at runtime so edits hot-reload via nodemon/tsx. +# +# Build: +# docker build -f docker/Dockerfile.dev -t learnault-api:dev . +# ============================================================================ + +FROM node:20-slim + +# OpenSSL is required by Prisma's engine detection +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* + +RUN corepack enable && corepack prepare pnpm@10 --activate + +WORKDIR /app + +# Install ALL dependencies (dev tooling included) in a cached layer +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Generate the Prisma client (needed before the app or worker can boot) +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN npx prisma generate + +EXPOSE 5000 + +# Entrypoints are referenced via the bind-mounted ./docker directory at runtime +CMD ["./docker/entrypoint-dev-api.sh"] diff --git a/docker/entrypoint-dev-api.sh b/docker/entrypoint-dev-api.sh new file mode 100755 index 0000000..cd2ffe7 --- /dev/null +++ b/docker/entrypoint-dev-api.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +# --------------------------------------------------------------------------- +# Learnault API — Development Container Entrypoint +# +# Environment variables: +# RUN_MIGRATIONS = "true" (default) → apply pending migrations on boot +# RUN_SEED = "true" (default) → seed deterministic fixtures on boot +# --------------------------------------------------------------------------- + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + echo "[entrypoint] Applying database migrations …" + npx prisma migrate deploy + echo "[entrypoint] Migrations applied." +fi + +if [ "${RUN_SEED:-true}" = "true" ]; then + echo "[entrypoint] Seeding database (deterministic fixtures) …" + npx prisma db seed + echo "[entrypoint] Seed complete." +fi + +echo "[entrypoint] Starting API dev server (nodemon) …" +exec pnpm dev diff --git a/docker/entrypoint-dev-worker.sh b/docker/entrypoint-dev-worker.sh new file mode 100755 index 0000000..3fb8fcd --- /dev/null +++ b/docker/entrypoint-dev-worker.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# --------------------------------------------------------------------------- +# Learnault Worker — Development Container Entrypoint +# +# Environment variables: +# RUN_MIGRATIONS = "true" (default) → apply pending migrations on boot +# --------------------------------------------------------------------------- + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + echo "[entrypoint] Applying database migrations …" + npx prisma migrate deploy + echo "[entrypoint] Migrations applied." +fi + +echo "[entrypoint] Starting wallet-provisioning worker …" +exec pnpm worker:dev diff --git a/docs/DEVELOPMENT_STACK.md b/docs/DEVELOPMENT_STACK.md new file mode 100644 index 0000000..2f29347 --- /dev/null +++ b/docs/DEVELOPMENT_STACK.md @@ -0,0 +1,104 @@ +# Local Development Stack (Docker Compose) + +A reproducible local stack for the Learnault API: **API**, **wallet worker**, **PostgreSQL**, and **Redis** — started with one command. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) with Docker Compose v2 (bundled with Docker Desktop) +- Node.js 20+ and pnpm 10+ (only needed for `pnpm` helper scripts; the stack itself is containerized) + +## Quick Start + +```bash +# 1. Configure environment (create once; defaults work out of the box) +cp .env.example .env + +# 2. One command builds and starts a healthy stack +docker compose up -d --build + +# 3. Verify everything is healthy +docker compose ps +# NAME STATUS +# learnault-dev-api Up ... (healthy) +# learnault-dev-db Up ... (healthy) +# learnault-dev-redis Up ... (healthy) +# learnault-dev-worker Up ... (healthy) +``` + +The API is available at `http://localhost:5000` (Swagger UI at `http://localhost:5000/api-docs`). + +## What happens on startup + +The `api` service entrypoint (`docker/entrypoint-dev-api.sh`) waits for PostgreSQL and Redis health, then: + +1. Applies pending migrations (`prisma migrate deploy`) — deterministic, no-op when up to date. +2. Seeds deterministic fixtures (`prisma db seed`) — idempotent, safe to run repeatedly. +3. Starts the Express server under `nodemon`, so source edits hot-reload via the bind mount. + +The `worker` service runs `src/workers/wallet-provisioning.worker.ts`, which polls the idempotent wallet-provisioning outbox and generates Stellar keys through the dev in-memory KMS adapter. In production, swap the KMS adapter for a real one (e.g. AWS KMS) behind the same `KmsSecretStore` interface. + +## Health checks & readiness + +| Endpoint | Meaning | +| ------------------- | ---------------------------------------------------- | +| `GET /health/live` | Process is alive (used by the container healthcheck) | +| `GET /health/ready` | Dependencies (database) are reachable | + +The API container only reports **healthy** after `/health/live` responds; `depends_on: condition: service_healthy` keeps the worker from racing migrations. `GET /health/ready` returns `200` only when PostgreSQL is reachable — the smoke test waits on it. + +## One-command helpers + +`package.json` exposes convenient wrappers: + +```bash +pnpm stack:up # docker compose up -d --build +pnpm stack:down # stop the stack (keeps data volumes) +pnpm stack:reset # stop + delete data volumes (project-scoped reset) +pnpm stack:logs # follow API + worker logs +pnpm stack:validate # docker compose config --quiet +pnpm stack:smoke # validate + start + probe health endpoints +``` + +## Logs & graceful shutdown + +```bash +docker compose logs -f # all services +docker compose logs -f api # API only +docker compose logs worker # worker only +``` + +Both services have `stop_grace_period: 30s`, matching the app's graceful-shutdown handler (`SHUTDOWN_TIMEOUT_MS`): `docker compose down` sends `SIGTERM`, the server drains HTTP connections and closes the Prisma pool before exiting. + +## Data persistence & reset + +- PostgreSQL data lives in the `learnault-dev_pgdata` named volume; Redis in `learnault-dev_redisdata`. +- **Reset is project-scoped**: `docker compose down -v` removes only this project's volumes. Other projects and containers are untouched. + +```bash +# Full project-scoped reset (drops all local data, then rebuild + reseed) +pnpm stack:reset +pnpm stack:up +``` + +## Smoke test + +```bash +pnpm stack:smoke +``` + +This validates the compose file, starts the stack, waits for `/health/ready`, probes `/health/live` and `/health/ready`, and prints service status. Run `./scripts/stack-smoke-test.sh --validate` for config-only validation. + +## Troubleshooting + +| Symptom | Fix | +| ------------------------------------ | -------------------------------------------------------------------- | +| Port 5432/6379/5000 already in use | Override in `.env`: `POSTGRES_PORT=5433`, `REDIS_PORT=6380`, `API_PORT=5001` | +| Prisma client errors (`@prisma/client` export) | Run `pnpm db:generate` (or `docker compose build`), then restart the stack | +| `JWT_SECRET` required error | Set a real `JWT_SECRET` in `.env` (defaults are dev-only) | +| Containers restarting after reset | Ensure `.env` exists before `docker compose up` | + +## Related + +- [Prisma Setup Guide](../prisma/SETUP.md) — database schema, migrations, seeding +- [Staging Runbook](./RUNBOOK.md) — production/staging deployment +- [Architecture](./ARCHITECTURE.md) — service design diff --git a/lint_output.json b/lint_output.json deleted file mode 100644 index c716141..0000000 Binary files a/lint_output.json and /dev/null differ diff --git a/lint_results.txt b/lint_results.txt deleted file mode 100644 index bdac08d..0000000 Binary files a/lint_results.txt and /dev/null differ diff --git a/lint_results_manual.txt b/lint_results_manual.txt deleted file mode 100644 index 99c6fb7..0000000 Binary files a/lint_results_manual.txt and /dev/null differ diff --git a/lint_results_utf8.txt b/lint_results_utf8.txt deleted file mode 100644 index 8916d4a..0000000 --- a/lint_results_utf8.txt +++ /dev/null @@ -1,16 +0,0 @@ - -> learnault-api@0.1.0 lint -> eslint . - - -C:\Users\EMMA\Desktop\learn\learnault-api\src\config\swagger.ts - 1:41 error Extra semicolon semi - 37:2 error Extra semicolon semi - 39:43 error Extra semicolon semi - -C:\Users\EMMA\Desktop\learn\learnault-api\src\controllers\user.controller.ts - 1:62 warning 'UpdateWalletData' is defined but never used. Allowed unused vars must match /^I[A-Z]|^_/u @typescript-eslint/no-unused-vars - -Γ£û 4 problems (3 errors, 1 warning) - 3 errors and 0 warnings potentially fixable with the `--fix` option. - diff --git a/package.json b/package.json index 5b33e31..c30a7c2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "license": "MIT", "author": "Learnault Contributors", "scripts": { + "predev": "prisma generate", "dev": "nodemon", + "prebuild": "prisma generate", "build": "tsc", "start": "node dist/server.js", "lint": "eslint .", @@ -29,13 +31,20 @@ "test:ci": "vitest run", "test:integration": "vitest run --reporter=verbose tests/integration/", "test:integration:watch": "vitest tests/integration/", - "prisma": "tsx node_modules/prisma/build/index.js", - "prisma:generate": "tsx node_modules/prisma/build/index.js generate", - "db:migrate": "tsx node_modules/prisma/build/index.js migrate dev", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", "seed": "tsx prisma/seed.ts", "seed:reset": "tsx prisma/seed.ts --reset", "db:seed": "npm run seed", - "db:studio": "tsx node_modules/prisma/build/index.js studio" + "db:studio": "prisma studio", + "worker:dev": "tsx src/workers/wallet-provisioning.worker.ts", + "stack:validate": "docker compose config --quiet", + "stack:up": "docker compose up -d --build", + "stack:down": "docker compose down", + "stack:reset": "docker compose down -v", + "stack:logs": "docker compose logs -f api worker", + "stack:smoke": "bash scripts/stack-smoke-test.sh" }, "dependencies": { "@prisma/adapter-pg": "^7.4.2", diff --git a/prisma.config.ts b/prisma.config.ts index 54fe951..7c15b04 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,12 +1,18 @@ import 'dotenv/config' import { defineConfig } from 'prisma/config' +// `prisma generate` (and the Docker build) must succeed even when no database +// is reachable, so fall back to a local default when DATABASE_URL is unset. +// `prisma migrate`/`db push` still require a real DATABASE_URL. +const DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/learnault_dev?schema=public' + export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { path: 'prisma/migrations', + seed: 'tsx prisma/seed.ts', }, datasource: { - url: process.env.DATABASE_URL ?? 'postgresql://user:password@localhost:5432/learnault', + url: DATABASE_URL, }, -}) \ No newline at end of file +}) diff --git a/prisma/migrations/20260718000000_add_learner_preferences/migration.sql b/prisma/migrations/20260719100043_add_learner_preferences/migration.sql similarity index 100% rename from prisma/migrations/20260718000000_add_learner_preferences/migration.sql rename to prisma/migrations/20260719100043_add_learner_preferences/migration.sql diff --git a/scripts/stack-smoke-test.sh b/scripts/stack-smoke-test.sh new file mode 100755 index 0000000..9bf34c2 --- /dev/null +++ b/scripts/stack-smoke-test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# Learnault API — Local Stack Smoke Test +# +# Validates docker-compose.yml, starts the stack, and verifies the API is +# live and ready. Used by `pnpm stack:smoke` and by CI. +# +# Usage: +# ./scripts/stack-smoke-test.sh # validate + start + probe +# ./scripts/stack-smoke-test.sh --validate # config validation only +# --------------------------------------------------------------------------- + +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +API_URL="${API_URL:-http://localhost:5000}" +MAX_RETRIES=60 +RETRY_INTERVAL=2 + +echo "==> Validating compose configuration ($COMPOSE_FILE)" +docker compose -f "$COMPOSE_FILE" config --quiet +echo "==> Compose configuration is valid" + +if [ "${1:-}" = "--validate" ]; then + exit 0 +fi + +echo "==> Building and starting the stack" +docker compose -f "$COMPOSE_FILE" up -d --build + +echo "==> Waiting for dependencies and API readiness ($API_URL/health/ready)" +ready=false +for ((i = 1; i <= MAX_RETRIES; i++)); do + if curl -fsS "$API_URL/health/ready" >/dev/null 2>&1; then + ready=true + break + fi + echo " ... attempt $i/$MAX_RETRIES" + sleep "$RETRY_INTERVAL" +done + +if [ "$ready" != "true" ]; then + echo "!! API did not become ready in time" >&2 + docker compose -f "$COMPOSE_FILE" logs api + exit 1 +fi + +echo "==> Liveness probe (GET $API_URL/health/live)" +curl -fsS "$API_URL/health/live" +echo + +echo "==> Readiness probe (GET $API_URL/health/ready)" +curl -fsS "$API_URL/health/ready" +echo + +echo "==> Service status" +docker compose -f "$COMPOSE_FILE" ps + +echo "" +echo "✅ Smoke test passed" diff --git a/server_error.txt b/server_error.txt deleted file mode 100644 index 937985c..0000000 Binary files a/server_error.txt and /dev/null differ diff --git a/server_error_2.txt b/server_error_2.txt deleted file mode 100644 index 6ef20b8..0000000 Binary files a/server_error_2.txt and /dev/null differ diff --git a/server_error_2_utf8.txt b/server_error_2_utf8.txt deleted file mode 100644 index 15f50c1..0000000 --- a/server_error_2_utf8.txt +++ /dev/null @@ -1,86 +0,0 @@ -node.exe : C:\Users\EMM -A\Desktop\learn\learnau -lt-api\node_modules\.pn -pm\@prisma+client@7.4.2 -_prisma_49b4b128965f74e -a9bbd7586bc0c7d7a\node_ -modules\@prisma\client\ -src\runtime\getPrismaCl -ient.ts:260 -At line:1 char:1 -+ & "C:\nvm4w\nodejs/no -de.exe" "C:\Users\EMMA\ -AppData\Roaming\npm/nod -e_ ... -+ ~~~~~~~~~~~~~~~~~~~~~ -~~~~~~~~~~~~~~~~~~~~~~~ -~~~~~~~~~~~~~~~~~~~~~~~ -~~ - + CategoryInfo - : NotSpecifi - ed: (C:\Users\EMMA - \D...maClient.ts:2 -60:String) [], Rem -oteException - + FullyQualifiedEr - rorId : NativeComm - andError - - throw new Prism -aClientInitializationEr -ror( - ^ - - -PrismaClientInitializat -ionError: -`PrismaClient` needs -to be constructed with -a non-empty, valid -`PrismaClientOptions`: - -``` -new PrismaClient({ - ... -}) -``` - -or - -``` -constructor() { - super({ ... }); -} -``` - - at new t (C:\Users\ -EMMA\Desktop\learn\lear -nault-api\node_modules\ -.pnpm\@prisma+client@7. -4.2_prisma_49b4b128965f -74ea9bbd7586bc0c7d7a\no -de_modules\@prisma\clie -nt\src\runtime\getPrism -aClient.ts:260:15) - at (C:\ -Users\EMMA\Desktop\lear -n\learnault-api\src\con -fig\database.ts:7:42) - at ModuleJob.run (n -ode:internal/modules/es -m/module_job:345:25) - at async onImport.t -racePromise.__proto__ ( -node:internal/modules/e -sm/loader:665:26) - at async asyncRunEn -tryPointWithESMLoader ( -node:internal/modules/r -un_main:117:5) { - clientVersion: -'7.4.2', - errorCode: undefined, - retryable: undefined -} - -Node.js v22.20.0 diff --git a/server_error_utf8.txt b/server_error_utf8.txt deleted file mode 100644 index cbd4b5f..0000000 --- a/server_error_utf8.txt +++ /dev/null @@ -1,50 +0,0 @@ -node.exe : C:\Users\EMM -A\Desktop\learn\learnau -lt-api\src\config\datab -ase.ts:1 -At line:1 char:1 -+ & "C:\nvm4w\nodejs/no -de.exe" "C:\Users\EMMA\ -AppData\Roaming\npm/nod -e_ ... -+ ~~~~~~~~~~~~~~~~~~~~~ -~~~~~~~~~~~~~~~~~~~~~~~ -~~~~~~~~~~~~~~~~~~~~~~~ -~~ - + CategoryInfo - : NotSpecifi - ed: (C:\Users\EMMA - \D...g\database.ts -:1:String) [], Rem -oteException - + FullyQualifiedEr - rorId : NativeComm - andError - -import { PrismaClient -} from '@prisma/client' - ^ - -SyntaxError: The -requested module -'@prisma/client' does -not provide an export -named 'PrismaClient' - at -ModuleJob._instantiate -(node:internal/modules/ -esm/module_job:228:21) - at async -ModuleJob.run (node:int -ernal/modules/esm/modul -e_job:337:5) - at async onImport.t -racePromise.__proto__ ( -node:internal/modules/e -sm/loader:665:26) - at async asyncRunEn -tryPointWithESMLoader ( -node:internal/modules/r -un_main:117:5) - -Node.js v22.20.0 diff --git a/src/config/jwt.ts b/src/config/jwt.ts index df5a5b5..bd9226a 100644 --- a/src/config/jwt.ts +++ b/src/config/jwt.ts @@ -1,4 +1,4 @@ -import { Algorithm, JsonWebTokenError, SignOptions, VerifyOptions } from 'jsonwebtoken' +import jwt, { Algorithm, SignOptions, VerifyOptions } from 'jsonwebtoken' import { JWTPayload, signToken, verifyToken } from '../utils/jwt' // Centralized JWT policy: one algorithm, one issuer/audience pair, and an @@ -113,7 +113,7 @@ export function verifyAccessToken(token: string, options: VerifyOptions = {}): A // Same error type jwt.verify() itself throws for a bad signature, so // callers (e.g. authenticate()) treat this as "invalid token" (401) // rather than an unexpected server error (500). - throw new JsonWebTokenError('Unknown or retired signing key') + throw new jwt.JsonWebTokenError('Unknown or retired signing key') } return verifyToken(token, secret, { diff --git a/src/workers/wallet-provisioning.worker.ts b/src/workers/wallet-provisioning.worker.ts new file mode 100644 index 0000000..77fd580 --- /dev/null +++ b/src/workers/wallet-provisioning.worker.ts @@ -0,0 +1,58 @@ +import 'dotenv/config' +import { WalletProvisioningOutboxHandler } from '../jobs/wallet-provisioning.handler' +import { InMemoryEnvelopeKms } from '../services/kms/in-memory-envelope-kms' +import { SdkStellarKeypairGenerator } from '../services/stellar-keypair.adapter' +import { PrismaWalletProvisioningRepository } from '../services/wallet-provisioning.repository' +import prisma from '../config/database' + +const POLL_INTERVAL_MS = parseInt(process.env.WORKER_POLL_INTERVAL_MS ?? '5000', 10) + +// Development worker: drains the idempotent wallet-provisioning outbox using +// the in-memory KMS adapter. Production should swap in a real KMS adapter +// (e.g. AWS KMS) — the handler only depends on the KmsSecretStore interface. +const kms = new InMemoryEnvelopeKms() +const repository = new PrismaWalletProvisioningRepository(prisma) +const keypairGenerator = new SdkStellarKeypairGenerator() +const handler = new WalletProvisioningOutboxHandler(repository, kms, keypairGenerator) + +let isShuttingDown = false + +async function drainOnce(): Promise { + const result = await handler.handleNext() + if (result.kind !== 'idle') { + console.log(`[worker] ${result.kind}:`, JSON.stringify(result)) + } +} + +async function runLoop(): Promise { + console.log(`[worker] Wallet provisioning worker started (poll every ${POLL_INTERVAL_MS}ms)`) + while (!isShuttingDown) { + try { + await drainOnce() + } catch (error) { + console.error('[worker] Unhandled error while draining outbox:', error) + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } +} + +function gracefulShutdown(signal: string): void { + if (isShuttingDown) return + isShuttingDown = true + console.log(`[worker] Received ${signal}, shutting down...`) + prisma + .$disconnect() + .then(() => process.exit(0)) + .catch((error) => { + console.error('[worker] Error disconnecting Prisma:', error) + process.exit(1) + }) +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) +process.on('SIGINT', () => gracefulShutdown('SIGINT')) + +runLoop().catch((error) => { + console.error('[worker] Fatal error:', error) + process.exit(1) +})