mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 13:15:41 +00:00
80d273ff11
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.
199 lines
7.3 KiB
JavaScript
199 lines
7.3 KiB
JavaScript
// 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
|
|
}
|