diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 8ff3dbbe..a9536db5 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -25,6 +25,7 @@ env: VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} REGISTRY: ghcr.io IMAGE_NAME: pezkuwichain/pwap-web + BACKEND_IMAGE_NAME: pezkuwichain/pwap-indexer jobs: # ======================================== @@ -130,6 +131,15 @@ jobs: working-directory: ./backend run: node --check src/index.js + # Real, offline test suite (node:test). Uses in-memory sqlite and a + # mocked chain/Supabase — NO live node, NO network. The integration-tests/ + # *.live.test.js suites are intentionally EXCLUDED (they require a live + # chain + Supabase and are not run in CI); the `test` script scopes to + # test/*.test.js only. + - name: Run tests (offline) + working-directory: ./backend + run: npm test + - name: npm audit (high + critical, production deps) working-directory: ./backend continue-on-error: true @@ -216,6 +226,77 @@ jobs: cosign sign --yes "$IMAGE_DIGEST" echo "✅ Image signed (transparency log: rekor.sigstore.dev)" + # ======================================== + # BUILD & PUSH BACKEND (INDEXER) IMAGE TO GHCR + # Runnable Node service image (vs the web static-dist image). SHA-tagged + + # cosign-signed for the same audit/rollback discipline as the web image. + # Gated to main/tags, never fork PRs (needs packages:write + the telegram gate). + # ======================================== + build-image-backend: + name: Build & Push Backend Image + runs-on: pwap-runner + needs: [backend, telegram-gate] + if: | + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to == '')) + permissions: + contents: read + packages: write + id-token: write # cosign keyless signing via Sigstore OIDC + outputs: + image_sha: ${{ steps.meta.outputs.image_sha }} + image_digest: ${{ steps.build.outputs.digest }} + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + with: + cosign-release: 'v2.4.1' + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "image_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "image=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}" >> $GITHUB_OUTPUT + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} + ${{ steps.meta.outputs.image }}:latest + cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache + cache-to: type=registry,ref=${{ steps.meta.outputs.image }}:cache,mode=max + provenance: false + + - name: Sign image with cosign (keyless, Sigstore Fulcio) + env: + COSIGN_EXPERIMENTAL: '1' + run: | + IMAGE_DIGEST="${{ steps.meta.outputs.image }}@${{ steps.build.outputs.digest }}" + echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin + echo "Signing $IMAGE_DIGEST" + cosign sign --yes "$IMAGE_DIGEST" + echo "✅ Backend image signed (transparency log: rekor.sigstore.dev)" + # ======================================== # TELEGRAM CEO APPROVAL GATE # Runs on self-hosted pwap-runner (DEV VPS) where pexsec-bot.service @@ -658,6 +739,163 @@ jobs: curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ -d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG" + # ======================================== + # DEPLOY BACKEND (INDEXER) TO ITS HOST + # Runs a container from the SHA-tagged GHCR image on the backend VPS. + # Same discipline as the web deploy: cosign verify → deploy → health check → + # auto-rollback to previous SHA → Telegram notify. Gated behind the SAME + # telegram-gate approval as the web deploy. Only main/tags, never fork PRs. + # + # STATEFUL DB: the sqlite file lives in the named docker volume + # `pwap-indexer-db` (mounted at /data). Deploy replaces the container/image + # but NEVER the volume — indexer state survives every deploy and rollback. + # + # Host param via secret BACKEND_VPS_HOST (no hardcoded IP). If the backend + # shares the web DEV VPS, set BACKEND_VPS_HOST = VPS_HOST (see backend/DEPLOY.md). + # ======================================== + deploy-backend: + name: Deploy Backend (indexer) + runs-on: pwap-runner + needs: [telegram-gate, build-image-backend] + if: | + always() && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && + needs.telegram-gate.result == 'success' && + ((github.event_name == 'push' && needs.build-image-backend.result == 'success') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != '')) + permissions: + contents: read + packages: read + + steps: + - name: Determine image SHA + id: sha + run: | + if [ -n "${{ github.event.inputs.rollback_to }}" ]; then + echo "sha=${{ github.event.inputs.rollback_to }}" >> $GITHUB_OUTPUT + echo "Rolling back backend to: ${{ github.event.inputs.rollback_to }}" + else + echo "sha=${{ needs.build-image-backend.outputs.image_sha }}" >> $GITHUB_OUTPUT + fi + + - name: Install cosign (for verify) + uses: sigstore/cosign-installer@v3 + with: + cosign-release: 'v2.4.1' + + - name: Log in to GHCR (runner-side, for verify) + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify image signature (cosign keyless) + env: + COSIGN_EXPERIMENTAL: '1' + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.sha.outputs.sha }}" + echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin + echo "Verifying signature for $IMAGE" + cosign verify "$IMAGE" \ + --certificate-identity-regexp "^https://github.com/pezkuwichain/pwap/.github/workflows/quality-gate.yml@" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + > /dev/null + echo "✅ Signature valid — backend image was built by trusted CI" + + - name: Deploy container to backend host (health-checked, auto-rollback) + id: deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.BACKEND_VPS_HOST }} + username: ${{ secrets.BACKEND_VPS_USER }} + key: ${{ secrets.BACKEND_VPS_SSH_KEY }} + port: ${{ secrets.BACKEND_VPS_SSH_PORT || 22 }} + envs: IMAGE_REF,NEW_SHA,GHCR_USER,GHCR_TOKEN + command_timeout: 10m + script_stop: true + script: | + set -e + REGISTRY_IMAGE="ghcr.io/pezkuwichain/pwap-indexer" + IMAGE="$REGISTRY_IMAGE:$NEW_SHA" + STATE_DIR="/opt/pwap-indexer" + NAME="pwap-indexer" + mkdir -p "$STATE_DIR" + PREV="$(cat "$STATE_DIR/.deploy-sha" 2>/dev/null || echo "")" + echo "Deploying $IMAGE (previous: ${PREV:-none})" + + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + docker pull "$IMAGE" + + run_container () { + local img="$1" + docker rm -f "$NAME" >/dev/null 2>&1 || true + # Named volume pwap-indexer-db = stateful sqlite DB. NEVER removed + # by this script — it is created on first run and reused forever. + docker run -d --name "$NAME" --restart unless-stopped \ + -p 3001:3001 \ + -v pwap-indexer-db:/data \ + -e DB_PATH=/data/transactions.db \ + -e PORT=3001 \ + --env-file "$STATE_DIR/.env" \ + "$img" + } + + healthy () { + for i in $(seq 1 12); do + if curl -fsS --max-time 5 "http://localhost:3001/health" >/dev/null 2>&1; then + return 0 + fi + echo "health attempt $i/12 failed, retry in 5s..." + sleep 5 + done + return 1 + } + + run_container "$IMAGE" + if healthy; then + echo "$NEW_SHA" > "$STATE_DIR/.deploy-sha" + echo "✅ Backend healthy on $NEW_SHA" + docker image prune -f >/dev/null 2>&1 || true + exit 0 + fi + + echo "❌ Health check failed for $NEW_SHA" + if [ -n "$PREV" ]; then + echo "🔄 Rolling back to $PREV" + docker pull "$REGISTRY_IMAGE:$PREV" + run_container "$REGISTRY_IMAGE:$PREV" + if healthy; then + echo "$PREV" > "$STATE_DIR/.deploy-sha" + echo "✅ Rolled back to $PREV — backend healthy" + exit 1 # still fail the job: the new SHA did not deploy + fi + echo "🚨 Rollback to $PREV ALSO failed" + exit 1 + fi + echo "No previous SHA to roll back to." + exit 1 + env: + IMAGE_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.sha.outputs.sha }} + NEW_SHA: ${{ steps.sha.outputs.sha }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post-deploy notification + if: success() + run: echo "✅ Deployed backend image ${{ steps.sha.outputs.sha }} to backend host" + + - name: Notify failure (Telegram) + if: failure() + env: + BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }} + CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }} + NEW_SHA: ${{ steps.sha.outputs.sha }} + run: | + MSG="❌ pwap/backend indexer: deploy ($NEW_SHA) failed. If a previous SHA existed the host auto-rolled-back to it (DB volume untouched). Manual: gh workflow run quality-gate.yml -f rollback_to=" + curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + -d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG" + # ======================================== # SECURITY CHECKS (BLOCKING) # npm audit (high + critical) + TruffleHog secret scan @@ -710,7 +948,7 @@ jobs: ci-gate: name: CI Gate ✅ runs-on: pwap-runner - needs: [web, security-audit] + needs: [web, backend, security-audit] if: always() steps: diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..a69296fc --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,16 @@ +node_modules +npm-debug.log +# Never bake stateful DB or secrets into the image +transactions.db +*.db +.env +.env.* +!.env.example +# Tests + docs are not needed at runtime +test +integration-tests +jest.config.js +.eslintrc.cjs +*.md +.git +.github diff --git a/backend/.env.example b/backend/.env.example index f938dd44..974ce6f3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,9 +1,22 @@ -# PezkuwiChain WebSocket Endpoint -WS_ENDPOINT=wss://ws.pezkuwichain.io - -# Sudo account seed phrase for auto-approval -# This account will sign approve_kyc transactions when threshold is reached -SUDO_SEED=your_seed_phrase_here +# ── Indexer service (src/index.js) ────────────────────────────── +# PezkuwiChain WebSocket endpoint +WS_ENDPOINT=wss://rpc.pezkuwichain.io # Server port PORT=3001 + +# Stateful sqlite DB path. In Docker this is set to /data/transactions.db +# (a mounted volume). Locally it defaults to ./transactions.db. +# DB_PATH=/data/transactions.db + +# ── KYC / council service (src/server.js) ─────────────────────── +# Supabase project (required by server.js — it exits if missing) +SUPABASE_URL=your_supabase_url_here +SUPABASE_ANON_KEY=your_supabase_anon_key_here + +# Founder SS58 address — authorizes /api/council/add-member (signature-gated) +FOUNDER_ADDRESS=your_founder_ss58_address_here + +# Sudo account seed — signs approveKyc when the vote threshold is reached. +# Keep secret; use a dedicated key for KYC approvals only. +SUDO_SEED=your_seed_phrase_here diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..c73bcfb9 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +# Stateful indexer DB — never commit (lives on a mounted volume in prod) +transactions.db +*.db +*.db-journal +*.sqlite +node_modules/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..e4bfc8d5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,47 @@ +# pwap indexer service — runnable Node container (not a static-dist image). +# +# Two stages on the SAME debian base so the natively-compiled sqlite3 binding +# built in stage 1 is ABI-compatible with the runtime in stage 2 (do NOT mix +# alpine/musl here — sqlite3 would fail to load). +# +# STATEFUL DATA: the indexer's sqlite file is the service's memory. It is kept +# OUT of the image, under /data, which MUST be a mounted volume. A deploy +# replaces the container/image but never the volume — see backend/DEPLOY.md. + +# ─── Stage 1: install production deps (compiles sqlite3) ──────── +FROM node:20-bookworm AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +# --omit=dev: the test-only devDeps (supertest, @pezkuwi/keyring) never ship. +RUN npm ci --omit=dev + +# ─── Stage 2: runtime ────────────────────────────────────────── +FROM node:20-bookworm-slim +ENV NODE_ENV=production +ENV PORT=3001 +# DB on the volume, never in the image layer. +ENV DB_PATH=/data/transactions.db +WORKDIR /app + +# curl only, for the HEALTHCHECK probe. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src + +# /data holds the stateful sqlite DB — declared as a volume and owned by the +# non-root runtime user so the container can write to the mount. +RUN mkdir -p /data && chown -R node:node /app /data +VOLUME ["/data"] + +USER node +EXPOSE 3001 + +# Probe the in-container /health endpoint added to the indexer API. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD sh -c 'curl -fsS "http://localhost:${PORT:-3001}/health" || exit 1' + +CMD ["node", "src/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json index d8722758..1d24d2bf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,11 +11,19 @@ "@pezkuwi/api": "^16.5.23", "@pezkuwi/util": "^14.0.13", "@pezkuwi/util-crypto": "^14.0.13", + "@supabase/supabase-js": "^2.45.0", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "pino": "^9.5.0", + "pino-http": "^10.3.0", + "pino-pretty": "^11.3.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7" + }, + "devDependencies": { + "@pezkuwi/keyring": "^14.0.13", + "supertest": "^7.0.0" } }, "node_modules/@bizinikiwi/connect": { @@ -143,6 +151,16 @@ "node": ">=10" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pezkuwi/api": { "version": "16.5.23", "resolved": "https://registry.npmjs.org/@pezkuwi/api/-/api-16.5.23.tgz", @@ -761,6 +779,12 @@ "node": ">=18" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -789,6 +813,90 @@ "integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==", "license": "Apache-2.0" }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -824,6 +932,18 @@ "license": "ISC", "optional": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -909,6 +1029,29 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1119,6 +1262,35 @@ "color-support": "bin.js" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1173,6 +1345,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -1199,6 +1378,15 @@ "node": ">= 12" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1240,6 +1428,16 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -1265,6 +1463,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -1392,6 +1601,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1407,12 +1632,30 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1465,6 +1708,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1515,6 +1770,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -1527,6 +1822,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1601,6 +1914,15 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1697,6 +2019,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1705,9 +2043,9 @@ "optional": true }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1716,6 +2054,12 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -1782,6 +2126,15 @@ "ms": "^2.0.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1918,6 +2271,15 @@ "license": "ISC", "optional": true }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2005,6 +2367,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2359,6 +2744,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2425,6 +2819,132 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz", + "integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-pretty/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2451,6 +2971,31 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -2519,6 +3064,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2572,6 +3123,15 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2644,12 +3204,27 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2895,6 +3470,24 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlite": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", @@ -2993,6 +3586,42 @@ "node": ">=0.10.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -3082,6 +3711,15 @@ "node": ">=18" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index c6ed1b6c..40d8c1f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,8 @@ "type": "module", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test test/*.test.js" }, "overrides": { "tar": "^7.5.22", @@ -35,10 +36,18 @@ "@pezkuwi/api": "^16.5.23", "@pezkuwi/util": "^14.0.13", "@pezkuwi/util-crypto": "^14.0.13", + "@supabase/supabase-js": "^2.45.0", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "pino": "^9.5.0", + "pino-http": "^10.3.0", + "pino-pretty": "^11.3.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7" + }, + "devDependencies": { + "@pezkuwi/keyring": "^14.0.13", + "supertest": "^7.0.0" } } diff --git a/backend/src/council.js b/backend/src/council.js new file mode 100644 index 00000000..fff1680f --- /dev/null +++ b/backend/src/council.js @@ -0,0 +1,198 @@ +// Council / KYC HTTP surface — signature-gated, fund/authz-relevant. +// +// Extracted from server.js as an injectable factory so the endpoints can be +// exercised in CI with NO live chain and NO live Supabase: +// - `supabase` is injected (tests pass an in-memory fake). +// - `getApi()` / `getSudo()` are injected getters (tests pass a stubbed +// tx surface, so the on-chain approveKyc path runs without a network). +// Real @pezkuwi/util-crypto signature verification is used as-is — it is pure +// crypto and runs fully offline, so auth rejection/acceptance is tested for real. + +import express from 'express' +import cors from 'cors' +import { signatureVerify } from '@pezkuwi/util-crypto' + +const THRESHOLD_PERCENT = 0.6 + +export function createApp ({ supabase, getApi = () => null, getSudo = () => null, logger = console }) { + const app = express() + app.use(cors()) + app.use(express.json()) + + // ======================================== + // COUNCIL MANAGEMENT + // ======================================== + app.post('/api/council/add-member', async (req, res) => { + const { newMemberAddress, signature, message } = req.body + const founderAddress = process.env.FOUNDER_ADDRESS + + if (!founderAddress) { + logger.error('Founder address is not configured.') + return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } }) + } + + if (process.env.NODE_ENV !== 'test') { + const { isValid } = signatureVerify(message, signature, founderAddress) + if (!isValid) { + return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } }) + } + if (!message.includes(`addCouncilMember:${newMemberAddress}`)) { + return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } }) + } + } + + if (!newMemberAddress || newMemberAddress.length < 47) { + return res.status(400).json({ error: { key: 'errors.request.invalid_address' } }) + } + + try { + const { error } = await supabase + .from('council_members') + .insert([{ address: newMemberAddress }]) + + if (error) { + if (error.code === '23505') { // Unique violation + return res.status(409).json({ error: { key: 'errors.council.member_exists' } }) + } + throw error + } + res.status(200).json({ success: true }) + } catch (error) { + logger.error({ err: error, newMemberAddress }, 'Error adding council member') + res.status(500).json({ error: { key: 'errors.server.internal_error' } }) + } + }) + + // ======================================== + // KYC VOTING + // ======================================== + app.post('/api/kyc/propose', async (req, res) => { + const { userAddress, proposerAddress, signature, message } = req.body + + try { + if (process.env.NODE_ENV !== 'test') { + const { isValid } = signatureVerify(message, signature, proposerAddress) + if (!isValid) { + return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } }) + } + if (!message.includes(`proposeKYC:${userAddress}`)) { + return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } }) + } + } + + const { data: councilMember, error: memberError } = await supabase + .from('council_members').select('address').eq('address', proposerAddress).single() + + if (memberError || !councilMember) { + return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } }) + } + + const { error: proposalError } = await supabase + .from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress }) + + if (proposalError) { + if (proposalError.code === '23505') { + return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } }) + } + throw proposalError + } + + const { data: proposal } = await supabase + .from('kyc_proposals').select('id').eq('user_address', userAddress).single() + + await supabase.from('votes') + .insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true }) + + await checkAndExecute(userAddress) + + res.status(201).json({ success: true, proposalId: proposal.id }) + } catch (error) { + logger.error({ err: error, ...req.body }, 'Error proposing KYC') + res.status(500).json({ error: { key: 'errors.server.internal_error' } }) + } + }) + + async function checkAndExecute (userAddress) { + try { + const api = getApi() + const sudoAccount = getSudo() + + const { count: totalMembers, error: countError } = await supabase + .from('council_members').select('*', { count: 'exact', head: true }) + + if (countError) throw countError + if (totalMembers === 0) return + + const { data: proposal, error: proposalError } = await supabase + .from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single() + + if (proposalError || !proposal || proposal.executed) return + + const { count: ayesCount, error: ayesError } = await supabase + .from('votes').select('*', { count: 'exact', head: true }) + .eq('proposal_id', proposal.id).eq('is_aye', true) + + if (ayesError) throw ayesError + + const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT) + + if (ayesCount >= requiredVotes) { + if (!sudoAccount || !api) { + logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection') + return + } + + logger.info({ userAddress }, 'Threshold reached! Executing approveKyc...') + const tx = api.tx.identityKyc.approveKyc(userAddress) + + await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => { + if (status.isFinalized) { + if (dispatchError) { + const decoded = api.registry.findMetaError(dispatchError.asModule) + const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}` + logger.error({ userAddress, error: errorMsg }, 'Approval failed') + return + } + + const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event)) + if (approvedEvent) { + logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.') + await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id) + } + } + }) + } + } catch (error) { + logger.error({ err: error, userAddress }, 'Error in checkAndExecute') + } + } + + // ======================================== + // GETTERS + // ======================================== + app.get('/api/kyc/pending', async (req, res) => { + try { + const { data, error } = await supabase + .from('kyc_proposals') + .select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )') + .eq('executed', false) + if (error) throw error + res.json({ pending: data }) + } catch (error) { + logger.error({ err: error }, 'Error fetching pending proposals') + res.status(500).json({ error: { key: 'errors.server.internal_error' } }) + } + }) + + // ======================================== + // HEALTH CHECK + // ======================================== + app.get('/health', async (req, res) => { + res.json({ + status: 'ok', + blockchain: getApi() ? 'connected' : 'disconnected' + }) + }) + + return app +} diff --git a/backend/src/index.js b/backend/src/index.js index 406c9281..de013f6b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,134 +1,42 @@ -import express from 'express'; -import cors from 'cors'; -import { open } from 'sqlite'; -import sqlite3 from 'sqlite3'; -import { ApiPromise, WsProvider } from '@pezkuwi/api'; -import dotenv from 'dotenv'; +// Indexer service bootstrap. +// +// Thin wiring layer: connects to the live chain and feeds decoded blocks into +// the injectable core in ./indexer.js (which holds all the DB + HTTP logic and +// is unit-tested offline). Keeping the @pezkuwi/api connection isolated here +// means the testable core never touches the network. -dotenv.config(); +import { ApiPromise, WsProvider } from '@pezkuwi/api' +import dotenv from 'dotenv' +import { initDb, indexBlock, createApp } from './indexer.js' -const app = express(); -const port = process.env.PORT || 3001; -const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io'; +dotenv.config() -app.use(cors()); -app.use(express.json()); - -// Initialize Database -async function initDb() { - const db = await open({ - filename: './transactions.db', - driver: sqlite3.Database - }); - - await db.exec(` - CREATE TABLE IF NOT EXISTS transfers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hash TEXT UNIQUE, - sender TEXT, - receiver TEXT, - amount TEXT, - asset_id INTEGER DEFAULT NULL, - symbol TEXT, - block_number INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - return db; -} +const port = process.env.PORT || 3001 +const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io' // Start Indexing -async function startIndexer(db) { - console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`); - const provider = new WsProvider(WS_ENDPOINT); - const api = await ApiPromise.create({ provider }); +async function startIndexer (db) { + console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`) + const provider = new WsProvider(WS_ENDPOINT) + const api = await ApiPromise.create({ provider }) - console.log('Connected! Listening for new blocks...'); + console.log('Connected! Listening for new blocks...') - api.rpc.chain.subscribeNewHeads(async (header) => { - const blockNumber = header.number.toNumber(); - const blockHash = await api.rpc.chain.getBlockHash(blockNumber); - const signedBlock = await api.rpc.chain.getBlock(blockHash); - - signedBlock.block.extrinsics.forEach(async (ex) => { - const { method: { method, section }, signer } = ex; - - // 1. Handle Native HEZ Transfers - if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) { - const [dest, value] = ex.method.args; - await saveTransfer(db, { - hash: ex.hash.toHex(), - sender: signer.toString(), - receiver: dest.toString(), - amount: value.toString(), - asset_id: null, - symbol: 'HEZ', - block_number: blockNumber - }); - } - - // 2. Handle Asset Transfers (PEZ, USDT) - if (section === 'assets' && method === 'transfer') { - const [id, dest, value] = ex.method.args; - const assetId = id.toNumber(); - const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`; - - await saveTransfer(db, { - hash: ex.hash.toHex(), - sender: signer.toString(), - receiver: dest.toString(), - amount: value.toString(), - asset_id: assetId, - symbol: symbol, - block_number: blockNumber - }); - } - }); - }); -} - -async function saveTransfer(db, tx) { - try { - await db.run( - `INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number] - ); - console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`); - } catch (err) { - console.error('DB Insert Error:', err); - } -} - -// API Routes -async function startServer(db) { - app.get('/api/history/:address', async (req, res) => { - const { address } = req.params; - try { - const history = await db.all( - `SELECT * FROM transfers - WHERE sender = ? OR receiver = ? - ORDER BY block_number DESC LIMIT 50`, - [address, address] - ); - res.json(history); - } catch (err) { - res.status(500).json({ error: err.message }); - } - }); - - app.get('/api/stats', async (req, res) => { - const stats = await db.get('SELECT COUNT(*) as total FROM transfers'); - res.json(stats); - }); - - app.listen(port, () => { - console.log(`Indexer API running at http://localhost:${port}`); - }); + api.rpc.chain.subscribeNewHeads(async (header) => { + const blockNumber = header.number.toNumber() + const blockHash = await api.rpc.chain.getBlockHash(blockNumber) + const signedBlock = await api.rpc.chain.getBlock(blockHash) + await indexBlock(db, signedBlock, blockNumber) + }) } // Launch -const db = await initDb(); -startIndexer(db); -startServer(db); +// DB_PATH lets the deploy point the stateful sqlite file at a mounted volume +// (e.g. /data/transactions.db); defaults to the historical ./transactions.db. +const db = await initDb(process.env.DB_PATH || './transactions.db') +startIndexer(db) + +const app = createApp(db) +app.listen(port, () => { + console.log(`Indexer API running at http://localhost:${port}`) +}) diff --git a/backend/src/indexer.js b/backend/src/indexer.js new file mode 100644 index 00000000..6368bcbe --- /dev/null +++ b/backend/src/indexer.js @@ -0,0 +1,135 @@ +// Indexer core — pure, injectable, offline-testable. +// +// This module deliberately does NOT import @pezkuwi/api. It operates on +// already-decoded extrinsic objects and an injected sqlite handle, so the +// dedup / symbol-mapping / DB logic can be exercised in CI with no live chain +// and an in-memory (`:memory:`) database. src/index.js wires this to the live +// WsProvider/ApiPromise subscription. + +import express from 'express' +import cors from 'cors' +import { open } from 'sqlite' +import sqlite3 from 'sqlite3' + +// Open (or create) the transfers DB. Pass ':memory:' in tests. +export async function initDb (filename = './transactions.db') { + const db = await open({ filename, driver: sqlite3.Database }) + + await db.exec(` + CREATE TABLE IF NOT EXISTS transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT UNIQUE, + sender TEXT, + receiver TEXT, + amount TEXT, + asset_id INTEGER DEFAULT NULL, + symbol TEXT, + block_number INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + return db +} + +// Persist a single transfer. INSERT OR IGNORE gives us hash-level dedup so the +// same extrinsic seen twice (re-org / replay / overlapping subscriptions) is a +// no-op instead of a duplicate row. +export async function saveTransfer (db, tx) { + try { + await db.run( + `INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number] + ) + console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`) + } catch (err) { + console.error('DB Insert Error:', err) + } +} + +// Decode a single (decoded) extrinsic into a transfer row, or null if it is not +// a transfer we index. Kept pure so it can be unit-tested with plain fixtures. +export function parseExtrinsic (ex, blockNumber) { + const { method: { method, section }, signer } = ex + + // 1. Native HEZ transfers + if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) { + const [dest, value] = ex.method.args + return { + hash: ex.hash.toHex(), + sender: signer.toString(), + receiver: dest.toString(), + amount: value.toString(), + asset_id: null, + symbol: 'HEZ', + block_number: blockNumber + } + } + + // 2. Asset transfers (PEZ, USDT, …) + if (section === 'assets' && method === 'transfer') { + const [id, dest, value] = ex.method.args + const assetId = id.toNumber() + const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}` + return { + hash: ex.hash.toHex(), + sender: signer.toString(), + receiver: dest.toString(), + amount: value.toString(), + asset_id: assetId, + symbol, + block_number: blockNumber + } + } + + return null +} + +// Index every relevant extrinsic in a block. Sequential await (vs the original +// fire-and-forget forEach) so inserts actually complete before the block is +// considered processed — same rows, just no lost/racing writes. +export async function indexBlock (db, signedBlock, blockNumber) { + for (const ex of signedBlock.block.extrinsics) { + const transfer = parseExtrinsic(ex, blockNumber) + if (transfer) await saveTransfer(db, transfer) + } +} + +// Build the read-only indexer API over an injected db handle. +export function createApp (db) { + const app = express() + app.use(cors()) + app.use(express.json()) + + // Liveness/readiness probe (used by the Docker HEALTHCHECK). + app.get('/health', (req, res) => { + res.json({ status: 'ok' }) + }) + + app.get('/api/history/:address', async (req, res) => { + const { address } = req.params + try { + const history = await db.all( + `SELECT * FROM transfers + WHERE sender = ? OR receiver = ? + ORDER BY block_number DESC LIMIT 50`, + [address, address] + ) + res.json(history) + } catch (err) { + res.status(500).json({ error: err.message }) + } + }) + + app.get('/api/stats', async (req, res) => { + try { + const stats = await db.get('SELECT COUNT(*) as total FROM transfers') + res.json(stats) + } catch (err) { + res.status(500).json({ error: err.message }) + } + }) + + return app +} diff --git a/backend/src/server.js b/backend/src/server.js index 1028b52b..5d5efca1 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,11 +1,17 @@ -import express from 'express' -import cors from 'cors' +// Council / KYC service bootstrap. +// +// Thin wiring layer: builds the real Supabase client + logger, connects to the +// live chain, and mounts the injectable route factory from ./council.js. All +// endpoint logic lives in council.js so it can be tested offline; this file only +// owns the live side effects (Supabase client, blockchain connection). + import dotenv from 'dotenv' import pino from 'pino' import pinoHttp from 'pino-http' import { createClient } from '@supabase/supabase-js' import { ApiPromise, WsProvider, Keyring } from '@pezkuwi/api' -import { cryptoWaitReady, signatureVerify } from '@pezkuwi/util-crypto' +import { cryptoWaitReady } from '@pezkuwi/util-crypto' +import { createApp } from './council.js' dotenv.config() @@ -25,7 +31,6 @@ const logger = pino({ // ======================================== // INITIALIZATION // ======================================== - const supabaseUrl = process.env.SUPABASE_URL const supabaseKey = process.env.SUPABASE_ANON_KEY if (!supabaseUrl || !supabaseKey) { @@ -34,19 +39,21 @@ if (!supabaseUrl || !supabaseKey) { } const supabase = createClient(supabaseUrl, supabaseKey) -const app = express() -app.use(cors()) -app.use(express.json()) -app.use(pinoHttp({ logger })) - -const THRESHOLD_PERCENT = 0.6 let sudoAccount = null let api = null +// Mount routes with live deps injected via getters (so reassignment in +// initBlockchain is picked up by handlers). +const app = createApp({ + supabase, + getApi: () => api, + getSudo: () => sudoAccount, + logger +}) + // ======================================== // BLOCKCHAIN CONNECTION // ======================================== - async function initBlockchain () { logger.info('🔗 Connecting to Blockchain...') const wsProvider = new WsProvider(process.env.WS_ENDPOINT || 'ws://127.0.0.1:9944') @@ -63,189 +70,12 @@ async function initBlockchain () { } } -// ======================================== -// COUNCIL MANAGEMENT -// ======================================== - -app.post('/api/council/add-member', async (req, res) => { - const { newMemberAddress, signature, message } = req.body - const founderAddress = process.env.FOUNDER_ADDRESS - - if (!founderAddress) { - logger.error('Founder address is not configured.') - return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } }) - } - - if (process.env.NODE_ENV !== 'test') { - const { isValid } = signatureVerify(message, signature, founderAddress) - if (!isValid) { - return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } }) - } - if (!message.includes(`addCouncilMember:${newMemberAddress}`)) { - return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } }) - } - } - - if (!newMemberAddress || newMemberAddress.length < 47) { - return res.status(400).json({ error: { key: 'errors.request.invalid_address' } }) - } - - try { - const { error } = await supabase - .from('council_members') - .insert([{ address: newMemberAddress }]) - - if (error) { - if (error.code === '23505') { // Unique violation - return res.status(409).json({ error: { key: 'errors.council.member_exists' } }) - } - throw error - } - res.status(200).json({ success: true }) - } catch (error) { - logger.error({ err: error, newMemberAddress }, 'Error adding council member') - res.status(500).json({ error: { key: 'errors.server.internal_error' } }) - } -}) - -// ======================================== -// KYC VOTING -// ======================================== - -app.post('/api/kyc/propose', async (req, res) => { - const { userAddress, proposerAddress, signature, message } = req.body - - try { - if (process.env.NODE_ENV !== 'test') { - const { isValid } = signatureVerify(message, signature, proposerAddress) - if (!isValid) { - return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } }) - } - if (!message.includes(`proposeKYC:${userAddress}`)) { - return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } }) - } - } - - const { data: councilMember, error: memberError } = await supabase - .from('council_members').select('address').eq('address', proposerAddress).single() - - if (memberError || !councilMember) { - return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } }) - } - - const { error: proposalError } = await supabase - .from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress }) - - if (proposalError) { - if (proposalError.code === '23505') { - return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } }) - } - throw proposalError - } - - const { data: proposal } = await supabase - .from('kyc_proposals').select('id').eq('user_address', userAddress).single() - - await supabase.from('votes') - .insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true }) - - await checkAndExecute(userAddress) - - res.status(201).json({ success: true, proposalId: proposal.id }) - } catch (error) { - logger.error({ err: error, ...req.body }, 'Error proposing KYC') - res.status(500).json({ error: { key: 'errors.server.internal_error' } }) - } -}) - -async function checkAndExecute (userAddress) { - try { - const { count: totalMembers, error: countError } = await supabase - .from('council_members').select('*', { count: 'exact', head: true }) - - if (countError) throw countError - if (totalMembers === 0) return - - const { data: proposal, error: proposalError } = await supabase - .from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single() - - if (proposalError || !proposal || proposal.executed) return - - const { count: ayesCount, error: ayesError } = await supabase - .from('votes').select('*', { count: 'exact', head: true }) - .eq('proposal_id', proposal.id).eq('is_aye', true) - - if (ayesError) throw ayesError - - const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT) - - if (ayesCount >= requiredVotes) { - if (!sudoAccount || !api) { - logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection') - return - } - - logger.info({ userAddress }, `Threshold reached! Executing approveKyc...`) - const tx = api.tx.identityKyc.approveKyc(userAddress) - - await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => { - if (status.isFinalized) { - if (dispatchError) { - const decoded = api.registry.findMetaError(dispatchError.asModule) - const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}` - logger.error({ userAddress, error: errorMsg }, `Approval failed`) - return - } - - const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event)) - if (approvedEvent) { - logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.') - await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id) - } - } - }) - } - } catch (error) { - logger.error({ err: error, userAddress }, `Error in checkAndExecute`) - } -} - -// ======================================== -// OTHER ENDPOINTS (GETTERS) -// ======================================== - -app.get('/api/kyc/pending', async (req, res) => { - try { - const { data, error } = await supabase - .from('kyc_proposals') - .select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )') - .eq('executed', false) - if (error) throw error - res.json({ pending: data }) - } catch (error) { - logger.error({ err: error }, 'Error fetching pending proposals') - res.status(500).json({ error: { key: 'errors.server.internal_error' } }) - } -}) - -// ======================================== -// HEALTH CHECK -// ======================================== - -app.get('/health', async (req, res) => { - res.json({ - status: 'ok', - blockchain: api ? 'connected' : 'disconnected' - }); -}) - // ======================================== // START & EXPORT // ======================================== - initBlockchain().catch(error => { logger.fatal({ err: error }, '❌ Failed to initialize blockchain') process.exit(1) }) -export { app, supabase, api, logger } \ No newline at end of file +export { app, supabase, logger } diff --git a/backend/test/council.test.js b/backend/test/council.test.js new file mode 100644 index 00000000..972ee597 --- /dev/null +++ b/backend/test/council.test.js @@ -0,0 +1,228 @@ +// Offline council / KYC tests — real signature crypto, mocked chain + Supabase. +// Run: node --test +// +// NODE_ENV must NOT be 'test' here: the handlers skip signature verification +// when NODE_ENV === 'test', and we specifically want to exercise the real +// @pezkuwi/util-crypto signature gate (reject unsigned/invalid, accept valid). + +process.env.NODE_ENV = 'ci-offline' + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import request from 'supertest' +import { Keyring } from '@pezkuwi/keyring' +import { cryptoWaitReady } from '@pezkuwi/util-crypto' +import { u8aToHex } from '@pezkuwi/util' +import { createApp } from '../src/council.js' +import { makeFakeSupabase } from './helpers/fake-supabase.js' + +await cryptoWaitReady() + +const keyring = new Keyring({ type: 'sr25519' }) +const founder = keyring.addFromUri('//Founder') +const proposer = keyring.addFromUri('//Proposer') +const stranger = keyring.addFromUri('//Stranger') +const user = keyring.addFromUri('//KycUser') + +const sign = (pair, message) => u8aToHex(pair.sign(message)) + +const silentLogger = { info () {}, warn () {}, error () {}, fatal () {}, debug () {} } + +// Stubbed chain: approveKyc(...).signAndSend(sudo, cb) drives the callback with +// a finalized, successful event so the "mark executed" branch runs — no network. +function makeMockApi () { + const calls = [] + const api = { + tx: { + identityKyc: { + approveKyc: (addr) => ({ + async signAndSend (sudo, cb) { + calls.push(addr) + await cb({ + status: { isFinalized: true }, + dispatchError: undefined, + events: [{ event: { __kycApproved: true } }] + }) + return () => {} + } + }) + } + }, + events: { + identityKyc: { + KycApproved: { is: (ev) => !!(ev && ev.__kycApproved) } + } + }, + registry: { findMetaError: () => ({ section: 's', name: 'n', docs: [] }) } + } + return { api, calls } +} + +// --- /api/council/add-member ------------------------------------------------ + +test('add-member: 500 when FOUNDER_ADDRESS not configured', async () => { + delete process.env.FOUNDER_ADDRESS + const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger }) + const res = await request(app).post('/api/council/add-member').send({ + newMemberAddress: stranger.address, + signature: '0x00', + message: `addCouncilMember:${stranger.address}` + }) + assert.equal(res.statusCode, 500) + assert.equal(res.body.error.key, 'errors.server.founder_not_configured') +}) + +test('add-member: 401 on invalid signature', async () => { + process.env.FOUNDER_ADDRESS = founder.address + const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger }) + const message = `addCouncilMember:${stranger.address}` + const res = await request(app).post('/api/council/add-member').send({ + newMemberAddress: stranger.address, + signature: sign(stranger, message), // well-formed, but NOT the founder's key + message + }) + assert.equal(res.statusCode, 401) + assert.equal(res.body.error.key, 'errors.auth.invalid_signature') +}) + +test('add-member: 400 when signed message does not match the action', async () => { + process.env.FOUNDER_ADDRESS = founder.address + const message = 'addCouncilMember:5SomeoneElse' + const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger }) + const res = await request(app).post('/api/council/add-member').send({ + newMemberAddress: stranger.address, + signature: sign(founder, message), + message + }) + assert.equal(res.statusCode, 400) + assert.equal(res.body.error.key, 'errors.request.message_mismatch') +}) + +test('add-member: 200 on valid founder signature', async () => { + process.env.FOUNDER_ADDRESS = founder.address + const message = `addCouncilMember:${stranger.address}` + const supabase = makeFakeSupabase() + const app = createApp({ supabase, logger: silentLogger }) + const res = await request(app).post('/api/council/add-member').send({ + newMemberAddress: stranger.address, + signature: sign(founder, message), + message + }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.success, true) + assert.equal(supabase._tables.council_members.length, 1) +}) + +test('add-member: 409 when member already exists', async () => { + process.env.FOUNDER_ADDRESS = founder.address + const message = `addCouncilMember:${stranger.address}` + const supabase = makeFakeSupabase({ council_members: [{ address: stranger.address }] }) + const app = createApp({ supabase, logger: silentLogger }) + const res = await request(app).post('/api/council/add-member').send({ + newMemberAddress: stranger.address, + signature: sign(founder, message), + message + }) + assert.equal(res.statusCode, 409) + assert.equal(res.body.error.key, 'errors.council.member_exists') +}) + +// --- /api/kyc/propose ------------------------------------------------------- + +test('propose: 401 on invalid proposer signature', async () => { + const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger }) + const message = `proposeKYC:${user.address}` + const res = await request(app).post('/api/kyc/propose').send({ + userAddress: user.address, + proposerAddress: proposer.address, + signature: sign(stranger, message), // signed by a different key than proposerAddress + message + }) + assert.equal(res.statusCode, 401) +}) + +test('propose: 403 when proposer is not a council member', async () => { + const message = `proposeKYC:${user.address}` + const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger }) + const res = await request(app).post('/api/kyc/propose').send({ + userAddress: user.address, + proposerAddress: proposer.address, // not seeded into council_members + signature: sign(proposer, message), + message + }) + assert.equal(res.statusCode, 403) + assert.equal(res.body.error.key, 'errors.auth.proposer_not_member') +}) + +test('propose: 201 and auto-executes when threshold reached (chain mocked)', async () => { + const message = `proposeKYC:${user.address}` + const supabase = makeFakeSupabase({ council_members: [{ address: proposer.address }] }) + const { api, calls } = makeMockApi() + const app = createApp({ + supabase, + getApi: () => api, + getSudo: () => ({ address: founder.address }), // stub signer + logger: silentLogger + }) + + const res = await request(app).post('/api/kyc/propose').send({ + userAddress: user.address, + proposerAddress: proposer.address, + signature: sign(proposer, message), + message + }) + + assert.equal(res.statusCode, 201) + assert.equal(res.body.success, true) + // Single-member council → 60% threshold (ceil(1*0.6)=1) met by the auto-aye, + // so approveKyc was signed+sent for the user and the proposal marked executed. + assert.deepEqual(calls, [user.address]) + const proposal = supabase._tables.kyc_proposals[0] + assert.equal(proposal.executed, true) +}) + +test('propose: does NOT execute below threshold (multi-member council)', async () => { + const message = `proposeKYC:${user.address}` + const supabase = makeFakeSupabase({ + council_members: [ + { address: proposer.address }, + { address: founder.address }, + { address: stranger.address } + ] + }) + const { api, calls } = makeMockApi() + const app = createApp({ + supabase, + getApi: () => api, + getSudo: () => ({ address: founder.address }), + logger: silentLogger + }) + + const res = await request(app).post('/api/kyc/propose').send({ + userAddress: user.address, + proposerAddress: proposer.address, + signature: sign(proposer, message), + message + }) + + assert.equal(res.statusCode, 201) + // 3 members → required = ceil(3*0.6)=2, only 1 aye → no on-chain execution. + assert.deepEqual(calls, []) + assert.equal(supabase._tables.kyc_proposals[0].executed, false) +}) + +// --- /api/kyc/pending ------------------------------------------------------- + +test('GET /api/kyc/pending lists non-executed proposals', async () => { + const supabase = makeFakeSupabase({ + kyc_proposals: [ + { user_address: user.address, proposer_address: proposer.address, executed: false }, + { user_address: stranger.address, proposer_address: proposer.address, executed: true } + ] + }) + const app = createApp({ supabase, logger: silentLogger }) + const res = await request(app).get('/api/kyc/pending') + assert.equal(res.statusCode, 200) + assert.equal(res.body.pending.length, 1) + assert.equal(res.body.pending[0].user_address, user.address) +}) diff --git a/backend/test/helpers/fake-supabase.js b/backend/test/helpers/fake-supabase.js new file mode 100644 index 00000000..37eaf153 --- /dev/null +++ b/backend/test/helpers/fake-supabase.js @@ -0,0 +1,105 @@ +// Minimal in-memory Supabase stand-in. +// +// Implements exactly the query-builder surface the council/KYC handlers use: +// .from(t).insert(v) +// .from(t).select(cols).eq(c,v).single() +// .from(t).select('*', { count:'exact', head:true }).eq(...).eq(...) +// .from(t).update(v).eq(c,v) +// .from(t).select(cols).eq(c,v) (list) +// Unique-violation (23505) is simulated on council_members.address and +// kyc_proposals.user_address so the 409 paths are covered. Everything lives in +// plain arrays — no network, no real DB. + +export function makeFakeSupabase (initial = {}) { + const tables = { + council_members: [], + kyc_proposals: [], + votes: [], + ...structuredCloneSafe(initial) + } + + const UNIQUE = { + council_members: 'address', + kyc_proposals: 'user_address' + } + + function from (name) { + const rows = tables[name] || (tables[name] = []) + const builder = { + _filters: [], + _count: null, + _head: false, + _update: null, + + insert (vals) { + const arr = Array.isArray(vals) ? vals : [vals] + for (const v of arr) { + const uniqueCol = UNIQUE[name] + if (uniqueCol && rows.some(r => r[uniqueCol] === v[uniqueCol])) { + return Promise.resolve({ error: { code: '23505' }, data: null }) + } + rows.push({ id: rows.length + 1, executed: false, created_at: new Date().toISOString(), ...v }) + } + return Promise.resolve({ error: null, data: null }) + }, + + select (cols, opts) { + if (opts) { + this._count = opts.count || null + this._head = !!opts.head + } + return this + }, + + eq (col, val) { + this._filters.push([col, val]) + return this + }, + + neq () { return this }, + + update (vals) { + this._update = vals + return this + }, + + _apply () { + return rows.filter(r => this._filters.every(([c, v]) => r[c] === v)) + }, + + single () { + const found = this._apply() + if (found.length === 0) { + return Promise.resolve({ data: null, error: { code: 'PGRST116', message: 'no rows' } }) + } + return Promise.resolve({ data: found[0], error: null }) + }, + + // Thenable terminal for chains awaited without .single() + then (resolve, reject) { + try { + const found = this._apply() + if (this._update) { + found.forEach(r => Object.assign(r, this._update)) + return resolve({ error: null, data: found }) + } + if (this._count) { + return resolve({ count: found.length, error: null, data: this._head ? null : found }) + } + return resolve({ data: found, error: null }) + } catch (err) { + return reject(err) + } + } + } + return builder + } + + return { from, _tables: tables } +} + +function structuredCloneSafe (obj) { + const out = {} + for (const [k, v] of Object.entries(obj)) out[k] = v.map(r => ({ ...r })) + return out +} diff --git a/backend/test/indexer.test.js b/backend/test/indexer.test.js new file mode 100644 index 00000000..bef3ffa1 --- /dev/null +++ b/backend/test/indexer.test.js @@ -0,0 +1,152 @@ +// Offline indexer tests — in-memory sqlite, no live chain. +// Run: node --test + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import request from 'supertest' +import { initDb, saveTransfer, parseExtrinsic, indexBlock, createApp } from '../src/indexer.js' + +// --- fixtures --------------------------------------------------------------- + +// Build a decoded-extrinsic look-alike matching the shape parseExtrinsic reads. +function mkEx ({ section, method, args, hash, signer }) { + return { + method: { section, method, args }, + signer: { toString: () => signer }, + hash: { toHex: () => hash } + } +} + +const scalar = (s) => ({ toString: () => s }) +const numeric = (n) => ({ toNumber: () => n }) + +// --- parseExtrinsic --------------------------------------------------------- + +test('parseExtrinsic maps native balances.transfer to HEZ', () => { + const ex = mkEx({ + section: 'balances', + method: 'transfer', + args: [scalar('5Receiver'), scalar('1000')], + hash: '0xhez1', + signer: '5Sender' + }) + const t = parseExtrinsic(ex, 42) + assert.deepEqual(t, { + hash: '0xhez1', + sender: '5Sender', + receiver: '5Receiver', + amount: '1000', + asset_id: null, + symbol: 'HEZ', + block_number: 42 + }) +}) + +test('parseExtrinsic maps balances.transferKeepAlive to HEZ', () => { + const ex = mkEx({ + section: 'balances', + method: 'transferKeepAlive', + args: [scalar('5R'), scalar('7')], + hash: '0xhez2', + signer: '5S' + }) + assert.equal(parseExtrinsic(ex, 1).symbol, 'HEZ') +}) + +test('parseExtrinsic maps asset ids to PEZ/USDT/ASSET-n', () => { + const mk = (id) => mkEx({ + section: 'assets', + method: 'transfer', + args: [numeric(id), scalar('5R'), scalar('9')], + hash: `0xa${id}`, + signer: '5S' + }) + assert.equal(parseExtrinsic(mk(1), 1).symbol, 'PEZ') + assert.equal(parseExtrinsic(mk(1000), 1).symbol, 'USDT') + assert.equal(parseExtrinsic(mk(5), 1).symbol, 'ASSET-5') + assert.equal(parseExtrinsic(mk(5), 1).asset_id, 5) +}) + +test('parseExtrinsic ignores non-transfer extrinsics', () => { + const ex = mkEx({ + section: 'system', + method: 'remark', + args: [scalar('hi')], + hash: '0xnope', + signer: '5S' + }) + assert.equal(parseExtrinsic(ex, 1), null) +}) + +// --- dedup + persistence ---------------------------------------------------- + +test('saveTransfer dedups on hash (INSERT OR IGNORE)', async () => { + const db = await initDb(':memory:') + const tx = { + hash: '0xdup', + sender: '5S', + receiver: '5R', + amount: '100', + asset_id: null, + symbol: 'HEZ', + block_number: 10 + } + await saveTransfer(db, tx) + await saveTransfer(db, tx) // same hash again — must be a no-op + const { total } = await db.get('SELECT COUNT(*) as total FROM transfers') + assert.equal(total, 1) + await db.close() +}) + +test('indexBlock persists only transfer extrinsics from a block', async () => { + const db = await initDb(':memory:') + const signedBlock = { + block: { + extrinsics: [ + mkEx({ section: 'balances', method: 'transfer', args: [scalar('5R'), scalar('1')], hash: '0x1', signer: '5A' }), + mkEx({ section: 'system', method: 'remark', args: [scalar('x')], hash: '0x2', signer: '5A' }), + mkEx({ section: 'assets', method: 'transfer', args: [numeric(1000), scalar('5R'), scalar('2')], hash: '0x3', signer: '5B' }) + ] + } + } + await indexBlock(db, signedBlock, 99) + const rows = await db.all('SELECT hash, symbol FROM transfers ORDER BY hash') + assert.equal(rows.length, 2) + assert.deepEqual(rows.map(r => r.symbol).sort(), ['HEZ', 'USDT']) + await db.close() +}) + +// --- HTTP surface ----------------------------------------------------------- + +test('GET /health returns 200 ok', async () => { + const db = await initDb(':memory:') + const app = createApp(db) + const res = await request(app).get('/health') + assert.equal(res.statusCode, 200) + assert.equal(res.body.status, 'ok') + await db.close() +}) + +test('GET /api/stats reflects indexed count', async () => { + const db = await initDb(':memory:') + await saveTransfer(db, { hash: '0xs1', sender: 'a', receiver: 'b', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 1 }) + const app = createApp(db) + const res = await request(app).get('/api/stats') + assert.equal(res.statusCode, 200) + assert.equal(res.body.total, 1) + await db.close() +}) + +test('GET /api/history/:address filters by sender or receiver', async () => { + const db = await initDb(':memory:') + await saveTransfer(db, { hash: '0xh1', sender: 'alice', receiver: 'bob', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 2 }) + await saveTransfer(db, { hash: '0xh2', sender: 'carol', receiver: 'dave', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 3 }) + await saveTransfer(db, { hash: '0xh3', sender: 'eve', receiver: 'alice', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 4 }) + const app = createApp(db) + const res = await request(app).get('/api/history/alice') + assert.equal(res.statusCode, 200) + assert.equal(res.body.length, 2) // sent one, received one + const hashes = res.body.map(r => r.hash).sort() + assert.deepEqual(hashes, ['0xh1', '0xh3']) + await db.close() +})