diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..a03d96841 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,166 @@ +# GitHub Copilot Instructions for Interledger Test Network + +## Purpose And Scope + +Test Network is a full-stack pnpm workspace monorepo for Interledger sandbox integrations. It contains two apps: + +- Wallet (`packages/wallet/*`): Next.js frontend + Node/Express backend. +- Boutique (`packages/boutique/*`): Vite/React frontend + Node/Express backend. + +Shared packages are in `packages/shared/backend`, `packages/wallet/shared`, and `packages/boutique/shared`. + +Use this file as the default source of truth. Trust these instructions and only search the repo when this file is incomplete or proven wrong. + +## Runtime And Tooling (Strict) + +- Node: `^20.12.1` required by `package.json` engines. +- Package manager: `pnpm@9.1.4` (`packageManager` field). +- Never use `npm install` or `yarn` in this repo. + +Local shell note validated on this machine: default `node` was `v18.19.1`, which causes engine failures even if `pnpm` exists. Ensure Node 20 is first on `PATH` before running scripts. + +Example reliable setup: + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH +corepack pnpm -v +node -v +``` + +## Bootstrap, Build, Test, Lint (Validated) + +Run from repo root `testnet/`. + +1. Bootstrap (always first): + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm install --frozen-lockfile +``` + +Validated: passes in ~1.5s when lockfile is up to date. + +2. Quality checks: + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm checks +``` + +Validated behavior: may fail on existing repo formatting drift (Prettier). In this workspace it failed on: + +- `local/docker-compose.yml` +- `local/scripts/rafiki-setup.js` +- `packages/wallet/frontend/next.config.js` +- `packages/wallet/frontend/src/middleware.ts` + +3. Lint only (for signal isolation): + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm lint:check +``` + +Validated behavior: currently fails on `packages/wallet/frontend/src/middleware.ts` (`@typescript-eslint/no-explicit-any`). + +4. Build all: + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm build +``` + +Validated behavior: currently fails quickly if Docker-created artifacts are root-owned (TS5033 / EACCES in `packages/wallet/shared/dist`). + +5. Package-scoped backend verification: + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm boutique:backend build +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm boutique:backend test +``` + +Validated: passes (`5` suites, `29` tests) in ~17s for tests. + +Wallet backend flow currently blocked by the same `wallet/shared/dist` permission issue: + +```bash +PATH=/home/$USER/.nvm/versions/node/v20.20.0/bin:$PATH corepack pnpm wallet:backend build +``` + +## Known Failure Modes And Workarounds + +1. Engine mismatch (`Unsupported environment`, Node 18 shown): + +- Cause: shell not using Node 20. +- Fix: put Node 20 bin first in `PATH` for every command (or `nvm use lts/iron` in an interactive shell that works reliably). + +2. `TS5033` / `EACCES` writing under `packages/wallet/shared/dist`: + +- Cause: prior Docker runs produced root-owned build artifacts. +- Symptom: root build and wallet backend build fail. +- Fix: ensure those files are writable by your user before rebuilding (example: adjust ownership/permissions of `packages/wallet/shared/dist` and `packages/wallet/shared/tsconfig.build.tsbuildinfo`). + +3. `pnpm checks` failing even without your changes: + +- Cause: pre-existing formatting/lint drift. +- Mitigation: run targeted checks for touched packages and report baseline failures explicitly in PR notes. + +No command timeouts were observed in this validation pass. Failing commands exited quickly (under ~6s except build/test commands). + +## Local Run Flow + +Required precondition before first `pnpm dev`: + +```bash +cp local/.env.example local/.env +``` + +GateHub-related variables in `local/.env` are required for full KYC/funding flows. + +Main run modes: + +- `pnpm dev` -> hot-reload backend containers + frontend dev servers. +- `pnpm dev:debug` -> backend debug mode (`9229`, `9230`). +- `pnpm dev:lite` -> run built backend (no hot reload). +- `pnpm localenv:stop` -> stop local docker environment. + +Service endpoints: + +- Wallet FE: `http://localhost:4003` +- Wallet BE: `http://localhost:3003` +- Boutique FE: `http://localhost:4004` +- Boutique BE: `http://localhost:3004` +- Rafiki Admin UI: `http://localhost:3012` + +## Architecture And File Map + +High-signal root files: + +- `package.json`: canonical scripts and engine constraints. +- `pnpm-workspace.yaml`: workspace package patterns. +- `tsconfig.json`: top-level project references. +- `eslint.config.mjs`, `.prettierrc.js`: repo-wide code quality rules. +- `local/docker-compose.yml`: full local dependency graph (Postgres, Redis, Rafiki, Kratos, app backends). + +Key source areas: + +- Wallet backend: `packages/wallet/backend/src` +- Wallet frontend: `packages/wallet/frontend` +- Boutique backend: `packages/boutique/backend/src` +- Boutique frontend: `packages/boutique/frontend/src` +- Shared backend utilities: `packages/shared/backend/src` + +## CI And PR Expectations + +Primary workflows: + +- `.github/workflows/ci.yml` + - Always runs `pnpm checks`. + - Build/test jobs are gated by PR labels (`package: wallet/backend`, `package: boutique/frontend`, etc.). + - Wallet backend CI test command: `pnpm wallet:backend build && pnpm wallet:backend test --detectOpenHandles --forceExit`. +- `.github/workflows/pr_title_check.yml` + - PR title must satisfy Conventional Commits. +- `.github/workflows/build-publish.yaml` + - Builds package matrix on PR/push; publishes images on `v*` tags. + +Before opening a PR, replicate the relevant CI subset for your changed package(s) and note any pre-existing baseline failures. + +## Agent Operating Rule + +When implementing changes, follow this file first, execute commands in the documented order, and avoid broad repo searches unless required details are missing or incorrect. diff --git a/.prettierignore b/.prettierignore index 0138993b0..f4e85359a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ Dockerfile .gitignore .prettierignore coverage +e2e/.features-gen +e2e/playwright-report +e2e/test-results diff --git a/docker/dev/.env.example b/docker/dev/.env.example deleted file mode 100644 index 4c1762e7c..000000000 --- a/docker/dev/.env.example +++ /dev/null @@ -1,33 +0,0 @@ -SEND_EMAIL= -FROM_EMAIL= -SENDGRID_API_KEY= -AUTH_IDENTITY_SERVER_SECRET= -AUTH_COOKIE_KEY= -AUTH_INTERACTION_COOKIE_SAME_SITE= -RATE_API_KEY= -WALLET_ADDRESS_REDIRECT_HTML_PAGE= - -GATEHUB_ACCESS_KEY= -GATEHUB_SECRET_KEY= -GATEHUB_WEBHOOK_SECRET= -GATEHUB_GATEWAY_UUID= -GATEHUB_SETTLEMENT_WALLET_ADDRESS= -GATEHUB_ORG_ID= -GATEHUB_CARD_APP_ID= -RATE_LIMIT= -RATE_LIMIT_LEVEL= -GATEHUB_ACCOUNT_PRODUCT_CODE= -GATEHUB_CARD_PRODUCT_CODE= -GATEHUB_NAME_ON_CARD= -GATEHUB_CARD_PP_PREFIX= -CARD_PIN_HREF= -CARD_DATA_HREF= - -# commerce env variables -# encoded base 64 private key -PRIVATE_KEY= -KEY_ID= -PAYMENT_POINTER= - -OPERATOR_TENANT_ID= -ADMIN_API_SECRET= diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml deleted file mode 100644 index 8afce4675..000000000 --- a/docker/dev/docker-compose.yml +++ /dev/null @@ -1,336 +0,0 @@ -version: '3.5' - -x-logging: &logging - logging: - driver: 'json-file' - options: - max-size: '100m' - -services: - postgres: - container_name: postgres - image: 'postgres:15' - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - ports: - - '5433:5432' - restart: unless-stopped - networks: - - testnet - volumes: - - pg-data:/var/lib/postgresql/data - - ../dbinit.sql:/docker-entrypoint-initdb.d/init.sql - - # Wallet - wallet-backend: - container_name: wallet-backend - build: - context: ../.. - args: - DEV_MODE: ${DEV_MODE} - dockerfile: ./packages/wallet/backend/Dockerfile.dev - depends_on: - - postgres - - rafiki-backend - - redis - volumes: - - ../../packages/wallet/backend:/home/testnet/packages/wallet/backend - - ../../packages/wallet/shared:/home/testnet/packages/wallet/shared - environment: - NODE_ENV: development - PORT: 3003 - DEBUG_PORT: 9229 - DATABASE_URL: postgres://wallet_backend:wallet_backend@postgres/wallet_backend - COOKIE_NAME: testnet.cookie - COOKIE_PASSWORD: testnet.cookie.password.super.secret.ilp - COOKIE_TTL: 2630000 - OPEN_PAYMENTS_HOST: https://rafiki-backend - GRAPHQL_ENDPOINT: http://rafiki-backend:3001/graphql - AUTH_GRAPHQL_ENDPOINT: http://rafiki-auth:3008/graphql - AUTH_DOMAIN: http://rafiki-auth:3009 - AUTH_IDENTITY_SERVER_SECRET: ${AUTH_IDENTITY_SERVER_SECRET} - RAFIKI_WEBHOOK_SIGNATURE_SECRET: ${RAFIKI_SIGNATURE_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} - SENDGRID_API_KEY: ${SENDGRID_API_KEY} - FROM_EMAIL: ${FROM_EMAIL} - SEND_EMAIL: ${SEND_EMAIL:-false} - REDIS_URL: redis://redis:6379/0 - KRATOS_ADMIN_URL: 'http://kratos:4434/admin' - GATEHUB_ACCESS_KEY: ${GATEHUB_ACCESS_KEY} - GATEHUB_SECRET_KEY: ${GATEHUB_SECRET_KEY} - GATEHUB_WEBHOOK_SECRET: ${GATEHUB_WEBHOOK_SECRET} - GATEHUB_GATEWAY_UUID: ${GATEHUB_GATEWAY_UUID} - GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS} - GATEHUB_ORG_ID: ${GATEHUB_ORG_ID} - GATEHUB_CARD_APP_ID: ${GATEHUB_CARD_APP_ID} - RATE_LIMIT: ${RATE_LIMIT} - RATE_LIMIT_LEVEL: ${RATE_LIMIT_LEVEL} - GATEHUB_ACCOUNT_PRODUCT_CODE: ${GATEHUB_ACCOUNT_PRODUCT_CODE} - GATEHUB_CARD_PRODUCT_CODE: ${GATEHUB_CARD_PRODUCT_CODE} - GATEHUB_NAME_ON_CARD: ${GATEHUB_NAME_ON_CARD} - GATEHUB_CARD_PP_PREFIX: ${GATEHUB_CARD_PP_PREFIX} - CARD_DATA_HREF: ${CARD_DATA_HREF} - CARD_PIN_HREF: ${CARD_PIN_HREF} - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} - STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} - USE_STRIPE: ${USE_STRIPE} - OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} - ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} - ADMIN_SIGNATURE_VERSION: 1 - restart: always - networks: - - testnet - ports: - - '3003:3003' - - '9229:9229' # Map debugger port to local machine's port 9229 - <<: *logging - - # Boutique - boutique-backend: - container_name: boutique-backend - build: - context: ../.. - args: - DEV_MODE: ${DEV_MODE} - dockerfile: ./packages/boutique/backend/Dockerfile.dev - volumes: - - ../../packages/boutique/backend:/home/testnet/packages/boutique/backend - - ../../packages/boutique/shared:/home/testnet/packages/boutique/shared - depends_on: - - postgres - environment: - NODE_ENV: development - PORT: 3004 - DEBUG_PORT: 9230 - DATABASE_URL: postgres://boutique_backend:boutique_backend@postgres/boutique_backend - PRIVATE_KEY: ${PRIVATE_KEY} - KEY_ID: ${KEY_ID} - PAYMENT_POINTER: ${PAYMENT_POINTER} - restart: always - networks: - - testnet - ports: - - '3004:3004' - - '9230:9230' # Map debugger port to local machine's port 9230 - <<: *logging - - # Rafiki - rafiki-auth: - container_name: rafiki-auth - image: ghcr.io/interledger/rafiki-auth:v2.3.0-beta - restart: always - networks: - - testnet - ports: - - '3006:3006' - - '3008:3008' - environment: - AUTH_PORT: 3006 - INTROSPECTION_PORT: 3007 - ADMIN_PORT: 3008 - NODE_ENV: development - AUTH_SERVER_URL: http://localhost:3006 - AUTH_DATABASE_URL: postgresql://rafiki_auth:rafiki_auth@postgres/rafiki_auth - IDENTITY_SERVER_URL: http://localhost:4003/grant-interactions - IDENTITY_SERVER_SECRET: ${AUTH_IDENTITY_SERVER_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} - COOKIE_KEY: ${AUTH_COOKIE_KEY:-8fd398393c47dd27a3167d9c081c094f} - INTERACTION_COOKIE_SAME_SITE: ${AUTH_INTERACTION_COOKIE_SAME_SITE:-lax} - WAIT_SECONDS: 1 - REDIS_URL: redis://redis:6379/0 - OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} - ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} - ADMIN_SIGNATURE_VERSION: 1 - depends_on: - - postgres - <<: *logging - - rafiki-backend: - container_name: rafiki-backend - image: ghcr.io/interledger/rafiki-backend:v2.3.0-beta - restart: always - privileged: true - volumes: - - ../temp/:/workspace/temp/ - ports: - - '3010:80' - - '3011:3001' - - '3005:3005' - - '3002:3002' - networks: - - testnet - environment: - NODE_ENV: development - LOG_LEVEL: debug - ADMIN_PORT: 3001 - CONNECTOR_PORT: 3002 - OPEN_PAYMENTS_PORT: 80 - DATABASE_URL: postgresql://rafiki_backend:rafiki_backend@postgres/rafiki_backend - USE_TIGERBEETLE: true - TIGERBEETLE_CLUSTER_ID: 0 - TIGERBEETLE_REPLICA_ADDRESSES: 10.5.0.50:4342 - NONCE_REDIS_KEY: test - AUTH_SERVER_GRANT_URL: http://rafiki-auth:3006 - AUTH_SERVER_INTROSPECTION_URL: http://rafiki-auth:3007 - ILP_ADDRESS: test.net - ILP_CONNECTOR_URL: http://127.0.0.1:3002 - STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= - ADMIN_KEY: admin - OPEN_PAYMENTS_URL: https://rafiki-backend - REDIS_URL: redis://redis:6379/0 - WALLET_ADDRESS_URL: https://rafiki-backend/.well-known/pay - # Testnet urls - not implemented - WEBHOOK_URL: http://wallet-backend:3003/webhooks - WEBHOOK_TIMEOUT: 60000 - SIGNATURE_SECRET: ${RAFIKI_SIGNATURE_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} - EXCHANGE_RATES_URL: http://wallet-backend:3003/rates - ENABLE_AUTO_PEERING: true - AUTO_PEERING_SERVER_PORT: 3005 - INSTANCE_NAME: 'Testnet Wallet' - SLIPPAGE: 0.01 - KEY_ID: rafiki - WALLET_ADDRESS_REDIRECT_HTML_PAGE: ${WALLET_ADDRESS_REDIRECT_HTML_PAGE} - OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} - ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} - ADMIN_SIGNATURE_VERSION: 1 - AUTH_SERVICE_API_URL: http://rafiki-auth:3011 - CARD_SERVICE_URL: 'http://rafiki-card-service:3007' - CARD_WEBHOOK_SERVICE_URL: 'http://rafiki-card-service:3007/webhook' - POS_SERVICE_URL: 'http://rafiki-pos-servicee:3014' - POS_WEBHOOK_SERVICE_URL: 'http://rafiki-pos-service:3014/webhook' - depends_on: - - postgres - - redis - <<: *logging - - rafiki-frontend: - container_name: rafiki-frontend - image: ghcr.io/interledger/rafiki-frontend:v2.3.0-beta - depends_on: - - rafiki-backend - restart: always - privileged: true - ports: - - '3012:3012' - networks: - - testnet - environment: - PORT: 3012 - GRAPHQL_URL: http://rafiki-backend:3001/graphql - OPEN_PAYMENTS_URL: https://rafiki-backend/ - ENABLE_INSECURE_MESSAGE_COOKIE: true - KRATOS_CONTAINER_PUBLIC_URL: 'http://kratos:4433' - KRATOS_BROWSER_PUBLIC_URL: 'http://localhost:4433' - KRATOS_ADMIN_URL: 'http://kratos:4434/admin' - AUTH_ENABLED: false - SIGNATURE_VERSION: 1 - <<: *logging - - rafiki-card-service: - container_name: rafiki-card-service - image: ghcr.io/interledger/rafiki-card-service:v2.3.0-beta - restart: always - privileged: true - networks: - - testnet - ports: - - '3007:3007' - environment: - NODE_ENV: development - LOG_LEVEL: debug - CARD_SERVICE_PORT: 3007 - REDIS_URL: redis://redis:6379/0 - GRAPHQL_URL: http://rafiki-backend:3001/graphql - TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} - TENANT_SECRET: ${ADMIN_API_SECRET:-secret-key} - TENANT_SIGNATURE_VERSION: 1 - <<: *logging - - rafiki-pos-service: - container_name: rafiki-pos-service - image: ghcr.io/interledger/rafiki-point-of-sale:v2.3.0-beta - restart: always - privileged: true - networks: - - testnet - ports: - - '3014:3014' - environment: - NODE_ENV: development - LOG_LEVEL: debug - PORT: 3014 - GRAPHQL_URL: http://rafiki-backend:3001/graphql - TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} - TENANT_SECRET: ${ADMIN_API_SECRET:-secret-key} - TENANT_SIGNATURE_VERSION: 1 - WEBHOOK_SIGNATURE_SECRET: ${RAFIKI_SIGNATURE_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} - WEBHOOK_SIGNATURE_VERSION: 1 - USE_HTTP: true - <<: *logging - - kratos: - image: 'oryd/kratos:v1.3.1' - privileged: true - depends_on: - - postgres - - mailslurper - ports: - - '4433:4433' - volumes: - - ../entrypoint.sh:/entrypoint.sh - - ../identity.schema.json:/etc/config/kratos/identity.schema.json - - ./kratos.yml:/etc/config/kratos/kratos.yml - entrypoint: ['/entrypoint.sh'] - networks: - - testnet - - tigerbeetle: - image: ghcr.io/tigerbeetle/tigerbeetle:0.16.60 - privileged: true - volumes: - - tigerbeetle-data:/var/lib/tigerbeetle - networks: - testnet: - ipv4_address: 10.5.0.50 - entrypoint: - - /bin/sh - - -c - - | - set -ex - DATA_FILE=/var/lib/tigerbeetle/cluster_0_replica_0.tigerbeetle - set +e - ls $$DATA_FILE - DATA_FILE_EXISTS="$$?" - set -e - echo $$DATA_FILE_EXISTS - if [ "$$DATA_FILE_EXISTS" != 0 ]; then - ./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE; - fi - hostname -i - ls /var/lib/tigerbeetle - ./tigerbeetle start --addresses=0.0.0.0:4342 $$DATA_FILE - - redis: - image: 'redis:7' - restart: unless-stopped - networks: - - testnet - - mailslurper: - image: oryd/mailslurper:latest-smtps - ports: - - '4436:4436' - - '4437:4437' - networks: - - testnet - -networks: - testnet: - driver: bridge - ipam: - config: - - subnet: 10.5.0.0/24 - gateway: 10.5.0.1 - -volumes: - pg-data: - tigerbeetle-data: # named volumes can be managed easier using docker-compose diff --git a/docker/dev/kratos.yml b/docker/dev/kratos.yml deleted file mode 100644 index 6089d1a3d..000000000 --- a/docker/dev/kratos.yml +++ /dev/null @@ -1,91 +0,0 @@ -version: v0.13.0 - -dsn: postgres://kratos:kratos@postgres:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4 - -serve: - public: - base_url: http://localhost:4433/ - cors: - enabled: true - admin: - base_url: http://kratos:4434/ - -selfservice: - default_browser_return_url: http://localhost:3012/ - allowed_return_urls: - - http://localhost:3012 - - methods: - link: - config: - lifespan: 1h - base_url: http://localhost:4433 - enabled: true - password: - enabled: true - - flows: - error: - ui_url: http://localhost:3012/error - - settings: - ui_url: http://localhost:3012/settings - privileged_session_max_age: 15m - required_aal: highest_available - - recovery: - enabled: true - ui_url: http://localhost:3012/auth/recovery - use: link - after: - hooks: - - hook: revoke_active_sessions - - verification: - enabled: false - - logout: - after: - default_browser_return_url: http://localhost:3012/auth - - login: - ui_url: http://localhost:3012/auth/login - lifespan: 10m - - registration: - enabled: false - -log: - level: debug - format: json - leak_sensitive_values: true - -secrets: - cookie: - - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE - cipher: - - 32-LONG-SECRET-NOT-SECURE-AT-ALL - -ciphers: - algorithm: xchacha20-poly1305 - -hashers: - algorithm: bcrypt - bcrypt: - cost: 8 - -identity: - schemas: - - id: default - url: file:///etc/config/kratos/identity.schema.json - -courier: - smtp: - connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true - -session: - lifespan: 1h - cookie: - persistent: false - same_site: Strict - path: / diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index 57a708b2b..000000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -set -e - -echo "Running Kratos Migrations..." -kratos -c /etc/config/kratos/kratos.yml migrate sql -e --yes - -if [ "$DEV_MODE" = true ]; then - echo "Starting Kratos in dev mode..." - exec kratos serve -c /etc/config/kratos/kratos.yml --dev --watch-courier -else - echo "Starting Kratos..." - exec kratos serve -c /etc/config/kratos/kratos.yml -fi \ No newline at end of file diff --git a/docker/identity.schema.json b/docker/identity.schema.json deleted file mode 100644 index 5af61c822..000000000 --- a/docker/identity.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Person", - "type": "object", - "properties": { - "traits": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "title": "E-Mail", - "minLength": 3, - "ory.sh/kratos": { - "credentials": { - "password": { - "identifier": true - } - }, - "verification": { - "via": "email" - }, - "recovery": { - "via": "email" - } - } - } - }, - "required": ["email"], - "additionalProperties": false - } - } -} diff --git a/docker/prod/.env.example b/docker/prod/.env.example deleted file mode 100644 index ff5b63f16..000000000 --- a/docker/prod/.env.example +++ /dev/null @@ -1,109 +0,0 @@ -# GENERAL -NODE_ENV= - -# DATABASE -POSTGRES_USER= -POSTGRES_PASSWORD= - -# WALLET FRONTEND -WALLET_FRONTEND_PORT= -WALLET_FRONTEND_BACKEND_URL= -WALLET_FRONTEND_OPEN_PAYMENTS_HOST= -WALLET_FRONTEND_AUTH_HOST= -WALLET_FRONTEND_THEME= - -# WALLET BACKEND -WALLET_BACKEND_PORT= -WALLET_BACKEND_DATABASE_URL= -WALLET_BACKEND_REDIS_URL= -WALLET_BACKEND_COOKIE_NAME= -WALLET_BACKEND_COOKIE_PASSWORD= -WALLET_BACKEND_COOKIE_TTL= -WALLET_BACKEND_OPEN_PAYMENTS_HOST= -WALLET_BACKEND_GRAPHQL_ENDPOINT= -WALLET_BACKEND_RAFIKI_MONEY_FRONTEND_HOST= -WALLET_BACKEND_SENDGRID_API_KEY= -WALLET_BACKEND_FROM_EMAIL= -WALLET_BACKEND_SEND_EMAIL= -WALLET_BACKEND_AUTH_DOMAIN= -WALLET_BACKEND_GATEHUB_ENV= -WALLET_BACKEND_GATEHUB_ACCESS_KEY= -WALLET_BACKEND_GATEHUB_SECRET_KEY= -WALLET_BACKEND_GATEHUB_WEBHOOK_SECRET= -WALLET_BACKEND_GATEHUB_GATEWAY_UUID= -WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS= -WALLET_BACKEND_GATEHUB_ORG_ID= -WALLET_BACKEND_GATEHUB_CARD_APP_ID= -WALLET_BACKEND_GATEHUB_ACCOUNT_PRODUCT_CODE= -WALLET_BACKEND_GATEHUB_CARD_PRODUCT_CODE= -WALLET_BACKEND_GATEHUB_NAME_ON_CARD= -WALLET_BACKEND_GATEHUB_CARD_PP_PREFIX= - -# BOUTIQUE -BOUTIQUE_BACKEND_PORT= -BOUTIQUE_BACKEND_DATABASE_URL= -BOUTIQUE_BACKEND_FRONTEND_URL= -BOUTIQUE_BACKEND_PRIVATE_KEY= -BOUTIQUE_BACKEND_KEY_ID= -BOUTIQUE_BACKEND_PAYMENT_POINTER= - -BOUTIQUE_FRONTEND_PORT= -BOUTIQUE_FRONTEND_API_BASE_URL= -BOUTIQUE_FRONTEND_CURRENCY= -BOUTIQUE_FRONTEND_THEME= - -# RAFIKI AUTH -RAFIKI_AUTH_PORT= -RAFIKI_AUTH_ADMIN_PORT= -RAFIKI_AUTH_INTROSPECTION_PORT= -RAFIKI_AUTH_DATABASE_URL= -RAFIKI_AUTH_IDENTITY_SERVER_DOMAIN= -RAFIKI_AUTH_IDENTITY_SERVER_SECRET= -RAFIKI_AUTH_COOKIE_KEY= -RAFIKI_AUTH_INTERACTION_COOKIE_SAME_SITE= -RAFIKI_AUTH_SERVER_DOMAIN= -RAFIKI_AUTH_WAIT_SECONDS= - -# RAFIKI BACKEND -RAFIKI_BACKEND_LOG_LEVEL= -RAFIKI_BACKEND_ADMIN_PORT= -RAFIKI_BACKEND_CONNECTOR_PORT= -RAFIKI_BACKEND_OPEN_PAYMENTS_PORT= -RAFIKI_BACKEND_DATABASE_URL= -RAFIKI_BACKEND_USE_TIGERBEETLE= -RAFIKI_BACKEND_TIGERBEETLE_CLUSTER_ID= -RAFIKI_BACKEND_TIGERBEETLE_REPLICA_ADDRESSES= -RAFIKI_BACKEND_NONCE_REDIS_KEY= -RAFIKI_BACKEND_AUTH_SERVER_GRANT_URL= -RAFIKI_BACKEND_AUTH_SERVER_INTROSPECTION_URL= -RAFIKI_BACKEND_ILP_ADDRESS= -RAFIKI_BACKEND_STREAM_SECRET= -RAFIKI_BACKEND_ADMIN_KEY= -RAFIKI_BACKEND_OPEN_PAYMENTS_URL= -RAFIKI_BACKEND_REDIS_URL= -RAFIKI_BACKEND_WALLET_ADDRESS_URL= -RAFIKI_BACKEND_WEBHOOK_URL= -RAFIKI_BACKEND_WEBHOOK_TIMEOUT= -RAFIKI_BACKEND_EXCHANGE_RATES_URL= -RAFIKI_BACKEND_AUTOPEERING_PORT= -RAFIKI_BACKEND_ILP_CONNECTOR_ADDRESS= -RAFIKI_BACKEND_INSTANCE_NAME= -RAFIKI_BACKEND_SLIPPAGE= -RAFIKI_BACKEND_ENABLE_TELEMETRY= -RAFIKI_BACKEND_LIVENET= -RAFIKI_BACKEND_SIGNATURE_SECRET= -RAFIKI_BACKEND_WALLET_ADDRESS_REDIRECT_HTML_PAGE= - -# RAFIKI FRONTEND -RAFIKI_FRONTEND_PORT= -RAFIKI_FRONTEND_GRAPHQL_URL= -RAFIKI_FRONTEND_OPEN_PAYMENTS_URL= - -# INTERLEDGER PAY -INTERLEDGER_PAY_PORT= -INTERLEDGER_PAY_KEY_ID= -INTERLEDGER_PAY_PRIVATE_KEY= -INTERLEDGER_PAY_WALLET_ADDRESS= -INTERLEDGER_PAY_REDIRECT_URL= -INTERLEDGER_PAY_HOST= -INTERLEDGER_PAY_SESSION_COOKIE_SECRET_KEY= diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml deleted file mode 100644 index 67df5ad8a..000000000 --- a/docker/prod/docker-compose.yml +++ /dev/null @@ -1,305 +0,0 @@ -version: '3.8' -name: 'testnet' - -x-logging: &logging - logging: - driver: 'gcplogs' - -services: - postgres: - image: 'postgres:15' - container_name: postgres - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - networks: - - testnet - restart: unless-stopped - volumes: - - pg-data:/var/lib/postgresql/data - - ../dbinit.sql:/docker-entrypoint-initdb.d/init.sql - - wallet-frontend: - hostname: wallet-frontend - build: - context: ../.. - dockerfile: ./packages/wallet/frontend/Dockerfile.prod - args: - PORT: ${WALLET_FRONTEND_PORT} - COOKIE_NAME: ${WALLET_BACKEND_COOKIE_NAME} - NEXT_PUBLIC_BACKEND_URL: ${WALLET_FRONTEND_BACKEND_URL} - NEXT_PUBLIC_OPEN_PAYMENTS_HOST: ${WALLET_FRONTEND_OPEN_PAYMENTS_HOST} - NEXT_PUBLIC_AUTH_HOST: ${WALLET_FRONTEND_AUTH_HOST} - NEXT_PUBLIC_GATEHUB_ENV: ${WALLET_BACKEND_GATEHUB_ENV} - NEXT_PUBLIC_THEME: ${WALLET_FRONTEND_THEME} - container_name: wallet-frontend - ports: - - '${WALLET_FRONTEND_PORT}:${WALLET_FRONTEND_PORT}' - restart: always - <<: *logging - - wallet-backend: - build: - context: ../.. - dockerfile: ./packages/wallet/backend/Dockerfile.prod - container_name: wallet-backend - depends_on: - - postgres - - rafiki-backend - environment: - NODE_ENV: ${NODE_ENV} - PORT: ${WALLET_BACKEND_PORT} - DATABASE_URL: ${WALLET_BACKEND_DATABASE_URL} - COOKIE_NAME: ${WALLET_BACKEND_COOKIE_NAME} - COOKIE_PASSWORD: ${WALLET_BACKEND_COOKIE_PASSWORD} - COOKIE_TTL: ${WALLET_BACKEND_COOKIE_TTL} - OPEN_PAYMENTS_HOST: ${WALLET_BACKEND_OPEN_PAYMENTS_HOST} - GRAPHQL_ENDPOINT: ${WALLET_BACKEND_GRAPHQL_ENDPOINT} - RAFIKI_MONEY_FRONTEND_HOST: ${WALLET_BACKEND_RAFIKI_MONEY_FRONTEND_HOST} - SENDGRID_API_KEY: ${WALLET_BACKEND_SENDGRID_API_KEY} - FROM_EMAIL: ${WALLET_BACKEND_FROM_EMAIL} - SEND_EMAIL: ${WALLET_BACKEND_SEND_EMAIL} - AUTH_IDENTITY_SERVER_SECRET: ${RAFIKI_AUTH_IDENTITY_SERVER_SECRET} - RAFIKI_WEBHOOK_SIGNATURE_SECRET: ${RAFIKI_BACKEND_SIGNATURE_SECRET} - AUTH_DOMAIN: ${WALLET_BACKEND_AUTH_DOMAIN} - REDIS_URL: ${WALLET_BACKEND_REDIS_URL} - GATEHUB_ENV: ${WALLET_BACKEND_GATEHUB_ENV} - GATEHUB_ACCESS_KEY: ${WALLET_BACKEND_GATEHUB_ACCESS_KEY} - GATEHUB_SECRET_KEY: ${WALLET_BACKEND_GATEHUB_SECRET_KEY} - GATEHUB_WEBHOOK_SECRET: ${WALLET_BACKEND_GATEHUB_WEBHOOK_SECRET} - GATEHUB_GATEWAY_UUID: ${WALLET_BACKEND_GATEHUB_GATEWAY_UUID} - GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS} - GATEHUB_ORG_ID: ${WALLET_BACKEND_GATEHUB_ORG_ID} - GATEHUB_CARD_APP_ID: ${WALLET_BACKEND_GATEHUB_CARD_APP_ID} - GATEHUB_ACCOUNT_PRODUCT_CODE: ${WALLET_BACKEND_GATEHUB_ACCOUNT_PRODUCT_CODE} - GATEHUB_CARD_PRODUCT_CODE: ${WALLET_BACKEND_GATEHUB_CARD_PRODUCT_CODE} - GATEHUB_NAME_ON_CARD: ${WALLET_BACKEND_GATEHUB_NAME_ON_CARD} - GATEHUB_CARD_PP_PREFIX: ${WALLET_BACKEND_GATEHUB_CARD_PP_PREFIX} - STRIPE_SECRET_KEY: ${WALLET_BACKEND_STRIPE_SECRET_KEY} - STRIPE_WEBHOOK_SECRET: ${WALLET_BACKEND_STRIPE_WEBHOOK_SECRET} - USE_STRIPE: ${USE_STRIPE} - networks: - - testnet - ports: - - '${WALLET_BACKEND_PORT}:${WALLET_BACKEND_PORT}' - restart: always - <<: *logging - - boutique-frontend: - container_name: boutique-frontend - build: - context: ../.. - dockerfile: ./packages/boutique/frontend/Dockerfile.prod - args: - PORT: ${BOUTIQUE_FRONTEND_API_BASE_URL} - VITE_API_BASE_URL: ${BOUTIQUE_FRONTEND_API_BASE_URL} - VITE_CURRENCY: ${BOUTIQUE_FRONTEND_CURRENCY} - VITE_THEME: ${BOUTIQUE_FRONTEND_THEME} - depends_on: - - postgres - environment: - NODE_ENV: ${NODE_ENV} - restart: always - networks: - - testnet - ports: - - '${BOUTIQUE_FRONTEND_PORT}:${BOUTIQUE_FRONTEND_PORT}' - <<: *logging - - boutique-backend: - container_name: boutique-backend - build: - context: ../.. - dockerfile: ./packages/boutique/backend/Dockerfile.prod - depends_on: - - postgres - environment: - NODE_ENV: ${NODE_ENV} - PORT: ${BOUTIQUE_BACKEND_PORT} - FRONTEND_URL: ${BOUTIQUE_BACKEND_FRONTEND_URL} - DATABASE_URL: ${BOUTIQUE_BACKEND_DATABASE_URL} - PRIVATE_KEY: ${BOUTIQUE_BACKEND_PRIVATE_KEY} - KEY_ID: ${BOUTIQUE_BACKEND_KEY_ID} - PAYMENT_POINTER: ${BOUTIQUE_BACKEND_PAYMENT_POINTER} - restart: always - networks: - - testnet - ports: - - '3004:3004' - <<: *logging - - rafiki-auth: - image: ghcr.io/interledger/rafiki-auth:v1.2.0-beta - container_name: rafiki-auth - environment: - NODE_ENV: ${NODE_ENV} - AUTH_PORT: ${RAFIKI_AUTH_PORT} - ADMIN_PORT: ${RAFIKI_AUTH_ADMIN_PORT} - INTROSPECTION_PORT: ${RAFIKI_AUTH_INTROSPECTION_PORT} - AUTH_DATABASE_URL: ${RAFIKI_AUTH_DATABASE_URL} - COOKIE_KEY: ${RAFIKI_AUTH_COOKIE_KEY} - INTERACTION_COOKIE_SAME_SITE: ${RAFIKI_AUTH_INTERACTION_COOKIE_SAME_SITE} - IDENTITY_SERVER_SECRET: ${RAFIKI_AUTH_IDENTITY_SERVER_SECRET} - IDENTITY_SERVER_URL: ${RAFIKI_AUTH_IDENTITY_SERVER_DOMAIN} - AUTH_SERVER_URL: ${RAFIKI_AUTH_SERVER_DOMAIN} - WAIT_SECONDS: ${RAFIKI_AUTH_WAIT_SECONDS} - TRUST_PROXY: true - REDIS_URL: redis://redis:6379/0 - depends_on: - - postgres - networks: - - testnet - ports: - - '${RAFIKI_AUTH_PORT}:${RAFIKI_AUTH_PORT}' - - '${RAFIKI_AUTH_ADMIN_PORT}:${RAFIKI_AUTH_ADMIN_PORT}' - restart: always - <<: *logging - - rafiki-backend: - image: ghcr.io/interledger/rafiki-backend:v1.1.2-beta - container_name: rafiki-backend - depends_on: - - postgres - - redis - environment: - NODE_ENV: ${NODE_ENV} - LOG_LEVEL: ${RAFIKI_BACKEND_LOG_LEVEL} - ADMIN_PORT: ${RAFIKI_BACKEND_ADMIN_PORT} - CONNECTOR_PORT: ${RAFIKI_BACKEND_CONNECTOR_PORT} - OPEN_PAYMENTS_PORT: ${RAFIKI_BACKEND_OPEN_PAYMENTS_PORT} - DATABASE_URL: ${RAFIKI_BACKEND_DATABASE_URL} - USE_TIGERBEETLE: ${RAFIKI_BACKEND_USE_TIGERBEETLE} - TIGERBEETLE_CLUSTER_ID: ${RAFIKI_BACKEND_TIGERBEETLE_CLUSTER_ID} - TIGERBEETLE_REPLICA_ADDRESSES: ${RAFIKI_BACKEND_TIGERBEETLE_REPLICA_ADDRESSES} - NONCE_REDIS_KEY: ${RAFIKI_BACKEND_NONCE_REDIS_KEY} - AUTH_SERVER_GRANT_URL: ${RAFIKI_BACKEND_AUTH_SERVER_GRANT_URL} - AUTH_SERVER_INTROSPECTION_URL: ${RAFIKI_BACKEND_AUTH_SERVER_INTROSPECTION_URL} - ILP_ADDRESS: ${RAFIKI_BACKEND_ILP_ADDRESS} - STREAM_SECRET: ${RAFIKI_BACKEND_STREAM_SECRET} - ADMIN_KEY: ${RAFIKI_BACKEND_ADMIN_KEY} - OPEN_PAYMENTS_URL: ${RAFIKI_BACKEND_OPEN_PAYMENTS_URL} - REDIS_URL: ${RAFIKI_BACKEND_REDIS_URL} - WALLET_ADDRESS_URL: ${RAFIKI_BACKEND_WALLET_ADDRESS_URL} - WEBHOOK_URL: ${RAFIKI_BACKEND_WEBHOOK_URL} - WEBHOOK_TIMEOUT: ${RAFIKI_BACKEND_WEBHOOK_TIMEOUT} - SIGNATURE_SECRET: ${RAFIKI_BACKEND_SIGNATURE_SECRET} - EXCHANGE_RATES_URL: ${RAFIKI_BACKEND_EXCHANGE_RATES_URL} - TRUST_PROXY: true - ENABLE_AUTO_PEERING: true - AUTO_PEERING_SERVER_PORT: ${RAFIKI_BACKEND_AUTOPEERING_PORT} - ILP_CONNECTOR_URL: ${RAFIKI_BACKEND_ILP_CONNECTOR_ADDRESS} - INSTANCE_NAME: ${RAFIKI_BACKEND_INSTANCE_NAME} - SLIPPAGE: ${RAFIKI_BACKEND_SLIPPAGE} - ENABLE_TELEMETRY: ${RAFIKI_BACKEND_ENABLE_TELEMETRY} - LIVENET: ${RAFIKI_BACKEND_LIVENET} - KEY_ID: ${RAFIKI_BACKEND_KEY_ID} - WALLET_ADDRESS_REDIRECT_HTML_PAGE: ${RAFIKI_BACKEND_WALLET_ADDRESS_REDIRECT_HTML_PAGE} - networks: - - testnet - ports: - - '3010:80' - - '3011:3001' - - '${RAFIKI_BACKEND_AUTOPEERING_PORT}:${RAFIKI_BACKEND_AUTOPEERING_PORT}' - - '${RAFIKI_BACKEND_CONNECTOR_PORT}:${RAFIKI_BACKEND_CONNECTOR_PORT}' - privileged: true - restart: always - volumes: - - ../temp/:/workspace/temp/ - <<: *logging - - interledger-pay: - image: ghcr.io/interledger/interledger-pay:latest - container_name: interledger-pay - environment: - NODE_ENV: ${NODE_ENV} - PORT: ${INTERLEDGER_PAY_PORT} - KEY_ID: ${INTERLEDGER_PAY_KEY_ID} - PRIVATE_KEY: ${INTERLEDGER_PAY_PRIVATE_KEY} - WALLET_ADDRESS: ${INTERLEDGER_PAY_WALLET_ADDRESS} - REDIRECT_URL: ${INTERLEDGER_PAY_REDIRECT_URL} - INTERLEDGER_PAY_HOST: ${INTERLEDGER_PAY_HOST} - SESSION_COOKIE_SECRET_KEY: ${INTERLEDGER_PAY_SESSION_COOKIE_SECRET_KEY} - networks: - - testnet - restart: always - privileged: true - ports: - - '${INTERLEDGER_PAY_PORT}:${INTERLEDGER_PAY_PORT}' - <<: *logging - - rafiki-frontend: - image: ghcr.io/interledger/rafiki-frontend:v1.1.2-beta - container_name: rafiki-frontend - depends_on: - - rafiki-backend - environment: - PORT: ${RAFIKI_FRONTEND_PORT} - GRAPHQL_URL: ${RAFIKI_FRONTEND_GRAPHQL_URL} - OPEN_PAYMENTS_URL: ${RAFIKI_FRONTEND_OPEN_PAYMENTS_URL} - KRATOS_CONTAINER_PUBLIC_URL: 'http://kratos:4433' - KRATOS_BROWSER_PUBLIC_URL: 'https://admin.rafiki.money/kratos' - KRATOS_ADMIN_URL: 'http://kratos:4434/admin' - networks: - - testnet - restart: always - privileged: true - ports: - - '${RAFIKI_FRONTEND_PORT}:${RAFIKI_FRONTEND_PORT}' - <<: *logging - - tigerbeetle: - image: ghcr.io/tigerbeetle/tigerbeetle:0.16.60 - privileged: true - volumes: - - tigerbeetle-data:/var/lib/tigerbeetle - networks: - testnet: - ipv4_address: 10.5.0.50 - entrypoint: - - /bin/sh - - -c - - | - set -ex - DATA_FILE=/var/lib/tigerbeetle/cluster_0_replica_0.tigerbeetle - set +e - ls $$DATA_FILE - DATA_FILE_EXISTS="$$?" - set -e - echo $$DATA_FILE_EXISTS - if [ "$$DATA_FILE_EXISTS" != 0 ]; then - ./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE; - fi - hostname -i - ls /var/lib/tigerbeetle - ./tigerbeetle start --addresses=0.0.0.0:4342 $$DATA_FILE - - redis: - image: 'redis:7' - restart: unless-stopped - networks: - - testnet - - kratos: - image: 'oryd/kratos:v1.3.1' - privileged: true - ports: - - '4433:4433' - volumes: - - ../entrypoint.sh:/entrypoint.sh - - ../identity.schema.json:/etc/config/kratos/identity.schema.json - - ./kratos.yml:/etc/config/kratos/kratos.yml - entrypoint: ['/entrypoint.sh'] - networks: - - testnet - -networks: - testnet: - driver: bridge - ipam: - config: - - subnet: 10.5.0.0/24 - gateway: 10.5.0.1 - -volumes: - pg-data: - tigerbeetle-data: # named volumes can be managed easier using docker-compose diff --git a/docker/prod/kratos.yml b/docker/prod/kratos.yml deleted file mode 100644 index 386d43672..000000000 --- a/docker/prod/kratos.yml +++ /dev/null @@ -1,91 +0,0 @@ -version: v0.13.0 - -dsn: postgres://kratos:kratos@postgres:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4 - -serve: - public: - base_url: https://admin.rafiki.money/kratos - cors: - enabled: true - admin: - base_url: http://kratos:4434/ - -selfservice: - default_browser_return_url: https://admin.rafiki.money/ - allowed_return_urls: - - https://admin.rafiki.money - - methods: - link: - config: - lifespan: 1h - base_url: https://admin.rafiki.money/kratos - enabled: true - password: - enabled: true - - flows: - error: - ui_url: https://admin.rafiki.money/error - - settings: - ui_url: https://admin.rafiki.money/settings - privileged_session_max_age: 15m - required_aal: highest_available - - recovery: - enabled: true - ui_url: https://admin.rafiki.money/auth/recovery - use: link - after: - hooks: - - hook: revoke_active_sessions - - verification: - enabled: false - - logout: - after: - default_browser_return_url: https://admin.rafiki.money/auth - - login: - ui_url: https://admin.rafiki.money/auth/login - lifespan: 10m - - registration: - enabled: false - -log: - level: debug - format: json - leak_sensitive_values: true - -secrets: - cookie: - - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE - cipher: - - 32-LONG-SECRET-NOT-SECURE-AT-ALL - -ciphers: - algorithm: xchacha20-poly1305 - -hashers: - algorithm: bcrypt - bcrypt: - cost: 8 - -identity: - schemas: - - id: default - url: file:///etc/config/kratos/identity.schema.json - -courier: - smtp: - connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true - -session: - lifespan: 1h - cookie: - persistent: false - same_site: Strict - path: / diff --git a/docker/temp/private-key.pem b/docker/temp/private-key.pem deleted file mode 100644 index 43fefaf8a..000000000 --- a/docker/temp/private-key.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIIZFs7Y4AoIP/4WcVtKt74Uim4mxnZhV9zQ5RSnQen5u ------END PRIVATE KEY----- diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 000000000..cd8ecbbb0 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,5 @@ +TEST_BASE_URL=https://testnet.test +WALLET_BACKEND_CONTAINER=wallet-backend-local +ENABLE_SCREENSHOTS=false +# Set to true only if your local cert trust is not configured. +PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true \ No newline at end of file diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..59f917a58 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules +playwright-report +test-results +.features-gen \ No newline at end of file diff --git a/e2e/features/auth-signup-dashboard.feature b/e2e/features/auth-signup-dashboard.feature new file mode 100644 index 000000000..86d137f75 --- /dev/null +++ b/e2e/features/auth-signup-dashboard.feature @@ -0,0 +1,19 @@ +Feature: Wallet authentication onboarding + As a new wallet user + I want to sign up, verify my email, complete KYC, and reach my default account + So that I can access my wallet dashboard + + Scenario: New user completes signup, verification, login, KYC, and account access + Given I am a new unique wallet user + When I open the signup page + And I complete the signup form + And I submit signup + Then I should see signup confirmation + When I open the verification link from backend logs + Then I should see verification success + When I continue to login + And I login with my new credentials + And I complete KYC if I am redirected to KYC + Then I should see the accounts dashboard + When I open the EUR default account + Then I should see the account balance page diff --git a/e2e/features/cross-currency-transfer.feature b/e2e/features/cross-currency-transfer.feature new file mode 100644 index 000000000..bc1bbbbac --- /dev/null +++ b/e2e/features/cross-currency-transfer.feature @@ -0,0 +1,12 @@ +Feature: Cross-currency payment transfers + As a wallet user + I want to send payments between accounts in different currencies + So that I can transfer value across currency boundaries + + Scenario: User can navigate to send page and select accounts + Given I am a verified and logged-in wallet user + When I navigate to the send page + And I select a source account + Then I should see the wallet address selector + When I select a wallet address + Then I should see the recipient address input field diff --git a/e2e/features/deposit-transactions-regression.feature b/e2e/features/deposit-transactions-regression.feature new file mode 100644 index 000000000..f738ba71e --- /dev/null +++ b/e2e/features/deposit-transactions-regression.feature @@ -0,0 +1,30 @@ +Feature: Deposit transaction regressions + As a wallet user + I want deposits to create one transaction and a correct balance delta + So that transaction history and balances stay consistent + + Scenario: Iframe deposit creates a single transaction row with matching balance delta + Given I am a verified and logged-in wallet user + When I open the EUR default account for deposit checks + And I record the current account balance + And I record the current transactions count + And I complete a deposit of 11.00 EUR via the GateHub iframe + And I open the transactions page for deposit checks + Then the transaction count should increase by exactly 1 + When I wait 10 seconds and refresh transactions + Then the transaction count should still increase by exactly 1 + And the latest transaction amount should match the deposit amount + And the account balance increase should match the deposit amount + + Scenario: Local dialog deposit creates a single transaction row with matching balance delta + Given I am a verified and logged-in wallet user + When I open the EUR default account for deposit checks + And I record the current account balance + And I record the current transactions count + And I complete a deposit of 11.00 EUR via the local dialog + And I open the transactions page for deposit checks + Then the transaction count should increase by exactly 1 + When I wait 10 seconds and refresh transactions + Then the transaction count should still increase by exactly 1 + And the latest transaction amount should match the deposit amount + And the account balance increase should match the deposit amount diff --git a/e2e/features/steps/auth-signup-dashboard.steps.ts b/e2e/features/steps/auth-signup-dashboard.steps.ts new file mode 100644 index 000000000..48c392c94 --- /dev/null +++ b/e2e/features/steps/auth-signup-dashboard.steps.ts @@ -0,0 +1,146 @@ +import { expect } from '@playwright/test' +import { + completeLocalMockKyc, + waitForVerificationLinkFromLogs +} from '../../helpers/local-wallet' +import { Given, Then, When } from './fixtures' + +Given('I am a new unique wallet user', async ({ flow }) => { + expect(flow.credentials.email).toContain('e2e-') + expect(flow.credentials.password).toContain('Testnet!') +}) + +When('I open the signup page', async ({ page, flow }) => { + await page.goto('/auth/signup') + await expect( + page.getByRole('heading', { name: 'Create Account' }) + ).toBeVisible() + await flow.takeScreenshot('signup-page') +}) + +When('I complete the signup form', async ({ page, flow }) => { + const signUpForm = page.locator('form') + + await signUpForm + .getByLabel('E-mail *', { exact: true }) + .fill(flow.credentials.email) + await flow.takeScreenshot('signup-email-filled') + await signUpForm + .getByLabel('Password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('signup-password-filled') + await signUpForm + .getByLabel('Confirm password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('signup-confirm-password-filled') +}) + +When('I submit signup', async ({ page, flow }) => { + const signUpForm = page.locator('form') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/signup') && + response.request().method() === 'POST' && + response.status() === 201 + ), + signUpForm.locator('button[type="submit"]').click() + ]) + + await flow.takeScreenshot('signup-submitted') +}) + +Then('I should see signup confirmation', async ({ page, flow }) => { + await expect( + page.getByText('A verification link has been sent to your email account.') + ).toBeVisible() + await flow.takeScreenshot('signup-success') +}) + +When( + 'I open the verification link from backend logs', + async ({ page, flow }) => { + const verificationLink = await waitForVerificationLinkFromLogs({ + since: flow.logMarker, + containerName: flow.containerName + }) + + flow.verificationLink = verificationLink + + await page.goto(verificationLink) + await flow.takeScreenshot('verification-page-opened') + } +) + +Then('I should see verification success', async ({ page, flow }) => { + await expect( + page.getByText( + 'Your email has been verified. Continue to login to use Interledger Test Wallet.' + ) + ).toBeVisible() + await flow.takeScreenshot('verify-success') +}) + +When('I continue to login', async ({ page, flow }) => { + await page.locator('a[href="/auth/login"]').first().click() + await expect(page).toHaveURL(/\/auth\/login$/) + await flow.takeScreenshot('login-page-opened') +}) + +When('I login with my new credentials', async ({ page, flow }) => { + const loginForm = page.locator('form') + + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(flow.credentials.email) + await flow.takeScreenshot('login-email-filled') + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(flow.credentials.password) + await flow.takeScreenshot('login-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + + await flow.takeScreenshot('login-submitted') + await page.waitForURL(/\/(kyc)?$/, { timeout: 60_000 }) + await flow.takeScreenshot('post-login') +}) + +When('I complete KYC if I am redirected to KYC', async ({ page, flow }) => { + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, flow.takeScreenshot) + } +}) + +Then('I should see the accounts dashboard', async ({ page, flow }) => { + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await expect(page.getByText('Here is your account overview!')).toBeVisible() + await flow.takeScreenshot('dashboard-confirmed') +}) + +When('I open the EUR default account', async ({ page, flow }) => { + const defaultAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + + await expect(defaultAccount).toBeVisible() + await flow.takeScreenshot('dashboard') + await defaultAccount.click() + await flow.takeScreenshot('default-account-opened') +}) + +Then('I should see the account balance page', async ({ page, flow }) => { + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + await flow.takeScreenshot('account-page') +}) diff --git a/e2e/features/steps/cross-currency-transfer.steps.ts b/e2e/features/steps/cross-currency-transfer.steps.ts new file mode 100644 index 000000000..aeffe6675 --- /dev/null +++ b/e2e/features/steps/cross-currency-transfer.steps.ts @@ -0,0 +1,110 @@ +import { expect } from '@playwright/test' +import { completeLocalMockKyc, setupVerifiedUser } from '../../helpers/local-wallet' +import { Given, Then, When } from './fixtures' + +Given('I am a verified and logged-in wallet user', async ({ page, flow }) => { + const containerName = flow.containerName + + // Use the helper to quickly set up a verified user + const credentials = await setupVerifiedUser({ + page, + takeScreenshot: flow.takeScreenshot, + containerName, + skipScreenshots: false + }) + + // Store credentials in flow for later use if needed + flow.credentials = credentials + + // Validate authenticated access to protected routes; recover by logging in again if needed. + await page.goto('/send') + + if (page.url().includes('/auth/login')) { + const loginForm = page.locator('form') + + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, flow.takeScreenshot) + await page.goto('/send') + } else { + await page.waitForURL(/\/send$/, { timeout: 60_000 }) + } + } + + await expect(page).toHaveURL(/\/send$/) + await flow.takeScreenshot('verified-user-ready') +}) + +When('I navigate to the send page', async ({ page, flow }) => { + await page.goto('/send') + await expect(page).toHaveURL(/\/send$/) + await expect(page.getByRole('heading', { name: 'Send' })).toBeVisible() + await flow.takeScreenshot('send-page-loaded') +}) + +When('I select a source account', async ({ page, flow }) => { + // Click on the account selector + const accountSelect = page.locator('#selectAccount') + await expect(accountSelect).toBeVisible() + await flow.takeScreenshot('before-select-account') + + await accountSelect.click() + await flow.takeScreenshot('account-dropdown-opened') + + // Select the first account (EUR Account or whatever is available) + const firstAccountOption = page.locator('[role="option"]').first() + await expect(firstAccountOption).toBeVisible() + await firstAccountOption.click() + await flow.takeScreenshot('account-selected') +}) + +Then('I should see the wallet address selector', async ({ page, flow }) => { + const walletAddressSelect = page.locator('#selectWalletAddress') + await expect(walletAddressSelect).toBeVisible() + await flow.takeScreenshot('wallet-address-selector-visible') +}) + +When('I select a wallet address', async ({ page, flow }) => { + const walletAddressSelect = page.locator('#selectWalletAddress') + await expect(walletAddressSelect).toBeVisible() + await flow.takeScreenshot('before-select-wallet-address') + + await walletAddressSelect.click() + await flow.takeScreenshot('wallet-address-dropdown-opened') + + // Select the first wallet address option + const firstWalletOption = page.locator('[role="option"]').first() + await expect(firstWalletOption).toBeVisible() + await firstWalletOption.click() + await flow.takeScreenshot('wallet-address-selected') +}) + +Then( + 'I should see the recipient address input field', + async ({ page, flow }) => { + const recipientInput = page.locator('#addRecipientWalletAddress') + await expect(recipientInput).toBeVisible() + await flow.takeScreenshot('recipient-address-input-visible') + + // Verify amount input is also visible + const amountInput = page.locator('#addAmount') + await expect(amountInput).toBeVisible() + await flow.takeScreenshot('amount-input-visible') + } +) diff --git a/e2e/features/steps/deposit-transactions-regression.steps.ts b/e2e/features/steps/deposit-transactions-regression.steps.ts new file mode 100644 index 000000000..7d82d4dbf --- /dev/null +++ b/e2e/features/steps/deposit-transactions-regression.steps.ts @@ -0,0 +1,264 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { Given, Then, When } from './fixtures' + +const EPSILON = 0.01 + +function parseAmountFromText(text: string): number { + const normalized = text.replace(/,/g, '').replace(/[^0-9.-]/g, '') + const parsed = Number.parseFloat(normalized) + + if (Number.isNaN(parsed)) { + throw new Error(`Unable to parse amount from text: "${text}"`) + } + + return parsed +} + +async function readAccountBalance(page: Page) { + const balanceSection = page + .getByRole('heading', { name: 'Balance' }) + .locator('xpath=following-sibling::div[1]') + + const balanceText = await balanceSection.innerText() + + return parseAmountFromText(balanceText) +} + +When('I open the EUR default account for deposit checks', async ({ + page, + flow +}) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + + const defaultAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + + await expect(defaultAccount).toBeVisible() + await defaultAccount.click() + + await expect(page).toHaveURL(/\/account\/.+/) + await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible() + + const url = new URL(page.url()) + flow.accountPath = `${url.pathname}${url.search}` + + await flow.takeScreenshot('deposit-check-account-opened') +}) + +When('I record the current account balance', async ({ page, flow }) => { + flow.initialBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-check-initial-balance') +}) + +When('I record the current transactions count', async ({ page, flow }) => { + await page.goto('/transactions') + await expect(page).toHaveURL(/\/transactions/) + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + flow.initialTransactionRows = await rows.count() + + await flow.takeScreenshot('deposit-check-initial-transactions') + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) +}) + +When( + 'I complete a deposit of {float} EUR via the GateHub iframe', + async ({ page, flow }, amount: number) => { + flow.depositAmount = amount + + await page.goto('/deposit') + await expect(page).toHaveURL(/\/deposit/, { + message: + 'Expected /deposit page — ensure MockGatehub is running and GATEHUB_IFRAME_MANAGED_RAMP_URL is configured' + }) + await expect(page.locator('iframe')).toBeVisible({ + message: 'Expected deposit iframe on /deposit page' + }) + + await expect(page.getByRole('heading', { name: 'Deposit' })).toBeVisible() + await flow.takeScreenshot('deposit-iframe-page-opened') + + const frame = page.frameLocator('iframe') + await expect(frame.locator('#amount')).toBeVisible() + + await frame.locator('#amount').fill(amount.toFixed(2)) + await frame.locator('#currency').selectOption('EUR') + await flow.takeScreenshot('deposit-iframe-filled') + + await frame.getByTestId('complete-button').click() + await expect(frame.locator('#status')).toContainText('successfully', { + timeout: 30_000 + }) + await flow.takeScreenshot('deposit-iframe-submitted') + + // Give webhook + UI caches a chance to settle before measuring balance. + await page.waitForTimeout(4000) + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) + + flow.postDepositBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-iframe-post-balance') + } +) + +When( + 'I complete a deposit of {float} EUR via the local dialog', + async ({ page, flow }, amount: number) => { + flow.depositAmount = amount + + if (!flow.accountPath) { + throw new Error('Missing account path in flow state') + } + + await page.goto(flow.accountPath) + await expect(page.locator('#fund')).toBeVisible() + await page.locator('#fund').click() + + await expect(page.getByText('Deposit to your egg basket')).toBeVisible() + + await page.getByLabel('Amount').fill(amount.toFixed(2)) + await flow.takeScreenshot('deposit-dialog-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/fund') && + response.request().method() === 'POST' && + response.status() >= 200 && + response.status() < 300 + ), + page.locator('button[aria-label="deposit"]').click() + ]) + + await expect(page.getByText('Deposit success')).toBeVisible() + await flow.takeScreenshot('deposit-dialog-submitted') + + // Give webhook + UI caches a chance to settle before measuring balance. + await page.waitForTimeout(4000) + + await page.goto(flow.accountPath) + await expect(page).toHaveURL(/\/account\/.+/) + + flow.postDepositBalance = await readAccountBalance(page) + await flow.takeScreenshot('deposit-dialog-post-balance') + } +) + +When('I open the transactions page for deposit checks', async ({ page, flow }) => { + await page.goto('/transactions') + await expect(page).toHaveURL(/\/transactions/) + await expect(page.getByRole('heading', { name: 'Transactions' })).toBeVisible() + + if (flow.initialTransactionRows === undefined) { + throw new Error('Missing initial transactions count in flow state') + } + + const expectedMinimumRows = flow.initialTransactionRows + 1 + let currentRows = 0 + + for (let attempt = 0; attempt < 6; attempt++) { + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + currentRows = await rows.count() + + if (currentRows >= expectedMinimumRows) { + break + } + + await page.waitForTimeout(2000) + await page.reload() + await expect(page).toHaveURL(/\/transactions/) + } + + flow.postDepositTransactionRows = currentRows + + expect(flow.postDepositTransactionRows).toBeGreaterThanOrEqual( + expectedMinimumRows + ) + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + + await expect(rows.first()).toBeVisible() + + const amountCellText = await rows + .first() + .locator('td') + .nth(2) + .innerText() + + flow.latestTransactionAmount = Math.abs(parseAmountFromText(amountCellText)) + + await flow.takeScreenshot('deposit-check-post-transactions') +}) + +Then('the transaction count should increase by exactly 1', async ({ flow }) => { + expect(flow.initialTransactionRows).toBeDefined() + expect(flow.postDepositTransactionRows).toBeDefined() + + expect(flow.postDepositTransactionRows! - flow.initialTransactionRows!).toBe(1) +}) + +When('I wait {int} seconds and refresh transactions', async ({ page, flow }, seconds: number) => { + await page.waitForTimeout(seconds * 1000) + await page.reload() + + await expect(page).toHaveURL(/\/transactions/) + await expect(page.getByRole('heading', { name: 'Transactions' })).toBeVisible() + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + flow.delayedRefreshTransactionRows = await rows.count() + + if (flow.delayedRefreshTransactionRows > 0) { + await expect(rows.first()).toBeVisible() + } + await flow.takeScreenshot('deposit-check-post-transactions-delayed-refresh') +}) + +Then( + 'the transaction count should still increase by exactly 1', + async ({ flow }) => { + expect(flow.initialTransactionRows).toBeDefined() + expect(flow.delayedRefreshTransactionRows).toBeDefined() + + expect( + flow.delayedRefreshTransactionRows! - flow.initialTransactionRows! + ).toBe(1) + } +) + +Then( + 'the latest transaction amount should match the deposit amount', + async ({ flow }) => { + expect(flow.depositAmount).toBeDefined() + expect(flow.latestTransactionAmount).toBeDefined() + + const delta = Math.abs(flow.latestTransactionAmount! - flow.depositAmount!) + expect(delta).toBeLessThanOrEqual(EPSILON) + } +) + +Then( + 'the account balance increase should match the deposit amount', + async ({ flow }) => { + expect(flow.initialBalance).toBeDefined() + expect(flow.postDepositBalance).toBeDefined() + expect(flow.depositAmount).toBeDefined() + + const increase = flow.postDepositBalance! - flow.initialBalance! + expect(Math.abs(increase - flow.depositAmount!)).toBeLessThanOrEqual(EPSILON) + } +) diff --git a/e2e/features/steps/fixtures.ts b/e2e/features/steps/fixtures.ts new file mode 100644 index 000000000..62f178c4d --- /dev/null +++ b/e2e/features/steps/fixtures.ts @@ -0,0 +1,61 @@ +import { createBdd, test as base } from 'playwright-bdd' +import { + type Credentials, + createUniqueCredentials +} from '../../helpers/local-wallet' +import { mkdir } from 'node:fs/promises' + +type FlowState = { + credentials: Credentials + logMarker: Date + containerName: string + screenshotCounter: number + verificationLink?: string + accountPath?: string + initialBalance?: number + postDepositBalance?: number + depositAmount?: number + initialTransactionRows?: number + postDepositTransactionRows?: number + delayedRefreshTransactionRows?: number + latestTransactionAmount?: number + featureName: string + takeScreenshot: (name: string) => Promise +} + +export const test = base.extend<{ flow: FlowState }>({ + flow: async ({ page }, use, testInfo) => { + // Extract feature name from the generated test file path + // e.g., ".features-gen/auth-signup-dashboard.feature.spec.js" → "auth-signup-dashboard" + const testFile = testInfo.file + const fileName = testFile.split('/').pop() || 'unknown' + const featureName = fileName + .replace('.feature.spec.js', '') + .replace('.feature.spec.ts', '') + .replace('.spec.js', '') + .replace('.spec.ts', '') + + const state: FlowState = { + credentials: createUniqueCredentials(), + logMarker: new Date(), + containerName: + process.env.WALLET_BACKEND_CONTAINER || 'wallet-backend-local', + screenshotCounter: 0, + featureName, + takeScreenshot: async (name: string) => { + state.screenshotCounter += 1 + const screenshotDir = `test-results/${featureName}` + await mkdir(screenshotDir, { recursive: true }) + await page.screenshot({ + path: `${screenshotDir}/${String(state.screenshotCounter).padStart(3, '0')}-${name}.png`, + fullPage: true + }) + } + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(state) + } +}) + +export const { Given, When, Then } = createBdd(test) diff --git a/e2e/helpers/local-wallet.ts b/e2e/helpers/local-wallet.ts new file mode 100644 index 000000000..6292f440a --- /dev/null +++ b/e2e/helpers/local-wallet.ts @@ -0,0 +1,219 @@ +import { expect, Page } from '@playwright/test' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +type ScreenshotFn = (name: string) => Promise + +export type Credentials = { + email: string + password: string +} + +export function createUniqueCredentials(): Credentials { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}` + + return { + email: `e2e-${suffix}@ilp.com`, + password: `Testnet!${suffix}Aa` + } +} + +export async function waitForVerificationLinkFromLogs(args: { + since: Date + containerName?: string + timeoutMs?: number + pollIntervalMs?: number +}): Promise { + const containerName = args.containerName || 'wallet-backend-local' + const timeoutMs = args.timeoutMs ?? 30_000 + const pollIntervalMs = args.pollIntervalMs ?? 1_000 + const deadline = Date.now() + timeoutMs + const linkPattern = + /Verify email link is:\s+(https?:\/\/\S+\/auth\/verify\/[a-f0-9]+)/g + + while (Date.now() < deadline) { + let output = '' + + try { + const result = await execFileAsync( + 'docker', + [ + 'logs', + '--since', + args.since.toISOString(), + '--timestamps', + containerName + ], + { maxBuffer: 1024 * 1024 } + ) + + output = `${result.stdout}\n${result.stderr}` + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + } + + if (execError.code === 'ENOENT') { + throw new Error( + 'docker CLI is required to retrieve local verification links' + ) + } + + output = `${execError.stdout ?? ''}\n${execError.stderr ?? ''}` + } + + const matches = [...output.matchAll(linkPattern)] + const latestMatch = matches.at(-1)?.[1] + + if (latestMatch) { + return latestMatch + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + + throw new Error( + `Timed out waiting for a verification link in docker logs for container ${containerName}` + ) +} + +export async function completeLocalMockKyc( + page: Page, + takeScreenshot: ScreenshotFn +): Promise { + await expect(page).toHaveURL(/\/kyc$/) + await takeScreenshot('kyc-page-loaded') + + const frame = page.frameLocator('iframe') + + await frame.getByLabel('First Name').fill('E2E') + await takeScreenshot('kyc-first-name-filled') + await frame.getByLabel('Last Name').fill('User') + await takeScreenshot('kyc-last-name-filled') + await frame.getByLabel('Date of Birth').fill('1990-01-01') + await takeScreenshot('kyc-date-of-birth-filled') + await frame.getByLabel('Address').fill('1 Test Lane') + await takeScreenshot('kyc-address-filled') + await frame.getByLabel('City').fill('Basel') + await takeScreenshot('kyc-city-filled') + await frame.getByLabel('Country').fill('Switzerland') + await takeScreenshot('kyc-country-filled') + + await Promise.all([ + page.waitForURL(/\/$/, { timeout: 60_000 }), + frame.locator('#submitBtn').click() + ]) + + await takeScreenshot('kyc-submitted') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await takeScreenshot('kyc-dashboard-visible') +} + +/** + * Complete the full signup, email verification, login, and KYC flow for a test user. + * Returns the credentials used so they can be reused for API calls if needed. + * Leaves the user logged in on the dashboard. + */ +export async function setupVerifiedUser(args: { + page: Page + takeScreenshot: (name: string) => Promise + containerName: string + skipScreenshots?: boolean +}): Promise { + const { page, takeScreenshot, containerName, skipScreenshots = false } = args + const credentials = createUniqueCredentials() + const logMarker = new Date() + + const ss = skipScreenshots ? async () => {} : takeScreenshot + + // Signup + await page.goto('/auth/signup') + await ss('001-signup-page') + const signUpForm = page.locator('form') + await signUpForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await ss('002-signup-email-filled') + await signUpForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + await ss('003-signup-password-filled') + await signUpForm + .getByLabel('Confirm password *', { exact: true }) + .fill(credentials.password) + await ss('004-signup-confirm-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/signup') && + response.request().method() === 'POST' && + response.status() === 201 + ), + signUpForm.locator('button[type="submit"]').click() + ]) + await ss('005-signup-submitted') + + await expect( + page.getByText('A verification link has been sent to your email account.') + ).toBeVisible() + await ss('006-signup-success') + + // Verify email + const verificationLink = await waitForVerificationLinkFromLogs({ + since: logMarker, + containerName + }) + + await page.goto(verificationLink) + await ss('007-verification-page-opened') + await expect( + page.getByText( + 'Your email has been verified. Continue to login to use Interledger Test Wallet.' + ) + ).toBeVisible() + await ss('008-verify-success') + + // Login + await page.locator('a[href="/auth/login"]').first().click() + await expect(page).toHaveURL(/\/auth\/login$/) + await ss('009-login-page-opened') + + const loginForm = page.locator('form') + await loginForm + .getByLabel('E-mail *', { exact: true }) + .fill(credentials.email) + await ss('010-login-email-filled') + await loginForm + .getByLabel('Password *', { exact: true }) + .fill(credentials.password) + await ss('011-login-password-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith('/login') && + response.request().method() === 'POST' && + response.status() === 200 + ), + loginForm.locator('button[type="submit"]').click() + ]) + await ss('012-login-submitted') + + await page.waitForURL(/\/(kyc)?$/, { timeout: 60_000 }) + await ss('013-post-login') + + // KYC if needed + if (page.url().endsWith('/kyc')) { + await completeLocalMockKyc(page, ss) + } + + // Verify we're on dashboard + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + await ss('014-dashboard-ready') + + return credentials +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..a0cbd9249 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@interledger/testnet-e2e", + "private": true, + "packageManager": "pnpm@9.1.4", + "scripts": { + "generate": "bddgen", + "test": "bddgen && playwright test", + "test:headed": "bddgen && playwright test --headed", + "test:debug": "bddgen && playwright test --debug" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "@types/node": "^20.17.30", + "dotenv": "^17.2.3", + "playwright-bdd": "^8.0.0", + "typescript": "^5.9.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..d4a67abeb --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test' +import { defineBddConfig } from 'playwright-bdd' +import dotenv from 'dotenv' +import path from 'path' + +dotenv.config({ path: path.resolve(__dirname, '.env') }) + +const testBaseURL = process.env.TEST_BASE_URL || 'https://testnet.test' +const ignoreHTTPSErrors = + process.env.PLAYWRIGHT_IGNORE_HTTPS_ERRORS === 'true' || + testBaseURL.startsWith('https://') + +const testDir = defineBddConfig({ + paths: ['features/**/*.feature'], + require: ['features/steps/**/*.ts'] +}) + +export default defineConfig({ + testDir, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + timeout: 3 * 60 * 1000, + expect: { + timeout: 15 * 1000 + }, + use: { + baseURL: testBaseURL, + ignoreHTTPSErrors, + trace: 'on-first-retry', + screenshot: 'only-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1080 } + } + } + ] +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..38517371e --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "types": ["node", "@playwright/test"], + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "helpers/**/*.ts", + "features/**/*.ts", + "tests/**/*.ts", + "playwright.config.ts" + ] +} diff --git a/local/.env.example b/local/.env.example new file mode 100644 index 000000000..37a34e3e7 --- /dev/null +++ b/local/.env.example @@ -0,0 +1,138 @@ +# Testnet local docker-compose environment file (example) +# Copy to .env and adjust as needed for your machine. +# +# By default, all GateHub variables point to the local MockGatehub container +# (no real credentials needed). To use the real GateHub Sandbox instead, see +# the "Real GateHub Sandbox overrides" section below. + +# --------------------------------------------------------------------------- +# General build / dev flags +# --------------------------------------------------------------------------- + +# DEV_MODE — Controls how the wallet and boutique backend containers start. +# true (default) — hot-reload via nodemon; source changes restart the server +# automatically. Best for day-to-day development. +# debug — runs the pre-built server with the Node inspector open on +# DEBUG_PORT (default 9229 / 9230), so you can attach a +# debugger from your IDE. +# lite — runs the pre-built server directly (no watcher, no +# debugger). Useful for quick smoke-testing of production- +# like behaviour without the overhead of a file watcher. +# Note: frontend containers always run in dev/HMR mode regardless of this flag. +DEV_MODE=true + +# NODE_ENV — Standard Node.js environment flag, passed to every service. +# Defaults to "development" in docker-compose.yml when unset. +# development (default) — enables error stack traces in API responses, +# allows HTTP (not just HTTPS) for internal service +# calls, relaxes cookie security (secure=false, +# sameSite=lax), and enables the /signup auth route. +# production — hides stack traces, enforces HTTPS and secure +# cookies (secure=true, sameSite=none), and may +# disable certain non-production endpoints. +# For local development you almost never need to change this. +#NODE_ENV=development + +# Wallet Backend secrets and config +AUTH_IDENTITY_SERVER_SECRET=dev_identity_server_secret +RAFIKI_MONEY_FRONTEND_HOST=testnet.test +SENDGRID_API_KEY= +FROM_EMAIL= +SEND_EMAIL=false + +# --------------------------------------------------------------------------- +# GateHub integration +# --------------------------------------------------------------------------- +# Two modes are supported: +# +# 1. MockGatehub (default) — everything works out of the box. +# The wallet backend talks to the mockgatehub container inside the +# compose network. No real credentials required. +# +# 2. Real GateHub Sandbox — for full KYC / fiat on-off ramp testing. +# Create an account at https://sandbox.gatehub.net (or contact +# timea@interledger.foundation) to obtain the values below, then +# uncomment the "Real GateHub Sandbox overrides" block. +# --------------------------------------------------------------------------- + +# --- Mock defaults (active when the override block below is commented) ----- +# GATEHUB_API_BASE_URL is used by the backend for signed GateHub API calls. +# The three GATEHUB_IFRAME_*_URL values are required and used for browser-facing +# iframe URLs. Backend startup fails if any of them is missing. +GATEHUB_API_BASE_URL=http://mockgatehub:8080 +GATEHUB_ENV=sandbox +GATEHUB_IFRAME_MANAGED_RAMP_URL=http://localhost:8080 +GATEHUB_IFRAME_EXCHANGE_URL=http://localhost:8080 +GATEHUB_IFRAME_ONBOARDING_URL=http://localhost:8080 +GATEHUB_ACCESS_KEY=mock_access_key +GATEHUB_SECRET_KEY=mock_secret_key +GATEHUB_WEBHOOK_SECRET=6d6f636b5f776562686f6f6b5f736563726574 +GATEHUB_GATEWAY_UUID=mock-gateway-uuid +GATEHUB_SETTLEMENT_WALLET_ADDRESS=$ilp.interledger-test.dev/interledger +GATEHUB_ORG_ID=mock-org-id +GATEHUB_CARD_APP_ID=mock-card-app-id + +# --- Real GateHub endpoint reference -------------------------------------- +# Use these endpoint shapes when configuring real GateHub hosts. +# +# Sandbox: +# GATEHUB_API_BASE_URL=https://api.sandbox.gatehub.net +# GATEHUB_IFRAME_MANAGED_RAMP_URL=https://managed-ramp.sandbox.gatehub.net +# GATEHUB_IFRAME_EXCHANGE_URL=https://exchange.sandbox.gatehub.net +# GATEHUB_IFRAME_ONBOARDING_URL=https://onboarding.sandbox.gatehub.net +# +# Production: +# GATEHUB_API_BASE_URL=https://api.gatehub.net +# GATEHUB_IFRAME_MANAGED_RAMP_URL=https://managed-ramp.gatehub.net +# GATEHUB_IFRAME_EXCHANGE_URL=https://exchange.gatehub.net +# GATEHUB_IFRAME_ONBOARDING_URL=https://onboarding.gatehub.net + +# --- Real GateHub Sandbox overrides ---------------------------------------- +# To use the real sandbox, comment out the mock block above and uncomment the +# lines below, filling in your credentials. +# +# IMPORTANT: Keep all three GATEHUB_IFRAME_* URL values configured. +# For real sandbox they should be the GateHub hosts shown below. +# +# GATEHUB_ENV=sandbox +# GATEHUB_API_BASE_URL=https://api.sandbox.gatehub.net +# GATEHUB_IFRAME_MANAGED_RAMP_URL=https://managed-ramp.sandbox.gatehub.net +# GATEHUB_IFRAME_EXCHANGE_URL=https://exchange.sandbox.gatehub.net +# GATEHUB_IFRAME_ONBOARDING_URL=https://onboarding.sandbox.gatehub.net +# GATEHUB_ACCESS_KEY= +# GATEHUB_SECRET_KEY= +# GATEHUB_WEBHOOK_SECRET= +# GATEHUB_GATEWAY_UUID= +# GATEHUB_SETTLEMENT_WALLET_ADDRESS= +# GATEHUB_ORG_ID= +# GATEHUB_CARD_APP_ID= + +# Wallet backend rate limits and product codes (optional) +RATE_LIMIT=false +RATE_LIMIT_LEVEL=LAX +GATEHUB_ACCOUNT_PRODUCT_CODE=DEFAULT +GATEHUB_CARD_PRODUCT_CODE=DEFAULT +GATEHUB_NAME_ON_CARD=TEST USER +GATEHUB_CARD_PP_PREFIX=ILF + +# Card service links (only used when cards are enabled) +CARD_DATA_HREF=http://rafiki-card-service:3007/card-data +CARD_PIN_HREF=http://rafiki-card-service:3007/card-pin + +# Stripe (optional for local) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +USE_STRIPE=false + +# Admin/shared IDs and secrets +OPERATOR_TENANT_ID=f829c064-762a-4430-ac5d-7af5df198551 +ADMIN_API_SECRET=secret-key +RAFIKI_SIGNATURE_SECRET=327132b5-99e9-4eb8-8a25-2b7d7738ece1 + +# Wallet Frontend public envs +NEXT_PUBLIC_BACKEND_URL=http://localhost:3003 +NEXT_PUBLIC_AUTH_HOST=http://localhost:3006 +NEXT_PUBLIC_OPEN_PAYMENTS_HOST=http://localhost:3010 +NEXT_PUBLIC_GATEHUB_ENV=sandbox +NEXT_PUBLIC_THEME=light +NEXT_PUBLIC_FEATURES_ENABLED=false diff --git a/local/.gitignore b/local/.gitignore new file mode 100644 index 000000000..9e06ea81e --- /dev/null +++ b/local/.gitignore @@ -0,0 +1 @@ +pg-data \ No newline at end of file diff --git a/local/Makefile b/local/Makefile new file mode 100644 index 000000000..34e139f72 --- /dev/null +++ b/local/Makefile @@ -0,0 +1,100 @@ +COMPOSE := docker compose + +help: + @echo "make " + @echo "e.g. make all" + @echo "" + @echo "help Show this help message" + @echo "all Start full local stack (with Traefik)" + @echo "all-nowatch Start full local stack in background" + @echo "build Build docker images for local stack" + @echo "rebuild Force rebuild docker images (no cache)" + @echo "rafiki-assets Run Rafiki asset setup script" + @echo "down Stop the local stack" + @echo "hosts Add testnet host aliases to /etc/hosts (requires sudo)" + @echo "certs Generate TLS cert if missing (set FORCE_CERTS=1 to regenerate)" + @echo "trust Trust local TLS certificate (auto-detect OS, reloads Traefik)" + @echo "trust-macos Trust certificate on macOS" + @echo "trust-linux Trust certificate on Debian-based Linux (reloads Traefik)" + @echo "" + +.PHONY: help all all-nowatch build rebuild rafiki-assets down hosts certs trust trust-macos trust-linux + +all: certs + $(COMPOSE) up -d + @echo "" + @echo "Please open these ALL of these URLs in your browser and accept the self-signed certificates:" + @echo " - https://mockgatehub.testnet.test/health" + @echo " - https://api.testnet.test/health" + @echo " - https://rafiki-frontend.testnet.test" + @echo " - https://testnet.test" + +build: + $(COMPOSE) build + +rebuild: + $(COMPOSE) build --no-cache + +rafiki-assets: + node ./scripts/rafiki-setup.js + +down: + $(COMPOSE) down + +certs: + mkdir -p config/certs + @if [ -f config/certs/local.crt ] && [ -f config/certs/local.key ] && [ "$(FORCE_CERTS)" != "1" ]; then \ + echo "TLS cert already exists. Reusing it (set FORCE_CERTS=1 to regenerate)."; \ + elif [ "$(HEADLESS)" = "1" ]; then \ + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout config/certs/local.key -out config/certs/local.crt \ + -subj "/CN=testnet.test/O=Interledger/C=US" \ + -config config/san.cnf; \ + else \ + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout config/certs/local.key -out config/certs/local.crt \ + -config config/san.cnf; \ + fi + +trust: certs + @if [ "$(shell uname -s)" = "Darwin" ]; then \ + $(MAKE) trust-macos; \ + else \ + $(MAKE) trust-linux; \ + fi + +trust-macos: certs + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain config/certs/local.crt + @if $(COMPOSE) ps --status running --services traefik 2>/dev/null | grep -q '^traefik$$'; then \ + echo "Reloading Traefik to pick up trusted cert..."; \ + $(COMPOSE) restart traefik; \ + else \ + echo "Traefik is not running; skipping reload."; \ + fi + +trust-linux: certs + sudo cp config/certs/local.crt /usr/local/share/ca-certificates/interledger-testnet-local.crt + sudo update-ca-certificates + rm -rf $(HOME)/.pki/nssdb + mkdir -p $(HOME)/.pki/nssdb + certutil -d sql:$(HOME)/.pki/nssdb -N --empty-password + certutil -d sql:$(HOME)/.pki/nssdb -A -t "C,," -n "testnet.test" -i config/certs/local.crt + @if $(COMPOSE) ps --status running --services traefik 2>/dev/null | grep -q '^traefik$$'; then \ + echo "Reloading Traefik to pick up trusted cert..."; \ + $(COMPOSE) restart traefik; \ + else \ + echo "Traefik is not running; skipping reload."; \ + fi + +HOSTS := testnet.test \ + api.testnet.test \ + auth.testnet.test \ + rafiki-auth.testnet.test \ + rafiki-backend.testnet.test \ + rafiki-frontend.testnet.test \ + rafiki-card-service.testnet.test \ + mockgatehub.testnet.test + +hosts: + @sudo sed -i.bak '/# generated by make hosts/d' /etc/hosts && sudo rm -f /etc/hosts.bak + @$(foreach host,$(HOSTS),echo "127.0.0.1 $(host) # generated by make hosts" | sudo tee -a /etc/hosts > /dev/null;) \ No newline at end of file diff --git a/local/config/certs/local.crt b/local/config/certs/local.crt new file mode 100644 index 000000000..425a28954 --- /dev/null +++ b/local/config/certs/local.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIUGQpgTEawPo2sgagRQ0Z817S6fkIwDQYJKoZIhvcNAQEL +BQAwOjEVMBMGA1UEAwwMdGVzdG5ldC50ZXN0MRQwEgYDVQQKDAtJbnRlcmxlZGdl +cjELMAkGA1UEBhMCVVMwHhcNMjYwNDA1MTYzMTQ5WhcNMzYwNDAyMTYzMTQ5WjA6 +MRUwEwYDVQQDDAx0ZXN0bmV0LnRlc3QxFDASBgNVBAoMC0ludGVybGVkZ2VyMQsw +CQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM7+4Llw +6aibJCJrFzyKvmrXykalyI955nD04P6ZHKhIPZYANWPUe4/ps5ZsqJFUUG0KTtkd +uEHp+eN8sOTNSZ+VcSKzecp+9lMLTUUMKWNxC8ktiWbvS7wxvOHR/tOpATQSz/VT +J/jHLemcURszhv8RGa+9ErKPYWyJJqnhaiLzB1ljyxIKFPQBIgxnEdGiFaNLOIXP +Oemuev/auL7P/f3/yIXY2ecisGvR3bBI4Y9HL2zNEfsmgBmA402ONlQuKNyMnchx +mP6YU5DyUVjYD3sS3v8obdtYM0ud2bNb07gqjzMOP5qVzN7YS1wyvz1XSpZDprRA +QwNKw5cPcZJrWG8CAwEAAaOCAQYwggECMIHgBgNVHREEgdgwgdWCDHRlc3RuZXQu +dGVzdIIQYXBpLnRlc3RuZXQudGVzdIIRYXV0aC50ZXN0bmV0LnRlc3SCGHJhZmlr +aS1hdXRoLnRlc3RuZXQudGVzdIIbcmFmaWtpLWJhY2tlbmQudGVzdG5ldC50ZXN0 +ghxyYWZpa2ktZnJvbnRlbmQudGVzdG5ldC50ZXN0giByYWZpa2ktY2FyZC1zZXJ2 +aWNlLnRlc3RuZXQudGVzdIIYbW9ja2dhdGVodWIudGVzdG5ldC50ZXN0gglsb2Nh +bGhvc3SHBH8AAAEwHQYDVR0OBBYEFJ7UXeBz9oTZq2km4kkxZKvmuK1eMA0GCSqG +SIb3DQEBCwUAA4IBAQBu1O2eIVSCALkaG6CEZ6u5xxXlHf+cmVi82hdMw3inP0Zj +5DVis6a6QbHJ+9BkEEm0e8gZAvCQb1KPYDedHisb5DrCTxolqipD6kwTwgunc/Hf +dINZfTi/FYhk+JrXl0H8h1VBTWxt84VUcwltuHX5xGDeI8KohV+HIBlqZrl1uzWX +ktG8Bwl2tu2D7TwoVA0scchaH16ZbXgi32ZCyEsFBKPqLjsnUp+5Ympa2fiY+R9j +jDk7LNUxXz/FPCF25f4dPjSlI2GUndO85GPcg1svhhjcKxq81AxXJqeFHgC2noCj +OxKlwDbiXhtdGMjcj/82sHkmTa8APC7CdVhqI9cl +-----END CERTIFICATE----- diff --git a/local/config/certs/local.key b/local/config/certs/local.key new file mode 100644 index 000000000..60bec6d01 --- /dev/null +++ b/local/config/certs/local.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDO/uC5cOmomyQi +axc8ir5q18pGpciPeeZw9OD+mRyoSD2WADVj1HuP6bOWbKiRVFBtCk7ZHbhB6fnj +fLDkzUmflXEis3nKfvZTC01FDCljcQvJLYlm70u8Mbzh0f7TqQE0Es/1Uyf4xy3p +nFEbM4b/ERmvvRKyj2FsiSap4Woi8wdZY8sSChT0ASIMZxHRohWjSziFzznprnr/ +2ri+z/39/8iF2NnnIrBr0d2wSOGPRy9szRH7JoAZgONNjjZULijcjJ3IcZj+mFOQ +8lFY2A97Et7/KG3bWDNLndmzW9O4Ko8zDj+alcze2EtcMr89V0qWQ6a0QEMDSsOX +D3GSa1hvAgMBAAECggEAHYHgim7Z0CrgJCE8pxO8K0I8V9qAKaZzPtJEVJGCWqGE +WKK0+a3WOLX+33L5Nfuymf392XN5dblN4auVEo4KcGZUQgxwUHki0hnAAmzGZaI9 +ejM0mBOFVKbedG6ECKuEkWYby66hTxR4VgnuLJYpff4eZEVBVL+dtWQjun8jVdHU +ztroyjvCMXEyLvyQ7tPReEJLyH0+r60TkD9nMFiE6wXZHnczi2mjEPZ4a+Dg9koe +lBPcl3RDeCb5F8OBQbEV2z8t2YimeZQKSeG/R6DAVKNJYU1JNVzSG61itSftTv7m +/8I/J35Cyes5FmtC35d0HolvTR+Klj/u05fH2uxq0QKBgQDuc3yyobR7Nu62rUZg +gUkr2xVtxbvtJ/lcnH4/eMcDQq8ubUF8wV0lh6km9VlGofWLOE/LZ//u6F0fmqlJ +CqxnMcI7OLaypAmR5t+JwibC98nDlpFaIhBGrs1sLg+JJtZdm4m78vLUlfzUz+3m +QYrjIXV7i6IHUUBd9hcUWFPz5wKBgQDeOsGzhZxeXJsQ4CTVIs15S0yd6HJ2clbP +7UlKFbb+fTy0P1RBdrQXr3DcvJAO6mvSvyswGwO0X8HEhNn005OwnSleCfBoJsWQ +f3ZIE3/W0a85qaSSg5TBkYLOJOOVhhjsSr+T0fF6efYrvaEAjBR/eqUkk3RJ1zhg +Lfu2lZRmOQKBgFirFSmPu/89dBL15/+m08C9HQDO0IzlmnJ3tvJ/RPHPcDkBY6Ky +fKKiCaJ1BZegTiBQ9ksbBy6dtVM1PdHtaCKPfDxtYHKZUP2yLlmZmrRRU/OpH6+2 +jvrZsYWSszA7AueYGOWM+ANZKsUv9LT0lmLTZE5mo/+4lQjbo8Vq+vztAoGBAJF6 +14mFg0tGxoCyq4oX4XlxMPQC0sP1sB0oUp08P/Kz28SH3MmKQvVqdbEbhBcvoWxy +l3xDgxZWxYu4CNM8mue4yvbz/LzB/P3OY4llsn/X/yT5iHIdz4k0j/QRtCcThUdS +272SZ8ntA5m45yhri5O0n12lZjBcw1w7Xhg2LdMJAoGAD5CiVDexVqtC56uycAej +NGcIsPVq/f6uUtFeIr21FrloSpdhpjKmeQMKqvHtDgIafFcuuBHcULufWuqoe6x1 +RDiUQPp6Im2WrTZXReNpUJLW6y8Ev98VHzRoODVOzx1jl1A/ebJP71NsSU6Fabyz +Ok7w6RV3iXvbVYXIK/N6XX4= +-----END PRIVATE KEY----- diff --git a/local/config/san.cnf b/local/config/san.cnf new file mode 100644 index 000000000..a41ae05b0 --- /dev/null +++ b/local/config/san.cnf @@ -0,0 +1,26 @@ +[req] +default_bits = 2048 +prompt = no +default_md = sha256 +x509_extensions = v3_req +distinguished_name = dn + +[dn] +CN = testnet.test +O = Interledger +C = US + +[v3_req] +subjectAltName = @alt_names + +[alt_names] +DNS.1 = testnet.test +DNS.2 = api.testnet.test +DNS.3 = auth.testnet.test +DNS.4 = rafiki-auth.testnet.test +DNS.5 = rafiki-backend.testnet.test +DNS.6 = rafiki-frontend.testnet.test +DNS.7 = rafiki-card-service.testnet.test +DNS.8 = mockgatehub.testnet.test +DNS.9 = localhost +IP.1 = 127.0.0.1 \ No newline at end of file diff --git a/local/config/traefik/dynamic.yml b/local/config/traefik/dynamic.yml new file mode 100644 index 000000000..2dfb690af --- /dev/null +++ b/local/config/traefik/dynamic.yml @@ -0,0 +1,6 @@ +tls: + stores: + default: + defaultCertificate: + certFile: /certs/local.crt + keyFile: /certs/local.key diff --git a/local/docker-compose.yml b/local/docker-compose.yml new file mode 100644 index 000000000..5fcac4608 --- /dev/null +++ b/local/docker-compose.yml @@ -0,0 +1,20 @@ +include: + - traefik.yaml + - postgres.yaml + - redis.yaml + - mockgatehub.yaml + - rafiki.yaml + - wallet.yaml + - mailslurper.yaml + +networks: + testnet: + driver: bridge + ipam: + config: + - subnet: 10.5.0.0/24 + gateway: 10.5.0.1 + +volumes: + pg-data: + redis-data: diff --git a/docker/dbinit.sql b/local/init/dbinit.sql similarity index 100% rename from docker/dbinit.sql rename to local/init/dbinit.sql diff --git a/local/mailslurper.yaml b/local/mailslurper.yaml new file mode 100644 index 000000000..f95a0b1eb --- /dev/null +++ b/local/mailslurper.yaml @@ -0,0 +1,9 @@ +services: + mailslurper: + container_name: mailslurper-local + image: oryd/mailslurper:latest-smtps + ports: + - '4436:4436' + - '4437:4437' + networks: + - testnet diff --git a/local/mockgatehub.yaml b/local/mockgatehub.yaml new file mode 100644 index 000000000..3875c978d --- /dev/null +++ b/local/mockgatehub.yaml @@ -0,0 +1,33 @@ +services: + # MockGatehub - Mock Gatehub API service for local development + mockgatehub: + container_name: mockgatehub-local + image: ghcr.io/interledger/mockgatehub:1.12.4 + # build: + # context: ../../mockgatehub + # dockerfile: Dockerfile + ports: + - '8080:8080' + environment: + MOCKGATEHUB_REDIS_URL: ${MOCKGATEHUB_REDIS_URL:-redis://redis:6379/1} + MOCKGATEHUB_REDIS_DB: ${MOCKGATEHUB_REDIS_DB:-1} + MOCKGATEHUB_VALID_CREDENTIALS: ${GATEHUB_ACCESS_KEY:-local-test-app-id}:${GATEHUB_SECRET_KEY:-local-test-app-secret} + WEBHOOK_URL: ${MOCKGATEHUB_WEBHOOK_URL:-http://wallet-backend:3003/gatehub-webhooks} + WEBHOOK_SECRET: ${GATEHUB_WEBHOOK_SECRET:-6d6f636b5f776562686f6f6b5f736563726574} + depends_on: + - redis + restart: always + networks: + - testnet + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/health'] + interval: 10s + timeout: 5s + retries: 3 + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.mockgatehub.rule=Host(`mockgatehub.testnet.test`) + - traefik.http.routers.mockgatehub.entrypoints=websecure + - traefik.http.routers.mockgatehub.tls=true + - traefik.http.services.mockgatehub.loadbalancer.server.port=8080 diff --git a/local/postgres.yaml b/local/postgres.yaml new file mode 100644 index 000000000..a515ad1ff --- /dev/null +++ b/local/postgres.yaml @@ -0,0 +1,15 @@ +services: + postgres: + container_name: postgres-local + image: 'postgres:15' + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + ports: + - '15434:5432' + restart: unless-stopped + networks: + - testnet + volumes: + - pg-data:/var/lib/postgresql/data + - ./init/dbinit.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/local/rafiki.yaml b/local/rafiki.yaml new file mode 100644 index 000000000..55615eedf --- /dev/null +++ b/local/rafiki.yaml @@ -0,0 +1,149 @@ +services: + # Rafiki + rafiki-auth: + container_name: rafiki-auth-local + image: ghcr.io/interledger/rafiki-auth:v2.2.0-beta + restart: always + networks: + - testnet + ports: + - '3006:3006' + - '3008:3008' + environment: + AUTH_PORT: ${RAFIKI_AUTH_PORT:-3006} + INTROSPECTION_PORT: ${RAFIKI_AUTH_INTROSPECTION_PORT:-3007} + ADMIN_PORT: ${RAFIKI_AUTH_ADMIN_PORT:-3008} + NODE_ENV: ${NODE_ENV:-development} + AUTH_SERVER_URL: ${RAFIKI_AUTH_SERVER_URL:-http://rafiki-auth:3006} + AUTH_DATABASE_URL: ${RAFIKI_AUTH_DATABASE_URL:-postgresql://rafiki_auth:rafiki_auth@postgres-local/rafiki_auth} + IDENTITY_SERVER_URL: ${RAFIKI_AUTH_IDENTITY_SERVER_URL:-http://wallet-frontend:4003/grant-interactions} + IDENTITY_SERVER_SECRET: ${AUTH_IDENTITY_SERVER_SECRET:-auth-secret-key-12345} + COOKIE_KEY: ${AUTH_COOKIE_KEY:-8fd398393c47dd27a3167d9c081c094f} + INTERACTION_COOKIE_SAME_SITE: ${AUTH_INTERACTION_COOKIE_SAME_SITE:-lax} + WAIT_SECONDS: ${RAFIKI_AUTH_WAIT_SECONDS:-1} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} + ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} + ADMIN_SIGNATURE_VERSION: ${ADMIN_SIGNATURE_VERSION:-1} + depends_on: + - postgres + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.rafiki-auth.rule=Host(`auth.testnet.test`) || Host(`rafiki-auth.testnet.test`) + - traefik.http.routers.rafiki-auth.entrypoints=websecure + - traefik.http.routers.rafiki-auth.tls=true + - traefik.http.services.rafiki-auth.loadbalancer.server.port=3006 + + rafiki-backend: + container_name: rafiki-backend-local + image: ghcr.io/interledger/rafiki-backend:v2.2.0-beta + restart: always + privileged: true + volumes: + - ../temp/:/workspace/temp/ + ports: + - '3010:80' + - '3011:3001' + - '3005:3005' + - '3002:3002' + networks: + - testnet + environment: + NODE_ENV: ${NODE_ENV:-development} + LOG_LEVEL: ${LOG_LEVEL:-debug} + ADMIN_PORT: ${RAFIKI_BACKEND_ADMIN_PORT:-3001} + CONNECTOR_PORT: ${RAFIKI_BACKEND_CONNECTOR_PORT:-3002} + OPEN_PAYMENTS_PORT: ${RAFIKI_BACKEND_OPEN_PAYMENTS_PORT:-80} + DATABASE_URL: ${RAFIKI_BACKEND_DATABASE_URL:-postgresql://rafiki_backend:rafiki_backend@postgres-local/rafiki_backend} + USE_TIGERBEETLE: ${RAFIKI_BACKEND_USE_TIGERBEETLE:-false} + NONCE_REDIS_KEY: ${RAFIKI_BACKEND_NONCE_REDIS_KEY:-test} + AUTH_SERVER_GRANT_URL: ${RAFIKI_BACKEND_AUTH_SERVER_GRANT_URL:-http://rafiki-auth:3006} + AUTH_SERVER_INTROSPECTION_URL: ${RAFIKI_BACKEND_AUTH_SERVER_INTROSPECTION_URL:-http://rafiki-auth:3007} + ILP_ADDRESS: ${RAFIKI_BACKEND_ILP_ADDRESS:-test.net} + ILP_CONNECTOR_URL: ${RAFIKI_BACKEND_ILP_CONNECTOR_URL:-http://127.0.0.1:3002} + STREAM_SECRET: ${RAFIKI_BACKEND_STREAM_SECRET:-BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU=} + ADMIN_KEY: ${RAFIKI_BACKEND_ADMIN_KEY:-admin} + OPEN_PAYMENTS_URL: ${RAFIKI_BACKEND_OPEN_PAYMENTS_URL:-https://rafiki-backend} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + WALLET_ADDRESS_URL: ${RAFIKI_BACKEND_WALLET_ADDRESS_URL:-https://rafiki-backend/.well-known/pay} + WEBHOOK_URL: ${RAFIKI_BACKEND_WEBHOOK_URL:-http://wallet-backend:3003/webhooks} + WEBHOOK_TIMEOUT: ${RAFIKI_BACKEND_WEBHOOK_TIMEOUT:-60000} + SIGNATURE_SECRET: ${RAFIKI_SIGNATURE_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} + EXCHANGE_RATES_URL: ${RAFIKI_BACKEND_EXCHANGE_RATES_URL:-http://wallet-backend:3003/rates} + ENABLE_AUTO_PEERING: ${RAFIKI_BACKEND_ENABLE_AUTO_PEERING:-true} + AUTO_PEERING_SERVER_PORT: ${RAFIKI_BACKEND_AUTO_PEERING_SERVER_PORT:-3005} + INSTANCE_NAME: ${RAFIKI_BACKEND_INSTANCE_NAME:-Testnet Wallet} + SLIPPAGE: ${RAFIKI_BACKEND_SLIPPAGE:-0.01} + KEY_ID: ${RAFIKI_BACKEND_KEY_ID:-rafiki} + WALLET_ADDRESS_REDIRECT_HTML_PAGE: ${RAFIKI_BACKEND_WALLET_ADDRESS_REDIRECT_HTML_PAGE:-https://testnet.test/account?walletAddress=%ewa} + OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} + ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} + ADMIN_SIGNATURE_VERSION: ${ADMIN_SIGNATURE_VERSION:-1} + AUTH_SERVICE_API_URL: ${RAFIKI_BACKEND_AUTH_SERVICE_API_URL:-http://rafiki-auth:3011} + CARD_SERVICE_URL: ${RAFIKI_BACKEND_CARD_SERVICE_URL:-http://rafiki-card-service:3007} + CARD_WEBHOOK_SERVICE_URL: ${RAFIKI_BACKEND_CARD_WEBHOOK_SERVICE_URL:-http://rafiki-card-service:3007/webhook} + POS_SERVICE_URL: ${RAFIKI_BACKEND_POS_SERVICE_URL:-http://rafiki-pos-service:3014} + POS_WEBHOOK_SERVICE_URL: ${RAFIKI_BACKEND_POS_WEBHOOK_SERVICE_URL:-http://rafiki-pos-service:3014/webhook} + depends_on: + - postgres + - redis + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.rafiki-backend.rule=Host(`rafiki-backend.testnet.test`) + - traefik.http.routers.rafiki-backend.entrypoints=websecure + - traefik.http.routers.rafiki-backend.tls=true + - traefik.http.services.rafiki-backend.loadbalancer.server.port=80 + + rafiki-frontend: + container_name: rafiki-frontend-local + image: ghcr.io/interledger/rafiki-frontend:v2.2.0-beta + depends_on: + - rafiki-backend + restart: always + privileged: true + ports: + - '3012:3012' + networks: + - testnet + environment: + PORT: ${RAFIKI_FRONTEND_PORT:-3012} + GRAPHQL_URL: ${GRAPHQL_URL:-http://rafiki-backend:3001/graphql} + OPEN_PAYMENTS_URL: ${RAFIKI_FRONTEND_OPEN_PAYMENTS_URL:-https://rafiki-backend/} + ENABLE_INSECURE_MESSAGE_COOKIE: ${RAFIKI_FRONTEND_ENABLE_INSECURE_MESSAGE_COOKIE:-true} + AUTH_ENABLED: ${RAFIKI_FRONTEND_AUTH_ENABLED:-false} + SIGNATURE_VERSION: ${RAFIKI_FRONTEND_SIGNATURE_VERSION:-1} + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.rafiki-frontend.rule=Host(`rafiki-frontend.testnet.test`) + - traefik.http.routers.rafiki-frontend.entrypoints=websecure + - traefik.http.routers.rafiki-frontend.tls=true + - traefik.http.services.rafiki-frontend.loadbalancer.server.port=3012 + + rafiki-card-service: + container_name: rafiki-card-service-local + image: ghcr.io/interledger/rafiki-card-service:v2.2.0-beta + restart: always + privileged: true + networks: + - testnet + ports: + - '3007:3007' + environment: + NODE_ENV: ${NODE_ENV:-development} + LOG_LEVEL: ${LOG_LEVEL:-debug} + CARD_SERVICE_PORT: ${RAFIKI_CARD_SERVICE_PORT:-3007} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + GRAPHQL_URL: ${GRAPHQL_URL:-http://rafiki-backend:3001/graphql} + TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} + TENANT_SECRET: ${ADMIN_API_SECRET:-secret-key} + TENANT_SIGNATURE_VERSION: ${RAFIKI_CARD_SERVICE_TENANT_SIGNATURE_VERSION:-1} + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.rafiki-card-service.rule=Host(`rafiki-card-service.testnet.test`) + - traefik.http.routers.rafiki-card-service.entrypoints=websecure + - traefik.http.routers.rafiki-card-service.tls=true + - traefik.http.services.rafiki-card-service.loadbalancer.server.port=3007 diff --git a/local/redis.yaml b/local/redis.yaml new file mode 100644 index 000000000..288ea701d --- /dev/null +++ b/local/redis.yaml @@ -0,0 +1,11 @@ +services: + redis: + container_name: redis + image: 'redis:7-alpine' + restart: unless-stopped + networks: + - testnet + ports: + - '6379:6379' + volumes: + - redis-data:/data diff --git a/local/scripts/rafiki-setup.js b/local/scripts/rafiki-setup.js new file mode 100644 index 000000000..fe59caa78 --- /dev/null +++ b/local/scripts/rafiki-setup.js @@ -0,0 +1,398 @@ +#!/usr/bin/env node +/** + * Configure Rafiki (local docker stack) with a tenant + assets. + * - Reads values from .env in this directory (local/scripts/.env) when present (process.env takes priority) + * - Creates the operator tenant (idpConsentUrl + idpSecret) + * - Ensures assets exist for the Testnet wallet + * + * Run after `docker compose up -d` from local/: + * node scripts/rafiki-setup.js + */ + +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') + +// ---- helpers --------------------------------------------------------------- +function loadDotEnv(envPath) { + const result = {} + if (!fs.existsSync(envPath)) { + return result + } + const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) + for (const line of lines) { + if (!line || line.trim().startsWith('#')) { + continue + } + const idx = line.indexOf('=') + if (idx === -1) { + continue + } + const key = line.slice(0, idx).trim() + const value = line.slice(idx + 1).trim() + result[key] = value + } + + return result +} + +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return value + } + if (Array.isArray(value)) { + return value.map(canonicalize) + } + const sortedKeys = Object.keys(value).sort() + const obj = {} + for (const key of sortedKeys) { + obj[key] = canonicalize(value[key]) + } + return obj +} + +function canonicalizeAndStringify(value) { + return JSON.stringify(canonicalize(value)) +} + +function buildEnv() { + const envPath = path.join(__dirname, '.env') + const fileEnv = loadDotEnv(envPath) + const get = (key, fallback) => process.env[key] ?? fileEnv[key] ?? fallback + + return { + GRAPHQL_ENDPOINT: get('GRAPHQL_ENDPOINT', 'http://localhost:3011/graphql'), + ADMIN_API_SECRET: get('ADMIN_API_SECRET', 'secret-key'), + ADMIN_SIGNATURE_VERSION: get('ADMIN_SIGNATURE_VERSION', '1'), + OPERATOR_TENANT_ID: get( + 'OPERATOR_TENANT_ID', + 'f829c064-762a-4430-ac5d-7af5df198551' + ), + AUTH_IDENTITY_SERVER_SECRET: get( + 'AUTH_IDENTITY_SERVER_SECRET', + 'auth-secret-key-12345' + ), + IDP_CONSENT_URL: get( + 'IDP_CONSENT_URL', + 'http://wallet-frontend:4003/grant-interactions' + ) + } +} + +function signRequest({ query, variables, operationName }, env, timestamp) { + const payload = `${timestamp}.${canonicalizeAndStringify({ + variables: variables ?? {}, + operationName, + query + })}` + const hmac = crypto.createHmac('sha256', env.ADMIN_API_SECRET) + hmac.update(payload) + const digest = hmac.digest('hex') + return `t=${timestamp}, v${env.ADMIN_SIGNATURE_VERSION}=${digest}` +} + +async function graphqlRequest({ query, variables, operationName }, env) { + const timestamp = Date.now() + const signature = signRequest( + { query, variables, operationName }, + env, + timestamp + ) + const body = JSON.stringify({ query, variables, operationName }) + + const response = await fetch(env.GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + signature, + 'tenant-id': env.OPERATOR_TENANT_ID + }, + body + }) + + const data = await response.json() + if (data.errors && data.errors.length) { + const message = data.errors.map((e) => e.message).join('\n') + throw new Error(message) + } + return data.data +} + +// ---- operations ----------------------------------------------------------- +const getTenantQuery = /* GraphQL */ ` + query GetTenant($id: String!) { + tenant(id: $id) { + id + publicName + idpConsentUrl + idpSecret + } + } +` + +const createTenantMutation = /* GraphQL */ ` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + publicName + idpConsentUrl + idpSecret + } + } + } +` + +const updateTenantMutation = /* GraphQL */ ` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + publicName + idpConsentUrl + idpSecret + } + } + } +` + +const listAssetsQuery = /* GraphQL */ ` + query Assets($first: Int = 100) { + assets(first: $first) { + edges { + node { + id + code + scale + } + } + } + } +` + +const createAssetMutation = /* GraphQL */ ` + mutation CreateAsset($input: CreateAssetInput!) { + createAsset(input: $input) { + asset { + id + code + scale + } + } + } +` + +const getAssetByCodeAndScaleQuery = /* GraphQL */ ` + query AssetByCodeAndScale($code: String!, $scale: UInt8!) { + assetByCodeAndScale(code: $code, scale: $scale) { + id + code + scale + } + } +` + +const depositAssetLiquidityMutation = /* GraphQL */ ` + mutation DepositAssetLiquidity($input: DepositAssetLiquidityInput!) { + depositAssetLiquidity(input: $input) { + success + } + } +` + +const assetsToEnsure = [ + { code: 'USD', scale: 2 }, + { code: 'EUR', scale: 2 }, + { code: 'GBP', scale: 2 }, + { code: 'ZAR', scale: 2 }, + { code: 'MXN', scale: 2 }, + { code: 'SGD', scale: 2 }, + { code: 'CAD', scale: 2 }, + { code: 'EGG', scale: 2 }, + { code: 'PEB', scale: 2 }, + { code: 'PKR', scale: 2 } +] + +async function ensureTenant(env) { + try { + const existing = await graphqlRequest( + { query: getTenantQuery, variables: { id: env.OPERATOR_TENANT_ID } }, + env + ) + if (existing?.tenant) { + console.log( + `Tenant already present: ${existing.tenant.id} (consent URL ${existing.tenant.idpConsentUrl})` + ) + if (!existing.tenant.idpConsentUrl || !existing.tenant.idpSecret) { + console.log('Updating tenant idp fields...') + await graphqlRequest( + { + query: updateTenantMutation, + variables: { + input: { + id: env.OPERATOR_TENANT_ID, + idpConsentUrl: env.IDP_CONSENT_URL, + idpSecret: env.AUTH_IDENTITY_SERVER_SECRET + } + } + }, + env + ) + console.log('Tenant idp fields updated') + } + return + } + } catch (err) { + // continue and try to create + console.log('Tenant lookup failed, attempting to create...', err.message) + } + + console.log('Creating tenant...') + try { + const created = await graphqlRequest( + { + query: createTenantMutation, + variables: { + input: { + id: env.OPERATOR_TENANT_ID, + publicName: 'Testnet Wallet', + apiSecret: env.ADMIN_API_SECRET, + idpSecret: env.AUTH_IDENTITY_SERVER_SECRET, + idpConsentUrl: env.IDP_CONSENT_URL + } + } + }, + env + ) + console.log('Tenant created:', created.createTenant.tenant) + } catch (err) { + if ( + typeof err.message === 'string' && + err.message.toLowerCase().includes('duplicate') + ) { + console.log('Tenant already exists (duplicate key), continuing...') + return + } + throw err + } +} + +async function ensureAssets(env) { + let current = { assets: { edges: [] } } + try { + current = await graphqlRequest( + { query: listAssetsQuery, variables: { first: 200 } }, + env + ) + } catch (err) { + console.log( + 'Asset list failed, continuing to create assets...', + err.message + ) + } + + const existingAssets = new Set( + (current?.assets?.edges ?? []).map((e) => `${e.node.code}:${e.node.scale}`) + ) + + for (const asset of assetsToEnsure) { + if (existingAssets.has(`${asset.code}:${asset.scale}`)) { + console.log(`Asset ${asset.code} (scale ${asset.scale}) already exists`) + continue + } + console.log(`Creating asset ${asset.code}...`) + try { + await graphqlRequest( + { + query: createAssetMutation, + variables: { + input: { + code: asset.code, + scale: asset.scale + } + } + }, + env + ) + console.log(`Asset ${asset.code} created`) + } catch (err) { + const msg = (err.message || '').toLowerCase() + if (msg.includes('already exists') || msg.includes('duplicate')) { + console.log(`Asset ${asset.code} already exists (api), continuing...`) + continue + } + throw err + } + } +} + +// Deposit liquidity for all assets (100000 units per asset, converted to minor units by scale) +async function ensureLiquidity(env) { + console.log('Ensuring asset liquidity...') + + for (const asset of assetsToEnsure) { + let node + try { + const res = await graphqlRequest( + { + query: getAssetByCodeAndScaleQuery, + variables: { code: asset.code, scale: asset.scale } + }, + env + ) + node = res?.assetByCodeAndScale + } catch (err) { + console.log(`Lookup failed for ${asset.code}:`, err.message) + continue + } + + if (!node?.id) { + console.log(`Skipping liquidity for ${asset.code}: asset id not found`) + continue + } + + // Amount in minor units: 100000 * 10^scale + const amount = BigInt(100000) * BigInt(10) ** BigInt(node.scale) + + console.log( + `Depositing liquidity for ${asset.code}: ${amount.toString()} (scale ${node.scale})` + ) + try { + const res = await graphqlRequest( + { + query: depositAssetLiquidityMutation, + variables: { + input: { + id: crypto.randomUUID(), + assetId: node.id, + amount: amount.toString(), + idempotencyKey: crypto.randomUUID() + } + } + }, + env + ) + + if (!res?.depositAssetLiquidity?.success) { + console.log(`Liquidity deposit failed for ${asset.code}`) + } else { + console.log(`Liquidity deposited for ${asset.code}`) + } + } catch (err) { + console.log(`Liquidity deposit error for ${asset.code}:`, err.message) + } + } +} + +// ---- main ----------------------------------------------------------------- +;(async function main() { + const env = buildEnv() + console.log('Rafiki admin endpoint:', env.GRAPHQL_ENDPOINT) + await ensureTenant(env) + await ensureAssets(env) + await ensureLiquidity(env) + console.log('✅ Rafiki configuration complete') +})().catch((err) => { + console.error('Setup failed:', err.message) + process.exit(1) +}) diff --git a/local/traefik.yaml b/local/traefik.yaml new file mode 100644 index 000000000..8326a0204 --- /dev/null +++ b/local/traefik.yaml @@ -0,0 +1,25 @@ +services: + traefik: + container_name: traefik-local + image: traefik:v3.2 + command: + - --api.dashboard=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + ports: + - '80:80' + - '443:443' + - '8081:8080' + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config/certs:/certs:ro + - ./config/traefik/dynamic.yml:/etc/traefik/dynamic/dynamic.yml:ro + restart: unless-stopped + networks: + - testnet diff --git a/local/wallet.yaml b/local/wallet.yaml new file mode 100644 index 000000000..ed92dec1f --- /dev/null +++ b/local/wallet.yaml @@ -0,0 +1,116 @@ +services: + # Wallet + wallet-backend: + container_name: wallet-backend-local + build: + context: ../ + args: + DEV_MODE: ${DEV_MODE:-true} + dockerfile: ./packages/wallet/backend/Dockerfile.dev + depends_on: + - postgres + - rafiki-backend + - redis + - mockgatehub + volumes: + - ../packages/wallet/backend:/home/testnet/packages/wallet/backend + - ../packages/wallet/shared:/home/testnet/packages/wallet/shared + environment: + NODE_ENV: ${NODE_ENV:-development} + PORT: ${WALLET_BACKEND_PORT:-3003} + DEBUG_PORT: ${WALLET_BACKEND_DEBUG_PORT:-9229} + DATABASE_URL: ${WALLET_BACKEND_DATABASE_URL:-postgres://wallet_backend:wallet_backend@postgres-local/wallet_backend} + COOKIE_NAME: ${WALLET_BACKEND_COOKIE_NAME:-testnet.cookie} + COOKIE_PASSWORD: ${WALLET_BACKEND_COOKIE_PASSWORD:-testnet.cookie.password.super.secret.ilp} + COOKIE_TTL: ${WALLET_BACKEND_COOKIE_TTL:-2630000} + OPEN_PAYMENTS_HOST: ${WALLET_BACKEND_OPEN_PAYMENTS_HOST:-https://rafiki-backend} + RAFIKI_MONEY_FRONTEND_HOST: ${RAFIKI_MONEY_FRONTEND_HOST:-testnet.test} + GRAPHQL_ENDPOINT: ${WALLET_BACKEND_GRAPHQL_ENDPOINT:-http://rafiki-backend:3001/graphql} + AUTH_GRAPHQL_ENDPOINT: ${WALLET_BACKEND_AUTH_GRAPHQL_ENDPOINT:-http://rafiki-auth:3008/graphql} + AUTH_DOMAIN: ${WALLET_BACKEND_AUTH_DOMAIN:-http://rafiki-auth:3006} + AUTH_IDENTITY_SERVER_SECRET: ${AUTH_IDENTITY_SERVER_SECRET:-auth-secret-key-12345} + RAFIKI_WEBHOOK_SIGNATURE_SECRET: ${RAFIKI_SIGNATURE_SECRET:-327132b5-99e9-4eb8-8a25-2b7d7738ece1} + SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} + FROM_EMAIL: ${FROM_EMAIL:-noreply@testnet.local} + SEND_EMAIL: ${SEND_EMAIL:-false} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + GATEHUB_API_BASE_URL: ${GATEHUB_API_BASE_URL:-http://mockgatehub:8080} + GATEHUB_ENV: ${GATEHUB_ENV:-sandbox} + GATEHUB_IFRAME_MANAGED_RAMP_URL: ${GATEHUB_IFRAME_MANAGED_RAMP_URL:-https://mockgatehub.testnet.test} + GATEHUB_IFRAME_EXCHANGE_URL: ${GATEHUB_IFRAME_EXCHANGE_URL:-https://mockgatehub.testnet.test} + GATEHUB_IFRAME_ONBOARDING_URL: ${GATEHUB_IFRAME_ONBOARDING_URL:-https://mockgatehub.testnet.test} + GATEHUB_ACCESS_KEY: ${GATEHUB_ACCESS_KEY:-local-test-app-id} + GATEHUB_SECRET_KEY: ${GATEHUB_SECRET_KEY:-local-test-app-secret} + GATEHUB_WEBHOOK_SECRET: ${GATEHUB_WEBHOOK_SECRET:-6d6f636b5f776562686f6f6b5f736563726574} + GATEHUB_GATEWAY_UUID: ${GATEHUB_GATEWAY_UUID:-mock_gateway_uuid} + GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS:-rMockSettlementAddress} + GATEHUB_ORG_ID: ${GATEHUB_ORG_ID:-mock_org_id} + GATEHUB_CARD_APP_ID: ${GATEHUB_CARD_APP_ID:-mock_card_app_id} + RATE_LIMIT: ${RATE_LIMIT:-false} + RATE_LIMIT_LEVEL: ${RATE_LIMIT_LEVEL:-LAX} + GATEHUB_ACCOUNT_PRODUCT_CODE: ${GATEHUB_ACCOUNT_PRODUCT_CODE:-account_product} + GATEHUB_CARD_PRODUCT_CODE: ${GATEHUB_CARD_PRODUCT_CODE:-card_product} + GATEHUB_NAME_ON_CARD: ${GATEHUB_NAME_ON_CARD:-TestnetUser} + GATEHUB_CARD_PP_PREFIX: ${GATEHUB_CARD_PP_PREFIX:-TEST} + CARD_DATA_HREF: ${CARD_DATA_HREF:-https://card.example.com/data} + CARD_PIN_HREF: ${CARD_PIN_HREF:-https://card.example.com/pin} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-notset} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-notset} + USE_STRIPE: ${USE_STRIPE:-false} + OPERATOR_TENANT_ID: ${OPERATOR_TENANT_ID:-f829c064-762a-4430-ac5d-7af5df198551} + ADMIN_API_SECRET: ${ADMIN_API_SECRET:-secret-key} + ADMIN_SIGNATURE_VERSION: ${ADMIN_SIGNATURE_VERSION:-1} + WEBHOOK_SECRET: ${GATEHUB_WEBHOOK_SECRET:-6d6f636b5f776562686f6f6b5f736563726574} + restart: always + networks: + - testnet + ports: + - '3003:3003' + - '9229:9229' # Map debugger port to local machine's port 9229 + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.wallet-backend.rule=Host(`api.testnet.test`) + - traefik.http.routers.wallet-backend.entrypoints=websecure + - traefik.http.routers.wallet-backend.tls=true + - traefik.http.services.wallet-backend.loadbalancer.server.port=3003 + + wallet-frontend: + container_name: wallet-frontend-local + build: + context: .. + args: + DEV_MODE: ${DEV_MODE:-true} + NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://api.testnet.test} + NEXT_PUBLIC_AUTH_HOST: ${NEXT_PUBLIC_AUTH_HOST:-https://auth.testnet.test} + NEXT_PUBLIC_OPEN_PAYMENTS_HOST: ${NEXT_PUBLIC_OPEN_PAYMENTS_HOST:-https://rafiki-backend.testnet.test} + NEXT_PUBLIC_GATEHUB_ENV: ${NEXT_PUBLIC_GATEHUB_ENV:-sandbox} + NEXT_PUBLIC_THEME: ${NEXT_PUBLIC_THEME:-light} + NEXT_PUBLIC_FEATURES_ENABLED: ${NEXT_PUBLIC_FEATURES_ENABLED:-false} + dockerfile: ./packages/wallet/frontend/Dockerfile.dev + depends_on: + - wallet-backend + volumes: + - ../packages/wallet/frontend:/home/testnet/packages/wallet/frontend + - ../packages/wallet/shared:/home/testnet/packages/wallet/shared + environment: + NODE_ENV: ${NODE_ENV:-development} + BACKEND_URL: ${WALLET_FRONTEND_BACKEND_URL:-http://wallet-backend:3003} + NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://api.testnet.test} + NEXT_PUBLIC_AUTH_HOST: ${NEXT_PUBLIC_AUTH_HOST:-https://auth.testnet.test} + NEXT_PUBLIC_OPEN_PAYMENTS_HOST: ${NEXT_PUBLIC_OPEN_PAYMENTS_HOST:-https://rafiki-backend.testnet.test} + NEXT_PUBLIC_GATEHUB_ENV: ${NEXT_PUBLIC_GATEHUB_ENV:-sandbox} + NEXT_PUBLIC_THEME: ${NEXT_PUBLIC_THEME:-light} + NEXT_PUBLIC_FEATURES_ENABLED: ${NEXT_PUBLIC_FEATURES_ENABLED:-false} + restart: always + networks: + - testnet + ports: + - '4003:4003' + labels: + - traefik.enable=true + - traefik.docker.network=testnet + - traefik.http.routers.wallet-frontend.rule=Host(`testnet.test`) + - traefik.http.routers.wallet-frontend.entrypoints=websecure + - traefik.http.routers.wallet-frontend.tls=true + - traefik.http.services.wallet-frontend.loadbalancer.server.port=4003 diff --git a/package.json b/package.json index 3ab353a94..704e2a3ee 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "format": "pnpm prettier:write && pnpm lint:fix", "lint:check": "eslint --max-warnings=0 .", "lint:fix": "eslint --max-warnings=0 --fix .", - "compose": "docker compose -f ./docker/dev/docker-compose.yml", - "compose:prod": "docker compose -f ./docker/prod/docker-compose.yml", + "compose": "docker compose -f ./local/docker-compose.yml", "localenv:start": "cross-env DEV_MODE=hot-reload pnpm compose up -d --build", "localenv:start:debug": "cross-env DEV_MODE=debug pnpm compose up -d --build", "localenv:start:lite": "cross-env DEV_MODE=lite pnpm compose up -d --build", @@ -31,8 +30,6 @@ "preinstall": "npx only-allow pnpm", "prettier:write": "prettier --config \".prettierrc.js\" --write .", "prettier:check": "prettier --config \".prettierrc.js\" --check .", - "prod": "pnpm compose:prod up -d --build", - "prod:down": "pnpm compose:prod down", "wallet:backend": "pnpm --filter @wallet/backend --", "wallet:frontend": "pnpm --filter @wallet/frontend --" }, diff --git a/packages/boutique/backend/Dockerfile.dev b/packages/boutique/backend/Dockerfile.dev index b55f45b98..3fe3ed21e 100644 --- a/packages/boutique/backend/Dockerfile.dev +++ b/packages/boutique/backend/Dockerfile.dev @@ -21,7 +21,7 @@ RUN pnpm fetch ADD . ./ # Install packages from virtual store -RUN pnpm install -r --offline +RUN pnpm install --filter @boutique/backend... --offline RUN pnpm boutique:backend build diff --git a/packages/wallet/backend/Dockerfile.dev b/packages/wallet/backend/Dockerfile.dev index 8c480a4f0..21540a3bb 100644 --- a/packages/wallet/backend/Dockerfile.dev +++ b/packages/wallet/backend/Dockerfile.dev @@ -21,7 +21,7 @@ RUN pnpm fetch ADD . ./ # Install packages from virtual store -RUN pnpm install -r --offline +RUN pnpm install --filter @wallet/backend... --offline # Build backend RUN pnpm wallet:backend build diff --git a/packages/wallet/backend/src/config/env.ts b/packages/wallet/backend/src/config/env.ts index 92b0f60d3..49d133a87 100644 --- a/packages/wallet/backend/src/config/env.ts +++ b/packages/wallet/backend/src/config/env.ts @@ -13,6 +13,22 @@ const envSchema = z.object({ .default('testnet.cookie.password.super.secret.ilp'), // min. 32 chars COOKIE_TTL: z.coerce.number().default(2630000), // 1 month GATEHUB_ENV: z.enum(['production', 'sandbox']).default('sandbox'), + // Required GateHub API base URL (fail fast if missing). + // Real GateHub examples: + // - Sandbox: https://api.sandbox.gatehub.net + // - Production: https://api.gatehub.net + GATEHUB_API_BASE_URL: z.string().url(), + // Required iframe URLs (fail fast if missing). + // Real GateHub examples: + // - Sandbox: https://managed-ramp.sandbox.gatehub.net + // https://exchange.sandbox.gatehub.net + // https://onboarding.sandbox.gatehub.net + // - Production: https://managed-ramp.gatehub.net + // https://exchange.gatehub.net + // https://onboarding.gatehub.net + GATEHUB_IFRAME_MANAGED_RAMP_URL: z.string().url(), + GATEHUB_IFRAME_EXCHANGE_URL: z.string().url(), + GATEHUB_IFRAME_ONBOARDING_URL: z.string().url(), GATEHUB_ACCESS_KEY: z.string().default('GATEHUB_ACCESS_KEY'), GATEHUB_SECRET_KEY: z.string().default('GATEHUB_SECRET_KEY'), GATEHUB_SEPA_ACCESS_KEY: z.string().optional(), diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index dc5abd46b..084f24060 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -68,7 +68,6 @@ export class GateHubClient { private supportedAssetCodes: string[] private clientIds = SANDBOX_CLIENT_IDS private vaultIds = SANDBOX_VAULT_IDS - private mainUrl = 'sandbox.gatehub.net' private iframeMappings: Record< IFRAME_TYPE, @@ -86,7 +85,6 @@ export class GateHubClient { if (this.isProduction) { this.clientIds = PRODUCTION_CLIENT_IDS this.vaultIds = PRODUCTION_VAULT_IDS - this.mainUrl = 'gatehub.net' } this.supportedAssetCodes = Object.keys(this.vaultIds) @@ -97,19 +95,19 @@ export class GateHubClient { } get apiUrl() { - return `https://api.${this.mainUrl}` + return this.env.GATEHUB_API_BASE_URL } get rampUrl() { - return `https://managed-ramp.${this.mainUrl}` + return this.env.GATEHUB_IFRAME_MANAGED_RAMP_URL } get exchangeUrl() { - return `https://exchange.${this.mainUrl}` + return this.env.GATEHUB_IFRAME_EXCHANGE_URL } get onboardingUrl() { - return `https://onboarding.${this.mainUrl}` + return this.env.GATEHUB_IFRAME_ONBOARDING_URL } async getWithdrawalUrl(managedUserUuid: string): Promise { diff --git a/packages/wallet/backend/src/gatehub/service.ts b/packages/wallet/backend/src/gatehub/service.ts index 976c51745..761fa72f5 100644 --- a/packages/wallet/backend/src/gatehub/service.ts +++ b/packages/wallet/backend/src/gatehub/service.ts @@ -260,6 +260,15 @@ export class GateHubService { } let customerId + // Check if customer already exists to prevent race condition between + // direct addUserToGateway call and webhook handler + if (user.customerId) { + this.logger.debug( + `Customer already exists for user ${userId}, skipping customer creation` + ) + return { isApproved, customerId: user.customerId } + } + if ( this.env.NODE_ENV === 'development' && this.env.GATEHUB_ENV === 'sandbox' @@ -325,6 +334,33 @@ export class GateHubService { firstName: string, lastName: string ): Promise { + // Check if customer setup already in progress or completed + // to prevent race condition between concurrent calls + const existingAccount = await Account.query().findOne({ + userId, + assetCode: 'EUR' + }) + + if (existingAccount) { + this.logger.warn( + `EUR account already exists for user ${userId}, skipping sandbox customer creation` + ) + const user = await User.query().findById(userId) + if (!user) { + this.logger.error( + `User ${userId} not found while EUR account exists, cannot retrieve customerId` + ) + throw new NotFound('User not found') + } + if (!user.customerId) { + this.logger.error( + `Missing customerId for user ${userId} with existing EUR account` + ) + throw new Error('CustomerId is missing for existing EUR account') + } + return user.customerId + } + const { account, walletAddress } = await this.createDefaultAccountAndWAForManagedUser(userId, true) diff --git a/packages/wallet/backend/src/middleware/withSession.ts b/packages/wallet/backend/src/middleware/withSession.ts index 5e1eaf946..fe97dca9d 100644 --- a/packages/wallet/backend/src/middleware/withSession.ts +++ b/packages/wallet/backend/src/middleware/withSession.ts @@ -6,10 +6,15 @@ import { getIronSession } from 'iron-session' -let domain = env.RAFIKI_MONEY_FRONTEND_HOST - +// Determine cookie domain. Avoid setting Domain=localhost, browsers ignore it. +let domain: string | undefined = undefined if (env.NODE_ENV === 'production' && env.GATEHUB_ENV === 'production') { domain = 'interledger.cards' +} else if ( + env.RAFIKI_MONEY_FRONTEND_HOST && + env.RAFIKI_MONEY_FRONTEND_HOST !== 'localhost' +) { + domain = env.RAFIKI_MONEY_FRONTEND_HOST } export const SESSION_OPTIONS: SessionOptions = { diff --git a/packages/wallet/backend/tests/auth/controller.test.ts b/packages/wallet/backend/tests/auth/controller.test.ts index 62be689c0..a9fcaef03 100644 --- a/packages/wallet/backend/tests/auth/controller.test.ts +++ b/packages/wallet/backend/tests/auth/controller.test.ts @@ -18,6 +18,7 @@ import { withSession } from '@/middleware/withSession' import { getRedisClient } from '@/config/redis' import { rateLimiterLogin, rateLimiterEmail } from '@/middleware/rateLimit' import type { UserService } from '@/user/service' +import type { EmailService } from '@/email/service' import { fakeLoginData, mockGateHubClient, @@ -37,6 +38,7 @@ describe('Authentication Controller', (): void => { let authService: AuthService let authController: AuthController let userService: UserService + let emailService: EmailService let req: MockRequest let res: MockResponse @@ -50,6 +52,7 @@ describe('Authentication Controller', (): void => { authService = await bindings.resolve('authService') authController = await bindings.resolve('authController') userService = await bindings.resolve('userService') + emailService = await bindings.resolve('emailService') Reflect.set( userService, @@ -77,6 +80,7 @@ describe('Authentication Controller', (): void => { describe('Sign Up', (): void => { it('should return status 201 if the user is created', async (): Promise => { req.body = mockSignUpRequest().body + jest.spyOn(emailService, 'verifyDomain').mockResolvedValueOnce(undefined) await authController.signUp(req, res, next) expect(next).toHaveBeenCalledTimes(0) @@ -107,6 +111,7 @@ describe('Authentication Controller', (): void => { it('should return status 500 on unexpected error', async (): Promise => { req.body = mockSignUpRequest().body + jest.spyOn(emailService, 'verifyDomain').mockResolvedValueOnce(undefined) const createSpy = jest .spyOn(userService, 'create') diff --git a/packages/wallet/frontend/Dockerfile.dev b/packages/wallet/frontend/Dockerfile.dev index 4a13dcab2..d6bbe8d22 100644 --- a/packages/wallet/frontend/Dockerfile.dev +++ b/packages/wallet/frontend/Dockerfile.dev @@ -21,6 +21,22 @@ RUN pnpm fetch ADD . ./ # Install packages from virtual store -RUN pnpm install -r --offline +RUN pnpm install --filter @wallet/frontend... --offline + +# Accept build arguments for Next.js public environment variables +ARG NEXT_PUBLIC_BACKEND_URL +ARG NEXT_PUBLIC_AUTH_HOST +ARG NEXT_PUBLIC_OPEN_PAYMENTS_HOST +ARG NEXT_PUBLIC_GATEHUB_ENV +ARG NEXT_PUBLIC_THEME +ARG NEXT_PUBLIC_FEATURES_ENABLED + +# Make them available as environment variables during build +ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL +ENV NEXT_PUBLIC_AUTH_HOST=$NEXT_PUBLIC_AUTH_HOST +ENV NEXT_PUBLIC_OPEN_PAYMENTS_HOST=$NEXT_PUBLIC_OPEN_PAYMENTS_HOST +ENV NEXT_PUBLIC_GATEHUB_ENV=$NEXT_PUBLIC_GATEHUB_ENV +ENV NEXT_PUBLIC_THEME=$NEXT_PUBLIC_THEME +ENV NEXT_PUBLIC_FEATURES_ENABLED=$NEXT_PUBLIC_FEATURES_ENABLED CMD ["pnpm", "wallet:frontend", "dev"] diff --git a/packages/wallet/frontend/next.config.js b/packages/wallet/frontend/next.config.js index 9e98bb8b9..361eec487 100644 --- a/packages/wallet/frontend/next.config.js +++ b/packages/wallet/frontend/next.config.js @@ -2,13 +2,18 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }) -let NEXT_PUBLIC_FEATURES_ENABLED = 'true' +// Default to env override; fall back to previous production/sandbox rule, then to 'true' +let NEXT_PUBLIC_FEATURES_ENABLED = process.env.NEXT_PUBLIC_FEATURES_ENABLED -if ( - process.env.NODE_ENV === 'production' && - process.env.NEXT_PUBLIC_GATEHUB_ENV === 'sandbox' -) { - NEXT_PUBLIC_FEATURES_ENABLED = 'false' +if (!NEXT_PUBLIC_FEATURES_ENABLED) { + if ( + process.env.NODE_ENV === 'production' && + process.env.NEXT_PUBLIC_GATEHUB_ENV === 'sandbox' + ) { + NEXT_PUBLIC_FEATURES_ENABLED = 'false' + } else { + NEXT_PUBLIC_FEATURES_ENABLED = 'true' + } } /** @type {import('next').NextConfig} */ @@ -18,6 +23,11 @@ const nextConfig = { env: { NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3003', + // Internal URL for server-side (middleware) to reach backend in Docker + BACKEND_INTERNAL_URL: + process.env.BACKEND_INTERNAL_URL || + process.env.BACKEND_URL || + 'http://wallet-backend:3003', NEXT_PUBLIC_OPEN_PAYMENTS_HOST: process.env.NEXT_PUBLIC_OPEN_PAYMENTS_HOST || '$rafiki-backend/', NEXT_PUBLIC_AUTH_HOST: diff --git a/packages/wallet/frontend/src/lib/httpClient.ts b/packages/wallet/frontend/src/lib/httpClient.ts index 8b2a1ad9d..957dde238 100644 --- a/packages/wallet/frontend/src/lib/httpClient.ts +++ b/packages/wallet/frontend/src/lib/httpClient.ts @@ -14,8 +14,14 @@ export type ErrorResponse = { errors?: T extends FieldValues ? Record, string> : undefined } +// Use internal backend URL when running on the server (SSR/middleware) +const isServer = typeof window === 'undefined' +const baseUrl = isServer + ? process.env.BACKEND_INTERNAL_URL || 'http://wallet-backend:3003' + : process.env.NEXT_PUBLIC_BACKEND_URL + export const httpClient = ky.extend({ - prefixUrl: process.env.NEXT_PUBLIC_BACKEND_URL, + prefixUrl: baseUrl, credentials: 'include', retry: 0, hooks: { diff --git a/packages/wallet/frontend/src/middleware.ts b/packages/wallet/frontend/src/middleware.ts index db8f681c0..c9a9c9b70 100644 --- a/packages/wallet/frontend/src/middleware.ts +++ b/packages/wallet/frontend/src/middleware.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { userService } from './lib/api/user' +import { userService } from '@/lib/api/user' +import type { SuccessResponse, ErrorResponse } from '@/lib/httpClient' +import type { UserResponse } from '@wallet/shared' const isPublicPath = (path: string) => { return publicPaths.find((x) => @@ -15,9 +17,19 @@ export async function middleware(req: NextRequest) { const isPublic = isPublicPath(req.nextUrl.pathname) const cookieName = process.env.COOKIE_NAME || 'testnet.cookie' - const response = await userService.me( - `${cookieName}=${req.cookies.get(cookieName)?.value}` - ) + const cookieVal = req.cookies.get(cookieName)?.value + + let response: SuccessResponse | ErrorResponse = { + success: false, + message: '' + } + if (cookieVal) { + try { + response = await userService.me(`${cookieName}=${cookieVal}`) + } catch { + // Ignore connectivity errors; fallback logic below handles unauthenticated state + } + } // Success TRUE - the user is logged in if (response.success && response.result) { @@ -40,7 +52,13 @@ export async function middleware(req: NextRequest) { } if (isPublic) { - return NextResponse.redirect(new URL(callbackUrl ?? '/', req.url)) + const dest = + callbackUrl && + callbackUrl.startsWith('/') && + !callbackUrl.startsWith('//') + ? callbackUrl + : '/' + return NextResponse.redirect(new URL(dest, req.url)) } } else { // If the user is not logged in and tries to access a private resource, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c44467fae..29b1f0f88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,24 @@ importers: specifier: ^5.9.3 version: 5.9.3 + e2e: + devDependencies: + '@playwright/test': + specifier: ^1.56.0 + version: 1.58.2 + '@types/node': + specifier: ^20.17.30 + version: 20.17.30 + dotenv: + specifier: ^17.2.3 + version: 17.3.1 + playwright-bdd: + specifier: ^8.0.0 + version: 8.5.0(@playwright/test@1.58.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/boutique/backend: dependencies: '@boutique/shared': @@ -453,7 +471,7 @@ importers: version: 1.11.0 next: specifier: 14.2.32 - version: 14.2.32(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.32(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-qrcode: specifier: ^2.5.1 version: 2.5.1(react@18.3.1) @@ -900,10 +918,51 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@cucumber/cucumber-expressions@18.0.1': + resolution: {integrity: sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==} + + '@cucumber/gherkin-utils@9.2.0': + resolution: {integrity: sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==} + hasBin: true + + '@cucumber/gherkin@31.0.0': + resolution: {integrity: sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==} + + '@cucumber/gherkin@32.2.0': + resolution: {integrity: sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==} + + '@cucumber/html-formatter@21.15.1': + resolution: {integrity: sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==} + peerDependencies: + '@cucumber/messages': '>=18' + + '@cucumber/junit-xml-formatter@0.7.1': + resolution: {integrity: sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/messages@26.0.1': + resolution: {integrity: sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==} + + '@cucumber/messages@27.2.0': + resolution: {integrity: sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==} + + '@cucumber/query@13.6.0': + resolution: {integrity: sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/tag-expressions@6.2.0': + resolution: {integrity: sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -1822,6 +1881,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} @@ -2501,6 +2565,10 @@ packages: '@tanstack/virtual-core@3.13.9': resolution: {integrity: sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==} + '@teppeis/multimaps@3.0.0': + resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} + engines: {node: '>=14'} + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -3193,6 +3261,9 @@ packages: cjs-module-lexer@1.3.1: resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3208,6 +3279,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -3292,6 +3367,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3612,6 +3691,10 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + drange@1.1.1: resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} engines: {node: '>=4'} @@ -4028,6 +4111,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4921,6 +5009,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -4981,10 +5073,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -5424,6 +5524,23 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-bdd@8.5.0: + resolution: {integrity: sha512-w/Bd5C1d6Xe5e1oREsbt2rDN0/Mcp+J2OjQwSl49/mroa2K6UZU33P7v91pLQPl1otDitMwJOaOeJsqaj7WU7w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@playwright/test': '>=1.44' + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -5808,6 +5925,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.9: resolution: {integrity: sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q==} engines: {node: '>= 0.4'} @@ -5815,6 +5935,13 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp-match-indices@1.0.2: + resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + regexp.prototype.flags@1.5.3: resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} @@ -6090,6 +6217,9 @@ packages: source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} engines: {node: '>=0.10.0'} @@ -6595,6 +6725,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -6773,6 +6907,10 @@ packages: utf-8-validate: optional: true + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlhttprequest-ssl@2.1.1: resolution: {integrity: sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==} engines: {node: '>=0.4.0'} @@ -7274,8 +7412,65 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@colors/colors@1.5.0': + optional: true + '@colors/colors@1.6.0': {} + '@cucumber/cucumber-expressions@18.0.1': + dependencies: + regexp-match-indices: 1.0.2 + + '@cucumber/gherkin-utils@9.2.0': + dependencies: + '@cucumber/gherkin': 31.0.0 + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + commander: 13.1.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin@31.0.0': + dependencies: + '@cucumber/messages': 26.0.1 + + '@cucumber/gherkin@32.2.0': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/html-formatter@21.15.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/junit-xml-formatter@0.7.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@cucumber/query': 13.6.0(@cucumber/messages@27.2.0) + '@teppeis/multimaps': 3.0.0 + luxon: 3.7.2 + xmlbuilder: 15.1.1 + + '@cucumber/messages@26.0.1': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 10.0.0 + + '@cucumber/messages@27.2.0': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 11.0.5 + + '@cucumber/query@13.6.0(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + lodash.sortby: 4.7.0 + + '@cucumber/tag-expressions@6.2.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -8453,6 +8648,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.25': {} '@protobufjs/aspromise@1.1.2': {} @@ -9075,6 +9274,8 @@ snapshots: '@tanstack/virtual-core@3.13.9': {} + '@teppeis/multimaps@3.0.0': {} + '@tootallnate/once@2.0.0': {} '@types/babel__core@7.20.5': @@ -9955,6 +10156,8 @@ snapshots: cjs-module-lexer@1.3.1: {} + class-transformer@0.5.1: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -9967,6 +10170,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -10037,6 +10246,8 @@ snapshots: commander@10.0.1: {} + commander@13.1.0: {} + commander@4.1.1: {} commander@7.2.0: {} @@ -10337,6 +10548,8 @@ snapshots: dotenv@16.4.5: {} + dotenv@17.3.1: {} + drange@1.1.1: {} dset@3.1.3: {} @@ -10912,6 +11125,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12099,6 +12315,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -12141,10 +12359,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mimic-fn@2.1.0: {} @@ -12218,7 +12442,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@14.2.32(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.32(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.32 '@swc/helpers': 0.5.5 @@ -12240,6 +12464,7 @@ snapshots: '@next/swc-win32-ia32-msvc': 14.2.32 '@next/swc-win32-x64-msvc': 14.2.32 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.58.2 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -12588,6 +12813,30 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-bdd@8.5.0(@playwright/test@1.58.2): + dependencies: + '@cucumber/cucumber-expressions': 18.0.1 + '@cucumber/gherkin': 32.2.0 + '@cucumber/gherkin-utils': 9.2.0 + '@cucumber/html-formatter': 21.15.1(@cucumber/messages@27.2.0) + '@cucumber/junit-xml-formatter': 0.7.1(@cucumber/messages@27.2.0) + '@cucumber/messages': 27.2.0 + '@cucumber/tag-expressions': 6.2.0 + '@playwright/test': 1.58.2 + cli-table3: 0.6.5 + commander: 13.1.0 + fast-glob: 3.3.3 + mime-types: 3.0.2 + xmlbuilder: 15.1.1 + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 @@ -12944,6 +13193,8 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.9: dependencies: call-bind: 1.0.8 @@ -12957,6 +13208,12 @@ snapshots: regenerator-runtime@0.14.1: {} + regexp-match-indices@1.0.2: + dependencies: + regexp-tree: 0.1.27 + + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.3: dependencies: call-bind: 1.0.8 @@ -13332,6 +13589,11 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.5.7: {} source-map@0.6.1: {} @@ -13882,6 +14144,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.0.5: {} + uuid@8.3.2: {} uuid@9.0.1: {} @@ -14045,6 +14309,8 @@ snapshots: ws@8.18.0: {} + xmlbuilder@15.1.1: {} + xmlhttprequest-ssl@2.1.1: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5bd259eb..9097680e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - 'e2e' - 'packages/wallet/*' - 'packages/boutique/*' - 'packages/shared/*'