ci/cd(backend): offline test suite + Dockerize + audit-grade deploy (b + c)

Closes the "backend has zero CI + manual SSH deploy" audit finding for the indexer.

Part B — CI-runnable tests (the integration-tests/*.live.test.js need a live chain and
are excluded, not stubbed): extract injectable cores (indexer.js: DB+HTTP+decode, no
@pezkuwi import; council.js: createApp factory for the council/KYC routes). New node:test
suite (19/19, fully offline) — in-memory sqlite, dependency-injected chain stub, an
in-memory fake-supabase, and REAL @pezkuwi keyring signatures proving 401 bad-sig /
400 msg-mismatch / 200 valid / 409 dup / 403 non-member / threshold auto-execute. Backend
job now runs npm ci + npm test and is in ci-gate (failing tests block merge). Fixed a
latent bug: block indexing now awaits each insert (was fire-and-forget forEach). Added
runtime deps server.js imported but were missing from package.json (@supabase/supabase-js,
pino, pino-http) — server.js could not have `npm ci`-run before.

Part C — Dockerfile (non-root, HEALTHCHECK /health, sqlite state on a /data VOLUME kept
out of the image) + backend deploy pipeline mirroring the web one: build+push to GHCR,
cosign keyless sign + verify, ssh deploy with the DB volume preserved across deploys,
/health poll, auto-rollback to previous SHA, same Telegram CEO approval gate, main/tags
only, never fork PRs. New secrets documented in backend/DEPLOY.md (BACKEND_VPS_HOST/USER/
SSH_KEY). DB_PATH env (default ./transactions.db) lets prod point at the volume.

Note for owner: if server.js (council/KYC bootstrap) is legacy/unused, its newly-added
deps can be dropped instead.
This commit is contained in:
2026-07-25 11:01:47 -07:00
parent 20a5602167
commit 80d273ff11
14 changed files with 1847 additions and 324 deletions
+239 -1
View File
@@ -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=<sha>"
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:
+16
View File
@@ -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
+19 -6
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Stateful indexer DB — never commit (lives on a mounted volume in prod)
transactions.db
*.db
*.db-journal
*.sqlite
node_modules/
+47
View File
@@ -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"]
+641 -3
View File
@@ -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",
+10 -1
View File
@@ -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"
}
}
+198
View File
@@ -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
}
+32 -124
View File
@@ -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}`)
})
+135
View File
@@ -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
}
+19 -189
View File
@@ -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 }
export { app, supabase, logger }
+228
View File
@@ -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)
})
+105
View File
@@ -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
}
+152
View File
@@ -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()
})