mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 10:55:41 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stateful indexer DB — never commit (lives on a mounted volume in prod)
|
||||
transactions.db
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
node_modules/
|
||||
@@ -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"]
|
||||
Generated
+641
-3
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user